From e0e6deb0677caaccaab6eb5e93dd481db1c7ba87 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Wed, 22 Jul 2026 16:01:49 -0700 Subject: [PATCH 1/5] fsck: decouple the exit status from the error-class bitmask fsck accumulates the problems it finds in errors_found, one bit per error class, and cmd_fsck() returns it as the process exit status. That status is truncated to eight bits, and the eight class bits (ERROR_OBJECT through ERROR_BITMAP) now fill them exactly. The next error class added would occupy bit eight and, on its own, truncate to a zero exit status, reporting success for a repository fsck just found to be broken. Stop returning the error bitmask as the exit status. Keep errors_found as the internal accumulator, which can grow past eight classes, and map it to the exit status in one place: fsck_exit_status() returns the low eight class bits for byte compatibility with existing behavior, and a plain non-zero status when a higher class is the only one set. A caller that needs to know which check failed should parse fsck's message output; the exit code cannot distinguish more than eight classes. No behavior changes yet, since no class occupies bit eight; this prepares fsck to gain more. --- builtin/fsck.c | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/builtin/fsck.c b/builtin/fsck.c index 76b723f36d3dca..52c33c9209f3c1 100644 --- a/builtin/fsck.c +++ b/builtin/fsck.c @@ -1005,6 +1005,23 @@ static struct option fsck_opts[] = { OPT_END(), }; +/* + * fsck records one bit per error class in errors_found for internal + * accounting, and new checks keep adding bits. The process exit status is + * only eight bits, so it cannot distinguish more than eight classes and + * must not be read as a bitmask. Map the internal mask to a status that + * stays byte-compatible with the historical low-eight classes and is + * non-zero whenever any error was found, including a class that does not + * fit. A caller that needs to know which check failed must parse fsck's + * message output, not the exit code. + */ +static int fsck_exit_status(int mask) +{ + if (mask & 0xff) + return mask & 0xff; + return mask ? 1 : 0; +} + int cmd_fsck(int argc, const char **argv, const char *prefix, @@ -1189,5 +1206,5 @@ int cmd_fsck(int argc, } free_snapshot_refs(&snap); - return errors_found; + return fsck_exit_status(errors_found); } From 36c179c92583a64c56935cbd267f8c67aac35106 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 23 Jul 2026 17:38:39 -0700 Subject: [PATCH 2/5] diff-hunks: add the store format, library, and command Blame and "git log --stat" recover hunk coordinates by diffing blob pairs, and recompute them on every run because the results are not stored. Add a cache of those coordinates under $GIT_DIR/objects so a later run can look them up instead of decompressing the blobs and running xdiff again. Each store file is a chunk-format file (see linkgit:gitformat-chunk[5]): an 8-byte header, a DHIX index of fixed-size entries sorted by key, a DHDT segment of hunk records, and a trailing hash checksum. An entry is keyed by the two blob object IDs and the settings the pair was diffed under (struct diff_hunks_settings: xdl_opts and the context length), so a stored result is served only where that exact key recurs, independent of path. Identical hunk blocks are interned once and shared across keys. The store has two tiers: a consolidated base "diff-hunks" and a small overlay "diff-hunks-overlay" of the pairs recorded since the last fold. A warm appends only to the overlay, so its cost is proportional to the pairs it records rather than to the whole store; a lookup checks both tiers, and because entries are content-keyed either tier's hit is identical. The library provides a reader (repo_diff_hunks_store and _sum, gated by core.diffHunks and always miss-tolerant), loaded once and cached on the object database as the commit-graph is, and a writer that accumulates entries and flushes them in one atomic pass. Writing is off by default and enabled per run by the GIT_DIFF_HUNKS_WRITE environment variable or the diffHunks.write config, the environment winning; a writer seeds from the overlay so a flush merges rather than replaces, and verifies the seed's checksum first so a corrupt tier is discarded rather than rewritten with a fresh checksum that verify could no longer catch. The writer fsyncs through a new diff-hunks core.fsync component. "git diff-hunks" inspects and manages the files: "verify" checks the checksum, chunk table, sort order, and entry bounds of each; "compact" folds the overlay into the base; and "clear" removes them. Later patches wire the readers and the writer into the diff and blame paths. --- .gitignore | 1 + Documentation/Makefile | 1 + Documentation/config.adoc | 2 + Documentation/config/core.adoc | 10 +- Documentation/config/diff-hunks.adoc | 7 + Documentation/git-diff-hunks.adoc | 134 ++++ Documentation/gitformat-diff-hunks.adoc | 143 ++++ Documentation/meson.build | 2 + Makefile | 2 + builtin.h | 1 + builtin/diff-hunks.c | 61 ++ command-list.txt | 2 + diff-hunks.c | 919 ++++++++++++++++++++++++ diff-hunks.h | 123 ++++ environment.c | 1 + git.c | 1 + meson.build | 2 + odb.c | 2 + odb.h | 4 + repo-settings.c | 1 + repo-settings.h | 1 + write-or-die.h | 7 +- 22 files changed, 1424 insertions(+), 3 deletions(-) create mode 100644 Documentation/config/diff-hunks.adoc create mode 100644 Documentation/git-diff-hunks.adoc create mode 100644 Documentation/gitformat-diff-hunks.adoc create mode 100644 builtin/diff-hunks.c create mode 100644 diff-hunks.c create mode 100644 diff-hunks.h diff --git a/.gitignore b/.gitignore index 4da58c6754899e..4173111c01b2b6 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ /git-diagnose /git-diff /git-diff-files +/git-diff-hunks /git-diff-index /git-diff-pairs /git-diff-tree diff --git a/Documentation/Makefile b/Documentation/Makefile index 2699f0b24af192..170fcee66e23d5 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -33,6 +33,7 @@ MAN5_TXT += gitattributes.adoc MAN5_TXT += gitformat-bundle.adoc MAN5_TXT += gitformat-chunk.adoc MAN5_TXT += gitformat-commit-graph.adoc +MAN5_TXT += gitformat-diff-hunks.adoc MAN5_TXT += gitformat-index.adoc MAN5_TXT += gitformat-loose.adoc MAN5_TXT += gitformat-pack.adoc diff --git a/Documentation/config.adoc b/Documentation/config.adoc index 15b1a4d5934758..464fde0e49680a 100644 --- a/Documentation/config.adoc +++ b/Documentation/config.adoc @@ -419,6 +419,8 @@ include::config/credential.adoc[] include::config/diff.adoc[] +include::config/diff-hunks.adoc[] + include::config/difftool.adoc[] include::config/extensions.adoc[] diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index a0ebf03e2eb050..9595619c610425 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -670,12 +670,13 @@ but risks losing recent work in the event of an unclean system shutdown. * `pack` hardens objects added to the repo in packfile form. * `pack-metadata` hardens packfile bitmaps and indexes. * `commit-graph` hardens the commit-graph file. +* `diff-hunks` hardens the diff-hunks store. * `index` hardens the index when it is modified. * `objects` is an aggregate option that is equivalent to `loose-object,pack`. * `reference` hardens references modified in the repo. * `derived-metadata` is an aggregate option that is equivalent to - `pack-metadata,commit-graph`. + `pack-metadata,commit-graph,diff-hunks`. * `committed` is an aggregate option that is currently equivalent to `objects`. This mode sacrifices some performance to ensure that work that is committed to the repository with `git commit` or similar commands @@ -750,6 +751,13 @@ core.commitGraph:: to parse the graph structure of commits. Defaults to true. See linkgit:git-commit-graph[1] for more information. +core.diffHunks:: + If true, then Git will consult the diff-hunks store (if it + exists) to skip recomputing diff hunk coordinates in commands + such as `git log --stat` and linkgit:git-blame[1]. This controls + only reading; writing the store is controlled by `diffHunks.write`. + See linkgit:git-diff-hunks[1] for more information. Defaults to true. + core.useReplaceRefs:: If set to `false`, behave as if the `--no-replace-objects` option was given on the command line. See linkgit:git[1] and diff --git a/Documentation/config/diff-hunks.adoc b/Documentation/config/diff-hunks.adoc new file mode 100644 index 00000000000000..60a4291e781755 --- /dev/null +++ b/Documentation/config/diff-hunks.adoc @@ -0,0 +1,7 @@ +diffHunks.write:: + If true, diff-producing commands (`git diff` and `git log` with a + `--stat`, `--numstat`, or `--shortstat` format) write the hunks + they compute to the diff-hunks store, filling it as a side effect. + The `GIT_DIFF_HUNKS_WRITE` environment variable overrides this for + a single invocation. Reading the store is controlled separately by + `core.diffHunks`. See linkgit:git-diff-hunks[1]. Defaults to false. diff --git a/Documentation/git-diff-hunks.adoc b/Documentation/git-diff-hunks.adoc new file mode 100644 index 00000000000000..0f4025574048bc --- /dev/null +++ b/Documentation/git-diff-hunks.adoc @@ -0,0 +1,134 @@ +git-diff-hunks(1) +================= + +NAME +---- +git-diff-hunks - Inspect and manage the precomputed diff hunk store + +SYNOPSIS +-------- +[synopsis] +git diff-hunks verify +git diff-hunks compact +git diff-hunks clear + +DESCRIPTION +----------- + +The diff hunk store is a cache of diff hunk coordinates, so that commands +which need them, such as linkgit:git-blame[1] and `git log` and `git diff` +with the `--stat`, `--numstat`, and `--shortstat` formats, can skip +decompressing blobs and running the diff algorithm. + +The store has two tiers under `$GIT_DIR/objects`: a consolidated base, +`diff-hunks`, and a small overlay, `diff-hunks-overlay`, holding the pairs +recorded since the last fold. Reading is enabled by default; writing is +off by default. A `git diff`, `git log`, `git show`, or `git diff-tree` +that produces one of the stat formats fills the store as a side effect, +but only when writing is enabled for that run (see "WARMING THE STORE" +below), so ordinary reads never modify the repository. A lookup checks +both tiers; when neither has the pair, the store holds a different object +hash, or the file is unreadable, the consumer falls back to computing the +diff. A store only speeds up these commands; it never changes their +output. + +`git diff-hunks` itself only inspects and manages the file. See +linkgit:gitformat-diff-hunks[5] for the file format. + +WARMING THE STORE +----------------- + +The store is filled by running ordinary commands with writing enabled. +Turn writing on for a single invocation with the `GIT_DIFF_HUNKS_WRITE` +environment variable, or persistently with the `diffHunks.write` +configuration; the environment variable takes precedence. A repository +owner warms the store by running the diff-producing commands they care +about with writing on, for example: + + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null + +A `--stat` walk records the hunks at both zero context (what +linkgit:git-blame[1] reads) and the configured context (what `--stat` +sums), so a single warming walk serves both. A warm appends only to the +overlay tier and never rewrites the base, so its cost is proportional to +the pairs it adds; a later warm adds newly computed pairs without +discarding what earlier warms recorded. +Run `git diff-hunks compact` to fold the overlay into the base once the +overlay has grown. + +COMMANDS +-------- + +`verify`:: + Check the integrity of the store: the trailing hash checksum, the + chunk table of contents, the sort order of the index, and the + bounds of every entry. Exits with non-zero status if the store is + corrupt. An absent store is valid. + +`compact`:: + Fold the overlay tier into the base and remove the overlay, so + subsequent reads consult a single consolidated file. This rewrites + the base, so its cost is proportional to the store size; run it + periodically rather than after every warm. + +`clear`:: + Remove the store files (base and overlay). + +CORRECTNESS +----------- + +A stored result is interchangeable with a freshly computed one because an +entry is keyed by everything that determines the diff: + +* the object IDs of the old and new blob, so a result is used only for + the exact contents it was computed from; and +* the diff settings the hunks were computed under: the diff algorithm + and ignore flags (`xdl_opts`) and the context length. A lookup whose + settings differ from a stored entry misses. This is why, for + example, `blame -w` and `--diff-algorithm=` (including a + per-path `diff..algorithm`) do not reuse entries recorded under + the default settings: they change `xdl_opts`. The context length is + part of the key because a zero context length triggers + `trim_common_tail`, which can produce a different but equally valid + set of hunks than a nonzero context; blame diffs at zero context and + `git log --stat` at its configured context, so they key apart and one + store serves both. + +Some options change the hunks in ways that are not part of the key, so +they are excluded from the store in both directions: break detection +(`-B`), `--ignore-matching-lines` (`-I`), `--anchored`, and +`--ignore-blank-lines`. linkgit:git-blame[1] additionally does not +consult the store for reverse blame, ignored revisions, or paths with a +textconv driver. + +The store carries a trailing hash checksum, but readers do not +re-checksum it on every load. As with the commit-graph and +multi-pack-index, the writer fsyncs the file (honoring `core.fsync`) and +commits it atomically, so a committed store is intact; every offset and +count is still bounds-checked as it is read. The checksum is verified by +`git diff-hunks verify`, not on the read path. + +CONFIGURATION +------------- + +`core.diffHunks`:: + Whether commands read the store. Defaults to true. See + linkgit:git-config[1]. + +`diffHunks.write`:: + Whether diff-producing commands write to the store. Defaults to + false. The `GIT_DIFF_HUNKS_WRITE` environment variable overrides it + for a single invocation. See linkgit:git-config[1]. + +Writing the store honors the `core.fsync` configuration through the +`diff-hunks` component; see linkgit:git-config[1]. + +SEE ALSO +-------- +linkgit:git-blame[1], +linkgit:git-log[1], +linkgit:gitformat-diff-hunks[5] + +GIT +--- +Part of the linkgit:git[1] suite diff --git a/Documentation/gitformat-diff-hunks.adoc b/Documentation/gitformat-diff-hunks.adoc new file mode 100644 index 00000000000000..476291fd7096d5 --- /dev/null +++ b/Documentation/gitformat-diff-hunks.adoc @@ -0,0 +1,143 @@ +gitformat-diff-hunks(5) +======================= + +NAME +---- +gitformat-diff-hunks - Precomputed diff hunk store format + +SYNOPSIS +-------- +[verse] +$GIT_DIR/objects/diff-hunks +$GIT_DIR/objects/diff-hunks-overlay + +DESCRIPTION +----------- + +The diff-hunks store memoizes diff hunk coordinates so that commands +that need them, such as `git log --stat` and linkgit:git-blame[1], can +skip decompressing blobs and running the diff algorithm. See +linkgit:git-diff-hunks[1] for how the store is filled and managed and the +configuration that controls it. + +The store has two files under `$GIT_DIR/objects` in this format: a +consolidated base, `diff-hunks`, and a small overlay, `diff-hunks-overlay`, +of the pairs recorded since the last fold. Each is written in one pass and +replaces its whole file atomically, so a reader sees either the old file +or the complete new one. The layout below describes one such file. + +Entries are keyed by the object IDs of the blob pair that was diffed and +by the diff settings the pair was diffed under. A blob pair fully +determines the diff input, so an entry is valid regardless of which +commits, branches, or index states the pair was encountered in, and +identical diffs performed in different contexts share one entry. + +The diff settings recorded in each entry are the diff algorithm and +ignore flags (`xdl_opts`) and the context length. Both change the +resulting hunks, so they are part of the key: in particular a zero +context length triggers `trim_common_tail`, which can pick a different +but equally valid set of hunks than a nonzero context. A reader whose +settings differ from an entry does not match it and falls back to +computing the diff. Because the settings are per entry, one store can +hold the hunks several consumers compute (for example blame at zero +context and `git log --stat` at its context). + +FILE FORMAT +----------- + +All multi-byte integers are stored in network byte order. The file is an +8-byte header, the chunk table of contents and chunk data described in +linkgit:gitformat-chunk[5], and a trailing checksum. + +HEADER +~~~~~~ + +- 4-byte signature: `DHPF` (diff-hunks precomputed format) +- 1-byte version number: currently 1 +- 1-byte hash version: 1 for SHA-1, 2 for SHA-256. A store whose hash + function differs from the repository's is ignored. +- 1-byte number of chunks +- 1-byte reserved + +CHUNK LOOKUP +~~~~~~~~~~~~ + +A table of contents in the format of linkgit:gitformat-chunk[5], listing +the offset of each chunk. Both chunks below are required; a file missing +either is treated as corrupt. + +CHUNK DATA +~~~~~~~~~~ + +DHIX (index):: + A sorted sequence of fixed-size entries. Each entry is the old + blob object ID, the new blob object ID, a 4-byte `xdl_opts` + value, a 4-byte context length, and a 4-byte offset into the + DHDT chunk. Entries are sorted by old object ID, then new object + ID, then `xdl_opts`, then context, so lookups can use binary + search on the full key. + +DHDT (hunk data):: + For each index entry, at its offset: a 4-byte hunk count followed + by that many 16-byte hunk records. A hunk record is four 4-byte + values: old start, old count, new start, new count, as produced + by the xdiff hunk callback. Identical hunk blocks are stored once: + distinct index entries whose recorded hunks are byte-for-byte + equal point at the same offset, so keying the same result under + several settings costs a single block. + +TRAILER +~~~~~~~ + +A checksum of all preceding bytes, computed with the repository hash +function. + +CORRECTNESS +----------- + +Serving hunks from a valid store produces the same output as recomputing +the diff. The diff of a blob pair is not unique: a zero context length +triggers trim_common_tail, which can pick a different but equally valid +set of hunks than a nonzero context does. Context is part of the key, so +these two are stored apart. + +A pair is recorded as up to two entries. The zero-context entry holds the +trimmed hunks, which git-blame reads directly (it diffs at zero context). +The nonzero-context entry holds the untrimmed hunks, whose per-hunk line +counts diffstat sums. One nonzero-context entry serves every context: +context length changes how many unchanged lines a hunk shows, not the +counts diffstat sums. Its coordinates are the untrimmed hunk boundaries, +not those of any particular context, so a future consumer that needs a +given context's boundaries recomputes them. + +A store that cannot be used is ignored, and the consumer falls back to +computing the diff. Every offset and count read from the file is +bounds-checked, so a store that is missing, truncated, of an unknown +version, or of a different object hash does not change the diff output +(a malformed store may still produce a diagnostic on stderr). + +The store is not re-checksummed on the read path. The writer fsyncs the +file (honoring `core.fsync`) and commits it atomically, so a +committed store is intact, the same trust model the commit-graph and +multi-pack-index use. The trailing checksum is recomputed by +`git diff-hunks verify` to detect corruption. + +The checksum detects corruption but does not prove who wrote the file. A +reader trusts the coordinates in a store that passes its checks, so +anything able to write a checksum-valid file at the store path can +influence output, the same as it could by writing objects directly. + +LIMITATIONS +----------- + +- Hunk counts, offsets, and line coordinates are 32-bit, capping a + single entry at roughly four billion hunks and the hunk data at 4 GiB. + A result whose coordinates cannot be represented is not recorded. +- Context lengths that produce identical hunks (for example 3 and 5, + where neither trims) are keyed separately rather than folded together, + so a lookup at a context the store was not built for misses even though + the result would be the same. + +GIT +--- +Part of the linkgit:git[1] suite diff --git a/Documentation/meson.build b/Documentation/meson.build index f4854f802d455f..85f37da47e9cac 100644 --- a/Documentation/meson.build +++ b/Documentation/meson.build @@ -41,6 +41,7 @@ manpages = { 'git-describe.adoc' : 1, 'git-diagnose.adoc' : 1, 'git-diff-files.adoc' : 1, + 'git-diff-hunks.adoc' : 1, 'git-diff-index.adoc' : 1, 'git-diff-pairs.adoc' : 1, 'git-difftool.adoc' : 1, @@ -175,6 +176,7 @@ manpages = { 'gitformat-bundle.adoc' : 5, 'gitformat-chunk.adoc' : 5, 'gitformat-commit-graph.adoc' : 5, + 'gitformat-diff-hunks.adoc' : 5, 'gitformat-index.adoc' : 5, 'gitformat-loose.adoc' : 5, 'gitformat-pack.adoc' : 5, diff --git a/Makefile b/Makefile index 1f3f099f5c5705..dfe18ce8d7029a 100644 --- a/Makefile +++ b/Makefile @@ -1147,6 +1147,7 @@ LIB_OBJS += diffcore-order.o LIB_OBJS += diffcore-pickaxe.o LIB_OBJS += diffcore-rename.o LIB_OBJS += diffcore-rotate.o +LIB_OBJS += diff-hunks.o LIB_OBJS += dir-iterator.o LIB_OBJS += dir.o LIB_OBJS += editor.o @@ -1409,6 +1410,7 @@ BUILTIN_OBJS += builtin/credential.o BUILTIN_OBJS += builtin/describe.o BUILTIN_OBJS += builtin/diagnose.o BUILTIN_OBJS += builtin/diff-files.o +BUILTIN_OBJS += builtin/diff-hunks.o BUILTIN_OBJS += builtin/diff-index.o BUILTIN_OBJS += builtin/diff-pairs.o BUILTIN_OBJS += builtin/diff-tree.o diff --git a/builtin.h b/builtin.h index 4e47a4ebd30ba3..7e64da9f433836 100644 --- a/builtin.h +++ b/builtin.h @@ -175,6 +175,7 @@ int cmd_credential_store(int argc, const char **argv, const char *prefix, struct int cmd_describe(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diagnose(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_files(int argc, const char **argv, const char *prefix, struct repository *repo); +int cmd_diff_hunks(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_index(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_pairs(int argc, const char **argv, const char *prefix, struct repository *repo); diff --git a/builtin/diff-hunks.c b/builtin/diff-hunks.c new file mode 100644 index 00000000000000..11d8fcbc6aba2d --- /dev/null +++ b/builtin/diff-hunks.c @@ -0,0 +1,61 @@ +#include "builtin.h" +#include "config.h" +#include "diff-hunks.h" +#include "gettext.h" +#include "parse-options.h" +#include "repository.h" + +static const char * const diff_hunks_usage[] = { + N_("git diff-hunks verify"), + N_("git diff-hunks compact"), + N_("git diff-hunks clear"), + NULL +}; + +static int cmd_diff_hunks_verify(int argc, const char **argv, + const char *prefix UNUSED, + struct repository *r) +{ + struct option options[] = { OPT_END() }; + + argc = parse_options(argc, argv, NULL, options, diff_hunks_usage, 0); + return diff_hunks_verify(r) ? 1 : 0; +} + +static int cmd_diff_hunks_compact(int argc, const char **argv, + const char *prefix UNUSED, + struct repository *r) +{ + struct option options[] = { OPT_END() }; + + argc = parse_options(argc, argv, NULL, options, diff_hunks_usage, 0); + return diff_hunks_compact(r) ? 1 : 0; +} + +static int cmd_diff_hunks_clear(int argc, const char **argv, + const char *prefix UNUSED, + struct repository *r) +{ + struct option options[] = { OPT_END() }; + + argc = parse_options(argc, argv, NULL, options, diff_hunks_usage, 0); + return diff_hunks_clear(r) ? 1 : 0; +} + +int cmd_diff_hunks(int argc, const char **argv, const char *prefix, + struct repository *repo) +{ + parse_opt_subcommand_fn *fn = NULL; + struct option options[] = { + OPT_SUBCOMMAND("verify", &fn, cmd_diff_hunks_verify), + OPT_SUBCOMMAND("compact", &fn, cmd_diff_hunks_compact), + OPT_SUBCOMMAND("clear", &fn, cmd_diff_hunks_clear), + OPT_END() + }; + + repo_config(repo, git_default_config, NULL); + + argc = parse_options(argc, argv, prefix, options, diff_hunks_usage, 0); + + return fn(argc, argv, prefix, repo); +} diff --git a/command-list.txt b/command-list.txt index 21b802c42026b3..e7b241e6ad8498 100644 --- a/command-list.txt +++ b/command-list.txt @@ -95,6 +95,7 @@ git-describe mainporcelain git-diagnose ancillaryinterrogators git-diff mainporcelain info git-diff-files plumbinginterrogators +git-diff-hunks plumbingmanipulators git-diff-index plumbinginterrogators git-diff-pairs plumbinginterrogators git-diff-tree plumbinginterrogators @@ -223,6 +224,7 @@ gitfaq guide gitformat-bundle developerinterfaces gitformat-chunk developerinterfaces gitformat-commit-graph developerinterfaces +gitformat-diff-hunks developerinterfaces gitformat-index developerinterfaces gitformat-pack developerinterfaces gitformat-signature developerinterfaces diff --git a/diff-hunks.c b/diff-hunks.c new file mode 100644 index 00000000000000..1aca3d16995796 --- /dev/null +++ b/diff-hunks.c @@ -0,0 +1,919 @@ +/* + * Precomputed diff hunks, keyed by diff input. + * + * A single store at .git/objects/diff-hunks maps an (old blob, new + * blob, diff settings) key to the hunk coordinates of diffing the pair. + * The key determines the diff result, so an entry is valid in any + * context it recurs in, independent of path. The settings (xdl_opts and + * context; see struct diff_hunks_settings) are per entry, so one store + * can hold the hunks computed at several settings, for example a zero + * context and a nonzero context. Reading is always on; writing is off + * by default and enabled per run (see diff_hunks_write_enabled), so an + * ordinary command populates the store only during a warming run the + * repository owner opts into. + * + * File layout: + * Header: "DHPF"(4) + version(1) + hash_version(1) + num_chunks(1) + reserved(1) + * Table of contents (chunk-format) + * DHIX chunk: sorted entries, each + * old_blob_oid, new_blob_oid, xdl_opts(4), context(4), hdat_offset(4) + * DHDT chunk: per entry, num_hunks(4) followed by that many 16-byte hunks + * Trailing hash checksum + */ +#include "git-compat-util.h" +#include "chunk-format.h" +#include "config.h" +#include "csum-file.h" +#include "diff-hunks.h" +#include "gettext.h" +#include "hash.h" +#include "hashmap.h" +#include "lockfile.h" +#include "odb.h" +#include "path.h" +#include "repo-settings.h" +#include "repository.h" +#include "strbuf.h" +#include "wrapper.h" + +#define DIFF_HUNKS_SIGNATURE 0x44485046 /* "DHPF" */ +/* + * Bump when the on-disk format changes, or when xdiff's emitted hunk + * coordinates change for a fixed (blobs, xdl_opts, context): an old store + * would otherwise serve stale hunks and change command output. + */ +#define DIFF_HUNKS_VERSION 1 +#define DIFF_HUNKS_HEADER_SIZE 8 + +#define DIFF_HUNKS_CHUNKID_INDEX 0x44484958 /* "DHIX" */ +#define DIFF_HUNKS_CHUNKID_DATA 0x44484454 /* "DHDT" */ + +/* Each hunk is 16 bytes on disk: old_start(4) old_count(4) new_start(4) new_count(4) */ +#define DIFF_HUNKS_HUNK_SIZE (4 * sizeof(uint32_t)) + +/* + * Result of a store lookup: num_hunks records encoded in the store's mmap, + * valid until the store is freed. Read them with nth_precomputed_hunk(). + */ +struct precomputed_entry { + uint32_t num_hunks; + const unsigned char *hunk_data; +}; + +/* Decode a single hunk from the raw on-disk format. */ +static inline void decode_precomputed_hunk(const unsigned char *data, + struct precomputed_hunk *h) +{ + h->old_start = get_be32(data); + h->old_count = get_be32(data + 4); + h->new_start = get_be32(data + 8); + h->new_count = get_be32(data + 12); +} + +/* Decode the nth hunk of a lookup result into *h. */ +static inline void nth_precomputed_hunk(const struct precomputed_entry *e, + uint32_t n, struct precomputed_hunk *h) +{ + decode_precomputed_hunk(e->hunk_data + (size_t)n * DIFF_HUNKS_HUNK_SIZE, h); +} + +/* Byte length of the (old_oid, new_oid, xdl_opts, context) lookup key. */ +static size_t store_index_key_size(const struct git_hash_algo *algo) +{ + return 2 * algo->rawsz + 2 * sizeof(uint32_t); +} + +/* Index entry: the lookup key followed by the 4-byte offset into DHDT. */ +static size_t store_index_entry_size(const struct git_hash_algo *algo) +{ + return store_index_key_size(algo) + sizeof(uint32_t); +} + +/* + * The smallest a valid store file can be: the header, a table of contents + * with one entry per chunk plus a terminating entry, and the trailing + * checksum. + */ +static size_t store_min_size(const struct git_hash_algo *algo, + uint8_t num_chunks) +{ + size_t toc_size = (num_chunks + 1) * CHUNK_TOC_ENTRY_SIZE; + + return DIFF_HUNKS_HEADER_SIZE + toc_size + algo->rawsz; +} + +/* + * Decode an index entry's key into pointers to the two oids and the two + * settings values (on-disk: old_oid, new_oid, then xdl_opts and context + * as big-endian uint32s). + */ +static void decode_store_index_key(const unsigned char *entry, unsigned int rawsz, + const unsigned char **old_hash, + const unsigned char **new_hash, + uint32_t *xdl_opts, uint32_t *context) +{ + *old_hash = entry; + *new_hash = entry + rawsz; + *xdl_opts = get_be32(entry + 2 * rawsz); + *context = get_be32(entry + 2 * rawsz + sizeof(uint32_t)); +} + +/* The DHDT offset stored in an index entry, in the field after its key. */ +static uint32_t index_entry_hdat_offset(const unsigned char *entry, size_t keysz) +{ + return get_be32(entry + keysz); +} + +static char *store_path_ext(struct repository *r, const char *ext) +{ + struct strbuf sb = STRBUF_INIT; + + strbuf_addf(&sb, "%s/diff-hunks%s", repo_get_object_directory(r), ext); + return strbuf_detach(&sb, NULL); +} + +static char *diff_hunks_store_path(struct repository *r) +{ + return store_path_ext(r, ""); +} + +/* + * The store is two tiers: a large consolidated base and a small overlay + * of the pairs recorded since the last fold. A warm appends to the + * overlay; "git diff-hunks compact" folds the overlay into the base. + * Both files use the same on-disk format. + */ +static char *diff_hunks_overlay_path(struct repository *r) +{ + return store_path_ext(r, "-overlay"); +} + +struct diff_hunks_store { + const unsigned char *data; + size_t data_len; + const struct git_hash_algo *hash_algo; + const unsigned char *index; + uint32_t num_entries; + const unsigned char *hdat; + size_t hdat_size; + struct diff_hunks_store *base; /* older tier, or NULL */ +}; + +static void free_store(struct diff_hunks_store *s) +{ + while (s) { + struct diff_hunks_store *base = s->base; + if (s->data) + munmap((void *)s->data, s->data_len); + free(s); + s = base; + } +} + +/* + * Open, mmap, and parse the store at fname. Returns the parsed store + * or NULL on any error. The diff output is unaffected either way; + * corruption is reported by verify, not treated as fatal here. + */ +static struct diff_hunks_store *load_store_at( + const struct git_hash_algo *repo_algo, const char *fname) +{ + struct diff_hunks_store *s; + struct chunkfile *cf; + int fd; + struct stat st; + void *data; + const unsigned char *p; + uint8_t num_chunks; + size_t index_size, entry_size, data_len; + + fd = git_open(fname); + if (fd < 0) + return NULL; + if (fstat(fd, &st) || st.st_size < DIFF_HUNKS_HEADER_SIZE) { + close(fd); + return NULL; + } + data_len = xsize_t(st.st_size); + data = xmmap(NULL, data_len, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + p = data; + + num_chunks = p[6]; + + /* + * Reject a file that is not a readable store: wrong signature, + * version, or object hash, or too small to hold the table of + * contents that read_table_of_contents() walks (it dereferences + * each entry before range-checking its offset). + */ + if (get_be32(p) != DIFF_HUNKS_SIGNATURE || + p[4] != DIFF_HUNKS_VERSION || + p[5] != oid_version(repo_algo) || + data_len < store_min_size(repo_algo, num_chunks)) { + munmap(data, data_len); + return NULL; + } + + /* + * The trailing checksum is not verified here: the writer fsyncs + * and commits atomically, so a committed file is intact, and + * every record is bounds-checked at read (see precomputed_entry_at). + * The checksum is checked separately, by diff_hunks_verify(). + */ + + CALLOC_ARRAY(s, 1); + s->data = data; + s->data_len = data_len; + s->hash_algo = repo_algo; + + cf = init_chunkfile(NULL); + if (read_table_of_contents(cf, p, data_len, + DIFF_HUNKS_HEADER_SIZE, num_chunks, 1) || + pair_chunk(cf, DIFF_HUNKS_CHUNKID_INDEX, &s->index, &index_size) || + pair_chunk(cf, DIFF_HUNKS_CHUNKID_DATA, &s->hdat, &s->hdat_size)) { + free_chunkfile(cf); + goto corrupt; + } + free_chunkfile(cf); + + entry_size = store_index_entry_size(s->hash_algo); + if (index_size % entry_size) + goto corrupt; + s->num_entries = index_size / entry_size; + return s; + +corrupt: + free_store(s); + return NULL; +} + +static struct diff_hunks_store *diff_hunks_store_load(struct repository *r) +{ + struct diff_hunks_store *base, *overlay; + char *fname; + + prepare_repo_settings(r); + if (!r->settings.core_diff_hunks) + return NULL; + + fname = diff_hunks_store_path(r); + base = load_store_at(r->hash_algo, fname); + free(fname); + + fname = diff_hunks_overlay_path(r); + overlay = load_store_at(r->hash_algo, fname); + free(fname); + + /* Newest tier first; a lookup walks ->base on a miss. */ + if (overlay) { + overlay->base = base; + return overlay; + } + return base; +} + +struct diff_hunks_store *repo_diff_hunks_store(struct repository *r) +{ + if (!r->objects) + return NULL; + if (r->objects->diff_hunks_store_attempted) + return r->objects->diff_hunks_store; + r->objects->diff_hunks_store_attempted = 1; + r->objects->diff_hunks_store = diff_hunks_store_load(r); + return r->objects->diff_hunks_store; +} + +void close_diff_hunks_store(struct object_database *o) +{ + if (!o->diff_hunks_store) + return; + free_store(o->diff_hunks_store); + o->diff_hunks_store = NULL; +} + +/* + * Fill *out with the hunk record at offset in the data chunk, and return + * 1 if the record is in bounds, 0 otherwise. The read path does not + * re-verify the checksum, and a valid checksum would not bound the count + * anyway, so a read must call this and use *out only when it returns + * non-zero. + * + * A record is a be32 hunk count followed by that many DIFF_HUNKS_HUNK_SIZE + * hunks. "remaining" tracks the bytes from offset to the end of the data + * chunk: it must hold the count, and after the count is consumed it must + * hold every hunk. The bounds are written as subtraction and division + * (never addition or multiplication) so a crafted offset or count cannot + * overflow them. + */ +static int precomputed_entry_at(const struct diff_hunks_store *s, + uint32_t offset, struct precomputed_entry *out) +{ + size_t remaining; + uint32_t num_hunks; + + if (offset >= s->hdat_size) + return 0; + remaining = s->hdat_size - offset; + if (remaining < sizeof(uint32_t)) + return 0; + + num_hunks = get_be32(s->hdat + offset); + remaining -= sizeof(uint32_t); + if (num_hunks > remaining / DIFF_HUNKS_HUNK_SIZE) + return 0; + + out->num_hunks = num_hunks; + out->hunk_data = s->hdat + offset + sizeof(uint32_t); + return 1; +} + +struct lookup_key { + const struct object_id *old_oid; + const struct object_id *new_oid; + struct diff_hunks_settings settings; + unsigned int rawsz; +}; + +/* + * The store's total order over (old_oid, new_oid, xdl_opts, context), + * defined once so the write-side sort (writer_entry_cmp) and the + * read-side search (store_bsearch_cmp) order the keys identically. + */ +static int cmp_store_index_key(const unsigned char *old_a, const unsigned char *new_a, + uint32_t opts_a, uint32_t ctx_a, + const unsigned char *old_b, const unsigned char *new_b, + uint32_t opts_b, uint32_t ctx_b, unsigned int rawsz) +{ + int cmp = memcmp(old_a, old_b, rawsz); + if (!cmp) + cmp = memcmp(new_a, new_b, rawsz); + if (!cmp) + cmp = (opts_a > opts_b) - (opts_a < opts_b); + if (!cmp) + cmp = (ctx_a > ctx_b) - (ctx_a < ctx_b); + return cmp; +} + +static int store_bsearch_cmp(const void *key, const void *entry_ptr) +{ + const struct lookup_key *k = key; + const unsigned char *old_hash, *new_hash; + uint32_t xdl_opts, context; + + decode_store_index_key(entry_ptr, k->rawsz, &old_hash, &new_hash, + &xdl_opts, &context); + return cmp_store_index_key(k->old_oid->hash, k->new_oid->hash, + (uint32_t)k->settings.xdl_opts, + (uint32_t)k->settings.context, + old_hash, new_hash, xdl_opts, context, k->rawsz); +} + +static int store_get_one(struct diff_hunks_store *s, const struct lookup_key *key, + struct precomputed_entry *out) +{ + size_t entry_size = store_index_entry_size(s->hash_algo); + const unsigned char *found; + + found = bsearch(key, s->index, s->num_entries, entry_size, + store_bsearch_cmp); + if (!found) + return 0; + return precomputed_entry_at(s, + index_entry_hdat_offset(found, store_index_key_size(s->hash_algo)), + out); +} + +static int diff_hunks_store_get(struct diff_hunks_store *s, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + struct precomputed_entry *out) +{ + struct lookup_key key; + + if (!s) + return 0; + /* The null OID names no blob and cannot key an entry. */ + if (is_null_oid(old_oid) || is_null_oid(new_oid)) + return 0; + + key.old_oid = old_oid; + key.new_oid = new_oid; + key.settings = *ds; + key.rawsz = s->hash_algo->rawsz; + + /* Content-keyed, so any tier's hit is identical; take the first. */ + for (; s; s = s->base) + if (store_get_one(s, &key, out)) + return 1; + return 0; +} + +int diff_hunks_store_sum(struct diff_hunks_store *s, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + uintmax_t *added, uintmax_t *deleted) +{ + struct precomputed_entry e; + uint32_t i; + + if (!diff_hunks_store_get(s, old_oid, new_oid, ds, &e)) + return 0; + for (i = 0; i < e.num_hunks; i++) { + struct precomputed_hunk h; + nth_precomputed_hunk(&e, i, &h); + *added += h.new_count; + *deleted += h.old_count; + } + return 1; +} + +/* Validate one store file. Returns 0 if valid or absent, -1 if corrupt. */ +static int verify_store_at(struct repository *r, const char *fname) +{ + struct diff_hunks_store *s; + size_t entry_size; + uint32_t i; + int ret = 0; + + if (access(fname, F_OK)) + return 0; /* absent is valid */ + s = load_store_at(r->hash_algo, fname); + if (!s) + return error(_("diff-hunks store failed to load (corrupt " + "header or hash mismatch): %s"), fname); + if (!hashfile_checksum_valid(r->hash_algo, s->data, s->data_len)) { + error(_("diff-hunks store has incorrect checksum and is " + "likely corrupt: %s"), fname); + free_store(s); + return -1; + } + + entry_size = store_index_entry_size(s->hash_algo); + for (i = 0; i < s->num_entries; i++) { + const unsigned char *ep = s->index + st_mult(entry_size, i); + size_t keysz = store_index_key_size(s->hash_algo); + uint32_t offset = index_entry_hdat_offset(ep, keysz); + struct precomputed_entry pe; + + /* + * Keyed by (old_oid, new_oid, xdl_opts, context), increasing. + * memcmp matches cmp_store_index_key's integer comparison of + * xdl_opts and context because both are non-negative, so their + * big-endian bytes order the same as their values. + */ + if (i > 0 && memcmp(ep - entry_size, ep, keysz) >= 0) { + error(_("diff-hunks entry %u not in sorted order"), i); + ret = -1; + } + if (!precomputed_entry_at(s, offset, &pe)) { + error(_("diff-hunks entry %u has out-of-bounds hunk " + "data"), i); + ret = -1; + } + } + + free_store(s); + return ret; +} + +int diff_hunks_verify(struct repository *r) +{ + char *base = diff_hunks_store_path(r); + char *overlay = diff_hunks_overlay_path(r); + int ret = 0; + + if (verify_store_at(r, base)) + ret = -1; + if (verify_store_at(r, overlay)) + ret = -1; + free(base); + free(overlay); + return ret; +} + +int diff_hunks_clear(struct repository *r) +{ + char *base = diff_hunks_store_path(r); + char *overlay = diff_hunks_overlay_path(r); + int ret = 0; + + if (unlink(base) && errno != ENOENT) + ret = error_errno(_("unable to remove %s"), base); + if (unlink(overlay) && errno != ENOENT) + ret = error_errno(_("unable to remove %s"), overlay); + free(base); + free(overlay); + return ret; +} + +struct writer_entry { + struct object_id old_oid; + struct object_id new_oid; + struct diff_hunks_settings settings; + uint32_t hdat_offset; +}; + +struct diff_hunks_writer { + struct repository *r; + struct writer_entry *entries; + size_t nr, alloc; + size_t seed_nr; /* nr after seeding; finish skips a no-op flush */ + struct strbuf hdat; + struct hashmap dedup; /* hunk block content -> offset in hdat */ +}; + +/* A record of one distinct hunk block already present in hdat. */ +struct dedup_entry { + struct hashmap_entry ent; + uint32_t offset; + uint32_t len; +}; + +static int dedup_cmp(const void *cmp_data, + const struct hashmap_entry *a, + const struct hashmap_entry *b, + const void *keydata UNUSED) +{ + const struct diff_hunks_writer *writer = cmp_data; + const struct dedup_entry *ea = container_of(a, const struct dedup_entry, ent); + const struct dedup_entry *eb = container_of(b, const struct dedup_entry, ent); + + if (ea->len != eb->len) + return 1; + return memcmp(writer->hdat.buf + ea->offset, + writer->hdat.buf + eb->offset, ea->len); +} + +static struct diff_hunks_writer *diff_hunks_writer_new(struct repository *r) +{ + struct diff_hunks_writer *w; + + CALLOC_ARRAY(w, 1); + w->r = r; + strbuf_init(&w->hdat, 0); + hashmap_init(&w->dedup, dedup_cmp, w, 0); + return w; +} + +static void strbuf_put_be32(struct strbuf *sb, uint32_t val) +{ + unsigned char buf[4]; + put_be32(buf, val); + strbuf_add(sb, buf, 4); +} + +/* + * The hunk block just appended at `start` is deduplicated: if an + * identical block is already in hdat, this copy is dropped and the + * earlier offset returned; otherwise it is kept and remembered. Distinct + * keys that diff to the same hunks then share one block, so keying the + * same hunks under several settings costs one block, not one per setting. + */ +static uint32_t intern_block(struct diff_hunks_writer *w, size_t start) +{ + size_t len = w->hdat.len - start; + struct dedup_entry key, *found, *added; + + hashmap_entry_init(&key.ent, memhash(w->hdat.buf + start, len)); + key.offset = (uint32_t)start; + key.len = (uint32_t)len; + + found = hashmap_get_entry(&w->dedup, &key, ent, NULL); + if (found) { + strbuf_setlen(&w->hdat, start); + return found->offset; + } + + added = xmalloc(sizeof(*added)); + hashmap_entry_init(&added->ent, key.ent.hash); + added->offset = key.offset; + added->len = key.len; + hashmap_add(&w->dedup, &added->ent); + return key.offset; +} + +void diff_hunks_writer_add(struct diff_hunks_writer *w, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + const struct precomputed_hunk *hunks, + size_t nr_hunks) +{ + struct writer_entry *e; + size_t i, block_start; + + /* + * The block appended for this entry is sizeof(uint32_t) + + * nr_hunks * DIFF_HUNKS_HUNK_SIZE bytes. Bound nr_hunks so that + * length fits the uint32_t the dedup index records (and so the + * count itself fits the uint32_t written to the store). + */ + if (!nr_hunks || + nr_hunks > (UINT32_MAX - sizeof(uint32_t)) / DIFF_HUNKS_HUNK_SIZE || + is_null_oid(old_oid) || is_null_oid(new_oid)) + return; + if (w->hdat.len > UINT32_MAX) + return; + /* + * Coordinates are stored as 32-bit values; a result that cannot + * round-trip is dropped rather than silently truncated. + */ + for (i = 0; i < nr_hunks; i++) + if ((uintmax_t)hunks[i].old_start > (uintmax_t)INT32_MAX || + (uintmax_t)hunks[i].old_count > (uintmax_t)INT32_MAX || + (uintmax_t)hunks[i].new_start > (uintmax_t)INT32_MAX || + (uintmax_t)hunks[i].new_count > (uintmax_t)INT32_MAX) + return; + + ALLOC_GROW(w->entries, w->nr + 1, w->alloc); + e = &w->entries[w->nr++]; + oidcpy(&e->old_oid, old_oid); + oidcpy(&e->new_oid, new_oid); + e->settings = *ds; + + block_start = w->hdat.len; + strbuf_put_be32(&w->hdat, (uint32_t)nr_hunks); + for (i = 0; i < nr_hunks; i++) { + strbuf_put_be32(&w->hdat, hunks[i].old_start); + strbuf_put_be32(&w->hdat, hunks[i].old_count); + strbuf_put_be32(&w->hdat, hunks[i].new_start); + strbuf_put_be32(&w->hdat, hunks[i].new_count); + } + e->hdat_offset = intern_block(w, block_start); +} + +/* + * Seed the writer with fname's entries so a rewrite preserves them. Returns + * 0 when the file is absent or seeded, and -1 when it is present but fails + * its checksum. A rewrite re-checksums, so a corrupt source must not be + * carried forward: that would launder the corruption into a checksum-valid + * file that verify can no longer catch. This path already reads the whole + * file, so verify it here (the reader stays trust-at-read and fast) and + * discard it on mismatch, leaving the caller to react: a warm proceeds + * without it, a fold refuses to consolidate a shrunken store. + */ +static int diff_hunks_writer_seed(struct diff_hunks_writer *w, const char *fname) +{ + struct diff_hunks_store *s = load_store_at(w->r->hash_algo, fname); + unsigned int rawsz; + size_t entry_size, keysz; + struct precomputed_hunk *hunks = NULL; + size_t hunks_alloc = 0; + uint32_t i; + + if (!s) + return 0; + if (!hashfile_checksum_valid(w->r->hash_algo, s->data, s->data_len)) { + warning(_("diff-hunks store %s failed its checksum; " + "discarding it"), fname); + free_store(s); + return -1; + } + rawsz = s->hash_algo->rawsz; + entry_size = store_index_entry_size(s->hash_algo); + keysz = store_index_key_size(s->hash_algo); + + for (i = 0; i < s->num_entries; i++) { + const unsigned char *ep = s->index + st_mult(entry_size, i); + const unsigned char *old_hash, *new_hash; + struct object_id old_oid, new_oid; + struct diff_hunks_settings ds; + uint32_t xdl_opts, context, j; + struct precomputed_entry pe; + + decode_store_index_key(ep, rawsz, &old_hash, &new_hash, + &xdl_opts, &context); + oidread(&old_oid, old_hash, s->hash_algo); + oidread(&new_oid, new_hash, s->hash_algo); + ds.xdl_opts = (int)xdl_opts; + ds.context = (int)context; + if (!precomputed_entry_at(s, index_entry_hdat_offset(ep, keysz), &pe)) + continue; + ALLOC_GROW(hunks, pe.num_hunks, hunks_alloc); + for (j = 0; j < pe.num_hunks; j++) + nth_precomputed_hunk(&pe, j, &hunks[j]); + diff_hunks_writer_add(w, &old_oid, &new_oid, &ds, + hunks, pe.num_hunks); + } + free(hunks); + free_store(s); + return 0; +} + +/* + * Writing is off by default. It is enabled per invocation by the + * GIT_DIFF_HUNKS_WRITE environment variable, or persistently by the + * diffHunks.write config, with the environment variable winning when + * set. Only a warming run (a diff or log the repository owner chooses + * to run with writing on) enables it, so ordinary reads never mutate + * the store. + */ +static int diff_hunks_write_enabled(struct repository *r) +{ + const char *env = getenv("GIT_DIFF_HUNKS_WRITE"); + int val; + + if (env) { + /* + * This is a warming opt-in, so an unparseable value must not + * abort an ordinary read command: treat it as disabled. + */ + val = git_parse_maybe_bool(env); + return val < 0 ? 0 : val; + } + if (!repo_config_get_bool(r, "diffhunks.write", &val)) + return val; + return 0; +} + +struct diff_hunks_writer *diff_hunks_writer_maybe_new(struct repository *r) +{ + struct diff_hunks_writer *w; + char *fname; + + if (!diff_hunks_write_enabled(r)) + return NULL; + /* + * A warm appends to the overlay tier: seed from the overlay (small) + * so a flush merges with it rather than replacing it, and leave the + * base untouched. "git diff-hunks compact" folds overlay into base. + */ + w = diff_hunks_writer_new(r); + fname = diff_hunks_overlay_path(r); + diff_hunks_writer_seed(w, fname); + free(fname); + w->seed_nr = w->nr; + return w; +} + +static int writer_entry_cmp(const void *va, const void *vb, void *ctx) +{ + const struct writer_entry *a = va, *b = vb; + unsigned int rawsz = *(const unsigned int *)ctx; + return cmp_store_index_key(a->old_oid.hash, a->new_oid.hash, + (uint32_t)a->settings.xdl_opts, + (uint32_t)a->settings.context, + b->old_oid.hash, b->new_oid.hash, + (uint32_t)b->settings.xdl_opts, + (uint32_t)b->settings.context, + rawsz); +} + +struct write_ctx { + struct diff_hunks_writer *w; + unsigned int rawsz; +}; + +static int write_index_chunk(struct hashfile *f, void *data) +{ + struct write_ctx *ctx = data; + size_t i; + + for (i = 0; i < ctx->w->nr; i++) { + hashwrite(f, ctx->w->entries[i].old_oid.hash, ctx->rawsz); + hashwrite(f, ctx->w->entries[i].new_oid.hash, ctx->rawsz); + hashwrite_be32(f, ctx->w->entries[i].settings.xdl_opts); + hashwrite_be32(f, ctx->w->entries[i].settings.context); + hashwrite_be32(f, ctx->w->entries[i].hdat_offset); + } + return 0; +} + +static int write_data_chunk(struct hashfile *f, void *data) +{ + struct write_ctx *ctx = data; + hashwrite(f, ctx->w->hdat.buf, ctx->w->hdat.len); + return 0; +} + +/* Sort, dedup, and write the accumulated entries to the file at fname. */ +static int diff_hunks_writer_flush(struct diff_hunks_writer *w, char *fname) +{ + struct lock_file lk = LOCK_INIT; + struct hashfile *f; + struct chunkfile *cf; + unsigned int rawsz = w->r->hash_algo->rawsz; + struct write_ctx ctx = { w, rawsz }; + size_t entry_size; + + QSORT_S(w->entries, w->nr, writer_entry_cmp, &rawsz); + + /* + * The same blob pair recurs across history (reverts, cherry- + * picks); identical keys carry identical hunks, so keep one of + * each. The index must stay duplicate-free for binary search. + */ + if (w->nr > 1) { + size_t kept = 1, i; + for (i = 1; i < w->nr; i++) + if (writer_entry_cmp(&w->entries[kept - 1], + &w->entries[i], &rawsz)) + w->entries[kept++] = w->entries[i]; + w->nr = kept; + } + + if (safe_create_leading_directories(w->r, fname)) { + error(_("unable to create directory for %s"), fname); + return -1; + } + if (hold_lock_file_for_update(&lk, fname, 0) < 0) { + error_errno(_("unable to lock %s"), fname); + return -1; + } + adjust_shared_perm(w->r, get_lock_file_path(&lk)); + f = hashfd(w->r->hash_algo, get_lock_file_fd(&lk), + get_lock_file_path(&lk)); + + entry_size = store_index_entry_size(w->r->hash_algo); + cf = init_chunkfile(f); + add_chunk(cf, DIFF_HUNKS_CHUNKID_INDEX, w->nr * entry_size, + write_index_chunk); + add_chunk(cf, DIFF_HUNKS_CHUNKID_DATA, w->hdat.len, write_data_chunk); + + hashwrite_be32(f, DIFF_HUNKS_SIGNATURE); + hashwrite_u8(f, DIFF_HUNKS_VERSION); + hashwrite_u8(f, oid_version(w->r->hash_algo)); + hashwrite_u8(f, get_num_chunks(cf)); + hashwrite_u8(f, 0); /* reserved */ + + write_chunkfile(cf, &ctx); + free_chunkfile(cf); + + /* + * fsync per the user's configuration (like commit-graph and the + * multi-pack-index), then commit atomically. Readers trust the + * committed file rather than re-checksumming it; diff_hunks_verify() + * checks the checksum separately. + */ + finalize_hashfile(f, NULL, FSYNC_COMPONENT_DIFF_HUNKS, + CSUM_HASH_IN_STREAM); + if (commit_lock_file(&lk)) { + error_errno(_("unable to write %s"), fname); + return -1; + } + return 0; +} + +static void diff_hunks_writer_free(struct diff_hunks_writer *w) +{ + if (!w) + return; + hashmap_clear_and_free(&w->dedup, struct dedup_entry, ent); + free(w->entries); + strbuf_release(&w->hdat); + free(w); +} + +void diff_hunks_writer_finish(struct diff_hunks_writer *w) +{ + if (!w) + return; + /* Skip the flush when the warm recorded nothing beyond its seed. */ + if (w->nr != w->seed_nr) { + char *fname = diff_hunks_overlay_path(w->r); + diff_hunks_writer_flush(w, fname); + free(fname); + } + diff_hunks_writer_free(w); +} + +int diff_hunks_compact(struct repository *r) +{ + char *base = diff_hunks_store_path(r); + char *overlay = diff_hunks_overlay_path(r); + struct diff_hunks_writer *w; + int ret = 0; + + /* Nothing to fold if there is no overlay; leave the base alone. */ + if (access(overlay, F_OK)) { + free(base); + free(overlay); + return 0; + } + + /* + * Merge the base and overlay into a fresh base, then drop the overlay. + * If either tier fails its checksum, seeding discards it; refuse to + * consolidate rather than rewrite a base that omits the discarded + * entries and then delete the overlay. + */ + w = diff_hunks_writer_new(r); + if (diff_hunks_writer_seed(w, base) < 0 || + diff_hunks_writer_seed(w, overlay) < 0) { + diff_hunks_writer_free(w); + ret = error(_("diff-hunks store is corrupt; run " + "'git diff-hunks clear' and warm it again")); + } else { + ret = diff_hunks_writer_flush(w, base); + diff_hunks_writer_free(w); + if (!ret && unlink(overlay) && errno != ENOENT) + ret = error_errno(_("unable to remove %s"), overlay); + } + + free(base); + free(overlay); + return ret; +} diff --git a/diff-hunks.h b/diff-hunks.h new file mode 100644 index 00000000000000..523e039ccab08b --- /dev/null +++ b/diff-hunks.h @@ -0,0 +1,123 @@ +#ifndef DIFF_HUNKS_H +#define DIFF_HUNKS_H + +#include "hash.h" + +struct object_id; +struct repository; +struct object_database; + +/* + * A persistent store of precomputed diff hunk coordinates, at + * .git/objects/diff-hunks. Entries are keyed by the two blobs diffed + * and the diff settings they were diffed under (see struct + * diff_hunks_settings), so a cached result is valid in any context that + * key recurs in, independent of path. + * + * The store is a cache: ordinary commands read it and fall back to + * computing the diff when it is absent, stale, or corrupt. It is filled + * as a side effect of diff and log runs, but only when writing is + * enabled; writing is off by default, so an ordinary command reads the + * store without recording into it. + */ + +/* + * The diff settings a blob pair was diffed under: the subset of + * diff_options that changes the resulting hunks. Together with the + * blob pair, these key an entry: a stored result is served only to a + * request whose settings match. Both mirror the (always non-negative) + * diff_options fields they project from, and are serialized and compared + * as 4-byte big-endian integers. + * + * The hunks a pair produces are not unique. They vary with the xdiff + * algorithm and ignore flags (xdl_opts), and with whether the diff is + * trimmed: a zero context triggers trim_common_tail, which can pick a + * different but equally valid set of hunks than an untrimmed diff. The + * context in the key selects the trimmed hunks git-blame reads or the + * untrimmed hunks diffstat sums; the recorded coordinates are always at + * zero context, so an entry keyed by a nonzero context holds the counts + * diffstat sums, not that context's hunk boundaries. Distinct settings + * that happen to produce identical hunks are not folded together, so a + * lookup at settings the store was not built for misses and the diff is + * recomputed. + */ +struct diff_hunks_settings { + int xdl_opts; + int context; +}; + +/* + * A hunk's coordinates. The type is long to match the xdiff emit + * callback; the values are a diff's line numbers and counts, always + * within the int32 range the on-disk format stores (see + * diff_hunks_writer_add()). + */ +struct precomputed_hunk { + long old_start; + long old_count; + long new_start; + long new_count; +}; + +/* + * The repository's store, loaded once on first use and cached on the + * object database. Returns NULL when reading is disabled + * (core.diffHunks=false), the store is absent, or its object hash + * differs. The lookup functions below accept a NULL store and treat it + * as empty (every lookup misses), so callers need not check for NULL. + * The object database owns the store; callers must not free it. + */ +struct diff_hunks_store *repo_diff_hunks_store(struct repository *r); + +/* Free the repository's cached store, at object-database teardown. */ +void close_diff_hunks_store(struct object_database *o); + +/* + * Sum the recorded hunk line counts for an (old blob, new blob) pair + * under the settings ds into *added/*deleted; returns 1 on a hit, 0 on a + * miss (the caller then computes the diff). This is the read side for + * diffstat, which needs only the counts. A consumer that needs the hunk + * coordinates uses diff_hunks_emit(), which validates them before replay. + */ +int diff_hunks_store_sum(struct diff_hunks_store *s, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + uintmax_t *added, uintmax_t *deleted); + +/* + * A warming run's writer: it accumulates the hunks it computes in memory + * and flushes them to the store in one pass at finish. + */ +struct diff_hunks_writer; + +/* + * Return a writer for a warming run, or NULL when writing is disabled + * (the default). diff_hunks_writer_add() tolerates a NULL writer, so a + * caller may attach the result unconditionally. Pair with + * diff_hunks_writer_finish(). + */ +struct diff_hunks_writer *diff_hunks_writer_maybe_new(struct repository *r); + +/* + * Record a blob pair's hunks as computed under the settings ds; a later + * lookup with matching settings is served these hunks. NULL-safe. + */ +void diff_hunks_writer_add(struct diff_hunks_writer *w, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + const struct precomputed_hunk *hunks, + size_t nr_hunks); + +/* Flush the accumulated entries to the store and free the writer. NULL-safe. */ +void diff_hunks_writer_finish(struct diff_hunks_writer *w); + +/* Remove the store files (base and overlay). Returns 0 (incl. absent) or -1. */ +int diff_hunks_clear(struct repository *r); +/* Validate the store (base and overlay). Returns 0 if valid/absent, -1 if corrupt. */ +int diff_hunks_verify(struct repository *r); +/* Fold the overlay tier into the base and drop it. Returns 0 or -1. */ +int diff_hunks_compact(struct repository *r); + +#endif /* DIFF_HUNKS_H */ diff --git a/environment.c b/environment.c index 8f0c1c4f250193..416d6f1cc953c7 100644 --- a/environment.c +++ b/environment.c @@ -233,6 +233,7 @@ static const struct fsync_component_name { { "pack", FSYNC_COMPONENT_PACK }, { "pack-metadata", FSYNC_COMPONENT_PACK_METADATA }, { "commit-graph", FSYNC_COMPONENT_COMMIT_GRAPH }, + { "diff-hunks", FSYNC_COMPONENT_DIFF_HUNKS }, { "index", FSYNC_COMPONENT_INDEX }, { "objects", FSYNC_COMPONENTS_OBJECTS }, { "reference", FSYNC_COMPONENT_REFERENCE }, diff --git a/git.c b/git.c index 387eabe38c1951..19d89b1a3a2ce6 100644 --- a/git.c +++ b/git.c @@ -566,6 +566,7 @@ static struct cmd_struct commands[] = { { "diagnose", cmd_diagnose, RUN_SETUP_GENTLY }, { "diff", cmd_diff, NO_PARSEOPT }, { "diff-files", cmd_diff_files, RUN_SETUP | NEED_WORK_TREE | NO_PARSEOPT }, + { "diff-hunks", cmd_diff_hunks, RUN_SETUP }, { "diff-index", cmd_diff_index, RUN_SETUP | NO_PARSEOPT }, { "diff-pairs", cmd_diff_pairs, RUN_SETUP | NO_PARSEOPT }, { "diff-tree", cmd_diff_tree, RUN_SETUP | NO_PARSEOPT }, diff --git a/meson.build b/meson.build index 9434b56960ba80..e1a9dfc038f96a 100644 --- a/meson.build +++ b/meson.build @@ -348,6 +348,7 @@ libgit_sources = [ 'diffcore-pickaxe.c', 'diffcore-rename.c', 'diffcore-rotate.c', + 'diff-hunks.c', 'dir-iterator.c', 'dir.c', 'editor.c', @@ -617,6 +618,7 @@ builtin_sources = [ 'builtin/describe.c', 'builtin/diagnose.c', 'builtin/diff-files.c', + 'builtin/diff-hunks.c', 'builtin/diff-index.c', 'builtin/diff-pairs.c', 'builtin/diff-tree.c', diff --git a/odb.c b/odb.c index cf6e7938c01e56..b300cd522d4d68 100644 --- a/odb.c +++ b/odb.c @@ -2,6 +2,7 @@ #include "abspath.h" #include "commit-graph.h" #include "config.h" +#include "diff-hunks.h" #include "dir.h" #include "environment.h" #include "gettext.h" @@ -1033,6 +1034,7 @@ void odb_close(struct object_database *o) for (source = o->sources; source; source = source->next) odb_source_close(source); close_commit_graph(o); + close_diff_hunks_store(o); } static void odb_free_sources(struct object_database *o) diff --git a/odb.h b/odb.h index 94754643d24997..e0f74ccbd11b48 100644 --- a/odb.h +++ b/odb.h @@ -8,6 +8,7 @@ #include "thread-utils.h" struct cached_object_entry; +struct diff_hunks_store; struct odb_source_inmemory; struct packed_git; struct repository; @@ -75,6 +76,9 @@ struct object_database { struct commit_graph *commit_graph; unsigned commit_graph_attempted : 1; /* if loading has been attempted */ + struct diff_hunks_store *diff_hunks_store; + unsigned diff_hunks_store_attempted : 1; /* if loading has been attempted */ + /* * This is meant to hold a *small* number of objects that you would * want odb_read_object() to be able to return, but yet you do not want diff --git a/repo-settings.c b/repo-settings.c index f3be3b8c5a3d09..c3015356ba81e6 100644 --- a/repo-settings.c +++ b/repo-settings.c @@ -77,6 +77,7 @@ void prepare_repo_settings(struct repository *r) repo_cfg_bool(r, "pack.usesparse", &r->settings.pack_use_sparse, 1); repo_cfg_bool(r, "pack.usepathwalk", &r->settings.pack_use_path_walk, 0); repo_cfg_bool(r, "core.multipackindex", &r->settings.core_multi_pack_index, 1); + repo_cfg_bool(r, "core.diffhunks", &r->settings.core_diff_hunks, 1); repo_cfg_bool(r, "index.sparse", &r->settings.sparse_index, 0); repo_cfg_bool(r, "index.skiphash", &r->settings.index_skip_hash, r->settings.index_skip_hash); repo_cfg_bool(r, "pack.readreverseindex", &r->settings.pack_read_reverse_index, 1); diff --git a/repo-settings.h b/repo-settings.h index e5253ead025c83..615a55cac49b1c 100644 --- a/repo-settings.h +++ b/repo-settings.h @@ -22,6 +22,7 @@ struct repo_settings { int core_commit_graph; int commit_graph_generation_version; int commit_graph_changed_paths_version; + int core_diff_hunks; int gc_write_commit_graph; int fetch_write_commit_graph; int command_requires_full_index; diff --git a/write-or-die.h b/write-or-die.h index ff0408bd849fd8..35ed324307a21b 100644 --- a/write-or-die.h +++ b/write-or-die.h @@ -22,13 +22,15 @@ enum fsync_component { FSYNC_COMPONENT_INDEX = 1 << 4, FSYNC_COMPONENT_REFERENCE = 1 << 5, FSYNC_COMPONENT_OBJECT_MAP = 1 << 6, + FSYNC_COMPONENT_DIFF_HUNKS = 1 << 7, }; #define FSYNC_COMPONENTS_OBJECTS (FSYNC_COMPONENT_LOOSE_OBJECT | \ FSYNC_COMPONENT_PACK) #define FSYNC_COMPONENTS_DERIVED_METADATA (FSYNC_COMPONENT_PACK_METADATA | \ - FSYNC_COMPONENT_COMMIT_GRAPH) + FSYNC_COMPONENT_COMMIT_GRAPH | \ + FSYNC_COMPONENT_DIFF_HUNKS) #define FSYNC_COMPONENTS_DEFAULT ((FSYNC_COMPONENTS_OBJECTS | \ FSYNC_COMPONENTS_DERIVED_METADATA) & \ @@ -46,7 +48,8 @@ enum fsync_component { FSYNC_COMPONENT_COMMIT_GRAPH | \ FSYNC_COMPONENT_INDEX | \ FSYNC_COMPONENT_REFERENCE | \ - FSYNC_COMPONENT_OBJECT_MAP) + FSYNC_COMPONENT_OBJECT_MAP | \ + FSYNC_COMPONENT_DIFF_HUNKS) #ifndef FSYNC_COMPONENTS_PLATFORM_DEFAULT #define FSYNC_COMPONENTS_PLATFORM_DEFAULT FSYNC_COMPONENTS_DEFAULT From 48ffde35a020a0be5208d618faa402757db43c56 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 23 Jul 2026 16:56:17 -0700 Subject: [PATCH 3/5] diff: record and read precomputed hunks for stat output Teach builtin_diffstat() to use the diff-hunks store. On a store hit it sums the recorded hunk counts instead of decompressing the blobs and running xdiff; on a miss it computes the diff as before, and when a writer is attached it records what it computed. It records both the zero-context hunks (what blame reads) and the untrimmed hunks keyed by the configured context (what diffstat sums), so one pass serves both readers. "git diff", "git log", "git show", and "git diff-tree" with the --stat, --numstat, and --shortstat formats read the repository's store directly (repo_diff_hunks_store()); when writing is enabled they also attach a writer, then flush it when the walk finishes. This is how the store is populated: a warming run such as GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null fills the cache as a side effect of the diff work the command already does. Reading is controlled by core.diffHunks and writing by diffHunks.write and GIT_DIFF_HUNKS_WRITE. A read-path hit is invisible in the output, so it is counted and emitted as a trace2 "read-hits" datum for tests and tuning. diff_hunks_settings_from_diffopt() is the single place that projects a diff_options down to the settings that key the store. Inputs that perturb the hunks outside the key (-B, -I, --anchored, and --ignore-blank-lines) disable both lookup and recording, so output stays identical to a store-less run. --- builtin/diff-tree.c | 3 + builtin/diff.c | 10 ++ builtin/log.c | 7 ++ diff.c | 247 ++++++++++++++++++++++++++++++++++++++++---- diff.h | 34 ++++++ 5 files changed, 280 insertions(+), 21 deletions(-) diff --git a/builtin/diff-tree.c b/builtin/diff-tree.c index 8b8f8b54e40664..296c6a137e23c6 100644 --- a/builtin/diff-tree.c +++ b/builtin/diff-tree.c @@ -170,6 +170,8 @@ int cmd_diff_tree(int argc, opt->diffopt.rotate_to_strict = 1; + diff_hunks_attach(&opt->diffopt); + /* * NOTE! We expect "a..b" to expand to "^a b" but it is * perfectly valid for revision range parser to yield "b ^a", @@ -234,5 +236,6 @@ int cmd_diff_tree(int argc, diff_free(&opt->diffopt); } + diff_hunks_detach(&opt->diffopt); return diff_result_code(opt); } diff --git a/builtin/diff.c b/builtin/diff.c index 4b46e394cecb8d..931d9d8bbe8f39 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -568,6 +568,15 @@ int cmd_diff(int argc, } } + /* + * The hunk store is keyed by blob pair, so any diff whose + * file pairs carry known blob object IDs (tree-to-tree, + * index-to-tree) can consult the same entries that + * "git log --stat" and "git blame" use; pairs without known + * blobs bypass it at lookup time. + */ + diff_hunks_attach(&rev.diffopt); + symdiff_prepare(&rev, &sdiff); for (i = 0; i < rev.pending.nr; i++) { struct object_array_entry *entry = &rev.pending.objects[i]; @@ -644,6 +653,7 @@ int cmd_diff(int argc, result = diff_result_code(&rev); if (1 < rev.diffopt.skip_stat_unmatch) refresh_index_quietly(); + diff_hunks_detach(&rev.diffopt); release_revisions(&rev); object_array_clear(&ent); symdiff_release(&sdiff); diff --git a/builtin/log.c b/builtin/log.c index 350b35c556362d..6903bdd5fff055 100644 --- a/builtin/log.c +++ b/builtin/log.c @@ -693,8 +693,11 @@ int cmd_show(int argc, opt.tweak = show_setup_revisions_tweak; cmd_log_init(argc, argv, prefix, &rev, &opt, &cfg); + diff_hunks_attach(&rev.diffopt); + if (!rev.no_walk) { ret = cmd_log_walk(&rev); + diff_hunks_detach(&rev.diffopt); release_revisions(&rev); log_config_release(&cfg); return ret; @@ -767,6 +770,7 @@ int cmd_show(int argc, } rev.diffopt.no_free = 0; + diff_hunks_detach(&rev.diffopt); diff_free(&rev.diffopt); release_revisions(&rev); log_config_release(&cfg); @@ -846,8 +850,11 @@ int cmd_log(int argc, opt.tweak = log_setup_revisions_tweak; cmd_log_init(argc, argv, prefix, &rev, &opt, &cfg); + diff_hunks_attach(&rev.diffopt); + ret = cmd_log_walk(&rev); + diff_hunks_detach(&rev.diffopt); release_revisions(&rev); log_config_release(&cfg); return ret; diff --git a/diff.c b/diff.c index 2a9d0d86871139..747fe9553d846f 100644 --- a/diff.c +++ b/diff.c @@ -16,6 +16,7 @@ #include "revision.h" #include "quote.h" #include "diff.h" +#include "diff-hunks.h" #include "diffcore.h" #include "delta.h" #include "hex.h" @@ -34,6 +35,7 @@ #include "tmp-objdir.h" #include "graph.h" #include "oid-array.h" +#include "trace2.h" #include "packfile.h" #include "pager.h" #include "parse-options.h" @@ -2819,6 +2821,102 @@ static struct diffstat_file *diffstat_add(struct diffstat_t *diffstat, return x; } +struct diffstat_hunk_cb_data { + struct precomputed_hunk **h; + size_t *nr, *alloc; +}; + +/* + * Hunk callback that appends each hunk's coordinates to a growable + * array, so one xdiff pass can both sum a diffstat and record hunks for + * the store. + */ +static int diffstat_hunk_cb(long start_a, long count_a, + long start_b, long count_b, + void *cb_data) +{ + struct diffstat_hunk_cb_data *d = cb_data; + + ALLOC_GROW(*d->h, *d->nr + 1, *d->alloc); + (*d->h)[*d->nr].old_start = start_a; + (*d->h)[*d->nr].old_count = count_a; + (*d->h)[*d->nr].new_start = start_b; + (*d->h)[*d->nr].new_count = count_b; + (*d->nr)++; + return 0; +} + +/* + * Collect the hunks of the two files at zero context. diff_fn chooses + * whether trimming runs: xdi_diff applies trim_common_tail, yielding the + * zero-context hunks blame reads; xdl_diff does not, yielding the + * untrimmed hunks. Both run at zero context, so the untrimmed hunks are + * not grouped the way a nonzero context would group them; diffstat only + * sums their counts, which grouping does not change. Sets *ph (caller + * frees) and *ph_nr. + */ +typedef int (*xdiff_fn)(mmfile_t *, mmfile_t *, xpparam_t const *, + xdemitconf_t const *, xdemitcb_t *); +static int collect_hunks(xdiff_fn diff_fn, mmfile_t *mf1, mmfile_t *mf2, + xpparam_t *xpp, struct precomputed_hunk **ph, + size_t *ph_nr) +{ + size_t ph_alloc = 0; + xdemitcb_t ecb = { 0 }; + xdemitconf_t xecfg = { 0 }; + struct diffstat_hunk_cb_data cd = { ph, ph_nr, &ph_alloc }; + + *ph = NULL; + *ph_nr = 0; + xecfg.hunk_func = diffstat_hunk_cb; + ecb.priv = &cd; + return diff_fn(mf1, mf2, xpp, &xecfg, &ecb); +} + +static void diff_hunks_settings_from_diffopt(struct diff_hunks_settings *ds, + const struct diff_options *o) +{ + /* + * xpparam_t is the diff algorithm's input. Its flags become the + * key's xdl_opts (below); ignore_regex (-I) and anchors (--anchored) + * are instead excluded by builtin_diffstat()'s "storable" guard. + * + * Adding an xpparam_t field fires this assert (its size no longer + * matches the reference struct). To clear it: (1) add the field to + * the reference struct below; then (2) decide how it affects the + * key -- capture it in struct diff_hunks_settings here, or exclude + * diffs that use it in the "storable" guard. The assert only tracks + * size: a same-size reorder or a changed field meaning slips past, + * so re-read the fields when it fires. + */ + (void)BUILD_ASSERT_OR_ZERO(sizeof(xpparam_t) == sizeof(struct { + unsigned long flags; + regex_t **ignore_regex; + size_t ignore_regex_nr; + char **anchors; + size_t anchors_nr; + })); + ds->xdl_opts = o->xdl_opts; + ds->context = o->context; +} + +void diff_hunks_attach(struct diff_options *o) +{ + if (!(o->output_format & + (DIFF_FORMAT_DIFFSTAT | DIFF_FORMAT_SHORTSTAT | DIFF_FORMAT_NUMSTAT))) + return; + o->hunks_writer = diff_hunks_writer_maybe_new(o->repo); +} + +void diff_hunks_detach(struct diff_options *o) +{ + if (o->hunks_read_hits) + trace2_data_intmax("diff-hunks", o->repo, "read-hits", + o->hunks_read_hits); + diff_hunks_writer_finish(o->hunks_writer); + o->hunks_writer = NULL; +} + static int diffstat_consume(void *priv, char *line, unsigned long len) { struct diffstat_t *diffstat = priv; @@ -4179,6 +4277,105 @@ static const char *get_compact_summary(const struct diff_filepair *p, int is_ren return NULL; } +/* + * Fill data->added/deleted for a modified pair from the diff-hunks store: on a + * read hit, sum the recorded counts; on a warming run, compute and record them. + * Returns 1 when it produced the counts, 0 when the store is not usable for this + * pair and the caller must compute the diffstat itself. + * + * The store is keyed by (old blob, new blob) and the xdiff settings, so it may + * only serve or receive pairs whose result is determined by those alone. Inputs + * that perturb the hunks outside the settings (-B, -I, --anchored) must be off. + * --ignore-blank-lines coalesces hunks differently between the emit and + * hunk-callback paths, so it is excluded to keep output identical to a + * store-less run. (--inter-hunk-context is not excluded: it only groups hunks, + * and diffstat sums their counts, which grouping does not change.) Both sides + * must be valid regular files with known blob IDs. blame applies the same rule + * to its own perturbing inputs. The -I and --anchored exclusions here, plus + * xdl_opts in the key, cover every field of xpparam_t; + * diff_hunks_settings_from_diffopt() asserts that at compile time. + */ +static int diffstat_from_hunks(struct diff_options *o, + struct diff_filespec *one, + struct diff_filespec *two, + struct diffstat_file *data) +{ + struct diff_hunks_store *store = repo_diff_hunks_store(o->repo); + struct diff_hunks_settings full_ds; + struct diff_hunks_settings trim_ds = { o->xdl_opts, 0 }; + struct precomputed_hunk *ph_trim, *ph_full, *counts; + size_t n_trim, n_full, n_counts, k; + mmfile_t mf1, mf2; + xpparam_t xpp; + + if (!((store || o->hunks_writer) && + o->break_opt == -1 && + !o->ignore_regex_nr && + !o->anchors_nr && + !(o->xdl_opts & XDF_IGNORE_BLANK_LINES) && + DIFF_FILE_VALID(one) && DIFF_FILE_VALID(two) && + S_ISREG(one->mode) && S_ISREG(two->mode) && + one->oid_valid && two->oid_valid)) + return 0; + + diff_hunks_settings_from_diffopt(&full_ds, o); + + /* + * On a store hit, sum hunk counts directly without decompressing blobs + * or running xdiff. The lookup keys on the current diff_options, so a + * hit carries the hunks a store-less run would have produced. + */ + if (store && diff_hunks_store_sum(store, &one->oid, &two->oid, &full_ds, + &data->added, &data->deleted)) { + o->hunks_read_hits++; + return 1; + } + + /* A miss on a read-only run: let the caller compute the diffstat. */ + if (!o->hunks_writer) + return 0; + + if (fill_mmfile(o->repo, &mf1, one) < 0 || + fill_mmfile(o->repo, &mf2, two) < 0) + die("unable to read files to diff"); + memset(&xpp, 0, sizeof(xpp)); + xpp.flags = o->xdl_opts; + xpp.ignore_regex = o->ignore_regex; + xpp.ignore_regex_nr = o->ignore_regex_nr; + xpp.anchors = o->anchors; + xpp.anchors_nr = o->anchors_nr; + + /* + * Record the zero-context diff (what blame computes) and, at a nonzero + * context, the untrimmed diff keyed by that context (what diffstat sums). + * xdi_diff runs first: it enforces the size limit, so the xdl_diff call + * is already bounded. + */ + if (collect_hunks(xdi_diff, &mf1, &mf2, &xpp, &ph_trim, &n_trim) || + collect_hunks(xdl_diff, &mf1, &mf2, &xpp, &ph_full, &n_full)) + die("unable to generate diffstat for %s", one->path); + + /* + * Match a store-less run: at zero context xdi_diff trims, so sum the + * trimmed diff; otherwise sum the untrimmed one. + */ + counts = o->context ? ph_full : ph_trim; + n_counts = o->context ? n_full : n_trim; + for (k = 0; k < n_counts; k++) { + data->added += counts[k].new_count; + data->deleted += counts[k].old_count; + } + + diff_hunks_writer_add(o->hunks_writer, &one->oid, &two->oid, + &trim_ds, ph_trim, n_trim); + if (o->context) + diff_hunks_writer_add(o->hunks_writer, &one->oid, &two->oid, + &full_ds, ph_full, n_full); + free(ph_trim); + free(ph_full); + return 1; +} + static void builtin_diffstat(const char *name_a, const char *name_b, struct diff_filespec *one, struct diff_filespec *two, @@ -4230,27 +4427,35 @@ static void builtin_diffstat(const char *name_a, const char *name_b, } else if (may_differ) { - /* Crazy xdl interfaces.. */ - xpparam_t xpp; - xdemitconf_t xecfg; - - if (fill_mmfile(o->repo, &mf1, one) < 0 || - fill_mmfile(o->repo, &mf2, two) < 0) - die("unable to read files to diff"); - - memset(&xpp, 0, sizeof(xpp)); - memset(&xecfg, 0, sizeof(xecfg)); - xpp.flags = o->xdl_opts; - xpp.ignore_regex = o->ignore_regex; - xpp.ignore_regex_nr = o->ignore_regex_nr; - xpp.anchors = o->anchors; - xpp.anchors_nr = o->anchors_nr; - xecfg.ctxlen = o->context; - xecfg.interhunkctxlen = o->interhunkcontext; - xecfg.flags = XDL_EMIT_NO_HUNK_HDR; - if (xdi_diff_outf(&mf1, &mf2, NULL, - diffstat_consume, diffstat, &xpp, &xecfg)) - die("unable to generate diffstat for %s", one->path); + /* + * Serve or record via the diff-hunks store; otherwise diff + * normally. + */ + if (!diffstat_from_hunks(o, one, two, data)) { + /* Crazy xdl interfaces.. */ + xpparam_t xpp; + xdemitconf_t xecfg; + + if (fill_mmfile(o->repo, &mf1, one) < 0 || + fill_mmfile(o->repo, &mf2, two) < 0) + die("unable to read files to diff"); + + memset(&xpp, 0, sizeof(xpp)); + memset(&xecfg, 0, sizeof(xecfg)); + xpp.flags = o->xdl_opts; + xpp.ignore_regex = o->ignore_regex; + xpp.ignore_regex_nr = o->ignore_regex_nr; + xpp.anchors = o->anchors; + xpp.anchors_nr = o->anchors_nr; + xecfg.ctxlen = o->context; + xecfg.interhunkctxlen = o->interhunkcontext; + xecfg.flags = XDL_EMIT_NO_HUNK_HDR; + if (xdi_diff_outf(&mf1, &mf2, NULL, + diffstat_consume, diffstat, + &xpp, &xecfg)) + die("unable to generate diffstat for %s", + one->path); + } if (DIFF_FILE_VALID(one) && DIFF_FILE_VALID(two)) { struct diffstat_file *file = diff --git a/diff.h b/diff.h index bb5cddaf3499e9..1098b2db082823 100644 --- a/diff.h +++ b/diff.h @@ -224,6 +224,17 @@ static inline void diff_flags_or(struct diff_flags *a, #define DIFF_WITH_ALG(opts, flag) (((opts)->xdl_opts & ~XDF_DIFF_ALGORITHM_MASK) | XDF_##flag) +/* + * The xdl_opts bits git turns on by default that a from-scratch xdl_opts + * (git blame's own option parsing) does not set, and so must OR in to match + * a store warmed at the default diff settings; a diff_options-based consumer + * (diffstat) already has them in o->xdl_opts. Today this is only the indent + * heuristic. It does NOT cover a non-default diff.algorithm: a repo that + * configures one records under that algorithm, and a consumer keying without + * it misses (a lost hit, not wrong output). + */ +#define DIFF_HUNKS_DEFAULT_XDL_OPTS XDF_INDENT_HEURISTIC + enum diff_words_type { DIFF_WORDS_NONE = 0, DIFF_WORDS_PORCELAIN, @@ -420,6 +431,18 @@ struct diff_options { */ int max_depth; int max_depth_valid; + + /* + * Precomputed diff hunks (see diff-hunks.h). diffstat consults the + * repository's store, repo_diff_hunks_store(), before running xdiff, + * keyed by each file pair's blob object IDs. When hunks_writer is set + * (a warming run), diffstat also records the hunks it computes; the + * writer is attached only for the stat output formats. hunks_read_hits + * counts store hits on the read path, emitted via trace2 so tests can + * confirm the reader is consulted. + */ + struct diff_hunks_writer *hunks_writer; + int hunks_read_hits; }; unsigned diff_filter_bit(char status); @@ -668,6 +691,17 @@ void diffcore_fix_diff_index(void); int diff_queue_is_empty(struct diff_options *o); void diff_flush(struct diff_options*); void diff_free(struct diff_options*); + +/* + * Attach a diff-hunks writer to a diff producing a stat format, so a + * warming run records the hunks it computes; a no-op when writing is off + * or for other formats. (Reads consult the repository's store directly; + * see repo_diff_hunks_store() in diff-hunks.h.) Pair with + * diff_hunks_detach() once the diff is done. + */ +void diff_hunks_attach(struct diff_options *o); +void diff_hunks_detach(struct diff_options *o); + void diff_warn_rename_limit(const char *varname, int needed, int degraded_cc); /* diff-raw status letters */ From 17a5f95e5a06eb7ecc38804aa2af77283d85d41d Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Wed, 22 Jul 2026 21:43:41 -0700 Subject: [PATCH 4/5] blame: read precomputed hunks Before diffing a target blob against a parent, consult the diff-hunks store for the pair via diff_hunks_emit(), added here: it replays a recorded pair's hunks through a caller's hunk callback without loading the blobs, or fills them and runs xdiff on a miss. Blame passes its blame_chunk_cb, so the lookup, replay, and fallback stay in the store library rather than in blame. Blame diffs at zero context, so it loads the store under those settings and is served the zero-context hunks a "--stat" warming run records. It skips the store where its diff is not the plain blob-pair diff the key describes: reverse blame, ignored revisions, and textconv paths. Whitespace and algorithm options such as -w instead change blame's xdl_opts, so the lookup keys a different entry and misses a store warmed without them. "--show-stats" reports how many pairs were served, for tests and tuning. --- blame.c | 104 ++++++++++++++++++++++++++++++++++++++++++------ blame.h | 2 + builtin/blame.c | 4 +- diff-hunks.c | 65 ++++++++++++++++++++++++++++++ diff-hunks.h | 18 +++++++++ 5 files changed, 179 insertions(+), 14 deletions(-) diff --git a/blame.c b/blame.c index 126e2324162353..a06f1228e9e691 100644 --- a/blame.c +++ b/blame.c @@ -23,6 +23,8 @@ #include "commit-slab.h" #include "bloom.h" #include "commit-graph.h" +#include "diff-hunks.h" +#include "userdiff.h" define_commit_slab(blame_suspects, struct blame_origin *); static struct blame_suspects blame_suspects; @@ -314,8 +316,8 @@ static struct commit *fake_working_tree_commit(struct repository *r, -static int diff_hunks(mmfile_t *file_a, mmfile_t *file_b, - xdl_emit_hunk_consume_func_t hunk_func, void *cb_data, int xdl_opts) +static int xdiff_hunks(mmfile_t *file_a, mmfile_t *file_b, + xdl_emit_hunk_consume_func_t hunk_func, void *cb_data, int xdl_opts) { xpparam_t xpp = {0}; xdemitconf_t xecfg = {0}; @@ -1936,6 +1938,60 @@ static int blame_chunk_cb(long start_a, long count_a, return 0; } + +/* + * The hunk store is keyed by the (old blob, new blob) pair and may + * only be consulted for a diff whose result is determined by that + * pair and the xdiff settings. Textconv rewrites the buffers being + * diffed away from the blob contents the key names, so any origin + * whose path has a textconv driver must bypass the store. + */ +static int blame_textconv_active(struct blame_scoreboard *sb, + const char *path) +{ + struct userdiff_driver *drv; + + if (!sb->revs->diffopt.flags.allow_textconv) + return 0; + drv = userdiff_find_by_path(sb->repo->index, path); + return drv && drv->textconv; +} + +static int hunks_store_usable(struct blame_scoreboard *sb, + struct blame_origin *target, + struct blame_origin *parent, + int ignore_diffs) +{ + return repo_diff_hunks_store(sb->repo) && + !sb->reverse && + !ignore_diffs && + !blame_textconv_active(sb, target->path) && + !blame_textconv_active(sb, parent->path); +} + +/* + * Try to use precomputed diff hunks instead of running xdiff. + * Returns 1 on a store hit (blame fully passed to parent), 0 on a miss. + */ +struct blame_diff_fill { + struct blame_scoreboard *sb; + struct blame_origin *parent, *target; + int ignore_diffs; +}; + +/* Lazy blob load for diff_hunks_emit(): only called on a store miss. */ +static int blame_diff_fill(void *data, mmfile_t *mf_old, mmfile_t *mf_new) +{ + struct blame_diff_fill *f = data; + + fill_origin_blob(&f->sb->revs->diffopt, f->parent, mf_old, + &f->sb->num_read_blob, f->ignore_diffs); + fill_origin_blob(&f->sb->revs->diffopt, f->target, mf_new, + &f->sb->num_read_blob, f->ignore_diffs); + f->sb->num_get_patch++; + return 0; +} + /* * We are looking at the origin 'target' and aiming to pass blame * for the lines it is suspected to its parent. Run diff to find @@ -1945,9 +2001,14 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb, struct blame_origin *target, struct blame_origin *parent, int ignore_diffs) { - mmfile_t file_p, file_o; struct blame_chunk_cb_data d; struct blame_entry *newdest = NULL; + struct blame_diff_fill fill = { sb, parent, target, ignore_diffs }; + struct diff_hunks_settings ds = { + .xdl_opts = sb->xdl_opts, .context = 0 + }; + struct diff_hunks_store *store; + int served; if (!target->suspects) return; /* nothing remains for this target */ @@ -1958,16 +2019,26 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb, d.ignore_diffs = ignore_diffs; d.dstq = &newdest; d.srcq = &target->suspects; - fill_origin_blob(&sb->revs->diffopt, parent, &file_p, - &sb->num_read_blob, ignore_diffs); - fill_origin_blob(&sb->revs->diffopt, target, &file_o, - &sb->num_read_blob, ignore_diffs); - sb->num_get_patch++; - - if (diff_hunks(&file_p, &file_o, blame_chunk_cb, &d, sb->xdl_opts)) + /* + * Consult the store only where blame's diff is the plain blob-pair + * diff the key describes; otherwise pass NULL to compute it. + */ + store = hunks_store_usable(sb, target, parent, ignore_diffs) ? + repo_diff_hunks_store(sb->repo) : NULL; + served = diff_hunks_emit(store, &parent->blob_oid, &target->blob_oid, + &ds, blame_diff_fill, &fill, + blame_chunk_cb, &d); + if (served < 0) die("unable to generate diff (%s -> %s)", oid_to_hex(&parent->commit->object.oid), oid_to_hex(&target->commit->object.oid)); + if (store) { + if (served) + sb->num_precomputed_hits++; + else + sb->num_precomputed_misses++; + } + /* The rest are the same as the parent */ blame_chunk(&d.dstq, &d.srcq, INT_MAX, d.offset, INT_MAX, 0, parent, target, 0); @@ -1975,8 +2046,6 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb, if (ignore_diffs) sort_blame_entries(&newdest, compare_blame_suspect); queue_blames(sb, parent, newdest); - - return; } /* @@ -2117,7 +2186,7 @@ static void find_copy_in_blob(struct blame_scoreboard *sb, * file_p partially may match that image. */ memset(split, 0, sizeof(struct blame_entry [3])); - if (diff_hunks(file_p, &file_o, handle_split_cb, &d, sb->xdl_opts)) + if (xdiff_hunks(file_p, &file_o, handle_split_cb, &d, sb->xdl_opts)) die("unable to generate diff (%s)", oid_to_hex(&parent->commit->object.oid)); /* remainder, if any, all match the preimage */ @@ -2954,4 +3023,13 @@ void cleanup_scoreboard(struct blame_scoreboard *sb) trace2_data_intmax("blame", sb->repo, "bloom/response-no", bloom_count_no); } + + if (repo_diff_hunks_store(sb->repo)) { + trace2_data_intmax("blame", sb->repo, + "precomputed/hits", + sb->num_precomputed_hits); + trace2_data_intmax("blame", sb->repo, + "precomputed/misses", + sb->num_precomputed_misses); + } } diff --git a/blame.h b/blame.h index 3b34be0e5c6932..cd478ca477dc72 100644 --- a/blame.h +++ b/blame.h @@ -132,6 +132,8 @@ struct blame_scoreboard { int num_read_blob; int num_get_patch; int num_commits; + int num_precomputed_hits; + int num_precomputed_misses; /* * blame for a blame_entry with score lower than these thresholds diff --git a/builtin/blame.c b/builtin/blame.c index 409af18f0fc1cf..509c17cb747544 100644 --- a/builtin/blame.c +++ b/builtin/blame.c @@ -1058,7 +1058,7 @@ int cmd_blame(int argc, parse_done: revision_opts_finish(&revs); no_whole_file_rename = !revs.diffopt.flags.follow_renames; - xdl_opts |= revs.diffopt.xdl_opts & XDF_INDENT_HEURISTIC; + xdl_opts |= revs.diffopt.xdl_opts & DIFF_HUNKS_DEFAULT_XDL_OPTS; revs.diffopt.flags.follow_renames = 0; argc = parse_options_end(&ctx); @@ -1316,6 +1316,8 @@ int cmd_blame(int argc, printf("num read blob: %d\n", sb.num_read_blob); printf("num get patch: %d\n", sb.num_get_patch); printf("num commits: %d\n", sb.num_commits); + printf("num precomputed hits: %d\n", sb.num_precomputed_hits); + printf("num precomputed misses: %d\n", sb.num_precomputed_misses); } cleanup: diff --git a/diff-hunks.c b/diff-hunks.c index 1aca3d16995796..406b6c857060f7 100644 --- a/diff-hunks.c +++ b/diff-hunks.c @@ -35,6 +35,7 @@ #include "repository.h" #include "strbuf.h" #include "wrapper.h" +#include "xdiff-interface.h" #define DIFF_HUNKS_SIGNATURE 0x44485046 /* "DHPF" */ /* @@ -430,6 +431,70 @@ int diff_hunks_store_sum(struct diff_hunks_store *s, return 1; } +/* + * A recorded hunk sequence is replayable if every coordinate fits in an int + * (a consumer may truncate to int) and the running old-vs-new offset stays + * consistent, as a well-formed diff's hunks always are. Coordinates decode + * from be32 into long, which is 32-bit on some platforms, so a crafted value + * can decode negative: bound each field to [0, INT32_MAX]. A store that + * fails this is treated as a miss, so the caller recomputes. + */ +static int replayable_hunks(const struct precomputed_entry *e) +{ + int64_t offset = 0; + uint32_t i; + + for (i = 0; i < e->num_hunks; i++) { + struct precomputed_hunk h; + nth_precomputed_hunk(e, i, &h); + if (h.old_start < 0 || h.old_count < 0 || + h.new_start < 0 || h.new_count < 0 || + h.old_start > INT32_MAX || h.old_count > INT32_MAX || + h.new_start > INT32_MAX || h.new_count > INT32_MAX || + h.old_start - h.new_start != offset) + return 0; + offset = (int64_t)h.old_start + h.old_count - + ((int64_t)h.new_start + h.new_count); + } + return 1; +} + +int diff_hunks_emit(struct diff_hunks_store *store, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + diff_hunks_fill_fn fill, void *fill_data, + xdl_emit_hunk_consume_func_t hunk_func, void *cb_data) +{ + mmfile_t mf_old, mf_new; + xpparam_t xpp = { 0 }; + xdemitconf_t xecfg = { 0 }; + xdemitcb_t ecb = { NULL }; + + if (store) { + struct precomputed_entry e; + if (diff_hunks_store_get(store, old_oid, new_oid, ds, &e) && + replayable_hunks(&e)) { + uint32_t i; + for (i = 0; i < e.num_hunks; i++) { + struct precomputed_hunk h; + nth_precomputed_hunk(&e, i, &h); + hunk_func(h.old_start, h.old_count, + h.new_start, h.new_count, cb_data); + } + return 1; + } + } + + if (fill(fill_data, &mf_old, &mf_new)) + return -1; + xpp.flags = ds->xdl_opts; + xecfg.ctxlen = xecfg.interhunkctxlen = ds->context; + xecfg.hunk_func = hunk_func; + ecb.priv = cb_data; + return xdi_diff(&mf_old, &mf_new, &xpp, &xecfg, &ecb) ? -1 : 0; +} + /* Validate one store file. Returns 0 if valid or absent, -1 if corrupt. */ static int verify_store_at(struct repository *r, const char *fname) { diff --git a/diff-hunks.h b/diff-hunks.h index 523e039ccab08b..5107b3fc0161d8 100644 --- a/diff-hunks.h +++ b/diff-hunks.h @@ -2,6 +2,7 @@ #define DIFF_HUNKS_H #include "hash.h" +#include "xdiff-interface.h" /* mmfile_t, xdl_emit_hunk_consume_func_t */ struct object_id; struct repository; @@ -85,6 +86,23 @@ int diff_hunks_store_sum(struct diff_hunks_store *s, const struct diff_hunks_settings *ds, uintmax_t *added, uintmax_t *deleted); +/* + * Emit the hunks of diffing (old_oid, new_oid) under ds through hunk_func. + * On a store hit (with a valid, in-range recorded diff) the hunks are + * replayed and the blobs are never loaded; otherwise fill() supplies the + * two mmfiles and xdi_diff() computes them. Returns 1 when served from the + * store, 0 when computed, and -1 on a fill or diff error. Pass store == NULL + * to always compute (e.g. when the diff is not the plain blob-pair diff the + * key describes). + */ +typedef int (*diff_hunks_fill_fn)(void *data, mmfile_t *mf_old, mmfile_t *mf_new); +int diff_hunks_emit(struct diff_hunks_store *store, + const struct object_id *old_oid, + const struct object_id *new_oid, + const struct diff_hunks_settings *ds, + diff_hunks_fill_fn fill, void *fill_data, + xdl_emit_hunk_consume_func_t hunk_func, void *cb_data); + /* * A warming run's writer: it accumulates the hunks it computes in memory * and flushes them to the store in one pass at finish. From 72da38552321cf95817c537bf0b3aa85e8fd57b0 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Tue, 21 Jul 2026 09:45:48 -0700 Subject: [PATCH 5/5] diff-hunks: check the store from fsck, and test Have "git fsck" run the diff-hunks verify so a corrupt store is reported alongside the other object-database checks. Add t4218 covering output parity with and without the store across log --stat, diff --stat, and blame at several context lengths; the write gate (off by default, the environment overriding the config); the two-tier behavior (a warm appends the overlay and leaves the base untouched, compact folds it in, reads are served from both tiers, and verify checks both); the settings that must bypass the store; and reading a truncated or corrupt file as absent. A trim-divergent fixture exercises the zero- versus nonzero-context split. Add p4218 measuring warm cost and the read speedups. --- Documentation/git-diff-hunks.adoc | 2 +- Documentation/git-fsck.adoc | 3 + Documentation/gitformat-diff-hunks.adoc | 2 +- builtin/fsck.c | 8 + t/meson.build | 1 + t/perf/p4218-diff-hunks.sh | 48 ++ t/t4218-diff-hunks.sh | 692 ++++++++++++++++++++++++ t/t4218/trim-divergent-new | 319 +++++++++++ t/t4218/trim-divergent-old | 316 +++++++++++ 9 files changed, 1389 insertions(+), 2 deletions(-) create mode 100755 t/perf/p4218-diff-hunks.sh create mode 100755 t/t4218-diff-hunks.sh create mode 100644 t/t4218/trim-divergent-new create mode 100644 t/t4218/trim-divergent-old diff --git a/Documentation/git-diff-hunks.adoc b/Documentation/git-diff-hunks.adoc index 0f4025574048bc..225b1b8fb2031e 100644 --- a/Documentation/git-diff-hunks.adoc +++ b/Documentation/git-diff-hunks.adoc @@ -106,7 +106,7 @@ re-checksum it on every load. As with the commit-graph and multi-pack-index, the writer fsyncs the file (honoring `core.fsync`) and commits it atomically, so a committed store is intact; every offset and count is still bounds-checked as it is read. The checksum is verified by -`git diff-hunks verify`, not on the read path. +`git diff-hunks verify` and by linkgit:git-fsck[1], not on the read path. CONFIGURATION ------------- diff --git a/Documentation/git-fsck.adoc b/Documentation/git-fsck.adoc index 1751f692d42b8c..b5a94482a63778 100644 --- a/Documentation/git-fsck.adoc +++ b/Documentation/git-fsck.adoc @@ -136,6 +136,9 @@ the hopes that somebody else has the object you have corrupted). If core.commitGraph is true, the commit-graph file will also be inspected using 'git commit-graph verify'. See linkgit:git-commit-graph[1]. +If core.diffHunks is true, the diff-hunks store will also be inspected +using 'git diff-hunks verify'. See linkgit:git-diff-hunks[1]. + Extracted Diagnostics --------------------- diff --git a/Documentation/gitformat-diff-hunks.adoc b/Documentation/gitformat-diff-hunks.adoc index 476291fd7096d5..cbf5171df62121 100644 --- a/Documentation/gitformat-diff-hunks.adoc +++ b/Documentation/gitformat-diff-hunks.adoc @@ -120,7 +120,7 @@ The store is not re-checksummed on the read path. The writer fsyncs the file (honoring `core.fsync`) and commits it atomically, so a committed store is intact, the same trust model the commit-graph and multi-pack-index use. The trailing checksum is recomputed by -`git diff-hunks verify` to detect corruption. +`git diff-hunks verify` and linkgit:git-fsck[1] to detect corruption. The checksum detects corruption but does not prove who wrote the file. A reader trusts the coordinates in a store that passes its checks, so diff --git a/builtin/fsck.c b/builtin/fsck.c index 52c33c9209f3c1..48372e4188b93c 100644 --- a/builtin/fsck.c +++ b/builtin/fsck.c @@ -3,6 +3,7 @@ #include "hex.h" #include "config.h" #include "commit.h" +#include "diff-hunks.h" #include "tree.h" #include "blob.h" #include "tag.h" @@ -59,6 +60,7 @@ static timestamp_t now; #define ERROR_MULTI_PACK_INDEX 040 #define ERROR_PACK_REV_INDEX 0100 #define ERROR_BITMAP 0200 +#define ERROR_DIFF_HUNKS 0400 static const char *describe_object(const struct object_id *oid) { @@ -1187,6 +1189,11 @@ int cmd_fsck(int argc, } } + if (repo->settings.core_diff_hunks) { + if (diff_hunks_verify(repo)) + errors_found |= ERROR_DIFF_HUNKS; + } + if (repo->settings.core_multi_pack_index) { struct child_process midx_verify = CHILD_PROCESS_INIT; @@ -1206,5 +1213,6 @@ int cmd_fsck(int argc, } free_snapshot_refs(&snap); + return fsck_exit_status(errors_found); } diff --git a/t/meson.build b/t/meson.build index 8ae6ab6c5fe1e2..657643cf18db6b 100644 --- a/t/meson.build +++ b/t/meson.build @@ -582,6 +582,7 @@ integration_tests = [ 't4215-log-skewed-merges.sh', 't4216-log-bloom.sh', 't4217-log-limit.sh', + 't4218-diff-hunks.sh', 't4219-log-follow-merge.sh', 't4252-am-options.sh', 't4253-am-keep-cr-dos.sh', diff --git a/t/perf/p4218-diff-hunks.sh b/t/perf/p4218-diff-hunks.sh new file mode 100755 index 00000000000000..a96bbd9f6c0eba --- /dev/null +++ b/t/perf/p4218-diff-hunks.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +test_description='diff-hunks store performance' +. ./perf-lib.sh + +test_perf_default_repo + +# Pick a file to blame pseudo-randomly. The sort key is the blob +# hash, so it is stable. +test_expect_success 'select a file' ' + git ls-tree HEAD | grep ^100644 | + sort -k 3 | head -n 1 | cut -f 2 >filelist +' + +file=$(cat filelist) +export file + +# Warm the store the way an owner would: a stat walk with writing on. +test_perf 'warm the store' ' + git diff-hunks clear && + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null +' + +test_expect_success 'warm the store' ' + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null +' + +test_perf 'log --stat -1000 (store)' ' + git log --stat -1000 >/dev/null +' + +test_perf 'log --stat -1000 (no store)' ' + git -c core.diffhunks=false log --stat -1000 >/dev/null +' + +test_perf 'blame $file (store)' ' + git blame "$file" >/dev/null +' + +test_perf 'blame $file (no store)' ' + git -c core.diffhunks=false blame "$file" >/dev/null +' + +test_expect_success 'clean up store' ' + git diff-hunks clear +' + +test_done diff --git a/t/t4218-diff-hunks.sh b/t/t4218-diff-hunks.sh new file mode 100755 index 00000000000000..5a00a7154e7ef2 --- /dev/null +++ b/t/t4218-diff-hunks.sh @@ -0,0 +1,692 @@ +#!/bin/sh + +test_description='precomputed diff hunks store (git diff-hunks) + +The store maps an (old blob, new blob, diff settings) key to the hunks of +diffing the pair. It is a cache: reading is always on, while writing is +off by default and enabled per run by GIT_DIFF_HUNKS_WRITE (or the +diffHunks.write config), so a diff or log warms the store only when the +owner opts in. These tests check that a warmed store never changes +output, that lookups honor the diff settings, and that a corrupt store is +read as absent while verify and fsck report the corruption.' + +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME + +. ./test-lib.sh + +STORE=.git/objects/diff-hunks +OVERLAY=.git/objects/diff-hunks-overlay + +# Warm the store the way a repository owner would: a stat walk with +# writing enabled (which appends to the overlay tier), then compact so +# the result is a single consolidated base file. A --stat walk records +# both the zero-context hunks blame reads and the default-context hunks +# diffstat sums. Extra arguments (e.g. -c options) are passed to git +# before "log". The overlay tier itself is exercised by the two-tier +# tests, which warm without compacting. +warm () { + GIT_DIFF_HUNKS_WRITE=1 git "$@" log --all --stat >/dev/null && + git diff-hunks compact +} + +# Run a command with the store disabled, for ground truth. +no_store () { + git -c core.diffhunks=false "$@" +} + +test_expect_success 'setup' ' + test_commit initial file.txt "line 1" && + test_commit second file.txt "line 1 +line 2" && + test_commit third file.txt "line 1 +line 2 +line 3" && + test_commit fourth file.txt "changed line 1 +line 2 +line 3 +line 4" +' + +test_expect_success 'ordinary commands do not create the store' ' + git log --stat >/dev/null && + git blame file.txt >/dev/null && + git diff --stat second third >/dev/null && + test_path_is_missing $STORE && + test_path_is_missing $OVERLAY +' + +test_expect_success 'writing is gated by env and config, env wins' ' + test_when_finished "git diff-hunks clear" && + # The diffHunks.write config enables writing (into the overlay tier). + git -c diffHunks.write=true log --all --stat >/dev/null && + test_path_is_file $OVERLAY && + git diff-hunks clear && + # GIT_DIFF_HUNKS_WRITE overrides the config: 0 disables it. + GIT_DIFF_HUNKS_WRITE=0 git -c diffHunks.write=true log --all --stat >/dev/null && + test_path_is_missing $OVERLAY && + # and enables it without any config. + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file $OVERLAY +' + +test_expect_success 'a warm then compact builds a consolidated store' ' + warm && + test_path_is_file $STORE && + test_path_is_missing $OVERLAY && + git diff-hunks verify +' + +test_expect_success 'two-tier: warm appends the overlay, compact folds it in' ' + git init two-tier && + ( + cd two-tier && + test_commit t1 f.txt "1" && + test_commit t2 f.txt "1 +2" && + test_commit t3 f.txt "1 +2 +3" && + + # A raw warm writes the overlay and no base. + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file .git/objects/diff-hunks-overlay && + test_path_is_missing .git/objects/diff-hunks && + + # Compact folds it into the base and removes the overlay. + git diff-hunks compact && + test_path_is_file .git/objects/diff-hunks && + test_path_is_missing .git/objects/diff-hunks-overlay && + git diff-hunks verify && + + # A later warm appends a new overlay and leaves the base intact. + test_commit t4 f.txt "1 +2 +3 +4" && + base=$(test_file_size .git/objects/diff-hunks) && + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file .git/objects/diff-hunks-overlay && + test "$base" = "$(test_file_size .git/objects/diff-hunks)" && + + # Reads are served from both tiers; output is unchanged, and + # blame (which reads the zero-context entries) hits. + git -c core.diffhunks=false log --stat >expect && + git log --stat >actual && + test_cmp expect actual && + git blame --show-stats f.txt >out 2>&1 && + test_grep "num precomputed hits: [1-9]" out + ) +' + +test_expect_success 'two-tier: verify checks the overlay, compact is a no-op when clean' ' + git init two-tier-verify && + ( + cd two-tier-verify && + test_commit v1 f.txt "1" && + test_commit v2 f.txt "1 +2" && + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file .git/objects/diff-hunks-overlay && + + # compact with an overlay consolidates; a second compact with no + # overlay must not rewrite the base. + git diff-hunks compact && + base=$(test_file_size .git/objects/diff-hunks) && + git diff-hunks compact && + test_path_is_missing .git/objects/diff-hunks-overlay && + test "$base" = "$(test_file_size .git/objects/diff-hunks)" && + + # A new commit gives the next warm a pair to append, since a + # warm that records nothing writes no overlay. + test_commit v3 f.txt "1 +2 +3" && + + # A corrupt overlay is caught by verify. + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file .git/objects/diff-hunks-overlay && + sz=$(test_file_size .git/objects/diff-hunks-overlay) && + printf "\\377" | dd of=.git/objects/diff-hunks-overlay bs=1 \ + seek=$((sz / 2)) count=1 conv=notrunc 2>/dev/null && + test_must_fail git diff-hunks verify + ) +' + +test_expect_success 'a second warming run refreshes the store in place' ' + warm && + test_commit fifth file.txt "brand new line" && + warm && + git diff-hunks verify && + no_store log --stat >expect && + git log --stat >actual && + test_cmp expect actual +' + +test_expect_success 'core.diffhunks=false disables lookups' ' + warm && + git -c core.diffhunks=false blame --show-stats file.txt >out 2>&1 && + test_grep "num precomputed hits: 0" out +' + +# Writing seeds from the current store and merges into it, so a later +# warming run keeps the entries an earlier one recorded rather than +# rebuilding. Warm one pair, then a different pair, and confirm the first +# is still served. +test_expect_success 'a later warming run preserves earlier entries' ' + git init incr && + ( + cd incr && + test_commit a1 f.txt "1" && + test_commit a2 f.txt "1 +2" && + test_commit a3 f.txt "1 +2 +3" && + GIT_DIFF_HUNKS_WRITE=1 git diff --stat a1 a2 >/dev/null && + git diff-hunks verify && + GIT_DIFF_HUNKS_WRITE=1 git diff --stat a2 a3 >/dev/null && + git diff-hunks verify && + + # Blaming as of a2 diffs the a1..a2 pair. If seeding had + # dropped it when the a2..a3 pair was warmed, this would + # report zero precomputed hits. + git blame --show-stats a2 -- f.txt >out 2>&1 && + test_grep "num precomputed hits: [1-9]" out && + + no_store log --stat >expect && + git log --stat >actual && + test_cmp expect actual + ) +' + +test_expect_success 'log --stat matches with and without the store' ' + no_store log --stat >expect && + warm && + git log --stat >actual && + test_cmp expect actual +' + +test_expect_success 'log --numstat and --shortstat match' ' + no_store log --numstat >expect_num && + no_store log --shortstat >expect_short && + warm && + git log --numstat >actual_num && + git log --shortstat >actual_short && + test_cmp expect_num actual_num && + test_cmp expect_short actual_short +' + +# A built store must reproduce diffstat output at every context length: +# the built context is served, and an unbuilt one falls back to xdiff. +# Zero context is where trim_common_tail runs, so it is keyed apart from +# the nonzero contexts. +test_expect_success 'diffstat matches at several context lengths' ' + no_store log --stat >expect_def && + no_store log -U0 --stat >expect_u0 && + no_store log -U7 --stat >expect_u7 && + warm && + git log --stat >got_def && + git log -U0 --stat >got_u0 && + git log -U7 --stat >got_u7 && + test_cmp expect_def got_def && + test_cmp expect_u0 got_u0 && + test_cmp expect_u7 got_u7 +' + +test_expect_success 'store built at a nonzero context serves that context' ' + no_store -c diff.context=5 log --stat >expect && + warm -c diff.context=5 && + git -c diff.context=5 log --stat >actual && + test_cmp expect actual +' + +# This blob pair (a real git test file being modernized) decomposes to +# fewer changed lines at zero context, where trim_common_tail runs, than +# at nonzero context: "diff -U0" reports 9/6, "diff -U3" reports 10/7. +# Keying the contexts apart is what keeps each reader correct. The split +# is an emergent property of the whole ~300-line file and does not reduce +# to a few lines, so the pair is shipped as a fixture under t4218/. +test_expect_success 'a trim-divergent file is correct at each context' ' + cp "$TEST_DIRECTORY/t4218/trim-divergent-old" div.sh && + git add div.sh && + git commit -m divergent-old && + cp "$TEST_DIRECTORY/t4218/trim-divergent-new" div.sh && + git add div.sh && + git commit -m divergent-new && + no_store log -1 --format= --stat -- div.sh >expect_def && + no_store log -1 --format= -U0 --stat -- div.sh >expect_u0 && + warm && + git log -1 --format= --stat -- div.sh >got_def && + git log -1 --format= -U0 --stat -- div.sh >got_u0 && + test_cmp expect_def got_def && + test_cmp expect_u0 got_u0 && + # The fixture must actually diverge, or the test would pass without + # exercising the split; fail loudly if a diff change ever levels it. + ! test_cmp expect_def expect_u0 +' + +# A warming run displays the diffstat it computes. At zero context xdi_diff +# trims, so the displayed counts must be the trimmed ones (what a store-less +# run shows), not the untrimmed counts recorded for a nonzero-context reader. +test_expect_success 'warming --stat at zero context matches a store-less run' ' + git init -q warm-u0 && + ( + cd warm-u0 && + cp "$TEST_DIRECTORY/t4218/trim-divergent-old" div.sh && + git add div.sh && git commit -q -m old && + cp "$TEST_DIRECTORY/t4218/trim-divergent-new" div.sh && + git add div.sh && git commit -q -m new && + git -c core.diffhunks=false log -1 --format= -U0 --stat -- div.sh >expect && + GIT_DIFF_HUNKS_WRITE=1 git log -1 --format= -U0 --stat -- div.sh >got && + test_cmp expect got + ) +' + +test_expect_success 'diff --stat matches with and without the store, both directions' ' + no_store diff --stat second fourth >expect_fwd && + no_store diff --stat fourth second >expect_rev && + warm && + git diff --stat second fourth >got_fwd && + git diff --stat fourth second >got_rev && + test_cmp expect_fwd got_fwd && + test_cmp expect_rev got_rev +' + +test_expect_success 'show and diff-tree --stat use the store' ' + test_when_finished "git diff-hunks clear" && + # The store attaches: a gated show/diff-tree --stat records an overlay + # (without the attach there is no writer, so no overlay is written). + git diff-hunks clear && + GIT_DIFF_HUNKS_WRITE=1 git show --stat fourth >/dev/null && + test_path_is_file "$OVERLAY" && + git diff-hunks clear && + GIT_DIFF_HUNKS_WRITE=1 git diff-tree --stat fourth >/dev/null && + test_path_is_file "$OVERLAY" && + # Reading never changes their output. + git diff-hunks clear && + no_store show --stat fourth >expect_show && + no_store diff-tree --stat fourth >expect_dt && + warm && + git show --stat fourth >got_show && + git diff-tree --stat fourth >got_dt && + test_cmp expect_show got_show && + test_cmp expect_dt got_dt +' + +test_expect_success 'log -R --stat matches (reversed pairs keyed apart)' ' + no_store log -R --stat >expect && + warm && + git log -R --stat >actual && + test_cmp expect actual +' + +# One warm serves both diffstat and blame: the blob pairs a blame +# walks are the same parent-child pairs the diffstat warm recorded. +test_expect_success 'a single warming run serves both blame and diffstat' ' + warm && + git blame --show-stats file.txt >out 2>&1 && + test_grep "num precomputed hits: [1-9][0-9]*" out +' + +# The diffstat read path produces identical output on a hit or a miss, so +# it emits a trace2 "read-hits" count to prove it consulted the store. +test_expect_success 'diffstat consults the store (trace shows read hits)' ' + warm && + GIT_TRACE2_EVENT="$PWD/trace_on.json" git log --stat >/dev/null && + test_grep read-hits trace_on.json && + test_env GIT_TRACE2_EVENT="$PWD/trace_off.json" no_store log --stat >/dev/null && + test_grep ! read-hits trace_off.json +' + +test_expect_success 'blame matches with and without the store' ' + no_store blame file.txt >expect && + warm && + git blame file.txt >actual && + test_cmp expect actual +' + +test_expect_success 'blame --porcelain and --incremental match' ' + no_store blame --porcelain file.txt >expect_p && + no_store blame --incremental file.txt >expect_i && + warm && + git blame --porcelain file.txt >got_p && + git blame --incremental file.txt >got_i && + test_cmp expect_p got_p && + test_cmp expect_i got_i +' + +# Diff settings that change hunks but are not part of the store key must +# bypass it in both directions, so output stays byte-identical to a +# store-less run. +test_expect_success 'setup ignore fixture' ' + git init ignore-repo && + ( + cd ignore-repo && + test_write_lines code keep "# c" >f && + git add f && + git commit -m c1 && + test_write_lines codeCH keep "# cX" >f && + git add f && + git commit -m c2 && + warm + ) +' + +test_expect_success '-I bypasses the store' ' + ( + cd ignore-repo && + no_store diff -I"^#" --numstat HEAD~ HEAD >expect && + git diff -I"^#" --numstat HEAD~ HEAD >actual && + test_cmp expect actual + ) +' + +test_expect_success '-B bypasses the store' ' + git init break-repo && + ( + cd break-repo && + test_write_lines a b c d e f g h >f && + git add f && + git commit -m orig && + test_write_lines 1 2 3 4 5 6 7 8 >f && + git add f && + git commit -m rewrite && + warm && + no_store diff -B --stat HEAD~ HEAD >expect && + git diff -B --stat HEAD~ HEAD >actual && + test_cmp expect actual + ) +' + +test_expect_success '--anchored bypasses the store' ' + ( + cd ignore-repo && + no_store diff --stat --anchored=keep HEAD~ HEAD >expect && + git diff --stat --anchored=keep HEAD~ HEAD >actual && + test_cmp expect actual + ) +' + +test_expect_success '--ignore-blank-lines output is unaffected by the store' ' + git init ibl-repo && + ( + cd ibl-repo && + printf "a\n\nx\ny\nb\n" >f && + git add f && + git commit -m v1 && + printf "a\nx\ny\nB\n" >f && + git add f && + git commit -m v2 && + warm && + no_store diff --stat --ignore-blank-lines HEAD~ HEAD >expect && + git diff --stat --ignore-blank-lines HEAD~ HEAD >actual && + test_cmp expect actual + ) +' + +test_expect_success 'a whitespace-ignoring diff is not served default entries' ' + git init ws-repo && + ( + cd ws-repo && + test_write_lines alpha beta gamma >f && + git add f && + git commit -m c1 && + test_write_lines " alpha" beta gamma delta >f && + git add f && + git commit -m c2 && + warm && + no_store diff -w --numstat HEAD~ HEAD >expect && + git diff -w --numstat HEAD~ HEAD >actual && + test_cmp expect actual + ) +' + +test_expect_success 'blame -w stays correct and does not hit default entries' ' + ( + cd ws-repo && + no_store blame -w f >expect && + git blame -w --show-stats f >out 2>&1 && + test_grep "num precomputed hits: 0" out && + git blame -w f >actual && + test_cmp expect actual + ) +' + +test_expect_success 'blame with indentHeuristic off stays correct and misses' ' + warm && + git -c diff.indentHeuristic=false blame --show-stats file.txt >out 2>&1 && + test_grep "num precomputed hits: 0" out && + no_store -c diff.indentHeuristic=false blame file.txt >expect && + git -c diff.indentHeuristic=false blame file.txt >actual && + test_cmp expect actual +' + +test_expect_success 'a driver algorithm override keeps output correct' ' + git init driver-algo && + ( + cd driver-algo && + echo "file.foo diff=foo" >.gitattributes && + git add .gitattributes && + git commit -m attributes && + test_write_lines 1 2 3 4 5 >file.foo && + git add file.foo && + git commit -m one && + test_write_lines 1 2 X 4 5 6 >file.foo && + git add file.foo && + git commit -m two && + warm -c diff.foo.algorithm=histogram && + no_store -c diff.foo.algorithm=histogram log --stat >expect && + git -c diff.foo.algorithm=histogram log --stat >actual && + test_cmp expect actual + ) +' + +test_expect_success 'blame --reverse never consults the store' ' + warm && + git blame --reverse HEAD~3..HEAD file.txt >actual 2>/dev/null && + no_store blame --reverse HEAD~3..HEAD file.txt >expect 2>/dev/null && + test_cmp expect actual +' + +test_expect_success 'blame with a textconv driver bypasses the store' ' + echo "tc.txt diff=tc" >>.gitattributes && + git add .gitattributes && + git commit -m tc-attr && + git config diff.tc.textconv "sed -e s/1/one/" && + test_commit tc1 tc.txt "line 1" && + test_commit tc2 tc.txt "line 1 +line 2" && + warm && + git blame --show-stats tc.txt >out 2>&1 && + test_grep "num precomputed hits: 0" out && + no_store blame tc.txt >expect && + git blame tc.txt >actual && + test_cmp expect actual +' + +test_expect_success 'blame -M and -C stay correct with the store' ' + warm && + no_store blame -M file.txt >expect_m && + no_store blame -C file.txt >expect_c && + git blame -M file.txt >got_m && + git blame -C file.txt >got_c && + test_cmp expect_m got_m && + test_cmp expect_c got_c +' + +# Copy-detecting (and reverse) blame still diff blob pairs through +# pass_blame_to_parent, so they must use the real blame xdl_opts. A +# whitespace-only change is invisible under -w; if -w were silently dropped +# (as it was for -C and --reverse) the -w and non-w results would coincide. +test_expect_success 'blame -C honors -w' ' + git init -q blame-cw && + ( + cd blame-cw && + printf "one\ntwo\nthree\n" >f && + git add f && git commit -q -m base && + printf "one\n two \nthree\n" >f && + git add f && git commit -q -m reindent && + git blame -C -w f >with_w && + git blame -C f >without_w && + ! test_cmp with_w without_w + ) +' + +# Robustness across the shapes an object walk encounters. +test_expect_success 'binary and mode-only changes do not break the writer' ' + printf "\\000\\001\\002" >bin.dat && + git add bin.dat && + git commit -m binary-1 && + printf "\\000\\001\\003\\004" >bin.dat && + git add bin.dat && + git commit -m binary-2 && + echo "mode content" >mode.txt && + git add mode.txt && + git commit -m mode-1 && + test_chmod +x mode.txt && + git commit -m mode-2 && + no_store log --stat >expect && + warm && + git log --stat >actual && + test_cmp expect actual +' + +test_expect_success 'blame across a rename matches' ' + echo "original content" >rename-src.txt && + git add rename-src.txt && + git commit -m "add rename-src" && + echo "more" >>rename-src.txt && + git add rename-src.txt && + git commit -m "modify rename-src" && + git mv rename-src.txt rename-dst.txt && + git commit -m "rename" && + echo "post" >>rename-dst.txt && + git add rename-dst.txt && + git commit -m "modify after rename" && + no_store blame rename-dst.txt >expect && + warm && + git blame rename-dst.txt >actual && + test_cmp expect actual +' + +test_expect_success 'blame handles merge commits' ' + git checkout -b merge-side main~2 && + test_commit merge-change merge-file.txt "side content" && + git checkout main && + git merge --no-edit merge-side && + no_store blame merge-file.txt >expect && + warm && + git blame merge-file.txt >actual && + test_cmp expect actual +' + +test_expect_success 'distinct --contents against one revision do not collide' ' + warm && + test_write_lines "line 1" "appended line" >c1 && + test_write_lines "rewritten line" >c2 && + # Ground truth without the store. + no_store blame -s --contents=c2 file.txt initial >expect && + # With the store, an intervening c1 run must not poison the c2 lookup. + git blame -s --contents=c1 file.txt initial >/dev/null && + git blame -s --contents=c2 file.txt initial >actual && + test_cmp expect actual +' + +# Integrity: a structurally broken header is read as absent (the reader +# falls back to xdiff and stays correct); a checksum mismatch is caught +# by verify and fsck, which is when integrity is checked. +test_expect_success 'a truncated store is read as absent' ' + warm && + test_copy_bytes 20 <$STORE >truncated && + mv truncated $STORE && + no_store blame file.txt >expect && + git blame file.txt >actual && + test_cmp expect actual +' + +test_expect_success 'a corrupt signature is read as absent' ' + warm && + printf "XXXX" >corrupt && + tail -c +5 <$STORE >>corrupt && + mv corrupt $STORE && + no_store blame file.txt >expect && + git blame file.txt >actual && + test_cmp expect actual +' + +# Byte 6 of the header is the chunk count; a value larger than the file +# can hold must be rejected before the chunk table is walked. +test_expect_success 'an over-claimed chunk count is read as absent' ' + warm && + printf "\377" | dd of=$STORE bs=1 seek=6 count=1 conv=notrunc 2>/dev/null && + no_store blame file.txt >expect && + git blame file.txt >actual && + test_cmp expect actual +' + +test_expect_success 'verify succeeds on a valid store and on an absent one' ' + warm && + git diff-hunks verify && + git diff-hunks clear && + test_path_is_missing $STORE && + git diff-hunks verify +' + +test_expect_success 'verify and fsck detect a checksum mismatch' ' + # A checksum-corrupt store is no longer laundered clean by a later + # warm/compact, so remove it when the test finishes. + test_when_finished "git diff-hunks clear" && + warm && + fsize=$(test_file_size $STORE) && + mid=$((fsize / 2)) && + printf "\\377" | dd of=$STORE bs=1 seek=$mid count=1 conv=notrunc 2>/dev/null && + test_must_fail git diff-hunks verify && + # ERROR_DIFF_HUNKS is fsck error-class bit 8; without patch 1 decoupling + # the exit status from the error mask, a corrupt store would exit 0. + test_must_fail git fsck +' + +test_expect_success 'compact refuses a checksum-corrupt tier and keeps the overlay' ' + test_when_finished "git diff-hunks clear" && + test_commit compact-corrupt f.txt && + warm && + test_commit compact-corrupt-more f.txt && + GIT_DIFF_HUNKS_WRITE=1 git log --all --stat >/dev/null && + test_path_is_file "$STORE" && + test_path_is_file "$OVERLAY" && + # Corrupt the base checksum; compact must refuse rather than rebuild + # the base without it and then delete the overlay. + fsize=$(test_file_size "$STORE") && + printf "\\377" | dd of="$STORE" bs=1 seek=$((fsize / 2)) count=1 conv=notrunc 2>/dev/null && + test_must_fail git diff-hunks compact 2>err && + test_grep "corrupt" err && + test_path_is_file "$OVERLAY" +' + +test_expect_success 'fsck is quiet about a valid store' ' + git diff-hunks clear && + warm && + git fsck 2>fsck_err && + test_grep ! -i "diff-hunks" fsck_err +' + +test_expect_success 'fsck skips the store when reading is disabled' ' + test_when_finished "git diff-hunks clear" && + warm && + fsize=$(test_file_size $STORE) && + printf "\\377" | dd of=$STORE bs=1 seek=$((fsize / 2)) count=1 conv=notrunc 2>/dev/null && + # With reading disabled, fsck does not consult the store, so a + # corrupt store does not make it fail or complain. + git -c core.diffhunks=false fsck 2>err && + test_grep ! -i "diff-hunks" err +' + +test_expect_success 'diff-hunks clear removes the store file' ' + warm && + test_path_is_file $STORE && + git diff-hunks clear && + test_path_is_missing $STORE +' + +test_done diff --git a/t/t4218/trim-divergent-new b/t/t4218/trim-divergent-new new file mode 100644 index 00000000000000..f2de40b5ed8f14 --- /dev/null +++ b/t/t4218/trim-divergent-new @@ -0,0 +1,319 @@ +#!/bin/sh +# +# Copyright (c) 2005 Jon Seymour +# +test_description='Tests git rev-list --bisect functionality' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-t6000.sh # t6xxx specific functions + +# usage: test_bisection max-diff bisect-option head ^prune... +# +# e.g. test_bisection 1 --bisect l1 ^l0 +# +test_bisection_diff() +{ + _max_diff=$1 + _bisect_option=$2 + shift 2 + _bisection=$(git rev-list $_bisect_option "$@") + _list_size=$(git rev-list "$@" | wc -l) + _head=$1 + shift 1 + _bisection_size=$(git rev-list $_bisection "$@" | wc -l) + [ -n "$_list_size" -a -n "$_bisection_size" ] || + error "test_bisection_diff failed" + + # Test if bisection size is close to half of list size within + # tolerance. + # + _bisect_err=$(($_list_size - $_bisection_size * 2)) + if test "$_bisect_err" -lt 0 + then + _bisect_err=$((0 - $_bisect_err)) + fi + _bisect_err=$(($_bisect_err / 2)) ; # floor + + test_expect_success "bisection diff $_bisect_option $_head $* <= $_max_diff" ' + test $_bisect_err -le $_max_diff + ' +} + +date >path0 +git update-index --add path0 +save_tag tree git write-tree +on_committer_date "00:00" hide_error save_tag root unique_commit root tree +on_committer_date "00:01" save_tag l0 unique_commit l0 tree -p root +on_committer_date "00:02" save_tag l1 unique_commit l1 tree -p l0 +on_committer_date "00:03" save_tag l2 unique_commit l2 tree -p l1 +on_committer_date "00:04" save_tag a0 unique_commit a0 tree -p l2 +on_committer_date "00:05" save_tag a1 unique_commit a1 tree -p a0 +on_committer_date "00:06" save_tag b1 unique_commit b1 tree -p a0 +on_committer_date "00:07" save_tag c1 unique_commit c1 tree -p b1 +on_committer_date "00:08" save_tag b2 unique_commit b2 tree -p b1 +on_committer_date "00:09" save_tag b3 unique_commit b2 tree -p b2 +on_committer_date "00:10" save_tag c2 unique_commit c2 tree -p c1 -p b2 +on_committer_date "00:11" save_tag c3 unique_commit c3 tree -p c2 +on_committer_date "00:12" save_tag a2 unique_commit a2 tree -p a1 +on_committer_date "00:13" save_tag a3 unique_commit a3 tree -p a2 +on_committer_date "00:14" save_tag b4 unique_commit b4 tree -p b3 -p a3 +on_committer_date "00:15" save_tag a4 unique_commit a4 tree -p a3 -p b4 -p c3 +on_committer_date "00:16" save_tag l3 unique_commit l3 tree -p a4 +on_committer_date "00:17" save_tag l4 unique_commit l4 tree -p l3 +on_committer_date "00:18" save_tag l5 unique_commit l5 tree -p l4 +git update-ref HEAD $(tag l5) + + +# E +# / \ +# e1 | +# | | +# e2 | +# | | +# e3 | +# | | +# e4 | +# | | +# | f1 +# | | +# | f2 +# | | +# | f3 +# | | +# | f4 +# | | +# e5 | +# | | +# e6 | +# | | +# e7 | +# | | +# e8 | +# \ / +# F + + +on_committer_date "00:00" hide_error save_tag F unique_commit F tree +on_committer_date "00:01" save_tag e8 unique_commit e8 tree -p F +on_committer_date "00:02" save_tag e7 unique_commit e7 tree -p e8 +on_committer_date "00:03" save_tag e6 unique_commit e6 tree -p e7 +on_committer_date "00:04" save_tag e5 unique_commit e5 tree -p e6 +on_committer_date "00:05" save_tag f4 unique_commit f4 tree -p F +on_committer_date "00:06" save_tag f3 unique_commit f3 tree -p f4 +on_committer_date "00:07" save_tag f2 unique_commit f2 tree -p f3 +on_committer_date "00:08" save_tag f1 unique_commit f1 tree -p f2 +on_committer_date "00:09" save_tag e4 unique_commit e4 tree -p e5 +on_committer_date "00:10" save_tag e3 unique_commit e3 tree -p e4 +on_committer_date "00:11" save_tag e2 unique_commit e2 tree -p e3 +on_committer_date "00:12" save_tag e1 unique_commit e1 tree -p e2 +on_committer_date "00:13" save_tag E unique_commit E tree -p e1 -p f1 + +on_committer_date "00:00" hide_error save_tag U unique_commit U tree +on_committer_date "00:01" save_tag u0 unique_commit u0 tree -p U +on_committer_date "00:01" save_tag u1 unique_commit u1 tree -p u0 +on_committer_date "00:02" save_tag u2 unique_commit u2 tree -p u0 +on_committer_date "00:03" save_tag u3 unique_commit u3 tree -p u0 +on_committer_date "00:04" save_tag u4 unique_commit u4 tree -p u0 +on_committer_date "00:05" save_tag u5 unique_commit u5 tree -p u0 +on_committer_date "00:06" save_tag V unique_commit V tree -p u1 -p u2 -p u3 -p u4 -p u5 + +test_sequence() +{ + _bisect_option=$1 + + test_bisection_diff 0 $_bisect_option l0 ^root + test_bisection_diff 0 $_bisect_option l1 ^root + test_bisection_diff 0 $_bisect_option l2 ^root + test_bisection_diff 0 $_bisect_option a0 ^root + test_bisection_diff 0 $_bisect_option a1 ^root + test_bisection_diff 0 $_bisect_option a2 ^root + test_bisection_diff 0 $_bisect_option a3 ^root + test_bisection_diff 0 $_bisect_option b1 ^root + test_bisection_diff 0 $_bisect_option b2 ^root + test_bisection_diff 0 $_bisect_option b3 ^root + test_bisection_diff 0 $_bisect_option c1 ^root + test_bisection_diff 0 $_bisect_option c2 ^root + test_bisection_diff 0 $_bisect_option c3 ^root + test_bisection_diff 0 $_bisect_option E ^F + test_bisection_diff 0 $_bisect_option e1 ^F + test_bisection_diff 0 $_bisect_option e2 ^F + test_bisection_diff 0 $_bisect_option e3 ^F + test_bisection_diff 0 $_bisect_option e4 ^F + test_bisection_diff 0 $_bisect_option e5 ^F + test_bisection_diff 0 $_bisect_option e6 ^F + test_bisection_diff 0 $_bisect_option e7 ^F + test_bisection_diff 0 $_bisect_option f1 ^F + test_bisection_diff 0 $_bisect_option f2 ^F + test_bisection_diff 0 $_bisect_option f3 ^F + test_bisection_diff 0 $_bisect_option f4 ^F + test_bisection_diff 0 $_bisect_option E ^F + + test_bisection_diff 1 $_bisect_option V ^U + test_bisection_diff 0 $_bisect_option V ^U ^u1 ^u2 ^u3 + test_bisection_diff 0 $_bisect_option u1 ^U + test_bisection_diff 0 $_bisect_option u2 ^U + test_bisection_diff 0 $_bisect_option u3 ^U + test_bisection_diff 0 $_bisect_option u4 ^U + test_bisection_diff 0 $_bisect_option u5 ^U + +# +# the following illustrates Linus' binary bug blatt idea. +# +# assume the bug is actually at l3, but you don't know that - all you know is that l3 is broken +# and it wasn't broken before +# +# keep bisecting the list, advancing the "bad" head and accumulating "good" heads until +# the bisection point is the head - this is the bad point. +# + +test_output_expect_success "$_bisect_option l5 ^root" 'git rev-list $_bisect_option l5 ^root' <expect && + git rev-list --bisect >actual && + test_cmp expect actual +' + +test_expect_success 'rev-parse --bisect can default to good/bad refs' ' + git rev-parse c3 ^b1 ^c1 >expect && + git rev-parse --bisect >actual && + + # output order depends on the refnames, which in turn depends on + # the exact sha1s. We just want to make sure we have the same set + # of lines in any order. + sort expect.sorted && + sort actual.sorted && + test_cmp expect.sorted actual.sorted +' + +test_output_expect_success '--bisect --first-parent' 'git rev-list --bisect --first-parent E ^F' <expect.unsorted <<-EOF && + $(git rev-parse E) (tag: E, dist=0) + $(git rev-parse e1) (tag: e1, dist=1) + $(git rev-parse e2) (tag: e2, dist=2) + $(git rev-parse e3) (tag: e3, dist=3) + $(git rev-parse e4) (tag: e4, dist=4) + $(git rev-parse e5) (tag: e5, dist=4) + $(git rev-parse e6) (tag: e6, dist=3) + $(git rev-parse e7) (tag: e7, dist=2) + $(git rev-parse e8) (tag: e8, dist=1) + EOF + + # expect results to be ordered by distance (descending), + # commit hash (ascending) + sort -k4,4r -k1,1 expect.unsorted >expect && + git rev-list --bisect-all --first-parent E ^F >actual && + test_cmp expect actual +' + +test_expect_success '--bisect without any revisions' ' + git rev-list --bisect HEAD..HEAD >out && + test_must_be_empty out +' + +test_done diff --git a/t/t4218/trim-divergent-old b/t/t4218/trim-divergent-old new file mode 100644 index 00000000000000..daa009c9a1b4b6 --- /dev/null +++ b/t/t4218/trim-divergent-old @@ -0,0 +1,316 @@ +#!/bin/sh +# +# Copyright (c) 2005 Jon Seymour +# +test_description='Tests git rev-list --bisect functionality' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-t6000.sh # t6xxx specific functions + +# usage: test_bisection max-diff bisect-option head ^prune... +# +# e.g. test_bisection 1 --bisect l1 ^l0 +# +test_bisection_diff() +{ + _max_diff=$1 + _bisect_option=$2 + shift 2 + _bisection=$(git rev-list $_bisect_option "$@") + _list_size=$(git rev-list "$@" | wc -l) + _head=$1 + shift 1 + _bisection_size=$(git rev-list $_bisection "$@" | wc -l) + [ -n "$_list_size" -a -n "$_bisection_size" ] || + error "test_bisection_diff failed" + + # Test if bisection size is close to half of list size within + # tolerance. + # + _bisect_err=$(expr $_list_size - $_bisection_size \* 2) + test "$_bisect_err" -lt 0 && _bisect_err=$(expr 0 - $_bisect_err) + _bisect_err=$(expr $_bisect_err / 2) ; # floor + + test_expect_success \ + "bisection diff $_bisect_option $_head $* <= $_max_diff" \ + 'test $_bisect_err -le $_max_diff' +} + +date >path0 +git update-index --add path0 +save_tag tree git write-tree +on_committer_date "00:00" hide_error save_tag root unique_commit root tree +on_committer_date "00:01" save_tag l0 unique_commit l0 tree -p root +on_committer_date "00:02" save_tag l1 unique_commit l1 tree -p l0 +on_committer_date "00:03" save_tag l2 unique_commit l2 tree -p l1 +on_committer_date "00:04" save_tag a0 unique_commit a0 tree -p l2 +on_committer_date "00:05" save_tag a1 unique_commit a1 tree -p a0 +on_committer_date "00:06" save_tag b1 unique_commit b1 tree -p a0 +on_committer_date "00:07" save_tag c1 unique_commit c1 tree -p b1 +on_committer_date "00:08" save_tag b2 unique_commit b2 tree -p b1 +on_committer_date "00:09" save_tag b3 unique_commit b2 tree -p b2 +on_committer_date "00:10" save_tag c2 unique_commit c2 tree -p c1 -p b2 +on_committer_date "00:11" save_tag c3 unique_commit c3 tree -p c2 +on_committer_date "00:12" save_tag a2 unique_commit a2 tree -p a1 +on_committer_date "00:13" save_tag a3 unique_commit a3 tree -p a2 +on_committer_date "00:14" save_tag b4 unique_commit b4 tree -p b3 -p a3 +on_committer_date "00:15" save_tag a4 unique_commit a4 tree -p a3 -p b4 -p c3 +on_committer_date "00:16" save_tag l3 unique_commit l3 tree -p a4 +on_committer_date "00:17" save_tag l4 unique_commit l4 tree -p l3 +on_committer_date "00:18" save_tag l5 unique_commit l5 tree -p l4 +git update-ref HEAD $(tag l5) + + +# E +# / \ +# e1 | +# | | +# e2 | +# | | +# e3 | +# | | +# e4 | +# | | +# | f1 +# | | +# | f2 +# | | +# | f3 +# | | +# | f4 +# | | +# e5 | +# | | +# e6 | +# | | +# e7 | +# | | +# e8 | +# \ / +# F + + +on_committer_date "00:00" hide_error save_tag F unique_commit F tree +on_committer_date "00:01" save_tag e8 unique_commit e8 tree -p F +on_committer_date "00:02" save_tag e7 unique_commit e7 tree -p e8 +on_committer_date "00:03" save_tag e6 unique_commit e6 tree -p e7 +on_committer_date "00:04" save_tag e5 unique_commit e5 tree -p e6 +on_committer_date "00:05" save_tag f4 unique_commit f4 tree -p F +on_committer_date "00:06" save_tag f3 unique_commit f3 tree -p f4 +on_committer_date "00:07" save_tag f2 unique_commit f2 tree -p f3 +on_committer_date "00:08" save_tag f1 unique_commit f1 tree -p f2 +on_committer_date "00:09" save_tag e4 unique_commit e4 tree -p e5 +on_committer_date "00:10" save_tag e3 unique_commit e3 tree -p e4 +on_committer_date "00:11" save_tag e2 unique_commit e2 tree -p e3 +on_committer_date "00:12" save_tag e1 unique_commit e1 tree -p e2 +on_committer_date "00:13" save_tag E unique_commit E tree -p e1 -p f1 + +on_committer_date "00:00" hide_error save_tag U unique_commit U tree +on_committer_date "00:01" save_tag u0 unique_commit u0 tree -p U +on_committer_date "00:01" save_tag u1 unique_commit u1 tree -p u0 +on_committer_date "00:02" save_tag u2 unique_commit u2 tree -p u0 +on_committer_date "00:03" save_tag u3 unique_commit u3 tree -p u0 +on_committer_date "00:04" save_tag u4 unique_commit u4 tree -p u0 +on_committer_date "00:05" save_tag u5 unique_commit u5 tree -p u0 +on_committer_date "00:06" save_tag V unique_commit V tree -p u1 -p u2 -p u3 -p u4 -p u5 + +test_sequence() +{ + _bisect_option=$1 + + test_bisection_diff 0 $_bisect_option l0 ^root + test_bisection_diff 0 $_bisect_option l1 ^root + test_bisection_diff 0 $_bisect_option l2 ^root + test_bisection_diff 0 $_bisect_option a0 ^root + test_bisection_diff 0 $_bisect_option a1 ^root + test_bisection_diff 0 $_bisect_option a2 ^root + test_bisection_diff 0 $_bisect_option a3 ^root + test_bisection_diff 0 $_bisect_option b1 ^root + test_bisection_diff 0 $_bisect_option b2 ^root + test_bisection_diff 0 $_bisect_option b3 ^root + test_bisection_diff 0 $_bisect_option c1 ^root + test_bisection_diff 0 $_bisect_option c2 ^root + test_bisection_diff 0 $_bisect_option c3 ^root + test_bisection_diff 0 $_bisect_option E ^F + test_bisection_diff 0 $_bisect_option e1 ^F + test_bisection_diff 0 $_bisect_option e2 ^F + test_bisection_diff 0 $_bisect_option e3 ^F + test_bisection_diff 0 $_bisect_option e4 ^F + test_bisection_diff 0 $_bisect_option e5 ^F + test_bisection_diff 0 $_bisect_option e6 ^F + test_bisection_diff 0 $_bisect_option e7 ^F + test_bisection_diff 0 $_bisect_option f1 ^F + test_bisection_diff 0 $_bisect_option f2 ^F + test_bisection_diff 0 $_bisect_option f3 ^F + test_bisection_diff 0 $_bisect_option f4 ^F + test_bisection_diff 0 $_bisect_option E ^F + + test_bisection_diff 1 $_bisect_option V ^U + test_bisection_diff 0 $_bisect_option V ^U ^u1 ^u2 ^u3 + test_bisection_diff 0 $_bisect_option u1 ^U + test_bisection_diff 0 $_bisect_option u2 ^U + test_bisection_diff 0 $_bisect_option u3 ^U + test_bisection_diff 0 $_bisect_option u4 ^U + test_bisection_diff 0 $_bisect_option u5 ^U + +# +# the following illustrates Linus' binary bug blatt idea. +# +# assume the bug is actually at l3, but you don't know that - all you know is that l3 is broken +# and it wasn't broken before +# +# keep bisecting the list, advancing the "bad" head and accumulating "good" heads until +# the bisection point is the head - this is the bad point. +# + +test_output_expect_success "$_bisect_option l5 ^root" 'git rev-list $_bisect_option l5 ^root' <expect && + git rev-list --bisect >actual && + test_cmp expect actual +' + +test_expect_success 'rev-parse --bisect can default to good/bad refs' ' + git rev-parse c3 ^b1 ^c1 >expect && + git rev-parse --bisect >actual && + + # output order depends on the refnames, which in turn depends on + # the exact sha1s. We just want to make sure we have the same set + # of lines in any order. + sort expect.sorted && + sort actual.sorted && + test_cmp expect.sorted actual.sorted +' + +test_output_expect_success '--bisect --first-parent' 'git rev-list --bisect --first-parent E ^F' <expect.unsorted <<-EOF && + $(git rev-parse E) (tag: E, dist=0) + $(git rev-parse e1) (tag: e1, dist=1) + $(git rev-parse e2) (tag: e2, dist=2) + $(git rev-parse e3) (tag: e3, dist=3) + $(git rev-parse e4) (tag: e4, dist=4) + $(git rev-parse e5) (tag: e5, dist=4) + $(git rev-parse e6) (tag: e6, dist=3) + $(git rev-parse e7) (tag: e7, dist=2) + $(git rev-parse e8) (tag: e8, dist=1) + EOF + + # expect results to be ordered by distance (descending), + # commit hash (ascending) + sort -k4,4r -k1,1 expect.unsorted >expect && + git rev-list --bisect-all --first-parent E ^F >actual && + test_cmp expect actual +' + +test_expect_success '--bisect without any revisions' ' + git rev-list --bisect HEAD..HEAD >out && + test_must_be_empty out +' + +test_done