]> git.ipfire.org Git - thirdparty/git.git/commitdiff
builtin: introduce diff-pairs command
authorJustin Tobler <jltobler@gmail.com>
Fri, 28 Feb 2025 21:33:45 +0000 (15:33 -0600)
committerJunio C Hamano <gitster@pobox.com>
Mon, 3 Mar 2025 16:17:47 +0000 (08:17 -0800)
Through git-diff(1), a single diff can be generated from a pair of blob
revisions directly. Unfortunately, there is not a mechanism to compute
batches of specific file pair diffs in a single process. Such a feature
is particularly useful on the server-side where diffing between a large
set of changes is not feasible all at once due to timeout concerns.

To facilitate this, introduce git-diff-pairs(1) which acts as a backend
passing its NUL-terminated raw diff format input from stdin through diff
machinery to produce various forms of output such as patch or raw.

The raw format was originally designed as an interchange format and
represents the contents of the diff_queued_diff list making it possible
to break the diff pipeline into separate stages. For example,
git-diff-tree(1) can be used as a frontend to compute file pairs to
queue and feed its raw output to git-diff-pairs(1) to compute patches.
With this, batches of diffs can be progressively generated without
having to recompute renames or retrieve object context. Something like
the following:

git diff-tree -r -z -M $old $new |
git diff-pairs -p -z

should generate the same output as `git diff-tree -p -M`. Furthermore,
each line of raw diff formatted input can also be individually fed to a
separate git-diff-pairs(1) process and still produce the same output.

Based-on-patch-by: Jeff King <peff@peff.net>
Signed-off-by: Justin Tobler <jltobler@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
.gitignore
Documentation/git-diff-pairs.adoc [new file with mode: 0644]
Documentation/meson.build
Makefile
builtin.h
builtin/diff-pairs.c [new file with mode: 0644]
command-list.txt
git.c
meson.build
t/meson.build
t/t4070-diff-pairs.sh [new file with mode: 0755]

index 08a66ca50844c5829e8fd499d16212248c6d8f05..04c444404e4ba835659089c0fdafc68bb9fccc93 100644 (file)
@@ -55,6 +55,7 @@
 /git-diff
 /git-diff-files
 /git-diff-index
+/git-diff-pairs
 /git-diff-tree
 /git-difftool
 /git-difftool--helper
