diff: consult oid-only hunk providers via diff.<driver>.process

The provider chain so far holds the diff-hunks store in front of the terminal builtin computation. Open it to external processes: a pair on a path whose driver configures diff.<driver>.process is answered by a long-running process speaking a pkt-line protocol (following the filter process protocol), registered at the head of the chain and consulted before the store and before any blob is loaded. The protocol starts with the smallest request that can carry an answer: object names alone. A request is the pathname and the pair's old-oid/new-oid, with no content. The process answers with hunk lines, with a zero-hunk success that asserts the blobs equivalent (trailing newlines included), or with status=need-content, on which the pair falls through to the builtin answer. This serves the two shapes that need no content pushed to them: a cache keyed on the blob pair, and a process that fetches the blobs itself (for example over "git cat-file --batch"). A pair whose side is not a stored blob carries a NULL id; the provider sends no request and passes it. Because Git holds no content for the exchange, the answer is used as sent: hunks are validated for order, overlap, lockstep alignment, and magnitude, then replayed without the normalization xdiff applies to diffs it computes itself. The magnitude bound is the blobs' sizes, read from the object database without loading content: a blob of N bytes holds at most N lines. Because the process's answer is authoritative, it outranks the store, and its head-of-chain position says so. A pair the process answers never reaches the store and is never recorded, so nothing it produces enters the store, which holds the builtin answer only. A request it does not answer, whether need-content, a missing capability, or a missing id, passes down the chain to the builtin answer, which is what the store serves, so the store may serve such a pair and a warming run may record it. Entries recorded before a process was configured are not purged; a pair the process answers ignores them, and "git diff-hunks clear" discards them. The provider gates itself per request. The driver is looked up by the old-side path, so a renamed file resolves to the same driver, and by the repository-relative path, so a diff.relative run from a subdirectory names the pair the same way. Options the process is never told about select no process: the whitespace-ignoring options, -I, --anchored, and an algorithm forced by option or configuration (blame routes its algorithm through xdl_opts, so --histogram is covered). The request gains its last field, the path; the consumers change only by filling it, and neither names the process. The provider's state is its repository's pool of running processes, keyed by the configured command, so drivers sharing a command share a process, a submodule speaks to its own, and releasing the provider (from repo_clear()) stops them. The pool owns a copy of each command string, so an entry outlives a config re-read. A command that fails stays as an entry that is not retried: its request and every later one pass, so the store may serve the path for the rest of the command. A protocol error in a response never kills the command. The response is read through a packet reader gentle about framing, so an error takes one path: a single warning, the process stopped and marked failed, and the builtin diff for the rest of the command. That covers garbage bytes, a truncated response, an empty packet, a bare status, and an unrecognized status. Semantically invalid coordinates cost only their pair: the response is drained, the pair is computed, and the process stays alive. A path the protocol cannot carry (an embedded newline, or one too long for a packet) falls back per path rather than costing the command its process. The handshake keeps one fatal check: a process that announces a capability Git did not request aborts the command, as the long-running filter protocol does. Consulting is allowed per command, following the allow_textconv precedent. "git diff", "git log" and "git show", and "git blame" set allow_diff_process; the plumbing diff commands and the interactive-patch machinery never set it, so scripted and staging output stays builtin. The options adjust the flag: - --no-ext-diff clears it and --ext-diff sets it; - --diff-process and --no-diff-process set and clear it alone, leaving external diff drivers as they were; - format-patch clears it unconditionally, so a generated patch applies for recipients without the process; - range-diff passes --no-ext-diff to the "git log" it compares. git blame and the summary formats consult the process. For blame, a pair reported equivalent emits no hunks, so the whole commit passes to its parent. In the stat formats such a pair sums to a zero-count entry, which the "nothing changed" rule omits, as under -w. The subprocess is long-running: one startup cost across a traversal, one round-trip per consulted pair. Answers travel in struct xdl_hunk, new in xdiff-interface.h, holding xdiff's 1-based coordinates; nothing feeds them back to xdiff, since only coordinate consumers consult. A content-carrying request is the natural extension: it would serve sides that are not stored blobs and processes that want content pushed to them, and bring patch output and log -L's range tracking to the same answer. As it stands, a process's answers show in blame and the summary formats while patch output stays builtin. t4080 exercises the protocol, the per-command gate, and the error paths: - each adversarial response shape warns and falls back to builtin, the request log proving which failures disable the process and which keep it alive (a malformed hunk line, coordinates past the blob size, a count overflowing strtol(), overlapping or misaligned hunks, an unrecognized status, a bare status, an empty packet, a mid-response crash, and raw garbage); - a capability-less process and status=abort degrade without noise, and a failed start warns once and returns the path to the store; - a trailing token on a hunk line is ignored, pinning field appendability; - positive consults for git diff, git show, and diff-tree under --ext-diff and --diff-process; textconv output and gitlink sides are never identified; a diff.relative run consults by the repo-relative path; - the equivalence answer is pinned from both consumers, and a warming run past a deferring process records the pair for a later read. Helped-by: Johannes Schindelin <johannes.schindelin@gmx.de> Signed-off-by: Michael Montalbo <mmontalbo@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Michael Montalbo committed Aug 1, 2026 at 10:41 UTC 91c8989d4e075a7760f2115f2860fbb370813b24
24 files changed +1916 -30
Documentation/config/diff.adoc
+6
@@ -218,6 +218,12 @@ endif::git-diff[]
218 Set this option to `true` to make the diff driver cache the text
219 conversion outputs. See linkgit:gitattributes[5] for details.
220
221 +`diff.<driver>.process`::
222 + The command to run as a long-running process that answers
223 + which line ranges changed between two blobs. See
224 + linkgit:gitattributes[5] for the protocol and when it is
225 + consulted.
226 +
227 `diff.indentHeuristic`::
228 Set this option to `false` to disable the default heuristics
229 that shift diff hunk boundaries to make patches easier to read.
Documentation/diff-algorithm-option.adoc
+3
@@ -18,3 +18,6 @@
18 For instance, if you configured the `diff.algorithm` variable to a
19 non-default value and want to use the default one, then you
20 have to use `--diff-algorithm=default` option.
21 ++
22 +Explicitly choosing a diff algorithm on the command line also
23 +bypasses `diff.<driver>.process` (see linkgit:gitattributes[5]).
Documentation/diff-options.adoc
+14 -1
@@ -833,7 +833,20 @@ endif::git-format-patch[]
833 to use this option with linkgit:git-log[1] and friends.
834
835 `--no-ext-diff`::
836 - Disallow external diff drivers.
836 + Disallow external diff drivers and processes, including
837 + `diff.<driver>.command` and `diff.<driver>.process`
838 + (see linkgit:gitattributes[5]).
839 +
840 +`--diff-process`::
841 +`--no-diff-process`::
842 + Allow (or forbid) consulting a diff process configured with
843 + ++diff.++__<driver>__++.process++ (see linkgit:gitattributes[5]),
844 + leaving external diff drivers unaffected. `git diff`, `git log`,
845 + `git show`, and `git blame` allow consulting by default; the
846 + plumbing diff commands forbid it unless this option or
847 + `--ext-diff` is given. linkgit:git-format-patch[1] accepts the
848 + option but ignores it: a generated patch is always based on the
849 + builtin diff.
850
851 `--textconv`::
852 `--no-textconv`::
Documentation/gitattributes.adoc
+160
@@ -832,6 +832,166 @@ NOTE: If `diff.<name>.command` is defined for path with the
832 (see above), and adding `diff.<name>.algorithm` has no effect, as the
833 algorithm is not passed to the external diff driver.
834
835 +Answering diffs from a long-running process
836 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
837 +
838 +Unlike `diff.<name>.command`, which replaces the textual patch, the
839 +process configured in `diff.<name>.process` feeds hunks back into
840 +Git's own machinery:
841 +it answers "which line ranges changed between these two blobs", and
842 +Git's output is produced from that answer. A process is started
843 +lazily, once per configured command string and per repository, and
844 +consulted over a pkt-line protocol (following the long-running filter
845 +process protocol; see the "Long Running Filter Process" section above
846 +for the filter analogue).
847 +
848 +The process is asked by object names alone: a request carries the
849 +pathname and the `old-oid`/`new-oid` of the blob pair, and no content.
850 +The pathname is the repository-relative old-side (preimage) path, not
851 +the shortened display path a `--relative` diff shows, so a driver
852 +scoped to a directory matches whatever directory the command runs from.
853 +This suits a process that keeps a persistent cache keyed on the pair, and
854 +a process that fetches the blobs itself (for example via
855 +`git cat-file --batch`) to compute its own notion of the changed
856 +lines. Pairs with a side that is not a stored blob (a working-tree
857 +file, textconv output) are not sent; Git computes those itself.
858 +
859 +The exchange opens with a handshake: Git announces its role and
860 +version, the process replies in kind, and then Git lists the
861 +capabilities it supports and the process replies with the ones it
862 +implements. A process that announces a capability Git did not list
863 +aborts the command.
864 +
865 +-----------------------
866 +packet: git> git-diff-client
867 +packet: git> version=1
868 +packet: git> 0000
869 +packet: git< git-diff-server
870 +packet: git< version=1
871 +packet: git< 0000
872 +packet: git> capability=hunks-by-oid
873 +packet: git> 0000
874 +packet: git< capability=hunks-by-oid
875 +packet: git< 0000
876 +-----------------------
877 +
878 +After the handshake, each request and response looks like:
879 +
880 +-----------------------
881 +packet: git> command=hunks-by-oid
882 +packet: git> pathname=path/file.c
883 +packet: git> old-oid=<hex>
884 +packet: git> new-oid=<hex>
885 +packet: git> 0000
886 +packet: git< hunk <old_start> <old_count> <new_start> <new_count>
887 +packet: git< 0000
888 +packet: git< status=success
889 +packet: git< 0000
890 +-----------------------
891 +
892 +Start values are 1-based and counts are non-negative; a count of 0
893 +describes a pure insertion or deletion at the 1-based line the change
894 +sits before (a start of 0 is accepted for an empty file side). Hunks
895 +must be listed in order, must not overlap, and must keep the unchanged
896 +runs between them the same length on both sides; Git validates this
897 +and, with a warning, falls back to its builtin diff on a response
898 +that violates these rules.
899 +
900 +A `status=success` response with zero hunks asserts that the blobs are
901 +equivalent, including their trailing newlines. A process that cannot
902 +answer a pair from its object names (or cannot rule out a
903 +trailing-newline-only difference) responds `status=need-content`, and
904 +Git produces that pair's diff itself. An asserted equivalence makes
905 +the pair vanish from the summary formats, but the pair still counts
906 +as changed for `--exit-code`, the same way a whitespace-only pair
907 +does under `-w`.
908 +
909 +The status names the disposition of the whole request. Git
910 +understands three: `success` (the hunk lines are the answer),
911 +`need-content` (Git produces this pair's diff itself), and `abort`, which
912 +withdraws the capability the request used: Git stops sending
913 +`hunks-by-oid` requests to that process for the rest of the command,
914 +while the process stays alive for request forms negotiated under
915 +other capabilities. Any other status is a protocol error: Git warns,
916 +stops the process, and uses the builtin diff for the remainder of the
917 +command.
918 +
919 +Every response has the same shape whatever its status: zero or more
920 +hunk lines, a flush packet, and a status packet terminated with a
921 +flush packet. A response that carries no hunks, `need-content`
922 +included, still begins with the empty hunk section's flush packet; a
923 +bare status packet is a protocol error:
924 +
925 +-----------------------
926 +packet: git< 0000
927 +packet: git< status=need-content
928 +packet: git< 0000
929 +-----------------------
930 +
931 +The process must read the entire request before it responds; Git
932 +writes the whole request before it reads the response.
933 +
934 +The protocol extends without breaking deployed processes: a process
935 +must ignore request keys it does not recognize, and Git ignores
936 +trailing space-separated tokens after the last field of a hunk line,
937 +so a later protocol version can append request keys and hunk fields.
938 +New request forms arrive as capabilities, which a process may decline
939 +to announce; announcing a capability Git did not request aborts the
940 +command, as it does under the long-running filter process protocol.
941 +
942 +There is no shutdown handshake: Git's side of the pipes closes when
943 +the command exits, and the process should exit when it reads EOF. No
944 +flush point is guaranteed, so a process that maintains persistent
945 +state (such as a cache) should persist as it answers rather than at
946 +exit. Git applies no timeout to a response; a process that hangs
947 +hangs the command, as with the long-running filter processes.
948 +
949 +`git blame` and the `--stat`, `--numstat`, and `--shortstat` formats
950 +consult the process; the textual patch and `git log -L` range
951 +tracking are produced by the builtin machinery, so a process whose
952 +answers deliberately differ from the builtin diff shows that
953 +difference only in blame and those formats. `--dirstat=lines` routes
954 +through the diffstat path and consults; the other `--dirstat` modes do
955 +not. A merge's `--stat` (including under `--cc`) is computed against
956 +the first parent, so it consults like any other stat; the combined
957 +patch itself compares one merge result against all of its parents at
958 +once, which the pairwise request above does not express, so that patch
959 +uses the builtin diff, and extending the protocol to combined diffs is
960 +left for future work. A content-carrying extension of this
961 +protocol would bring patch output and `git log -L` range tracking to
962 +the same answer.
963 +
964 +Consulting is allowed per command, as with textconv: `git diff`,
965 +`git log` (`git whatchanged` included) and `git show`, and
966 +`git blame` consult a configured process; the plumbing diff commands
967 +do not unless `--ext-diff` or `--diff-process` is given explicitly,
968 +and the interactive-patch commands (`git add -p` and friends), which
969 +build the hunks they present from plumbing output, always stage from
970 +the builtin diff. `git range-diff` generates the patches it
971 +compares with `--no-ext-diff`. `--diff-process` and
972 +`--no-diff-process` allow or forbid only the consulting;
973 +`--no-ext-diff` disables all external diff mechanisms, this one
974 +included. Options the process is never told about never select it:
975 +with the whitespace-ignoring options, `--ignore-matching-lines`, and
976 +`--anchored`, the pair is answered as when no process is configured.
977 +`--diff-algorithm` (or a configured `diff.algorithm`) forces a builtin
978 +algorithm and bypasses the process the same way. A per-path
979 +++diff.++__<driver>__++.algorithm++ does so for `git diff` and the stat
980 +formats, which build their diff parameters from it; `git blame` builds
981 +its parameters from its own diff options, so a per-driver algorithm
982 +does not by itself keep blame from consulting the process.
983 +`git format-patch` never
984 +consults the process, so generated patches are always based on the
985 +builtin diff and apply for recipients without the process. On a
986 +path whose driver has a process, the process is consulted before the
987 +diff-hunks store (see linkgit:git-diff-hunks[1]): a pair the process
988 +answers is never served from the store and never recorded into it.
989 +A pair the process does not answer, for example with
990 +`status=need-content`, gets the builtin diff, so the store may serve
991 +it and a warming run may record it: the store holds builtin results,
992 +and for such a pair the builtin result is what would be computed
993 +anyway.
994 +
995 Defining a custom hunk-header
996 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
997
Makefile
+2
@@ -819,6 +819,7 @@ TEST_BUILTINS_OBJS += test-csprng.o
819 TEST_BUILTINS_OBJS += test-date.o
820 TEST_BUILTINS_OBJS += test-delete-gpgsig.o
821 TEST_BUILTINS_OBJS += test-delta.o
822 +TEST_BUILTINS_OBJS += test-diff-process-backend.o
823 TEST_BUILTINS_OBJS += test-dir-iterator.o
824 TEST_BUILTINS_OBJS += test-drop-caches.o
825 TEST_BUILTINS_OBJS += test-dump-cache-tree.o
@@ -1151,6 +1152,7 @@ LIB_OBJS += diff-delta.o
1152 LIB_OBJS += diff-merges.o
1153 LIB_OBJS += diff-lib.o
1154 LIB_OBJS += diff-no-index.o
1155 +LIB_OBJS += diff-process.o
1156 LIB_OBJS += diff-provider.o
1157 LIB_OBJS += diff.o
1158 LIB_OBJS += diffcore-break.o
blame.c
+9 -1
@@ -2010,17 +2010,25 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb,
2010 * compute. The working-tree/--contents pseudo-commit (marked by
2011 * its null commit id) holds a blob that is not a stored object,
2012 * so its pairs withhold identity too: no id may be sent that
2013 - * names bytes a provider cannot look up.
2013 + * names bytes a process cannot look up.
2014 */
2015 provider_usable = !sb->reverse && !ignore_diffs &&
2016 !is_null_oid(&target->commit->object.oid) &&
2017 !blame_textconv_active(sb, target->path) &&
2018 !blame_textconv_active(sb, parent->path);
2019
2020 + /*
2021 + * Look up the driver by the parent (old) path, as builtin_diff()
2022 + * does with name_a, so a renamed file resolves to the same driver
2023 + * across diff and blame. A process that reports a pair
2024 + * equivalent emits no hunks, so blame passes the whole commit
2025 + * through and looks past it.
2026 + */
2027 if (provider_usable) {
2028 req.old_oid = &parent->blob_oid;
2029 req.new_oid = &target->blob_oid;
2030 }
2031 + req.path = parent->path;
2032 req.diffopt = &sb->revs->diffopt;
2033 if (diff_provider_emit_hunks(&req, blame_diff_fill, &fill_data,
2034 blame_chunk_cb, &d) == DIFF_PROVIDER_ERROR)
builtin/blame.c
+1
@@ -1025,6 +1025,7 @@ int cmd_blame(int argc,
1025 repo_init_revisions(the_repository, &revs, NULL);
1026 revs.date_mode = blame_date_mode;
1027 revs.diffopt.flags.allow_textconv = 1;
1028 + revs.diffopt.flags.allow_diff_process = 1;
1029 revs.diffopt.flags.follow_renames = 1;
1030
1031 save_commit_buffer = 0;
builtin/diff.c
+1
@@ -510,6 +510,7 @@ int cmd_diff(int argc,
510 init_diffstat_widths(&rev.diffopt);
511 rev.diffopt.flags.allow_external = 1;
512 rev.diffopt.flags.allow_textconv = 1;
513 + rev.diffopt.flags.allow_diff_process = 1;
514
515 /* If this is a no-index diff, just run it and exit there. */
516 if (no_index)
builtin/log.c
+8 -3
@@ -209,6 +209,7 @@ static void cmd_log_init_defaults(struct rev_info *rev,
209 init_diffstat_widths(&rev->diffopt);
210 rev->diffopt.flags.recursive = 1;
211 rev->diffopt.flags.allow_textconv = 1;
212 + rev->diffopt.flags.allow_diff_process = 1;
213 rev->abbrev_commit = cfg->default_abbrev_commit;
214 rev->show_root_diff = cfg->default_show_root;
215 rev->subject_prefix = cfg->fmt_patch_subject_prefix;
@@ -2226,11 +2227,15 @@ int cmd_format_patch(int argc,
2227 die(_("unrecognized argument: %s"), argv[1]);
2228
2229 /*
2229 - * A patch generated by format-patch carries the builtin diffstat,
2230 - * not one served from a local store, so its counts do not depend
2231 - * on whether the sender warmed the store.
2230 + * Patches generated by format-patch must be based on the builtin
2231 + * diff so recipients without the store or the process can apply
2232 + * them, and so the emitted diffstat does not depend on the sender's
2233 + * local cache: the precomputed-hunks store is not consulted for the
2234 + * diffstat, and the diff process is not consulted even when
2235 + * --ext-diff enables the external diff command.
2236 */
2237 rev.diffopt.flags.no_precomputed_hunks = 1;
2238 + rev.diffopt.flags.allow_diff_process = 0;
2239
2240 if (rev.diffopt.output_format & DIFF_FORMAT_NAME)
2241 die(_("--name-only does not make sense"));
diff-process.c new
+669
@@ -0,0 +1,669 @@
1 +/*
2 + * The process provider of the hunk provider interface: consult a
3 + * long-running external process via the pkt-line protocol for the
4 + * hunks of a blob pair. The process answers from the pair's object
5 + * names alone: it can serve a persistent cache keyed on the pair, or
6 + * fetch the blobs from the repository itself (e.g. via "git cat-file
7 + * --batch") and compute its own notion of which lines changed. The
8 + * provider sits at the head of its repository's chain and gates
9 + * itself per request; its state is the repository's pool of running
10 + * processes, one per configured command, stopped when the provider
11 + * is released.
12 + *
13 + * Protocol: pkt-line over stdin/stdout, following the pattern of
14 + * the long-running filter process protocol (see convert.c).
15 + *
16 + * Handshake:
17 + * git> git-diff-client / version=1 / flush
18 + * process< git-diff-server / version=1 / flush
19 + * git> capability=hunks-by-oid / flush
20 + * process< capability=hunks-by-oid / flush
21 + *
22 + * Per-pair, when both sides are stored blobs:
23 + * git> command=hunks-by-oid / pathname=<path>
24 + * git> old-oid=<hex> / new-oid=<hex> / flush
25 + * process< hunk <old_start> <old_count> <new_start> <new_count>
26 + * process< ... / flush
27 + * process< status=success / flush
28 + *
29 + * No content is sent. Because Git holds no content for the exchange,
30 + * the answer is used as the process sent it: the hunks are not re-run
31 + * through xdiff's compaction, and a status=success response with zero
32 + * hunks asserts that the blobs are equivalent, including their
33 + * trailing newlines. A process that cannot answer from the object names
34 + * (or cannot rule out a trailing-newline-only difference) responds
35 + * status=need-content; the pair then gets the builtin answer, served
36 + * from the diff-hunks store or computed. A later
37 + * protocol extension can define a content-carrying request for such
38 + * processes and for sides that are not stored blobs.
39 + */
40 +
41 +#include "git-compat-util.h"
42 +#include "diff.h"
43 +#include "diff-provider-internal.h"
44 +#include "gettext.h"
45 +#include "hex.h"
46 +#include "odb.h"
47 +#include "repository.h"
48 +#include "sigchain.h"
49 +#include "userdiff.h"
50 +#include "sub-process.h"
51 +#include "pkt-line.h"
52 +#include "strbuf.h"
53 +
54 +#define CAP_OID_HUNKS (1u << 0)
55 +
56 +/*
57 + * The provider's state: the repository's diff processes, keyed by
58 + * their command string, so drivers that configure the same command
59 + * share one process. An entry whose process failed stays in the
60 + * pool with the failed bit set, so the command is not retried while
61 + * the entry lives; the pool and its entries last until the provider
62 + * is released.
63 + */
64 +struct diff_process_state {
65 + struct hashmap subprocesses;
66 +};
67 +
68 +struct diff_subprocess {
69 + struct subprocess_entry subprocess;
70 + /*
71 + * Owns the string subprocess.cmd and the hashmap key borrow: the
72 + * entry outlives the userdiff config a re-read may replace.
73 + */
74 + char *cmd;
75 + unsigned int supported_capabilities;
76 + unsigned failed : 1;
77 +};
78 +
79 +static int start_diff_process_fn(struct subprocess_entry *subprocess)
80 +{
81 + static int versions[] = { 1, 0 };
82 + static struct subprocess_capability capabilities[] = {
83 + { "hunks-by-oid", CAP_OID_HUNKS },
84 + { NULL, 0 }
85 + };
86 + struct diff_subprocess *entry =
87 + container_of(subprocess, struct diff_subprocess, subprocess);
88 +
89 + return subprocess_handshake(subprocess, "git-diff",
90 + versions, NULL,
91 + capabilities,
92 + &entry->supported_capabilities);
93 +}
94 +
95 +/*
96 + * The pool entry for a command, or NULL when its process fails to
97 + * start here: the failure leaves a failed entry in the pool, so only
98 + * the request that observed it maps it to an error and later
99 + * requests pass the provider by.
100 + */
101 +static struct diff_subprocess *get_or_launch_process(
102 + struct diff_process_state *state,
103 + struct userdiff_driver *drv)
104 +{
105 + struct subprocess_entry *running;
106 + struct diff_subprocess *entry;
107 +
108 + running = subprocess_find_entry(&state->subprocesses, drv->process);
109 + if (running) {
110 + entry = container_of(running, struct diff_subprocess,
111 + subprocess);
112 + return entry->failed ? NULL : entry;
113 + }
114 +
115 + entry = xcalloc(1, sizeof(*entry));
116 + entry->cmd = xstrdup(drv->process);
117 + if (subprocess_start_command(&entry->subprocess, entry->cmd,
118 + start_diff_process_fn))
119 + entry->failed = 1;
120 + hashmap_entry_init(&entry->subprocess.ent, strhash(entry->cmd));
121 + hashmap_add(&state->subprocesses, &entry->subprocess.ent);
122 + if (entry->failed) {
123 + warning(_("diff process '%s' failed to start;"
124 + " using the builtin diff"), drv->process);
125 + return NULL;
126 + }
127 + return entry;
128 +}
129 +
130 +/*
131 + * A hunk in the diff process's presentation coordinates: the line
132 + * numbering it reports over the protocol. Kept distinct from struct
133 + * xdl_hunk (xdiff's coordinates) so that only translated hunks ever
134 + * reach a consumer; diff_process_hunk_to_xdl() is the single
135 + * crossing point.
136 + */
137 +struct diff_process_hunk {
138 + long old_start, old_count;
139 + long new_start, new_count;
140 +};
141 +
142 +/*
143 + * Parse one non-negative decimal field of a hunk line into *out and
144 + * advance *line past it. Fields must be plain decimal with no leading
145 + * whitespace or sign (isdigit() takes an unsigned char to stay defined
146 + * for high-bit bytes). The first three fields are followed by a single
147 + * space; the last (is_last) is followed by end-of-string or a space.
148 + * Trailing space-separated tokens after the last field are allowed and
149 + * ignored, so a future protocol version can append fields (e.g. a
150 + * "moved" marker) without an older Git rejecting the line, mirroring
151 + * the request-side rule that processes ignore unknown keys.
152 + *
153 + * A value that overflows strtol() is not a parse failure: the line is
154 + * well-formed, so the stream stays in protocol sync. It is reported
155 + * through *out_of_range, and the caller skips the pair the same way
156 + * it skips any other out-of-range coordinate.
157 + */
158 +static int parse_hunk_field(const char **line, long *out, int is_last,
159 + int *out_of_range)
160 +{
161 + const char *p = *line;
162 + char *end;
163 +
164 + if (!isdigit((unsigned char)*p))
165 + return -1;
166 + errno = 0;
167 + *out = strtol(p, &end, 10);
168 + if (end == p)
169 + return -1;
170 + if (errno == ERANGE)
171 + *out_of_range = 1;
172 + else if (errno)
173 + return -1;
174 + if (is_last) {
175 + if (*end != '\0' && *end != ' ')
176 + return -1;
177 + } else {
178 + if (*end != ' ')
179 + return -1;
180 + end++;
181 + }
182 + *line = end;
183 + return 0;
184 +}
185 +
186 +static int parse_hunk_line(const char *line,
187 + struct diff_process_hunk *presented,
188 + int *out_of_range)
189 +{
190 + *out_of_range = 0;
191 + /* Format: "hunk <old_start> <old_count> <new_start> <new_count>" */
192 + if (!skip_prefix(line, "hunk ", &line))
193 + return -1;
194 + if (parse_hunk_field(&line, &presented->old_start, 0, out_of_range) ||
195 + parse_hunk_field(&line, &presented->old_count, 0, out_of_range) ||
196 + parse_hunk_field(&line, &presented->new_start, 0, out_of_range) ||
197 + parse_hunk_field(&line, &presented->new_count, 1, out_of_range))
198 + return -1;
199 + return 0;
200 +}
201 +
202 +/*
203 + * Translate a hunk from the diff process's presentation coordinates
204 + * into xdiff's.
205 + *
206 + * Protocol starts are already 1-based positions (the line a change
207 + * sits before), the same numbering xdiff uses, so the only adjustment
208 + * is for an empty file side: "git diff" addresses it with a start of 0
209 + * and a count of 0 (e.g. "0 0 1 5" adds five lines to an empty old
210 + * side), and since xdiff uses start-1 as an array index that 0 becomes
211 + * 1 here. This is NOT the full inverse of xdl_emit_hunk_hdr()
212 + * (xdiff/xutils.c): that emitter shifts a count-0 range to start-1 for
213 + * the displayed "@@" header, but the protocol keeps the unshifted
214 + * 1-based position for a mid-file insert or delete. This is the single
215 + * point where presentation coordinates become xdiff coordinates, so
216 + * any consumer of these coordinates may assume 1-based starts.
217 + *
218 + * Returns -1 for a start of 0 paired with a nonzero count, which names
219 + * no line in either coordinate system. (parse_hunk_line() already
220 + * guarantees non-negative starts and counts.)
221 + */
222 +static int diff_process_hunk_to_xdl(const struct diff_process_hunk *presented,
223 + struct xdl_hunk *xdl)
224 +{
225 + long old_start = presented->old_start;
226 + long new_start = presented->new_start;
227 +
228 + if ((!old_start && presented->old_count) ||
229 + (!new_start && presented->new_count))
230 + return -1;
231 + if (!old_start)
232 + old_start = 1;
233 + if (!new_start)
234 + new_start = 1;
235 +
236 + xdl->old_start = old_start;
237 + xdl->old_count = presented->old_count;
238 + xdl->new_start = new_start;
239 + xdl->new_count = presented->new_count;
240 + return 0;
241 +}
242 +
243 +/*
244 + * Validate the process's hunks (already in xdiff coordinates) before they
245 + * bypass the diff algorithm. The content-independent rules (in-order,
246 + * non-overlapping, lockstep-aligned, int32-bounded coordinates) are the
247 + * provider interface's shared rule, diff_provider_check_hunk(); this
248 + * function adds the two checks that need the blobs' line counts (a hunk
249 + * past the end of a file, the run after the last hunk) and the
250 + * per-rule diagnostics naming the process. On a bad response we warn
251 + * and the caller falls back to the builtin diff. Returns 0 if valid,
252 + * -1 (after warning) otherwise.
253 + *
254 + * old_lines/new_lines bound the line count of each side, or are
255 + * negative when no bound is known. An oid-only answer arrives without
256 + * content, so its caller passes upper bounds derived from the blobs'
257 + * byte sizes, which caps coordinate magnitude but cannot support the
258 + * run-after-the-last-hunk check: that one compares exact line counts,
259 + * so it runs only when lines_exact is set, which no caller does today.
260 + * It is kept for a content-carrying request, whose loaded buffers
261 + * would provide exact counts.
262 + */
263 +static int validate_external_hunks(const struct xdl_hunk *hunks, size_t nr,
264 + long old_lines, long new_lines,
265 + int lines_exact,
266 + const char *process, const char *path)
267 +{
268 + struct diff_provider_hunks_check c = { 0 };
269 + size_t i;
270 +
271 + for (i = 0; i < nr; i++) {
272 + const struct xdl_hunk *h = &hunks[i];
273 +
274 + if (old_lines >= 0 &&
275 + (h->old_count > old_lines - h->old_start + 1 ||
276 + h->new_count > new_lines - h->new_start + 1)) {
277 + warning(_("diff process '%s' returned a hunk past the "
278 + "end of '%s'; using the builtin diff"),
279 + process, path);
280 + return -1;
281 + }
282 + switch (diff_provider_check_hunk(&c, h->old_start,
283 + h->old_count, h->new_start,
284 + h->new_count)) {
285 + case DIFF_PROVIDER_HUNKS_OK:
286 + break;
287 + case DIFF_PROVIDER_HUNKS_RANGE:
288 + warning(_("diff process '%s' returned out-of-range "
289 + "coordinates for '%s'; using the builtin diff"),
290 + process, path);
291 + return -1;
292 + case DIFF_PROVIDER_HUNKS_OVERLAP:
293 + warning(_("diff process '%s' returned overlapping hunks "
294 + "for '%s'; using the builtin diff"),
295 + process, path);
296 + return -1;
297 + case DIFF_PROVIDER_HUNKS_MISALIGNED:
298 + warning(_("diff process '%s' returned hunks that leave "
299 + "'%s' misaligned; using the builtin diff"),
300 + process, path);
301 + return -1;
302 + }
303 + }
304 + if (lines_exact &&
305 + old_lines - c.prev_old_end != new_lines - c.prev_new_end) {
306 + warning(_("diff process '%s' returned hunks that leave '%s' "
307 + "misaligned; using the builtin diff"),
308 + process, path);
309 + return -1;
310 + }
311 + return 0;
312 +}
313 +
314 +/*
315 + * The most lines a blob can hold, from its size alone: every line,
316 + * even an empty one, costs at least one byte, so a blob of N bytes
317 + * holds at most N lines. Returns -1 when the size is unavailable,
318 + * leaving the response bounded only by the shared int32 rule. A size
319 + * beyond INT32_MAX clamps to it, which loses nothing: a coordinate
320 + * that large fails the shared rule anyway. In a partial clone the
321 + * size lookup must not fetch the blob from the promisor remote:
322 + * validating an answer that exists to avoid loading content must not
323 + * itself download that content, so a missing blob reads as size
324 + * unavailable instead.
325 + */
326 +static long blob_line_cap(struct repository *r, const struct object_id *oid)
327 +{
328 + unsigned long size;
329 + struct object_info oi = OBJECT_INFO_INIT;
330 +
331 + oi.sizep = &size;
332 + if (odb_read_object_info_extended(r->objects, oid, &oi,
333 + OBJECT_INFO_SKIP_FETCH_OBJECT) < 0)
334 + return -1;
335 + if (size > INT32_MAX)
336 + return INT32_MAX;
337 + return (long)size;
338 +}
339 +
340 +/*
341 + * The driver whose process a consultation for path would ask, or NULL
342 + * when none applies (no driver, process not allowed, or xpp carries
343 + * options the process is never told about). Needs no content, so
344 + * the driver is picked before any blob is loaded.
345 + */
346 +static struct userdiff_driver *diff_process_driver(struct diff_options *diffopt,
347 + const char *path,
348 + const xpparam_t *xpp)
349 +{
350 + struct userdiff_driver *drv;
351 +
352 + if (!diffopt || !path)
353 + return NULL;
354 + if (!diffopt->flags.allow_diff_process || diffopt->ignore_driver_algorithm)
355 + return NULL;
356 + /*
357 + * Whitespace-ignoring, regex-ignore (-I) and anchored options
358 + * change which lines count as different, but the process is never
359 + * told about them, so its hunks could not honor them. A forced
360 + * diff algorithm (an option or configured algorithm setting)
361 + * requests a specific builtin computation, which an
362 + * authoritative answer would override. Rather than silently
363 + * override the user's request, fall back to the builtin diff,
364 + * which does honor these flags. Key this off xpp (the
365 + * parameters this diff actually runs with) rather than diffopt,
366 + * so a caller like blame, which keeps its algorithm and
367 + * whitespace flags outside diffopt, is covered without a
368 + * separate guard of its own.
369 + */
370 + if ((xpp->flags & (XDF_WHITESPACE_FLAGS | XDF_IGNORE_BLANK_LINES |
371 + XDF_DIFF_ALGORITHM_MASK)) ||
372 + xpp->ignore_regex_nr || xpp->anchors_nr)
373 + return NULL;
374 +
375 + /*
376 + * A path the protocol cannot carry never selects a process: an
377 + * embedded newline would let the rest of the path forge further
378 + * request keys, and the pathname must fit one packet. Passing
379 + * here keeps the cost local to the path; a failed write would
380 + * instead cost the whole command its process.
381 + */
382 + if (strchr(path, '\n') ||
383 + strlen(path) > LARGE_PACKET_DATA_MAX - strlen("pathname=\n"))
384 + return NULL;
385 +
386 + drv = userdiff_find_by_path(diffopt->repo->index, path);
387 + if (!drv || !drv->process)
388 + return NULL;
389 + return drv;
390 +}
391 +
392 +/*
393 + * Without content there is no size-derived bound on a response, so cap
394 + * accumulation at a constant instead. A response that exceeds the
395 + * cap is a protocol error: the process is disabled for the rest of
396 + * the command and the caller falls back to the builtin diff.
397 + */
398 +#define OID_HUNKS_MAX (1 << 20)
399 +
400 +enum diff_process_result {
401 + DIFF_PROCESS_ERROR = -1, /* failed; caller falls back to builtin */
402 + DIFF_PROCESS_OK = 0, /* the process supplied hunks */
403 + DIFF_PROCESS_SKIP, /* process did not apply: use builtin */
404 + DIFF_PROCESS_EQUIVALENT, /* process says files are equivalent */
405 +};
406 +
407 +/*
408 + * Ask drv's diff process to answer the request from the blob pair's
409 + * object ids alone (the "hunks-by-oid" capability): no content is
410 + * loaded or sent. On DIFF_PROCESS_OK the process's hunks are emitted
411 + * through hunk_cb in 0-based emission coordinates, validated for order,
412 + * overlap, and lockstep alignment first; because Git holds no content,
413 + * the answer is used as the process sent it, without xdiff's compaction.
414 + * DIFF_PROCESS_EQUIVALENT means the process asserts the pair equal.
415 + * DIFF_PROCESS_SKIP covers everything that should fall through to the
416 + * builtin computation: a missing capability, a missing object id, a
417 + * status=need-content answer, or an invalid response.
418 + */
419 +static enum diff_process_result diff_process_query_hunks(
420 + struct diff_process_state *state,
421 + struct userdiff_driver *drv,
422 + const struct diff_provider_request *req,
423 + xdl_emit_hunk_consume_func_t hunk_cb,
424 + void *cb_data)
425 +{
426 + const char *path = req->path;
427 + struct diff_subprocess *entry;
428 + struct child_process *process;
429 + int fd_in, fd_out;
430 + struct packet_reader reader;
431 + struct strbuf status = STRBUF_INIT;
432 + struct xdl_hunk *hunks = NULL;
433 + struct diff_process_hunk presented;
434 + struct xdl_hunk hunk;
435 + size_t nr_hunks = 0, alloc_hunks = 0, i;
436 + int bad_coords = 0;
437 + long old_cap, new_cap;
438 + enum diff_process_result res;
439 +
440 + if (!req->old_oid || !req->new_oid)
441 + return DIFF_PROCESS_SKIP;
442 +
443 + entry = get_or_launch_process(state, drv);
444 + if (!entry)
445 + return DIFF_PROCESS_ERROR;
446 + if (!(entry->supported_capabilities & CAP_OID_HUNKS))
447 + return DIFF_PROCESS_SKIP;
448 +
449 + process = subprocess_get_child_process(&entry->subprocess);
450 + fd_in = process->in;
451 + fd_out = process->out;
452 +
453 + sigchain_push(SIGPIPE, SIG_IGN);
454 +
455 + if (packet_write_fmt_gently(fd_in, "command=hunks-by-oid\n") ||
456 + packet_write_fmt_gently(fd_in, "pathname=%s\n", path) ||
457 + packet_write_fmt_gently(fd_in, "old-oid=%s\n",
458 + oid_to_hex(req->old_oid)) ||
459 + packet_write_fmt_gently(fd_in, "new-oid=%s\n",
460 + oid_to_hex(req->new_oid)) ||
461 + packet_flush_gently(fd_in))
462 + goto comm_error;
463 +
464 + packet_reader_init(&reader, fd_out, NULL, 0,
465 + PACKET_READ_CHOMP_NEWLINE |
466 + PACKET_READ_GENTLE_ON_EOF |
467 + PACKET_READ_GENTLE_ON_READ_ERROR);
468 + for (;;) {
469 + enum packet_read_status rs = packet_reader_read(&reader);
470 + int out_of_range;
471 +
472 + if (rs == PACKET_READ_FLUSH)
473 + break;
474 + /*
475 + * Only a hunk line may precede the flush. EOF and a
476 + * malformed frame end the session; an empty packet, which
477 + * a length-only read cannot tell from a flush, would
478 + * truncate the hunk section here and leave the status
479 + * section to poison the next request, so it is a protocol
480 + * error too.
481 + */
482 + if (rs != PACKET_READ_NORMAL || !reader.pktlen)
483 + goto comm_error;
484 + if (parse_hunk_line(reader.line, &presented,
485 + &out_of_range) < 0)
486 + goto comm_error;
487 + if (bad_coords)
488 + continue;
489 + if (out_of_range ||
490 + diff_process_hunk_to_xdl(&presented, &hunk) < 0) {
491 + /*
492 + * Semantically invalid coordinates in a well-formed
493 + * response: the stream stays in protocol sync, so
494 + * drain the rest and fall back for this file while
495 + * keeping the process alive, the same treatment
496 + * validate_external_hunks() failures receive.
497 + */
498 + bad_coords = 1;
499 + continue;
500 + }
501 + if (nr_hunks >= OID_HUNKS_MAX) {
502 + warning(_("diff process '%s' sent too many hunks"
503 + " for '%s'; disabling it for the"
504 + " remainder of this command"),
505 + drv->process, path);
506 + goto disable;
507 + }
508 + ALLOC_GROW(hunks, nr_hunks + 1, alloc_hunks);
509 + hunks[nr_hunks++] = hunk;
510 + }
511 +
512 + if (subprocess_read_status_gently(fd_out, &status))
513 + goto comm_error;
514 +
515 + if (!strcmp(status.buf, "success")) {
516 + if (bad_coords) {
517 + warning(_("diff process '%s' returned out-of-range "
518 + "coordinates for '%s'; using the builtin diff"),
519 + drv->process, path);
520 + res = DIFF_PROCESS_SKIP;
521 + goto out;
522 + }
523 + if (!nr_hunks) {
524 + res = DIFF_PROCESS_EQUIVALENT;
525 + goto out;
526 + }
527 + /*
528 + * Bound the coordinates by the blobs' sizes, read from the
529 + * object database without loading content. Either both
530 + * bounds hold or neither is applied: a partial bound would
531 + * misclassify a response that the other side's size would
532 + * have caught.
533 + */
534 + old_cap = blob_line_cap(req->repo, req->old_oid);
535 + new_cap = blob_line_cap(req->repo, req->new_oid);
536 + if (old_cap < 0 || new_cap < 0)
537 + old_cap = new_cap = -1;
538 + if (validate_external_hunks(hunks, nr_hunks, old_cap, new_cap,
539 + 0, drv->process, path) < 0) {
540 + res = DIFF_PROCESS_SKIP;
541 + goto out;
542 + }
543 + /*
544 + * Replay in the coordinates a hunk consumer receives from
545 + * xdiff's emission: 0-based starts. The answer is used as
546 + * the process sent it; with no content in hand it cannot be
547 + * re-run through xdiff's compaction.
548 + */
549 + for (i = 0; i < nr_hunks; i++)
550 + hunk_cb(hunks[i].old_start - 1, hunks[i].old_count,
551 + hunks[i].new_start - 1, hunks[i].new_count,
552 + cb_data);
553 + res = DIFF_PROCESS_OK;
554 + goto out;
555 + }
556 + if (!strcmp(status.buf, "need-content")) {
557 + /*
558 + * The process cannot answer this pair from its object names;
559 + * the caller computes the diff itself.
560 + */
561 + res = DIFF_PROCESS_SKIP;
562 + goto out;
563 + }
564 + if (!strcmp(status.buf, "abort")) {
565 + /* The process withdrew: stop asking it for this session. */
566 + entry->supported_capabilities &= ~CAP_OID_HUNKS;
567 + res = DIFF_PROCESS_SKIP;
568 + goto out;
569 + }
570 + /*
571 + * An unrecognized status is a protocol error, not a per-pair
572 + * failure: this Git did not request anything it does not know,
573 + * so the process is answering some other protocol, and asking
574 + * it again would warn on every pair of the traversal.
575 + */
576 + warning(_("diff process '%s' sent unrecognized status '%s' for "
577 + "'%s'; disabling it for the remainder of this command"),
578 + drv->process, status.buf, path);
579 + goto disable;
580 +out:
581 + free(hunks);
582 + strbuf_release(&status);
583 + sigchain_pop(SIGPIPE);
584 + return res;
585 +
586 +comm_error:
587 + warning(_("diff process '%s' failed for '%s'; disabling it"
588 + " for the remainder of this command"),
589 + drv->process, path);
590 +disable:
591 + subprocess_stop_command(&entry->subprocess);
592 + entry->failed = 1;
593 + free(hunks);
594 + strbuf_release(&status);
595 + sigchain_pop(SIGPIPE);
596 + return DIFF_PROCESS_ERROR;
597 +}
598 +
599 +/*
600 + * The process outranks every later provider through its chain
601 + * position: when it answers, the walk ends, so no later provider
602 + * serves the pair, and an answered pair is never recorded. When it
603 + * does not answer (it defers with need-content, lacks the
604 + * capability, or failed), the caller computes the builtin diff for
605 + * that pair. The store holds builtin results and nothing else, so
606 + * an identity answer for such a pair equals what the caller would
607 + * compute. Every non-answer is therefore a pass: a refusal would
608 + * suppress that equal answer, and would keep a warming run from
609 + * recording the builtin result the caller computes anyway.
610 + */
611 +static enum diff_provider_disposition
612 +diff_process_consult(struct diff_provider *provider,
613 + const struct diff_provider_request *req,
614 + diff_provider_fill_fn fill UNUSED, void *fill_data UNUSED,
615 + xdl_emit_hunk_consume_func_t hunk_cb, void *cb_data)
616 +{
617 + struct diff_process_state *state = provider->state;
618 + struct userdiff_driver *drv;
619 + struct subprocess_entry *running;
620 +
621 + drv = diff_process_driver(req->diffopt, req->path, req->xpp);
622 + if (!drv)
623 + return DIFF_PROVIDER_DISP_PASS;
624 + running = subprocess_find_entry(&state->subprocesses, drv->process);
625 + if (running && container_of(running, struct diff_subprocess,
626 + subprocess)->failed)
627 + return DIFF_PROVIDER_DISP_PASS;
628 +
629 + switch (diff_process_query_hunks(state, drv, req,
630 + hunk_cb, cb_data)) {
631 + case DIFF_PROCESS_OK:
632 + case DIFF_PROCESS_EQUIVALENT:
633 + return DIFF_PROVIDER_DISP_ANSWERED;
634 + case DIFF_PROCESS_SKIP:
635 + case DIFF_PROCESS_ERROR:
636 + break;
637 + }
638 + return DIFF_PROVIDER_DISP_PASS;
639 +}
640 +
641 +static void diff_process_release(struct diff_provider *provider)
642 +{
643 + struct diff_process_state *state = provider->state;
644 + struct hashmap_iter iter;
645 + struct diff_subprocess *entry;
646 +
647 + /* A failed entry's process is already stopped or never ran. */
648 + hashmap_for_each_entry(&state->subprocesses, &iter, entry,
649 + subprocess.ent) {
650 + if (!entry->failed)
651 + subprocess_stop_command(&entry->subprocess);
652 + free(entry->cmd);
653 + }
654 + hashmap_clear_and_free(&state->subprocesses,
655 + struct diff_subprocess, subprocess.ent);
656 + free(state);
657 +}
658 +
659 +struct diff_provider *diff_process_provider_new(void)
660 +{
661 + struct diff_process_state *state = xcalloc(1, sizeof(*state));
662 + struct diff_provider *p = xcalloc(1, sizeof(*p));
663 +
664 + hashmap_init(&state->subprocesses, cmd2process_cmp, NULL, 0);
665 + p->consult = diff_process_consult;
666 + p->release = diff_process_release;
667 + p->state = state;
668 + return p;
669 +}
diff-provider-internal.h
+1
@@ -93,6 +93,7 @@ struct diff_provider {
93 * diff-provider.c holds itself. Each call returns a fresh provider
94 * for one repository's chain.
95 */
96 +struct diff_provider *diff_process_provider_new(void);
97 struct diff_provider *diff_hunks_store_provider_new(void);
98
99 /*
diff-provider.c
+7 -5
@@ -41,11 +41,11 @@ static struct diff_provider *builtin_provider_new(void)
41
42 /*
43 * The repository's chain, assembled on first walk. The composition
44 - * is fixed, and the order is the authority resolution: the store is
45 - * consulted before the builtin computation, the terminal provider,
46 - * so the chain always ends in an implementor that can answer.
47 - * Nothing is decided per repository here; each provider gates itself
48 - * per request.
44 + * is fixed, and the order is the authority resolution: the process
45 + * outranks the store, and the builtin computation is the terminal
46 + * provider, so the chain always ends in an implementor that can
47 + * answer. Nothing is decided per repository here; each provider
48 + * gates itself per request.
49 */
50 static struct diff_provider *provider_chain(struct repository *r)
51 {
@@ -53,6 +53,8 @@ static struct diff_provider *provider_chain(struct repository *r)
53
54 if (*tail)
55 return *tail;
56 + *tail = diff_process_provider_new();
57 + tail = &(*tail)->next;
58 *tail = diff_hunks_store_provider_new();
59 tail = &(*tail)->next;
60 *tail = builtin_provider_new();
diff-provider.h
+23 -16
@@ -11,19 +11,23 @@
11 * its content is loaded.
12 *
13 * A hunk provider answers a consumer's request from the pair's
14 - * identity (its blob object ids) and the parameters that determine
15 - * the diff; a request no provider answers falls through to the
16 - * consumer's own computation. A provider is either authoritative for
17 - * its requests, meaning its answer may deliberately differ from the
18 - * builtin diff, or not, meaning its answer must reproduce the builtin
19 - * result exactly. The interface resolves that authority through a
20 - * provider chain owned by the repository, built on first consultation
21 - * and released by repo_clear(): chain order is the resolution, and
22 - * the builtin computation itself is the chain's terminal provider. A
23 - * consumer never names a provider; it reads the outcome below.
24 - * Every answer a provider serves from identity passes the shared
25 - * coordinate check (diff-provider-internal.h) before any consumer
26 - * sees it.
14 + * identity, its blob object ids and the settings that determine the
15 + * diff, before any content is loaded; a request no provider answers
16 + * falls through to the consumer's own computation. Two providers implement this
17 + * interface with different authority. The diff-hunks store
18 + * (diff-hunks.h) is in-process and not authoritative: it may only
19 + * reproduce the builtin result, so it never asserts a pair
20 + * equivalent, and it stands aside wherever a process outranks it. A
21 + * process configured in diff.<driver>.process (diff-process.c) is
22 + * authoritative for its paths: its answer may deliberately differ
23 + * from the builtin diff, including asserting a pair equivalent. The
24 + * interface resolves that authority through a provider chain owned
25 + * by the repository, built on first consultation and released by
26 + * repo_clear(): chain order is the resolution, and the builtin
27 + * computation itself is the chain's terminal provider. A consumer
28 + * never names a provider; it reads the outcome below. Every answer a
29 + * provider serves from identity passes the shared coordinate check
30 + * (diff-provider-internal.h) before any consumer sees it.
31 */
32
33 struct diff_options;
@@ -95,14 +99,17 @@ enum diff_provider_outcome {
99 * name the blobs whose bytes are diffed; pass NULL for a side whose
100 * bytes are not a stored blob (a working-tree file, textconv output,
101 * a gitlink), so no provider answers from an id it cannot look up.
98 - * diffopt carries the diff settings that live outside xpp; xpp
99 - * carries the parameters the diff runs with. Each provider gates
100 - * itself on the fields that concern it.
102 + * path names the file the pair is diffed as; a provider selected by
103 + * path applies only where it is set. diffopt carries the diff
104 + * settings that live outside xpp; xpp carries the parameters the
105 + * diff runs with. Each provider gates itself on the fields that
106 + * concern it.
107 */
108 struct diff_provider_request {
109 struct repository *repo;
110 const struct object_id *old_oid;
111 const struct object_id *new_oid;
112 + const char *path;
113 struct diff_options *diffopt;
114 const xpparam_t *xpp;
115 };
diff.c
+38 -4
@@ -4377,6 +4377,13 @@ static int diffstat_from_hunks(struct diff_options *o,
4377 &one->oid : NULL,
4378 .new_oid = (two->oid_valid && !S_ISGITLINK(two->mode)) ?
4379 &two->oid : NULL,
4380 + /*
4381 + * Attribute lookup and the process protocol need the
4382 + * repo-relative path; the display name a caller passes
4383 + * around may be stripped of o->prefix and would miss a
4384 + * driver scoped to a directory.
4385 + */
4386 + .path = one->path,
4387 .diffopt = o,
4388 .xpp = &xpp,
4389 };
@@ -4491,12 +4498,14 @@ static void builtin_diffstat(const char *name_a, const char *name_b,
4498
4499 else if (may_differ) {
4500 /*
4494 - * Serve or record via the diff-hunks store. A "log -L"
4501 + * Serve from a hunk provider (the process, then the store),
4502 + * or record into the store on a warming run. A "log -L"
4503 * range-scoped stat is not the whole-pair diff the store
4504 * keys, so it neither reads nor records. Otherwise diff
4505 * normally.
4506 */
4499 - if (p->line_ranges || !diffstat_from_hunks(o, one, two, data)) {
4507 + if (p->line_ranges ||
4508 + !diffstat_from_hunks(o, one, two, data)) {
4509 /* Crazy xdl interfaces.. */
4510 xpparam_t xpp;
4511 xdemitconf_t xecfg;
@@ -6252,6 +6261,27 @@ static int diff_opt_submodule(const struct option *opt,
6261 return 0;
6262 }
6263
6264 +static int diff_opt_ext_diff(const struct option *opt,
6265 + const char *arg, int unset)
6266 +{
6267 + struct diff_options *options = opt->value;
6268 +
6269 + BUG_ON_OPT_ARG(arg);
6270 + options->flags.allow_external = !unset;
6271 + options->flags.allow_diff_process = !unset;
6272 + return 0;
6273 +}
6274 +
6275 +static int diff_opt_diff_process(const struct option *opt,
6276 + const char *arg, int unset)
6277 +{
6278 + struct diff_options *options = opt->value;
6279 +
6280 + BUG_ON_OPT_ARG(arg);
6281 + options->flags.allow_diff_process = !unset;
6282 + return 0;
6283 +}
6284 +
6285 static int diff_opt_textconv(const struct option *opt,
6286 const char *arg, int unset)
6287 {
@@ -6582,8 +6612,12 @@ struct option *add_diff_options(const struct option *opts,
6612 N_("exit with 1 if there were differences, 0 otherwise")),
6613 OPT_BOOL(0, "quiet", &options->flags.quick,
6614 N_("disable all output of the program")),
6585 - OPT_BOOL(0, "ext-diff", &options->flags.allow_external,
6586 - N_("allow an external diff helper to be executed")),
6615 + OPT_CALLBACK_F(0, "ext-diff", options, NULL,
6616 + N_("allow an external diff helper to be executed"),
6617 + PARSE_OPT_NOARG, diff_opt_ext_diff),
6618 + OPT_CALLBACK_F(0, "diff-process", options, NULL,
6619 + N_("allow a configured diff process to be consulted"),
6620 + PARSE_OPT_NOARG, diff_opt_diff_process),
6621 OPT_CALLBACK_F(0, "textconv", options, NULL,
6622 N_("run external text conversion filters when comparing binary files"),
6623 PARSE_OPT_NOARG, diff_opt_textconv),
diff.h
+9
@@ -173,6 +173,15 @@ struct diff_flags {
173 */
174 unsigned allow_external;
175
176 + /**
177 + * Allows diff.<driver>.process to be consulted. Set by the
178 + * porcelain commands whose output may reflect a diff process
179 + * (diff, log, show, blame) and by --ext-diff or --diff-process;
180 + * plumbing does not set it by default, so its output stays
181 + * builtin. Cleared by --no-ext-diff or --no-diff-process.
182 + */
183 + unsigned allow_diff_process;
184 +
185 /**
186 * For communication between the calling program and the options parser;
187 * tell the calling program to signal the presence of difference using
meson.build
+1
@@ -356,6 +356,7 @@ libgit_sources = [
356 'diff-merges.c',
357 'diff-lib.c',
358 'diff-no-index.c',
359 + 'diff-process.c',
360 'diff-provider.c',
361 'diff.c',
362 'diffcore-break.c',
range-diff.c
+6
@@ -52,6 +52,12 @@ static int read_patches(const char *range, struct string_list *list,
52 int ret = -1;
53
54 strvec_pushl(&cp.args, "log", "--no-color", "-p",
55 + /*
56 + * The patches being compared must be the builtin
57 + * diff's: an external diff command or diff process
58 + * could change either side of the comparison.
59 + */
60 + "--no-ext-diff",
61 "--reverse", "--date-order", "--decorate=no",
62 "--no-prefix", "--submodule=short",
63 /*
t/helper/meson.build
+1
@@ -12,6 +12,7 @@ test_tool_sources = [
12 'test-date.c',
13 'test-delete-gpgsig.c',
14 'test-delta.c',
15 + 'test-diff-process-backend.c',
16 'test-dir-iterator.c',
17 'test-drop-caches.c',
18 'test-dump-cache-tree.c',
t/helper/test-diff-process-backend.c new
+349
@@ -0,0 +1,349 @@
1 +/*
2 + * Test process implementing the diff process protocol (diff.<driver>.process).
3 + *
4 + * Speaks the long-running process protocol over stdin/stdout and
5 + * answers command=hunks-by-oid requests from the blob object names
6 + * alone; no content is exchanged. The --mode= switch selects the
7 + * response shape:
8 + *
9 + * oid-fixed packet: git< hunk 5 2 5 2
10 + * oid-equal packet: git< status=success (zero hunks: equivalent)
11 + * oid-need-content packet: git< status=need-content
12 + * oid-empty packet: git< hunk 0 0 1 2 (empty old side)
13 + *
14 + * and the adversarial shapes the protocol error paths are tested
15 + * with:
16 + *
17 + * oid-trailing a hunk line with a trailing token to ignore
18 + * oid-malformed a hunk line that does not parse
19 + * oid-huge coordinates far past the end of any test blob
20 + * oid-erange a count too large for any long
21 + * oid-overlap two hunks out of order
22 + * oid-misaligned two hunks whose unchanged runs differ in length
23 + * oid-badstart a start of 0 paired with a nonzero count
24 + * oid-unknown-status status=frobnicate
25 + * oid-abort status=abort
26 + * oid-bare-status a status packet without the hunk-section flush
27 + * oid-empty-packet an empty packet (0004) inside the hunk section
28 + * oid-crash one hunk line, then exit with no flush or status
29 + * oid-garbage raw non-pkt-line bytes, then exit
30 + * cap-none handshake announcing no capability at all
31 + *
32 + * Success responses end with:
33 + *
34 + * packet: git< 0000
35 + * packet: git< status=success
36 + * packet: git< 0000
37 + *
38 + * Each request is logged to --log as:
39 + *
40 + * command=<cmd> pathname=<path> old-oid=<hex> new-oid=<hex>
41 + */
42 +
43 +#include "test-tool.h"
44 +#include "pkt-line.h"
45 +#include "parse-options.h"
46 +#include "strbuf.h"
47 +
48 +static FILE *logfile;
49 +
50 +enum mode {
51 + MODE_OID_FIXED,
52 + MODE_OID_EQUAL,
53 + MODE_OID_NEED_CONTENT,
54 + MODE_OID_EMPTY,
55 + MODE_OID_TRAILING,
56 + MODE_OID_MALFORMED,
57 + MODE_OID_HUGE,
58 + MODE_OID_ERANGE,
59 + MODE_OID_OVERLAP,
60 + MODE_OID_MISALIGNED,
61 + MODE_OID_BADSTART,
62 + MODE_OID_UNKNOWN_STATUS,
63 + MODE_OID_ABORT,
64 + MODE_OID_BARE_STATUS,
65 + MODE_OID_EMPTY_PACKET,
66 + MODE_OID_CRASH,
67 + MODE_OID_GARBAGE,
68 + MODE_CAP_NONE,
69 +};
70 +
71 +static enum mode parse_mode(const char *s)
72 +{
73 + if (!strcmp(s, "oid-fixed"))
74 + return MODE_OID_FIXED;
75 + if (!strcmp(s, "oid-equal"))
76 + return MODE_OID_EQUAL;
77 + if (!strcmp(s, "oid-need-content"))
78 + return MODE_OID_NEED_CONTENT;
79 + if (!strcmp(s, "oid-empty"))
80 + return MODE_OID_EMPTY;
81 + if (!strcmp(s, "oid-trailing"))
82 + return MODE_OID_TRAILING;
83 + if (!strcmp(s, "oid-malformed"))
84 + return MODE_OID_MALFORMED;
85 + if (!strcmp(s, "oid-huge"))
86 + return MODE_OID_HUGE;
87 + if (!strcmp(s, "oid-erange"))
88 + return MODE_OID_ERANGE;
89 + if (!strcmp(s, "oid-overlap"))
90 + return MODE_OID_OVERLAP;
91 + if (!strcmp(s, "oid-misaligned"))
92 + return MODE_OID_MISALIGNED;
93 + if (!strcmp(s, "oid-badstart"))
94 + return MODE_OID_BADSTART;
95 + if (!strcmp(s, "oid-unknown-status"))
96 + return MODE_OID_UNKNOWN_STATUS;
97 + if (!strcmp(s, "oid-abort"))
98 + return MODE_OID_ABORT;
99 + if (!strcmp(s, "oid-bare-status"))
100 + return MODE_OID_BARE_STATUS;
101 + if (!strcmp(s, "oid-empty-packet"))
102 + return MODE_OID_EMPTY_PACKET;
103 + if (!strcmp(s, "oid-crash"))
104 + return MODE_OID_CRASH;
105 + if (!strcmp(s, "oid-garbage"))
106 + return MODE_OID_GARBAGE;
107 + if (!strcmp(s, "cap-none"))
108 + return MODE_CAP_NONE;
109 + die("unknown --mode=%s", s);
110 +}
111 +
112 +/*
113 + * Read "key=value" packets up to a flush, capturing "command" and
114 + * "pathname". Returns 1 if a request was read, 0 on EOF.
115 + *
116 + * The first packet uses the gentle variant so that a clean shutdown
117 + * by Git (EOF) does not produce a spurious "the remote end hung up
118 + * unexpectedly" on stderr. Subsequent packets use the non-gentle
119 + * variant: once inside a request, truncation is a protocol violation
120 + * and dying loudly is the correct response.
121 + */
122 +static int read_request_header(char **command, char **pathname,
123 + char **old_oid, char **new_oid)
124 +{
125 + int first = 1;
126 + char *line;
127 +
128 + *command = *pathname = *old_oid = *new_oid = NULL;
129 + for (;;) {
130 + const char *value;
131 +
132 + if (first) {
133 + if (packet_read_line_gently(0, NULL, &line) < 0)
134 + return 0;
135 + first = 0;
136 + } else {
137 + line = packet_read_line(0, NULL);
138 + }
139 + if (!line)
140 + break;
141 + if (skip_prefix(line, "command=", &value))
142 + *command = xstrdup(value);
143 + else if (skip_prefix(line, "pathname=", &value))
144 + *pathname = xstrdup(value);
145 + else if (skip_prefix(line, "old-oid=", &value))
146 + *old_oid = xstrdup(value);
147 + else if (skip_prefix(line, "new-oid=", &value))
148 + *new_oid = xstrdup(value);
149 + }
150 + return 1;
151 +}
152 +
153 +static void send_status(const char *status)
154 +{
155 + packet_flush(1);
156 + packet_write_fmt(1, "%s\n", status);
157 + packet_flush(1);
158 +}
159 +
160 +static void command_loop(enum mode mode)
161 +{
162 + for (;;) {
163 + char *command = NULL, *pathname = NULL;
164 + char *old_oid = NULL, *new_oid = NULL;
165 +
166 + if (!read_request_header(&command, &pathname,
167 + &old_oid, &new_oid))
168 + break; /* EOF: Git closed its end */
169 +
170 + if (!command || strcmp(command, "hunks-by-oid"))
171 + die("unexpected command: '%s'",
172 + command ? command : "(none)");
173 +
174 + if (logfile) {
175 + fprintf(logfile,
176 + "command=%s pathname=%s old-oid=%s new-oid=%s\n",
177 + command,
178 + pathname ? pathname : "(none)",
179 + old_oid ? old_oid : "(none)",
180 + new_oid ? new_oid : "(none)");
181 + fflush(logfile);
182 + }
183 +
184 + switch (mode) {
185 + case MODE_OID_FIXED:
186 + packet_write_fmt(1, "hunk 5 2 5 2\n");
187 + send_status("status=success");
188 + break;
189 + case MODE_OID_EQUAL:
190 + send_status("status=success");
191 + break;
192 + case MODE_OID_EMPTY:
193 + /*
194 + * An empty old side: the "git diff" convention
195 + * addresses it with a start of 0 and a count of 0.
196 + * Claims two lines added, fewer than the builtin
197 + * would show, so the answer is observable.
198 + */
199 + packet_write_fmt(1, "hunk 0 0 1 2\n");
200 + send_status("status=success");
201 + break;
202 + case MODE_OID_TRAILING:
203 + /*
204 + * Git must ignore trailing space-separated tokens
205 + * on a hunk line (the appendability rule), so this
206 + * must behave exactly like oid-fixed.
207 + */
208 + packet_write_fmt(1, "hunk 5 2 5 2 moved=yes\n");
209 + send_status("status=success");
210 + break;
211 + case MODE_OID_MALFORMED:
212 + packet_write_fmt(1, "hunk five two 5 2\n");
213 + send_status("status=success");
214 + break;
215 + case MODE_OID_HUGE:
216 + /*
217 + * In-range for int32 (and for a 32-bit long), so
218 + * only the blob-size bound can reject it.
219 + */
220 + packet_write_fmt(1, "hunk 1 1000000000 1 1000000000\n");
221 + send_status("status=success");
222 + break;
223 + case MODE_OID_ERANGE:
224 + /* Overflows strtol() even where long is 64-bit. */
225 + packet_write_fmt(1, "hunk 1 99999999999999999999 1 1\n");
226 + send_status("status=success");
227 + break;
228 + case MODE_OID_OVERLAP:
229 + packet_write_fmt(1, "hunk 3 2 3 2\n");
230 + packet_write_fmt(1, "hunk 2 2 2 2\n");
231 + send_status("status=success");
232 + break;
233 + case MODE_OID_MISALIGNED:
234 + packet_write_fmt(1, "hunk 2 1 2 1\n");
235 + packet_write_fmt(1, "hunk 5 1 6 1\n");
236 + send_status("status=success");
237 + break;
238 + case MODE_OID_BADSTART:
239 + /*
240 + * A start of 0 names an empty side, so a nonzero
241 + * count beside it names no line; the coordinate is
242 + * rejected per pair while the process stays alive.
243 + */
244 + packet_write_fmt(1, "hunk 0 2 1 2\n");
245 + send_status("status=success");
246 + break;
247 + case MODE_OID_UNKNOWN_STATUS:
248 + send_status("status=frobnicate");
249 + break;
250 + case MODE_OID_ABORT:
251 + send_status("status=abort");
252 + break;
253 + case MODE_OID_BARE_STATUS:
254 + /* No hunk-section flush: a protocol violation. */
255 + packet_write_fmt(1, "status=success\n");
256 + packet_flush(1);
257 + break;
258 + case MODE_OID_EMPTY_PACKET:
259 + /*
260 + * An empty packet is not a flush; inside the hunk
261 + * section it is a protocol violation.
262 + */
263 + if (write(1, "0004", 4) < 0)
264 + die_errno("write empty packet");
265 + send_status("status=success");
266 + break;
267 + case MODE_OID_CRASH:
268 + packet_write_fmt(1, "hunk 5 2 5 2\n");
269 + exit(0);
270 + case MODE_OID_GARBAGE:
271 + if (write(1, "@@@@ not a pkt-line @@@@", 24) < 0)
272 + die_errno("write garbage");
273 + exit(0);
274 + default:
275 + send_status("status=need-content");
276 + break;
277 + }
278 +
279 + free(command);
280 + free(pathname);
281 + free(old_oid);
282 + free(new_oid);
283 + }
284 +}
285 +
286 +static void handshake(enum mode mode)
287 +{
288 + char *line;
289 +
290 + line = packet_read_line(0, NULL);
291 + if (!line || strcmp(line, "git-diff-client"))
292 + die("bad welcome: '%s'", line ? line : "(eof)");
293 + line = packet_read_line(0, NULL);
294 + if (!line || strcmp(line, "version=1"))
295 + die("bad version: '%s'", line ? line : "(eof)");
296 + if (packet_read_line(0, NULL))
297 + die("expected flush after version");
298 +
299 + packet_write_fmt(1, "git-diff-server\n");
300 + packet_write_fmt(1, "version=1\n");
301 + packet_flush(1);
302 +
303 + /* Drain capabilities advertised by Git */
304 + while ((line = packet_read_line(0, NULL)))
305 + ; /* drain */
306 +
307 + if (mode != MODE_CAP_NONE)
308 + packet_write_fmt(1, "capability=hunks-by-oid\n");
309 + packet_flush(1);
310 +}
311 +
312 +static const char *const usage_str[] = {
313 + "test-tool diff-process-backend --mode=<mode> [--log=<path>]",
314 + NULL
315 +};
316 +
317 +int cmd__diff_process_backend(int argc, const char **argv)
318 +{
319 + const char *mode_str = NULL, *log_path = NULL;
320 + enum mode mode = MODE_OID_FIXED;
321 + struct option options[] = {
322 + OPT_STRING(0, "mode", &mode_str, "mode",
323 + "response shape (default oid-fixed);"
324 + " see the file header for the full list of modes"),
325 + OPT_STRING(0, "log", &log_path, "path",
326 + "append per-request summary to this file"),
327 + OPT_END()
328 + };
329 +
330 + argc = parse_options(argc, argv, NULL, options, usage_str, 0);
331 + if (argc)
332 + usage_with_options(usage_str, options);
333 +
334 + if (mode_str)
335 + mode = parse_mode(mode_str);
336 +
337 + if (log_path) {
338 + logfile = fopen(log_path, "a");
339 + if (!logfile)
340 + die_errno("failed to open log '%s'", log_path);
341 + }
342 +
343 + handshake(mode);
344 + command_loop(mode);
345 +
346 + if (logfile && fclose(logfile))
347 + die_errno("error closing log");
348 + return 0;
349 +}
t/helper/test-tool.c
+1
@@ -22,6 +22,7 @@ static struct test_cmd cmds[] = {
22 { "date", cmd__date },
23 { "delete-gpgsig", cmd__delete_gpgsig },
24 { "delta", cmd__delta },
25 + { "diff-process-backend", cmd__diff_process_backend },
26 { "dir-iterator", cmd__dir_iterator },
27 { "drop-caches", cmd__drop_caches },
28 { "dump-cache-tree", cmd__dump_cache_tree },
t/helper/test-tool.h
+1
@@ -15,6 +15,7 @@ int cmd__csprng(int argc, const char **argv);
15 int cmd__date(int argc, const char **argv);
16 int cmd__delta(int argc, const char **argv);
17 int cmd__delete_gpgsig(int argc, const char **argv);
18 +int cmd__diff_process_backend(int argc, const char **argv);
19 int cmd__dir_iterator(int argc, const char **argv);
20 int cmd__drop_caches(int argc, const char **argv);
21 int cmd__dump_cache_tree(int argc, const char **argv);
t/meson.build
+1
@@ -518,6 +518,7 @@ integration_tests = [
518 't4072-diff-max-depth.sh',
519 't4073-diff-stat-name-width.sh',
520 't4074-diff-shifted-matched-group.sh',
521 + 't4080-diff-process.sh',
522 't4100-apply-stat.sh',
523 't4101-apply-nonl.sh',
524 't4102-apply-rename.sh',
t/t4080-diff-process.sh new
+593
@@ -0,0 +1,593 @@
1 +#!/bin/sh
2 +
3 +test_description='diff.<driver>.process: oid-only hunk requests'
4 +
5 +TEST_PASSES_SANITIZE_LEAK=true
6 +. ./test-lib.sh
7 +
8 +# See t/helper/test-diff-process-backend.c for the process implementation
9 +# and available --mode= options.
10 +
11 +BACKEND="test-tool diff-process-backend"
12 +
13 +test_expect_success 'setup' '
14 + echo "*.c diff=cdiff" >.gitattributes &&
15 + git add .gitattributes &&
16 +
17 + # 10 lines, changes at 5-6 and 9-10 between the two commits.
18 + cat >pair.c <<-\EOF &&
19 + line1
20 + line2
21 + line3
22 + line4
23 + original5
24 + original6
25 + line7
26 + line8
27 + line9
28 + line10
29 + EOF
30 + git add pair.c &&
31 + git commit -m "add pair.c" &&
32 +
33 + cat >pair.c <<-\EOF &&
34 + line1
35 + line2
36 + line3
37 + line4
38 + changed5
39 + changed6
40 + line7
41 + line8
42 + changed9
43 + changed10
44 + EOF
45 + git add pair.c &&
46 + git commit -m "change pair.c"
47 +'
48 +
49 +test_expect_success 'an oid-capable process answers blame by object names alone' '
50 + test_when_finished "rm -f backend.log" &&
51 + ORIG=$(git rev-parse --short HEAD~1) &&
52 + CHANGE=$(git rev-parse --short HEAD) &&
53 + # The process reports only lines 5-6 as changed, so blame attributes
54 + # lines 9-10 to the original commit even though the builtin diff
55 + # would show them as changed.
56 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
57 + blame pair.c >actual &&
58 + sed -n "9p" actual >line9 &&
59 + sed -n "10p" actual >line10 &&
60 + test_grep "$ORIG" line9 &&
61 + test_grep "$ORIG" line10 &&
62 + sed -n "5p" actual >line5 &&
63 + test_grep "$CHANGE" line5 &&
64 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
65 +'
66 +
67 +test_expect_success 'an oid-capable process answers --numstat by object names alone' '
68 + test_when_finished "rm -f backend.log" &&
69 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
70 + log -1 --format= --numstat -- pair.c >actual &&
71 + printf "2\t2\tpair.c\n" >expect &&
72 + test_cmp expect actual &&
73 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
74 +'
75 +
76 +test_expect_success 'need-content falls through to the builtin diff' '
77 + test_when_finished "rm -f backend.log" &&
78 + git -c diff.cdiff.process="$BACKEND --mode=oid-need-content --log=backend.log" \
79 + log -1 --format= --numstat -- pair.c >actual &&
80 + printf "4\t4\tpair.c\n" >expect &&
81 + test_cmp expect actual &&
82 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
83 +'
84 +
85 +test_expect_success 'a warmed hunk store does not override process hunks' '
86 + test_when_finished "git diff-hunks clear" &&
87 + ORIG=$(git rev-parse --short HEAD~1) &&
88 + GIT_DIFF_HUNKS_WRITE=1 git log -2 --stat -- pair.c >/dev/null &&
89 +
90 + # Control: without a process, blame is served from the store.
91 + git blame --show-stats pair.c >stats &&
92 + test_grep "num precomputed hits: 1" stats &&
93 +
94 + # The store holds the builtin hunks, but a process-capable driver
95 + # makes the process authoritative, so blame must reflect the
96 + # process hunks (only lines 5-6), not a store hit.
97 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed" \
98 + blame pair.c >actual &&
99 + sed -n "9p" actual >line9 &&
100 + test_grep "$ORIG" line9
101 +'
102 +
103 +test_expect_success 'a worktree side is not asked by object names' '
104 + test_when_finished "rm -f backend.log && git checkout -- pair.c" &&
105 + echo "worktree edit" >>pair.c &&
106 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
107 + diff --numstat -- pair.c >actual &&
108 + printf "1\t0\tpair.c\n" >expect &&
109 + test_cmp expect actual &&
110 + test_path_is_missing backend.log
111 +'
112 +
113 +test_expect_success 'diff process bypassed by --no-ext-diff' '
114 + test_when_finished "rm -f backend.log" &&
115 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
116 + log -1 --format= --numstat --no-ext-diff -- pair.c >actual &&
117 + printf "4\t4\tpair.c\n" >expect &&
118 + test_cmp expect actual &&
119 + test_path_is_missing backend.log
120 +'
121 +
122 +test_expect_success 'format-patch keeps its diffstat off the process' '
123 + test_when_finished "rm -f backend.log" &&
124 + # format-patch emits a diffstat, and a diffstat consults the
125 + # process, but the gate keeps it builtin so a generated patch
126 + # applies for recipients without the process.
127 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
128 + format-patch -1 --stdout --stat -- pair.c >actual &&
129 + test_grep "^+changed9" actual &&
130 + test_path_is_missing backend.log
131 +'
132 +
133 +test_expect_success 'format-patch --ext-diff keeps its diffstat off the process' '
134 + test_when_finished "rm -f backend.log" &&
135 + # The gate holds even when --ext-diff enables the external command.
136 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
137 + format-patch -1 --stdout --ext-diff --stat -- pair.c >actual &&
138 + test_grep "^+changed9" actual &&
139 + test_path_is_missing backend.log
140 +'
141 +
142 +test_expect_success 'diff process not consulted by plumbing diff commands' '
143 + test_when_finished "rm -f backend.log && git checkout -f HEAD -- pair.c" &&
144 + # diff-tree diffs the two commits, a real pair a defeated gate would
145 + # consult the process for.
146 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
147 + diff-tree --numstat HEAD >actual &&
148 + test_grep "pair.c" actual &&
149 + # diff-index needs a change to diff, or there is no pair and a
150 + # missing log proves nothing; stage one and diff it against HEAD.
151 + echo "staged change" >>pair.c &&
152 + git add pair.c &&
153 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
154 + diff-index --cached --numstat HEAD -- pair.c >actual &&
155 + test_grep "pair.c" actual &&
156 + test_path_is_missing backend.log
157 +'
158 +
159 +test_expect_success 'add -p stages from the builtin diff with a process configured' '
160 + test_when_finished "rm -f backend.log" &&
161 + cat >gate.c <<-\EOF &&
162 + int gate(void) { return 1; }
163 + EOF
164 + git add gate.c &&
165 + git commit -m "add gate.c" &&
166 + cat >gate.c <<-\EOF &&
167 + int gate(void) { return 2; }
168 + EOF
169 + # add -p builds its hunks from patch text, which is not a provider
170 + # consumer today, so a configured process cannot shape what it
171 + # offers. This pins that interactive patch stays builtin for the
172 + # current consumers, rather than exercising the plumbing gate.
173 + test_write_lines y |
174 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
175 + add -p gate.c &&
176 + git diff --cached -- gate.c >staged &&
177 + test_grep "return 2" staged &&
178 + test_path_is_missing backend.log &&
179 + git commit -m "gate.c v2"
180 +'
181 +
182 +test_expect_success 'blame withholds identity for the working-tree pair' '
183 + test_when_finished "rm -f backend.log && git checkout -- pair.c" &&
184 + echo "uncommitted" >>pair.c &&
185 + wt=$(git hash-object pair.c) &&
186 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
187 + blame pair.c >actual &&
188 + # The dirty working-tree side is not a stored blob: no request
189 + # may name its bytes by object id.
190 + test_grep ! "new-oid=$wt" backend.log
191 +'
192 +
193 +test_expect_success 'a replaced blob makes the process step aside' '
194 + new_blob=$(git rev-parse HEAD:pair.c) &&
195 + test_when_finished "rm -f backend.log && git replace -d $new_blob" &&
196 + # Replacing the new-side blob redirects the content the diff reads
197 + # under the id the process would be sent, so a raw-id request would
198 + # name bytes other than the ones diffed. Identity is withheld: the
199 + # process is not consulted and the builtin computes the pair from
200 + # the replaced content.
201 + repl=$(printf "just one line\n" | git hash-object -w --stdin) &&
202 + git replace "$new_blob" "$repl" &&
203 + git log -1 --format= --numstat -- pair.c >expect &&
204 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
205 + log -1 --format= --numstat -- pair.c >actual &&
206 + test_cmp expect actual &&
207 + test_path_is_missing backend.log
208 +'
209 +
210 +test_expect_success 'an equivalence answer omits the pair from the stat' '
211 + test_when_finished "rm -f backend.log" &&
212 + git -c diff.cdiff.process="$BACKEND --mode=oid-equal --log=backend.log" \
213 + log -1 --format= --numstat -- pair.c >actual &&
214 + # An equivalent pair sums to a zero-count entry, and the stat
215 + # code omits zero-count modified entries, the same way a
216 + # whitespace-only pair prints nothing under -w. The builtin
217 + # diff would print nonzero counts here, and the log proves the
218 + # process was consulted.
219 + test_must_be_empty actual &&
220 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
221 +'
222 +
223 +test_expect_success 'blame passes equivalent pairs through to the boundary' '
224 + ORIG=$(git rev-parse --short ":/add pair.c") &&
225 + # The process asserts every consulted pair equal, so no line is
226 + # ever treated as changed: every line passes through to the
227 + # commit that added the file, marked as the blame boundary.
228 + git -c diff.cdiff.process="$BACKEND --mode=oid-equal" \
229 + blame pair.c >actual &&
230 + sed -n "5p" actual >line5 &&
231 + test_grep "^\^$ORIG" line5 &&
232 + sed -n "10p" actual >line10 &&
233 + test_grep "^\^$ORIG" line10
234 +'
235 +
236 +test_expect_success 'a warming run records a pair the process defers' '
237 + test_when_finished "git diff-hunks clear" &&
238 + git diff-hunks clear &&
239 + # The process owns the path but defers this pair with
240 + # need-content, so the pair gets the builtin diff; that is the
241 + # result the store holds, so the warming run records it and a
242 + # later read is served from the store.
243 + GIT_DIFF_HUNKS_WRITE=1 git -c core.diffHunks=true \
244 + -c diff.cdiff.process="$BACKEND --mode=oid-need-content" \
245 + log -1 --format= --stat -- pair.c >/dev/null &&
246 + git -c core.diffHunks=true blame --show-stats pair.c >stats 2>&1 &&
247 + test_grep "num precomputed hits: 1" stats
248 +'
249 +
250 +# The protocol error paths: each adversarial response shape must warn,
251 +# fall back to the builtin output, and either keep the process alive
252 +# (a per-pair rejection) or disable it for the rest of the command (a
253 +# protocol error). The request log tells the two apart: the log walk
254 +# below consults two pairs (gate.c first, then pair.c), so a disabled
255 +# process shows one logged request and a live one shows two.
256 +
257 +test_expect_success 'a malformed hunk line disables the process for the command' '
258 + test_when_finished "rm -f backend.log err" &&
259 + git log --format= --numstat -- "*.c" >expect &&
260 + git -c diff.cdiff.process="$BACKEND --mode=oid-malformed --log=backend.log" \
261 + log --format= --numstat -- "*.c" >actual 2>err &&
262 + test_cmp expect actual &&
263 + test_grep "disabling it for the remainder" err &&
264 + test_line_count = 1 backend.log
265 +'
266 +
267 +test_expect_success 'coordinates past the blob size skip the pair, process stays alive' '
268 + test_when_finished "rm -f backend.log err" &&
269 + git log --format= --numstat -- "*.c" >expect &&
270 + git -c diff.cdiff.process="$BACKEND --mode=oid-huge --log=backend.log" \
271 + log --format= --numstat -- "*.c" >actual 2>err &&
272 + test_cmp expect actual &&
273 + test_grep "past the end" err &&
274 + test_line_count = 2 backend.log
275 +'
276 +
277 +test_expect_success 'a count that overflows long skips the pair, process stays alive' '
278 + test_when_finished "rm -f backend.log err" &&
279 + git log --format= --numstat -- "*.c" >expect &&
280 + git -c diff.cdiff.process="$BACKEND --mode=oid-erange --log=backend.log" \
281 + log --format= --numstat -- "*.c" >actual 2>err &&
282 + test_cmp expect actual &&
283 + test_grep "out-of-range coordinates" err &&
284 + test_line_count = 2 backend.log
285 +'
286 +
287 +test_expect_success 'overlapping hunks are rejected per pair' '
288 + test_when_finished "rm -f backend.log err" &&
289 + git log --format= --numstat -- "*.c" >expect &&
290 + git -c diff.cdiff.process="$BACKEND --mode=oid-overlap --log=backend.log" \
291 + log --format= --numstat -- "*.c" >actual 2>err &&
292 + test_cmp expect actual &&
293 + test_grep "overlapping hunks" err &&
294 + test_line_count = 2 backend.log
295 +'
296 +
297 +test_expect_success 'misaligned hunks are rejected per pair' '
298 + test_when_finished "rm -f backend.log err" &&
299 + git log --format= --numstat -- "*.c" >expect &&
300 + git -c diff.cdiff.process="$BACKEND --mode=oid-misaligned --log=backend.log" \
301 + log --format= --numstat -- "*.c" >actual 2>err &&
302 + test_cmp expect actual &&
303 + test_grep "misaligned" err &&
304 + test_line_count = 2 backend.log
305 +'
306 +
307 +test_expect_success 'a start of zero with a nonzero count is rejected per pair' '
308 + test_when_finished "rm -f backend.log err" &&
309 + git log --format= --numstat -- "*.c" >expect &&
310 + # A start of 0 names an empty side, so a nonzero count beside it
311 + # names no line; the coordinate is rejected and the pair falls back
312 + # to the builtin diff while the process stays alive.
313 + git -c diff.cdiff.process="$BACKEND --mode=oid-badstart --log=backend.log" \
314 + log --format= --numstat -- "*.c" >actual 2>err &&
315 + test_cmp expect actual &&
316 + test_grep "out-of-range coordinates" err &&
317 + test_line_count = 2 backend.log
318 +'
319 +
320 +test_expect_success 'an unrecognized status disables the process for the command' '
321 + test_when_finished "rm -f backend.log err" &&
322 + git log --format= --numstat -- "*.c" >expect &&
323 + git -c diff.cdiff.process="$BACKEND --mode=oid-unknown-status --log=backend.log" \
324 + log --format= --numstat -- "*.c" >actual 2>err &&
325 + test_cmp expect actual &&
326 + test_grep "unrecognized status .frobnicate." err &&
327 + test_line_count = 1 backend.log
328 +'
329 +
330 +test_expect_success 'status=abort withdraws the capability without a warning' '
331 + test_when_finished "rm -f backend.log err" &&
332 + git log --format= --numstat -- "*.c" >expect &&
333 + git -c diff.cdiff.process="$BACKEND --mode=oid-abort --log=backend.log" \
334 + log --format= --numstat -- "*.c" >actual 2>err &&
335 + test_cmp expect actual &&
336 + test_grep ! "disabling" err &&
337 + test_line_count = 1 backend.log
338 +'
339 +
340 +test_expect_success 'a bare status without the hunk-section flush is a protocol error' '
341 + test_when_finished "rm -f backend.log err" &&
342 + git log --format= --numstat -- "*.c" >expect &&
343 + git -c diff.cdiff.process="$BACKEND --mode=oid-bare-status --log=backend.log" \
344 + log --format= --numstat -- "*.c" >actual 2>err &&
345 + test_cmp expect actual &&
346 + test_grep "disabling it for the remainder" err &&
347 + test_line_count = 1 backend.log
348 +'
349 +
350 +test_expect_success 'an empty packet in the hunk section is a protocol error' '
351 + test_when_finished "rm -f backend.log err" &&
352 + git log --format= --numstat -- "*.c" >expect &&
353 + git -c diff.cdiff.process="$BACKEND --mode=oid-empty-packet --log=backend.log" \
354 + log --format= --numstat -- "*.c" >actual 2>err &&
355 + test_cmp expect actual &&
356 + test_grep "disabling it for the remainder" err &&
357 + test_line_count = 1 backend.log
358 +'
359 +
360 +test_expect_success 'a process that dies mid-response fails the command over to builtin' '
361 + test_when_finished "rm -f backend.log err" &&
362 + git log --format= --numstat -- "*.c" >expect &&
363 + git -c diff.cdiff.process="$BACKEND --mode=oid-crash --log=backend.log" \
364 + log --format= --numstat -- "*.c" >actual 2>err &&
365 + test_cmp expect actual &&
366 + test_grep "disabling it for the remainder" err &&
367 + test_line_count = 1 backend.log
368 +'
369 +
370 +test_expect_success 'garbage bytes on stdout fail the command over to builtin' '
371 + test_when_finished "rm -f backend.log err" &&
372 + git log --format= --numstat -- "*.c" >expect &&
373 + git -c diff.cdiff.process="$BACKEND --mode=oid-garbage --log=backend.log" \
374 + log --format= --numstat -- "*.c" >actual 2>err &&
375 + test_cmp expect actual &&
376 + test_grep "disabling it for the remainder" err &&
377 + test_line_count = 1 backend.log
378 +'
379 +
380 +test_expect_success 'a process announcing no capability is never asked' '
381 + test_when_finished "rm -f backend.log" &&
382 + git log --format= --numstat -- "*.c" >expect &&
383 + git -c diff.cdiff.process="$BACKEND --mode=cap-none --log=backend.log" \
384 + log --format= --numstat -- "*.c" >actual &&
385 + test_cmp expect actual &&
386 + test_must_be_empty backend.log
387 +'
388 +
389 +test_expect_success 'a trailing token on a hunk line is ignored' '
390 + test_when_finished "rm -f backend.log" &&
391 + git -c diff.cdiff.process="$BACKEND --mode=oid-trailing --log=backend.log" \
392 + log -1 --format= --numstat -- pair.c >actual &&
393 + printf "2\t2\tpair.c\n" >expect &&
394 + test_cmp expect actual &&
395 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
396 +'
397 +
398 +test_expect_success 'a failed start warns once and the store may serve the path' '
399 + git init failrepo &&
400 + (
401 + cd failrepo &&
402 + echo "*.c diff=cdiff" >.gitattributes &&
403 + git add .gitattributes &&
404 + test_commit f1 f.c "one" &&
405 + test_commit f2 f.c "one
406 +two" &&
407 + test_commit f3 f.c "one
408 +two
409 +three" &&
410 + GIT_DIFF_HUNKS_WRITE=1 git log --format= --stat -- f.c >/dev/null &&
411 + # The command has no shell metacharacters, so it fails at
412 + # exec time; a shell-wrapped command would fail at the
413 + # handshake, which the gentle handshake in the base
414 + # (061a68e443) likewise degrades to the builtin diff.
415 + git blame f.c >expect &&
416 + git -c diff.cdiff.process=/does-not-exist-diff-backend \
417 + blame f.c >actual 2>err &&
418 + test_cmp expect actual &&
419 + # One warning even though the blame consults two pairs.
420 + test $(grep -c "failed to start" err) = 1 &&
421 + # A failure is a non-answer like any other: the request
422 + # that observes it and every later request pass to the
423 + # store, so the warmed store serves both pairs.
424 + git -c diff.cdiff.process=/does-not-exist-diff-backend \
425 + blame --show-stats f.c >stats 2>&1 &&
426 + test_grep "num precomputed hits: 2" stats
427 + )
428 +'
429 +
430 +test_expect_success 'the store serves a pair the process defers' '
431 + test_when_finished "git diff-hunks clear" &&
432 + git diff-hunks clear &&
433 + GIT_DIFF_HUNKS_WRITE=1 git log --format= --stat -- pair.c >/dev/null &&
434 + git blame pair.c >expect &&
435 + # need-content defers the pair to the builtin diff, which is
436 + # what the store holds, so the walk continues past the process
437 + # and the store serves the pair.
438 + git -c diff.cdiff.process="$BACKEND --mode=oid-need-content" \
439 + blame pair.c >actual &&
440 + test_cmp expect actual &&
441 + git -c diff.cdiff.process="$BACKEND --mode=oid-need-content" \
442 + blame --show-stats pair.c >stats 2>&1 &&
443 + test_grep "num precomputed hits: 1" stats
444 +'
445 +
446 +test_expect_success 'git diff between commits consults the process' '
447 + test_when_finished "rm -f backend.log" &&
448 + ORIG=$(git rev-parse ":/add pair.c") &&
449 + CHANGE=$(git rev-parse ":/change pair.c") &&
450 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
451 + diff --numstat $ORIG $CHANGE -- pair.c >actual &&
452 + printf "2\t2\tpair.c\n" >expect &&
453 + test_cmp expect actual &&
454 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
455 +'
456 +
457 +test_expect_success 'git show consults the process' '
458 + test_when_finished "rm -f backend.log" &&
459 + CHANGE=$(git rev-parse ":/change pair.c") &&
460 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
461 + show --format= --numstat $CHANGE -- pair.c >actual &&
462 + printf "2\t2\tpair.c\n" >expect &&
463 + test_cmp expect actual &&
464 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
465 +'
466 +
467 +test_expect_success 'diff-tree --ext-diff consults the process' '
468 + test_when_finished "rm -f backend.log" &&
469 + CHANGE=$(git rev-parse ":/change pair.c") &&
470 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
471 + diff-tree --ext-diff --no-commit-id --numstat $CHANGE >actual &&
472 + printf "2\t2\tpair.c\n" >expect &&
473 + test_cmp expect actual &&
474 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
475 +'
476 +
477 +test_expect_success '--no-diff-process forbids consulting alone' '
478 + test_when_finished "rm -f backend.log" &&
479 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
480 + log -1 --no-diff-process --format= --numstat -- pair.c >actual &&
481 + printf "4\t4\tpair.c\n" >expect &&
482 + test_cmp expect actual &&
483 + test_path_is_missing backend.log
484 +'
485 +
486 +test_expect_success '--diff-process allows plumbing to consult' '
487 + test_when_finished "rm -f backend.log" &&
488 + CHANGE=$(git rev-parse ":/change pair.c") &&
489 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
490 + diff-tree --diff-process --no-commit-id --numstat $CHANGE >actual &&
491 + printf "2\t2\tpair.c\n" >expect &&
492 + test_cmp expect actual &&
493 + test_grep "command=hunks-by-oid pathname=pair.c" backend.log
494 +'
495 +
496 +test_expect_success 'a forced blame diff algorithm bypasses the process' '
497 + test_when_finished "rm -f backend.log" &&
498 + CHANGE=$(git rev-parse --short ":/change pair.c") &&
499 + # The process would attribute lines 9-10 to the original commit
500 + # (see the oid-fixed blame test above); a forced builtin
501 + # algorithm must produce the builtin attribution and never start
502 + # the process.
503 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
504 + blame --histogram pair.c >actual &&
505 + sed -n "9p" actual >line9 &&
506 + test_grep "$CHANGE" line9 &&
507 + test_path_is_missing backend.log
508 +'
509 +
510 +test_expect_success 'textconv output is never identified to the process' '
511 + test_when_finished "rm -f backend.log" &&
512 + echo "*.tcv diff=tcv" >>.gitattributes &&
513 + git add .gitattributes &&
514 + git commit -m tcv-attr &&
515 + test_config diff.tcv.textconv cat &&
516 + test_commit tcv1 file.tcv "alpha" &&
517 + test_commit tcv2 file.tcv "alpha
518 +beta" &&
519 + git blame file.tcv >expect &&
520 + git -c diff.tcv.process="$BACKEND --mode=oid-fixed --log=backend.log" \
521 + blame file.tcv >actual &&
522 + test_cmp expect actual &&
523 + test_path_is_missing backend.log
524 +'
525 +
526 +test_expect_success 'a gitlink side is never identified to the process' '
527 + test_when_finished "rm -f backend.log" &&
528 + echo "sub diff=cdiff" >>.gitattributes &&
529 + git add .gitattributes &&
530 + git commit -m sub-attr &&
531 + C1=$(git rev-parse HEAD) &&
532 + C2=$(git rev-parse HEAD~1) &&
533 + git update-index --add --cacheinfo 160000,$C1,sub &&
534 + git commit -m sub-1 &&
535 + git update-index --add --cacheinfo 160000,$C2,sub &&
536 + git commit -m sub-2 &&
537 + git -c diff.cdiff.process="$BACKEND --mode=oid-fixed --log=backend.log" \
538 + log -1 --format= --numstat -- sub >actual &&
539 + printf "1\t1\tsub\n" >expect &&
540 + test_cmp expect actual &&
541 + test_path_is_missing backend.log
542 +'
543 +
544 +test_expect_success 'a relative diff consults by the repo-relative path' '
545 + test_when_finished "rm -f backend.log" &&
546 + echo "reldir/*.rel diff=rdrv" >>.gitattributes &&
547 + git add .gitattributes &&
548 + git commit -m rel-attr &&
549 + mkdir reldir &&
550 + test_write_lines line1 line2 line3 line4 original5 original6 \
551 + line7 line8 line9 line10 >reldir/x.rel &&
552 + git add reldir/x.rel &&
553 + git commit -m "add x.rel" &&
554 + test_write_lines line1 line2 line3 line4 changed5 changed6 \
555 + line7 line8 changed9 changed10 >reldir/x.rel &&
556 + git add reldir/x.rel &&
557 + git commit -m "change x.rel" &&
558 + # diff.relative strips the prefix from the displayed name; the
559 + # driver lookup and the request pathname must still use the
560 + # repo-relative path, or the directory-scoped attribute above
561 + # would not match and the process would never be consulted.
562 + # The process runs at the repository root, so its log lands there.
563 + (
564 + cd reldir &&
565 + git -c diff.relative=true \
566 + -c diff.rdrv.process="$BACKEND --mode=oid-fixed --log=backend.log" \
567 + log -1 --format= --numstat -- x.rel
568 + ) >actual &&
569 + printf "2\t2\tx.rel\n" >expect &&
570 + test_cmp expect actual &&
571 + test_grep "command=hunks-by-oid pathname=reldir/x.rel" backend.log
572 +'
573 +
574 +test_expect_success 'an empty file side is answered with a start of zero' '
575 + test_when_finished "rm -f backend.log" &&
576 + >empty.c &&
577 + git add empty.c &&
578 + git commit -m "add empty.c" &&
579 + printf "x\ny\nz\n" >empty.c &&
580 + git add empty.c &&
581 + git commit -m "fill empty.c" &&
582 + # The process addresses the empty old side with a start of 0 and a
583 + # count of 0, and claims two of the three added lines. The answer is
584 + # used as sent, so the stat shows the two lines it named, not the
585 + # three the builtin would.
586 + git -c diff.cdiff.process="$BACKEND --mode=oid-empty --log=backend.log" \
587 + log -1 --format= --numstat -- empty.c >actual &&
588 + printf "2\t0\tempty.c\n" >expect &&
589 + test_cmp expect actual &&
590 + test_grep "command=hunks-by-oid pathname=empty.c" backend.log
591 +'
592 +
593 +test_done
xdiff-interface.h
+12
@@ -6,6 +6,18 @@
6
7 struct object_database;
8
9 +/*
10 + * Hunk descriptor for externally computed diffs, in xdiff's own
11 + * coordinates: line numbers are 1-based and a hunk's start is the
12 + * first line it covers. A caller translates any external "empty side"
13 + * idiom (such as git diff's start-0/count-0) to a 1-based start before
14 + * storing hunks in this struct.
15 + */
16 +struct xdl_hunk {
17 + long old_start, old_count;
18 + long new_start, new_count;
19 +};
20 +
21 /*
22 * xdiff isn't equipped to handle content over a gigabyte;
23 * we make the cutoff 1GB - 1MB to give some breathing