diff --git a/Documentation/git-diff-pairs.adoc b/Documentation/git-diff-pairs.adoc
new file mode 100644 (file)
index 0000000..e31f2e2
--- /dev/null
@@ -0,0 +1,56 @@
+git-diff-pairs(1)
+=================
+
+NAME
+----
+git-diff-pairs - Compare the content and mode of provided blob pairs
+
+SYNOPSIS
+--------
+[synopsis]
+git diff-pairs -z [<diff-options>]
+
+DESCRIPTION
+-----------
+Show changes for file pairs provided on stdin. Input for this command must be
+in the NUL-terminated raw output format as generated by commands such as `git
+diff-tree -z -r --raw`. By default, the outputted diffs are computed and shown
+in the patch format when stdin closes.
+
+Usage of this command enables the traditional diff pipeline to be broken up
+into separate stages where `diff-pairs` acts as the output phase. Other
+commands, such as `diff-tree`, may serve as a frontend to compute the raw
+diff format used as input.
+
+Instead of computing diffs via `git diff-tree -p -M` in one step, `diff-tree`
+can compute the file pairs and rename information without the blob diffs. This
+output can be fed to `diff-pairs` to generate the underlying blob diffs as done
+in the following example:
+
+-----------------------------
+git diff-tree -z -r -M $a $b |
+git diff-pairs -z
+-----------------------------
+
+Computing the tree diff upfront with rename information allows patch output
+from `diff-pairs` to be progressively computed over the course of potentially
+multiple invocations.
+
+Pathspecs are not currently supported by `diff-pairs`. Pathspec limiting should
+be performed by the upstream command generating the raw diffs used as input.
+
+Tree objects are not currently supported as input and are rejected.
+
+Abbreviated object IDs in the `diff-pairs` input are not supported. Outputted
+object IDs can be abbreviated using the `--abbrev` option.
+
+OPTIONS
+-------
+
+include::diff-options.adoc[]
+
+include::diff-generate-patch.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
index 1129ce4c85676dbeb480e739bde566455fce04c9..ce990e9fe531aba40980e8b5f258b325cef5da50 100644 (file)
@@ -42,6 +42,7 @@ manpages = {
   'git-diagnose.adoc' : 1,
   'git-diff-files.adoc' : 1,
   'git-diff-index.adoc' : 1,
+  'git-diff-pairs.adoc' : 1,
   'git-difftool.adoc' : 1,
   'git-diff-tree.adoc' : 1,
   'git-diff.adoc' : 1,
index 6d45093089d1363733324f2b10c5a5b61360f31d..ee48bcad20979e8615518c32f2e2d3303afd94e5 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1242,6 +1242,7 @@ BUILTIN_OBJS += builtin/describe.o
 BUILTIN_OBJS += builtin/diagnose.o
 BUILTIN_OBJS += builtin/diff-files.o
 BUILTIN_OBJS += builtin/diff-index.o
+BUILTIN_OBJS += builtin/diff-pairs.o
 BUILTIN_OBJS += builtin/diff-tree.o
 BUILTIN_OBJS += builtin/diff.o
 BUILTIN_OBJS += builtin/difftool.o
index 89928ccf92f5324e94221157b89ce19cba66eff3..e6aad3a6a1f0ee8942b07e6a8b3b80c3bca2ed87 100644 (file)
--- a/builtin.h
+++ b/builtin.h
@@ -153,6 +153,7 @@ int cmd_diagnose(int argc, const char **argv, const char *prefix, struct reposit
 int cmd_diff_files(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_diff_index(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_diff(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_diff_pairs(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_diff_tree(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_difftool(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_env__helper(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/diff-pairs.c b/builtin/diff-pairs.c
new file mode 100644 (file)
index 0000000..6be17c1
--- /dev/null
@@ -0,0 +1,193 @@
+#include "builtin.h"
+#include "config.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "gettext.h"
+#include "hash.h"
+#include "hex.h"
+#include "object.h"
+#include "parse-options.h"
+#include "revision.h"
+#include "strbuf.h"
+
+static unsigned parse_mode_or_die(const char *mode, const char **end)
+{
+       uint16_t ret;
+
+       *end = parse_mode(mode, &ret);
+       if (!*end)
+               die(_("unable to parse mode: %s"), mode);
+       return ret;
+}
+
+static void parse_oid_or_die(const char *hex, struct object_id *oid,
+                            const char **end, const struct git_hash_algo *algop)
+{
+       if (parse_oid_hex_algop(hex, oid, end, algop) || *(*end)++ != ' ')
+               die(_("unable to parse object id: %s"), hex);
+}
+
+int cmd_diff_pairs(int argc, const char **argv, const char *prefix,
+                  struct repository *repo)
+{
+       struct strbuf path_dst = STRBUF_INIT;
+       struct strbuf path = STRBUF_INIT;
+       struct strbuf meta = STRBUF_INIT;
+       struct option *parseopts;
+       struct rev_info revs;
+       int line_term = '\0';
+       int ret;
+
+       const char * const builtin_diff_pairs_usage[] = {
+               N_("git diff-pairs -z [<diff-options>]"),
+               NULL
+       };
+       struct option builtin_diff_pairs_options[] = {
+               OPT_END()
+       };
+
+       repo_init_revisions(repo, &revs, prefix);
+
+       /*
+        * Diff options are usually parsed implicitly as part of
+        * setup_revisions(). Explicitly handle parsing to ensure options are
+        * printed in the usage message.
+        */
+       parseopts = add_diff_options(builtin_diff_pairs_options, &revs.diffopt);
+       show_usage_with_options_if_asked(argc, argv, builtin_diff_pairs_usage, parseopts);
+
+       repo_config(repo, git_diff_basic_config, NULL);
+       revs.disable_stdin = 1;
+       revs.abbrev = 0;
+       revs.diff = 1;
+
+       argc = parse_options(argc, argv, prefix, parseopts, builtin_diff_pairs_usage,
+                            PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_DASHDASH);
+
+       if (setup_revisions(argc, argv, &revs, NULL) > 1)
+               usagef(_("unrecognized argument: %s"), argv[0]);
+
+       /*
+        * With the -z option, both command input and raw output are
+        * NUL-delimited (this mode does not affect patch output). At present
+        * only NUL-delimited raw diff formatted input is supported.
+        */
+       if (revs.diffopt.line_termination)
+               usage(_("working without -z is not supported"));
+
+       if (revs.prune_data.nr)
+               usage(_("pathspec arguments not supported"));
+
+       if (revs.pending.nr || revs.max_count != -1 ||
+           revs.min_age != (timestamp_t)-1 ||
+           revs.max_age != (timestamp_t)-1)
+               usage(_("revision arguments not allowed"));
+
+       if (!revs.diffopt.output_format)
+               revs.diffopt.output_format = DIFF_FORMAT_PATCH;
+
+       /*
+        * If rename detection is not requested, use rename information from the
+        * raw diff formatted input. Setting skip_resolving_statuses ensures
+        * diffcore_std() does not mess with rename information already present
+        * in queued filepairs.
+        */
+       if (!revs.diffopt.detect_rename)
+               revs.diffopt.skip_resolving_statuses = 1;
+
+       while (1) {
+               struct object_id oid_a, oid_b;
+               struct diff_filepair *pair;
+               unsigned mode_a, mode_b;
+               const char *p;
+               char status;
+
+               if (strbuf_getwholeline(&meta, stdin, line_term) == EOF)
+                       break;
+
+               p = meta.buf;
+               if (*p != ':')
+                       die(_("invalid raw diff input"));
+               p++;
+
+               mode_a = parse_mode_or_die(p, &p);
+               mode_b = parse_mode_or_die(p, &p);
+
+               if (S_ISDIR(mode_a) || S_ISDIR(mode_b))
+                       die(_("tree objects not supported"));
+
+               parse_oid_or_die(p, &oid_a, &p, repo->hash_algo);
+               parse_oid_or_die(p, &oid_b, &p, repo->hash_algo);
+
+               status = *p++;
+
+               if (strbuf_getwholeline(&path, stdin, line_term) == EOF)
+                       die(_("got EOF while reading path"));
+
+               switch (status) {
+               case DIFF_STATUS_ADDED:
+                       pair = diff_queue_addremove(&diff_queued_diff,
+                                                   &revs.diffopt, '+', mode_b,
+                                                   &oid_b, 1, path.buf, 0);
+                       if (pair)
+                               pair->status = status;
+                       break;
+
+               case DIFF_STATUS_DELETED:
+                       pair = diff_queue_addremove(&diff_queued_diff,
+                                                   &revs.diffopt, '-', mode_a,
+                                                   &oid_a, 1, path.buf, 0);
+                       if (pair)
+                               pair->status = status;
+                       break;
+
+               case DIFF_STATUS_TYPE_CHANGED:
+               case DIFF_STATUS_MODIFIED:
+                       pair = diff_queue_change(&diff_queued_diff, &revs.diffopt,
+                                                mode_a, mode_b, &oid_a, &oid_b,
+                                                1, 1, path.buf, 0, 0);
+                       if (pair)
+                               pair->status = status;
+                       break;
+
+               case DIFF_STATUS_RENAMED:
+               case DIFF_STATUS_COPIED: {
+                               struct diff_filespec *a, *b;
+                               unsigned int score;
+
+                               if (strbuf_getwholeline(&path_dst, stdin, line_term) == EOF)
+                                       die(_("got EOF while reading destination path"));
+
+                               a = alloc_filespec(path.buf);
+                               b = alloc_filespec(path_dst.buf);
+                               fill_filespec(a, &oid_a, 1, mode_a);
+                               fill_filespec(b, &oid_b, 1, mode_b);
+
+                               pair = diff_queue(&diff_queued_diff, a, b);
+
+                               if (strtoul_ui(p, 10, &score))
+                                       die(_("unable to parse rename/copy score: %s"), p);
+
+                               pair->score = score * MAX_SCORE / 100;
+                               pair->status = status;
+                               pair->renamed_pair = 1;
+                       }
+                       break;
+
+               default:
+                       die(_("unknown diff status: %c"), status);
+               }
+       }
+
+       diffcore_std(&revs.diffopt);
+       diff_flush(&revs.diffopt);
+       ret = diff_result_code(&revs);
+
+       strbuf_release(&path_dst);
+       strbuf_release(&path);
+       strbuf_release(&meta);
+       release_revisions(&revs);
+       FREE_AND_NULL(parseopts);
+
+       return ret;
+}
index c537114b4687b8f98e4c2678b1f7a782e38fa984..b7ade3ab9f3319264bf08af2aa2ce073c1b337c7 100644 (file)
@@ -96,6 +96,7 @@ git-diagnose                            ancillaryinterrogators
 git-diff                                mainporcelain           info
 git-diff-files                          plumbinginterrogators
 git-diff-index                          plumbinginterrogators
+git-diff-pairs                          plumbinginterrogators
 git-diff-tree                           plumbinginterrogators
 git-difftool                            ancillaryinterrogators          complete
 git-fast-export                         ancillarymanipulators
diff --git a/git.c b/git.c
index 450d6aaa863de4a0ed6bd51366ef262cf51c88fc..77c435952232f60435bfd96697e304ee1761f565 100644 (file)
--- a/git.c
+++ b/git.c
@@ -541,6 +541,7 @@ static struct cmd_struct commands[] = {
        { "diff", cmd_diff, NO_PARSEOPT },
        { "diff-files", cmd_diff_files, RUN_SETUP | NEED_WORK_TREE | NO_PARSEOPT },
        { "diff-index", cmd_diff_index, RUN_SETUP | NO_PARSEOPT },
+       { "diff-pairs", cmd_diff_pairs, RUN_SETUP | NO_PARSEOPT },
        { "diff-tree", cmd_diff_tree, RUN_SETUP | NO_PARSEOPT },
        { "difftool", cmd_difftool, RUN_SETUP_GENTLY },
        { "fast-export", cmd_fast_export, RUN_SETUP },
index 021a182135f39927e2e905b5699a7cd774ac447e..383e304798e7aedf089be50f6d327d5857c0c43f 100644 (file)
@@ -540,6 +540,7 @@ builtin_sources = [
   'builtin/diagnose.c',
   'builtin/diff-files.c',
   'builtin/diff-index.c',
+  'builtin/diff-pairs.c',
   'builtin/diff-tree.c',
   'builtin/diff.c',
   'builtin/difftool.c',
index 780939d49fda56fb83aaff4c79f25f8a02053cf9..09c7bc2fad28fa2e45e87b720b801219c956943a 100644 (file)
@@ -500,6 +500,7 @@ integration_tests = [
   't4067-diff-partial-clone.sh',
   't4068-diff-symmetric-merge-base.sh',
   't4069-remerge-diff.sh',
+  't4070-diff-pairs.sh',
   't4100-apply-stat.sh',
   't4101-apply-nonl.sh',
   't4102-apply-rename.sh',
diff --git a/t/t4070-diff-pairs.sh b/t/t4070-diff-pairs.sh
new file mode 100755 (executable)
index 0000000..0878ad0
--- /dev/null
@@ -0,0 +1,81 @@
+#!/bin/sh
+
+test_description='basic diff-pairs tests'
+. ./test-lib.sh
+
+# This creates a diff with added, modified, deleted, renamed, copied, and
+# typechange entries. This includes a submodule to test submodule diff support.
+test_expect_success 'setup' '
+       test_config_global protocol.file.allow always &&
+       git init sub &&
+       test_commit -C sub initial &&
+
+       git init main &&
+       cd main &&
+       echo to-be-gone >deleted &&
+       echo original >modified &&
+       echo now-a-file >symlink &&
+       test_seq 200 >two-hundred &&
+       test_seq 201 500 >five-hundred &&
+       git add . &&
+       test_tick &&
+       git commit -m base &&
+       git tag base &&
+
+       git submodule add ../sub &&
+       echo now-here >added &&
+       echo new >modified &&
+       rm deleted &&
+       mkdir subdir &&
+       echo content >subdir/file &&
+       mv two-hundred renamed &&
+       test_seq 201 500 | sed s/300/modified/ >copied &&
+       rm symlink &&
+       git add -A . &&
+       test_ln_s_add dest symlink &&
+       test_tick &&
+       git commit -m new &&
+       git tag new
+'
+
+test_expect_success 'diff-pairs recreates --raw' '
+       git diff-tree -r -M -C -C -z base new >expect &&
+       git diff-pairs --raw -z >actual <expect &&
+       test_cmp expect actual
+'
+
+test_expect_success 'diff-pairs can create -p output' '
+       git diff-tree -p -M -C -C base new >expect &&
+       git diff-tree -r -M -C -C -z base new |
+       git diff-pairs -p -z >actual &&
+       test_cmp expect actual
+'
+
+test_expect_success 'diff-pairs does not support normal raw diff input' '
+       git diff-tree -r base new |
+       test_must_fail git diff-pairs >out 2>err &&
+
+       echo "usage: working without -z is not supported" >expect &&
+       test_must_be_empty out &&
+       test_cmp expect err
+'
+
+test_expect_success 'diff-pairs does not support tree objects as input' '
+       git diff-tree -z base new |
+       test_must_fail git diff-pairs -z >out 2>err &&
+
+       echo "fatal: tree objects not supported" >expect &&
+       test_must_be_empty out &&
+       test_cmp expect err
+'
+
+test_expect_success 'diff-pairs does not support pathspec arguments' '
+       git diff-tree -r -z base new |
+       test_must_fail git diff-pairs -z -- new >out 2>err &&
+
+       echo "usage: pathspec arguments not supported" >expect &&
+       test_must_be_empty out &&
+       test_cmp expect err
+'
+
+test_done