* [PATCH v4 4/6] diff: add long-running diff process via diff.<driver>.process
From: Michael Montalbo via GitGitGadget @ 2026-06-14 18:59 UTC (permalink / raw)
To: git; +Cc: Johannes Schindelin, Michael Montalbo, Michael Montalbo
In-Reply-To: <pull.2120.v4.git.1781463564.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
Add support for external diff processes that communicate via the
long-running process protocol (pkt-line over stdin/stdout).
A diff process is configured per userdiff driver:
[diff "cdiff"]
process = /path/to/diff-tool
The tool provides custom line-matching: it receives file pairs
and returns hunks that reference line numbers in the content.
When textconv is also configured, the tool receives the
textconv-transformed content. The tool controls which lines
are marked as changed while the display shows the file content.
Patch output features (word diff, function context, color) work
normally; --stat uses its own diff codepath and never consults
the diff process.
The handshake negotiates version=1 and capability=hunks. Per-file
requests send command=hunks, pathname, and both file contents as
packetized data. The tool responds with hunk lines and a status
packet (success, error, or abort). On error, Git warns and falls
back to the builtin diff algorithm for that file. On abort, Git
silently falls back for the current file and stops sending further
requests to the tool for the remainder of the session.
When the tool returns no hunks followed by status=success, Git
treats the file as having no changes and produces no diff output.
This also means --exit-code reports no changes for that file.
The subprocess is stored on the userdiff_driver struct and
launched on first use. If the process fails to start, the
handshake fails, or a communication error occurs mid-stream,
the failure is cached on the driver to avoid retrying and
re-warning on every subsequent file.
diff_process_fill_hunks() is the sole public entry point. It
handles driver lookup, flag checks, subprocess management, and
error reporting, returning an enum that lets callers distinguish
"hunks populated" from "files equivalent" from "not applicable"
from "tool failure."
Helped-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
Documentation/config/diff.adoc | 5 +
Documentation/gitattributes.adoc | 143 +++++++++
Makefile | 2 +
diff-process.c | 297 ++++++++++++++++++
diff-process.h | 39 +++
diff.c | 13 +
diff.h | 3 +
meson.build | 1 +
t/helper/meson.build | 1 +
t/helper/test-diff-process-backend.c | 299 ++++++++++++++++++
t/helper/test-tool.c | 1 +
t/helper/test-tool.h | 1 +
t/meson.build | 1 +
t/t4080-diff-process.sh | 432 +++++++++++++++++++++++++++
userdiff.h | 3 +
15 files changed, 1241 insertions(+)
create mode 100644 diff-process.c
create mode 100644 diff-process.h
create mode 100644 t/helper/test-diff-process-backend.c
create mode 100755 t/t4080-diff-process.sh
diff --git a/Documentation/config/diff.adoc b/Documentation/config/diff.adoc
index 1135a62a0a..ac0635bb3b 100644
--- a/Documentation/config/diff.adoc
+++ b/Documentation/config/diff.adoc
@@ -218,6 +218,11 @@ endif::git-diff[]
Set this option to `true` to make the diff driver cache the text
conversion outputs. See linkgit:gitattributes[5] for details.
+`diff.<driver>.process`::
+ The command to run as a long-running diff process that
+ provides hunks to Git's diff pipeline.
+ See linkgit:gitattributes[5] for details.
+
`diff.indentHeuristic`::
Set this option to `false` to disable the default heuristics
that shift diff hunk boundaries to make patches easier to read.
diff --git a/Documentation/gitattributes.adoc b/Documentation/gitattributes.adoc
index bd76167a45..49ed11d069 100644
--- a/Documentation/gitattributes.adoc
+++ b/Documentation/gitattributes.adoc
@@ -821,6 +821,149 @@ NOTE: If `diff.<name>.command` is defined for path with the
(see above), and adding `diff.<name>.algorithm` has no effect, as the
algorithm is not passed to the external diff driver.
+Using an external diff process
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If `diff.<name>.process` is defined, Git sends the old and new file
+content to an external tool and receives back a list of changed
+regions (pairs of line ranges in the old and new file). Git uses
+these instead of its builtin diff algorithm, but still controls
+all output formatting, so features like word diff, function context,
+color, and blame work normally. This is achieved by using the
+long-running process protocol (described in
+Documentation/technical/long-running-process-protocol.adoc).
+Unlike `diff.<name>.command`, which replaces Git's output entirely,
+the diff process feeds results back into the standard pipeline.
+
+First, in `.gitattributes`, assign the `diff` attribute for paths.
+
+------------------------
+*.c diff=cdiff
+------------------------
+
+Then, define a "diff.<name>.process" configuration to specify
+the diff process command.
+
+----------------------------------------------------------------
+[diff "cdiff"]
+ process = /path/to/diff-process-tool
+----------------------------------------------------------------
+
+When Git encounters the first file that needs to be diffed, it starts
+the process and performs the handshake. In the handshake, the welcome
+message sent by Git is "git-diff-client", only version 1 is supported,
+and the supported capability is "hunks" (the changed regions
+described below).
+
+For each file, Git sends a list of "key=value" pairs terminated with
+a flush packet, followed by the old and new file content as packetized
+data, each terminated with a flush packet. The pathname is relative
+to the repository root. When `diff.<name>.textconv` is also set,
+the tool receives the textconv-transformed content rather than the
+raw blob. Git does not send binary files to the diff process.
+
+-----------------------
+packet: git> command=hunks
+packet: git> pathname=path/file.c
+packet: git> 0000
+packet: git> OLD_CONTENT
+packet: git> 0000
+packet: git> NEW_CONTENT
+packet: git> 0000
+-----------------------
+
+The tool is expected to respond with zero or more hunk lines,
+a flush packet, and a status packet terminated with a flush packet.
+Each hunk line has the form:
+
+ `hunk <old_start> <old_count> <new_start> <new_count>`
+
+where `<old_start>` and `<old_count>` identify a range of lines in
+the old file, and `<new_start>` and `<new_count>` identify the
+replacement range in the new file. Start values are 1-based and
+counts are non-negative. Ranges must not extend beyond the end of
+the file. For example, `hunk 3 2 3 4` means that 2 lines starting
+at line 3 in the old file were replaced by 4 lines starting at
+line 3 in the new file. An `<old_count>` of 0 means no lines were
+removed (pure insertion); a `<new_count>` of 0 means no lines were
+added (pure deletion). A start value of 0 is accepted when
+the corresponding count is 0 (e.g., `hunk 0 0 1 5` for a newly
+added file), matching what `git diff` itself emits for empty
+file sides.
+
+Lines are delimited by newlines. A file `"foo\nbar\n"` and a
+file `"foo\nbar"` both have 2 lines.
+
+Hunks must be listed in order and must not overlap. Any line
+not covered by a hunk is treated as unchanged, so the total
+number of unchanged lines must be the same on both sides.
+For example, if the old file has 10 lines and the hunks cover
+4 of them (`old_count` values summing to 4), then 6 old lines
+are unchanged. The new file must also have exactly 6 lines
+not covered by hunks, so the `new_count` values must sum to
+`new_file_lines - 6`.
+
+-----------------------
+packet: git< hunk 1 3 1 5
+packet: git< hunk 10 2 12 2
+packet: git< 0000
+packet: git< status=success
+packet: git< 0000
+-----------------------
+
+If the tool responds with hunks and "success", Git marks those lines
+as changed and feeds them into the standard diff pipeline. Patch
+output features (word diff, function context, color) work normally.
+Note that `--stat` and other summary formats use their own diff path
+and are not affected by the diff process.
+
+If no hunk lines precede the flush, followed by "success", Git
+treats the files as having no changes: `git diff` produces no output
+and `git blame` skips the commit, attributing lines to earlier commits.
+
+-----------------------
+packet: git< 0000
+packet: git< status=success
+packet: git< 0000
+-----------------------
+
+If the tool returns invalid hunks (out of bounds, overlapping, or
+mismatched unchanged line counts), Git warns and falls back to the
+builtin diff algorithm.
+
+In case the tool cannot or does not want to process the content,
+it is expected to respond with an "error" status. Git warns and
+falls back to the builtin diff algorithm for this file. The tool
+remains available for subsequent files.
+
+-----------------------
+packet: git< 0000
+packet: git< status=error
+packet: git< 0000
+-----------------------
+
+In case the tool cannot or does not want to process the content as
+well as any future content for the lifetime of the Git process, it
+is expected to respond with an "abort" status. Git silently falls
+back to the builtin diff algorithm for this file and does not send
+further requests to the tool.
+
+-----------------------
+packet: git< 0000
+packet: git< status=abort
+packet: git< 0000
+-----------------------
+
+If the tool dies during the communication or does not adhere to the
+protocol then Git will stop the process and fall back to the builtin
+diff algorithm. Git warns once and does not restart the process for
+subsequent files.
+
+Tools should ignore unknown keys in the per-file request to remain
+forward-compatible. Future versions of Git may send additional
+`command=` values; tools that receive an unrecognized command should
+respond with `status=error` rather than terminating.
+
Defining a custom hunk-header
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/Makefile b/Makefile
index 0976a69b4c..5de4718b24 100644
--- a/Makefile
+++ b/Makefile
@@ -811,6 +811,7 @@ TEST_BUILTINS_OBJS += test-csprng.o
TEST_BUILTINS_OBJS += test-date.o
TEST_BUILTINS_OBJS += test-delete-gpgsig.o
TEST_BUILTINS_OBJS += test-delta.o
+TEST_BUILTINS_OBJS += test-diff-process-backend.o
TEST_BUILTINS_OBJS += test-dir-iterator.o
TEST_BUILTINS_OBJS += test-drop-caches.o
TEST_BUILTINS_OBJS += test-dump-cache-tree.o
@@ -1140,6 +1141,7 @@ LIB_OBJS += diff-delta.o
LIB_OBJS += diff-merges.o
LIB_OBJS += diff-lib.o
LIB_OBJS += diff-no-index.o
+LIB_OBJS += diff-process.o
LIB_OBJS += diff.o
LIB_OBJS += diffcore-break.o
LIB_OBJS += diffcore-delta.o
diff --git a/diff-process.c b/diff-process.c
new file mode 100644
index 0000000000..fec63cf233
--- /dev/null
+++ b/diff-process.c
@@ -0,0 +1,297 @@
+/*
+ * Diff process backend: communicates with a long-running external
+ * tool via the pkt-line protocol to obtain custom line-matching
+ * results. The tool controls which lines are marked as changed
+ * while the display shows the file content (after any textconv
+ * transformation, if configured).
+ *
+ * Protocol: pkt-line over stdin/stdout, following the pattern of
+ * the long-running filter process protocol (see convert.c).
+ *
+ * Handshake:
+ * git> git-diff-client / version=1 / flush
+ * tool< git-diff-server / version=1 / flush
+ * git> capability=hunks / flush
+ * tool< capability=hunks / flush
+ *
+ * Per-file:
+ * git> command=hunks / pathname=<path> / flush
+ * git> <old content packetized> / flush
+ * git> <new content packetized> / flush
+ * tool< hunk <old_start> <old_count> <new_start> <new_count>
+ * tool< ... / flush
+ * tool< status=success / flush
+ *
+ * When the tool returns no hunks with status=success, it considers
+ * the files equivalent. Git will skip the diff for that file.
+ */
+
+#include "git-compat-util.h"
+#include "diff-process.h"
+#include "diff.h"
+#include "gettext.h"
+#include "repository.h"
+#include "sigchain.h"
+#include "userdiff.h"
+#include "sub-process.h"
+#include "pkt-line.h"
+#include "strbuf.h"
+#include "xdiff/xdiff.h"
+
+#define CAP_HUNKS (1u << 0)
+
+struct diff_subprocess {
+ struct subprocess_entry subprocess;
+ unsigned int supported_capabilities;
+};
+
+static int start_diff_process_fn(struct subprocess_entry *subprocess)
+{
+ static int versions[] = { 1, 0 };
+ static struct subprocess_capability capabilities[] = {
+ { "hunks", CAP_HUNKS },
+ { NULL, 0 }
+ };
+ struct diff_subprocess *entry =
+ container_of(subprocess, struct diff_subprocess, subprocess);
+
+ return subprocess_handshake(subprocess, "git-diff",
+ versions, NULL,
+ capabilities,
+ &entry->supported_capabilities);
+}
+
+static struct diff_subprocess *get_or_launch_process(
+ struct userdiff_driver *drv)
+{
+ struct diff_subprocess *entry;
+
+ if (drv->diff_subprocess)
+ return drv->diff_subprocess;
+
+ entry = xcalloc(1, sizeof(*entry));
+ if (subprocess_start_command(&entry->subprocess, drv->process,
+ start_diff_process_fn)) {
+ free(entry);
+ drv->diff_process_failed = 1;
+ return NULL;
+ }
+
+ drv->diff_subprocess = entry;
+ return entry;
+}
+
+static int send_file_content(int fd, const char *buf, long size)
+{
+ int ret = 0;
+
+ if (size < 0)
+ return -1;
+ if (size > 0)
+ ret = write_packetized_from_buf_no_flush(buf, size, fd);
+ if (ret)
+ return ret;
+ return packet_flush_gently(fd);
+}
+
+static int parse_hunk_line(const char *line, struct xdl_hunk *hunk)
+{
+ char *end;
+
+ /*
+ * Format: "hunk <old_start> <old_count> <new_start> <new_count>"
+ * All numbers must be non-negative decimal with no leading
+ * whitespace or sign characters.
+ */
+ if (!skip_prefix(line, "hunk ", &line))
+ return -1;
+
+ if (!isdigit(*line))
+ return -1;
+ errno = 0;
+ hunk->old_start = strtol(line, &end, 10);
+ if (errno || end == line || *end++ != ' ')
+ return -1;
+ line = end;
+
+ if (!isdigit(*line))
+ return -1;
+ errno = 0;
+ hunk->old_count = strtol(line, &end, 10);
+ if (errno || end == line || *end++ != ' ')
+ return -1;
+ line = end;
+
+ if (!isdigit(*line))
+ return -1;
+ errno = 0;
+ hunk->new_start = strtol(line, &end, 10);
+ if (errno || end == line || *end++ != ' ')
+ return -1;
+ line = end;
+
+ if (!isdigit(*line))
+ return -1;
+ errno = 0;
+ hunk->new_count = strtol(line, &end, 10);
+ if (errno || end == line || *end != '\0')
+ return -1;
+
+ /*
+ * git diff emits start=0 when count=0 (empty file side).
+ * Normalize to 1-based so downstream validation can assume start >= 1.
+ */
+ if (!hunk->old_count && !hunk->old_start)
+ hunk->old_start = 1;
+ if (!hunk->new_count && !hunk->new_start)
+ hunk->new_start = 1;
+
+ return 0;
+}
+
+static enum diff_process_result get_hunks(
+ struct userdiff_driver *drv,
+ const char *path,
+ const char *old_buf, long old_size,
+ const char *new_buf, long new_size,
+ struct xdl_hunk **hunks_out,
+ size_t *nr_hunks_out)
+{
+ struct diff_subprocess *backend;
+ struct child_process *process;
+ int fd_in, fd_out;
+ struct strbuf status = STRBUF_INIT;
+ struct xdl_hunk *hunks = NULL;
+ struct xdl_hunk hunk;
+ size_t nr_hunks = 0, alloc_hunks = 0;
+ int len;
+ char *line;
+
+ backend = get_or_launch_process(drv);
+ if (!backend)
+ return DIFF_PROCESS_ERROR;
+
+ if (!(backend->supported_capabilities & CAP_HUNKS))
+ return DIFF_PROCESS_SKIP;
+
+ process = subprocess_get_child_process(&backend->subprocess);
+ fd_in = process->in;
+ fd_out = process->out;
+
+ sigchain_push(SIGPIPE, SIG_IGN);
+
+ /* Send request */
+ if (packet_write_fmt_gently(fd_in, "command=hunks\n") ||
+ packet_write_fmt_gently(fd_in, "pathname=%s\n", path) ||
+ packet_flush_gently(fd_in))
+ goto comm_error;
+
+ /* Send old file content */
+ if (send_file_content(fd_in, old_buf, old_size))
+ goto comm_error;
+
+ /* Send new file content */
+ if (send_file_content(fd_in, new_buf, new_size))
+ goto comm_error;
+
+ /* Read hunks until flush packet */
+ while ((len = packet_read_line_gently(fd_out, NULL, &line)) >= 0 &&
+ line) {
+ if (parse_hunk_line(line, &hunk) < 0)
+ goto comm_error;
+ ALLOC_GROW(hunks, nr_hunks + 1, alloc_hunks);
+ hunks[nr_hunks++] = hunk;
+ }
+ if (len < 0)
+ goto comm_error;
+
+ /* Read status */
+ if (subprocess_read_status(fd_out, &status))
+ goto comm_error;
+
+ if (!strcmp(status.buf, "success")) {
+ *hunks_out = hunks;
+ *nr_hunks_out = nr_hunks;
+ strbuf_release(&status);
+ sigchain_pop(SIGPIPE);
+ return DIFF_PROCESS_OK;
+ }
+
+ if (!strcmp(status.buf, "abort")) {
+ /*
+ * The tool voluntarily withdrew: stop sending requests
+ * but do not warn (this is not a failure).
+ */
+ backend->supported_capabilities &= ~CAP_HUNKS;
+ free(hunks);
+ strbuf_release(&status);
+ sigchain_pop(SIGPIPE);
+ return DIFF_PROCESS_SKIP;
+ }
+
+ /* status=error or unknown status */
+ free(hunks);
+ strbuf_release(&status);
+ sigchain_pop(SIGPIPE);
+ return DIFF_PROCESS_ERROR;
+
+comm_error:
+ /*
+ * Communication failure (broken pipe, malformed response).
+ * Tear down the process and mark as failed so we do not
+ * retry on every subsequent file.
+ */
+ drv->diff_process_failed = 1;
+ drv->diff_subprocess = NULL;
+ subprocess_stop_command(&backend->subprocess);
+ free(backend);
+ free(hunks);
+ strbuf_release(&status);
+ sigchain_pop(SIGPIPE);
+ return DIFF_PROCESS_ERROR;
+}
+
+enum diff_process_result diff_process_fill_hunks(
+ struct diff_options *diffopt,
+ const char *path,
+ const mmfile_t *file_a,
+ const mmfile_t *file_b,
+ xpparam_t *xpp)
+{
+ struct userdiff_driver *drv;
+ struct xdl_hunk *ext_hunks = NULL;
+ size_t nr = 0;
+ enum diff_process_result res;
+
+ if (!diffopt || !path)
+ return DIFF_PROCESS_SKIP;
+ if (diffopt->flags.no_diff_process || diffopt->ignore_driver_algorithm)
+ return DIFF_PROCESS_SKIP;
+
+ drv = userdiff_find_by_path(diffopt->repo->index, path);
+ if (!drv || !drv->process)
+ return DIFF_PROCESS_SKIP;
+ if (drv->diff_process_failed)
+ return DIFF_PROCESS_SKIP;
+
+ res = get_hunks(drv, path,
+ file_a->ptr, file_a->size,
+ file_b->ptr, file_b->size,
+ &ext_hunks, &nr);
+ if (res == DIFF_PROCESS_OK) {
+ if (!nr) {
+ free(ext_hunks);
+ return DIFF_PROCESS_EQUIVALENT;
+ }
+ xpp->external_hunks = ext_hunks;
+ xpp->external_hunks_nr = nr;
+ return DIFF_PROCESS_OK;
+ }
+ if (res == DIFF_PROCESS_ERROR) {
+ warning(_("diff process '%s' failed for '%s',"
+ " falling back to builtin diff"),
+ drv->process, path);
+ return DIFF_PROCESS_ERROR;
+ }
+ return DIFF_PROCESS_SKIP;
+}
diff --git a/diff-process.h b/diff-process.h
new file mode 100644
index 0000000000..d34b42f811
--- /dev/null
+++ b/diff-process.h
@@ -0,0 +1,39 @@
+#ifndef DIFF_PROCESS_H
+#define DIFF_PROCESS_H
+
+#include "xdiff/xdiff.h"
+
+struct diff_options;
+
+enum diff_process_result {
+ DIFF_PROCESS_ERROR = -1, /* tool failure: warned, fell back */
+ DIFF_PROCESS_OK = 0, /* hunks populated in xpp */
+ DIFF_PROCESS_SKIP, /* no process configured: use builtin */
+ DIFF_PROCESS_EQUIVALENT, /* tool says files are equivalent */
+};
+
+/*
+ * Consult the diff process configured for 'path' and populate
+ * xpp->external_hunks with the returned hunks.
+ *
+ * Handles driver lookup, flag checks (--no-ext-diff,
+ * --diff-algorithm), subprocess management, and error reporting.
+ *
+ * Returns DIFF_PROCESS_OK when hunks are populated in xpp.
+ * The caller owns xpp->external_hunks and must free() it.
+ *
+ * Returns DIFF_PROCESS_EQUIVALENT when the tool returns no hunks
+ * (files are considered identical); caller should skip diff/blame.
+ * Returns DIFF_PROCESS_SKIP when no process applies; caller
+ * should use the builtin diff algorithm.
+ * Returns DIFF_PROCESS_ERROR on tool failure (already warned);
+ * caller should fall back to the builtin diff algorithm.
+ */
+enum diff_process_result diff_process_fill_hunks(
+ struct diff_options *diffopt,
+ const char *path,
+ const mmfile_t *file_a,
+ const mmfile_t *file_b,
+ xpparam_t *xpp);
+
+#endif /* DIFF_PROCESS_H */
diff --git a/diff.c b/diff.c
index 5a584fa1d5..3d97a188b9 100644
--- a/diff.c
+++ b/diff.c
@@ -25,6 +25,7 @@
#include "utf8.h"
#include "odb.h"
#include "userdiff.h"
+#include "diff-process.h"
#include "submodule.h"
#include "hashmap.h"
#include "mem-pool.h"
@@ -4054,6 +4055,17 @@ static void builtin_diff(const char *name_a,
xpp.ignore_regex_nr = o->ignore_regex_nr;
xpp.anchors = o->anchors;
xpp.anchors_nr = o->anchors_nr;
+
+ if (diff_process_fill_hunks(o, name_a,
+ &mf1, &mf2, &xpp)
+ == DIFF_PROCESS_EQUIVALENT) {
+ if (textconv_one)
+ free(mf1.ptr);
+ if (textconv_two)
+ free(mf2.ptr);
+ goto free_ab_and_return;
+ }
+
xecfg.ctxlen = o->context;
xecfg.interhunkctxlen = o->interhunkcontext;
xecfg.flags = XDL_EMIT_FUNCNAMES;
@@ -4134,6 +4146,7 @@ static void builtin_diff(const char *name_a,
} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,
&ecbdata, &xpp, &xecfg))
die("unable to generate diff for %s", one->path);
+ free(xpp.external_hunks);
if (o->word_diff)
free_diff_words_data(&ecbdata);
if (textconv_one)
diff --git a/diff.h b/diff.h
index bb5cddaf34..7dc157968d 100644
--- a/diff.h
+++ b/diff.h
@@ -173,6 +173,9 @@ struct diff_flags {
*/
unsigned allow_external;
+ /** Disables diff.<driver>.process. */
+ unsigned no_diff_process;
+
/**
* For communication between the calling program and the options parser;
* tell the calling program to signal the presence of difference using
diff --git a/meson.build b/meson.build
index 3247697f74..aa532f5200 100644
--- a/meson.build
+++ b/meson.build
@@ -328,6 +328,7 @@ libgit_sources = [
'diff-merges.c',
'diff-lib.c',
'diff-no-index.c',
+ 'diff-process.c',
'diff.c',
'diffcore-break.c',
'diffcore-delta.c',
diff --git a/t/helper/meson.build b/t/helper/meson.build
index 3235f10ab8..6abcda4afb 100644
--- a/t/helper/meson.build
+++ b/t/helper/meson.build
@@ -12,6 +12,7 @@ test_tool_sources = [
'test-date.c',
'test-delete-gpgsig.c',
'test-delta.c',
+ 'test-diff-process-backend.c',
'test-dir-iterator.c',
'test-drop-caches.c',
'test-dump-cache-tree.c',
diff --git a/t/helper/test-diff-process-backend.c b/t/helper/test-diff-process-backend.c
new file mode 100644
index 0000000000..ad392694e6
--- /dev/null
+++ b/t/helper/test-diff-process-backend.c
@@ -0,0 +1,299 @@
+/*
+ * Test backend for the long-running diff process protocol
+ * (see diff-process.c and Documentation/gitattributes.adoc).
+ *
+ * Usage: test-tool diff-process-backend --mode=<mode> [--log=<path>]
+ *
+ * Implements the server side of the pkt-line handshake and a per-file
+ * response loop. The --mode= switch selects the response shape
+ * (success, error, abort, crash, malformed hunks).
+ *
+ * Per-file request from Git:
+ *
+ * packet: git> command=hunks
+ * packet: git> pathname=<path>
+ * packet: git> 0000
+ * packet: git> OLD_CONTENT
+ * packet: git> 0000
+ * packet: git> NEW_CONTENT
+ * packet: git> 0000
+ *
+ * Response varies by --mode (default: whole-file):
+ *
+ * whole-file packet: git< hunk 1 <old_lines> 1 <new_lines>
+ * fixed-hunk packet: git< hunk 5 2 5 2
+ * no-hunks (no hunk packets)
+ * bad-hunk packet: git< hunk 999 1 999 1
+ * bad-parse packet: git< garbage not a hunk
+ * bad-sync packet: git< hunk 1 2 1 1
+ * overlap packet: git< hunk 1 5 1 5
+ * packet: git< hunk 3 2 3 2
+ * no-cap (omits capability=hunks during handshake)
+ * error (status=error instead of status=success)
+ * abort (status=abort instead of status=success)
+ * crash exit(1) before sending any response
+ *
+ * All non-error/abort modes end with:
+ *
+ * packet: git< 0000
+ * packet: git< status=success
+ * packet: git< 0000
+ *
+ * Each request is logged to --log as:
+ *
+ * command=<cmd> pathname=<path> old=<first line> new=<first line>
+ */
+
+#include "test-tool.h"
+#include "pkt-line.h"
+#include "parse-options.h"
+#include "strbuf.h"
+
+static FILE *logfile;
+
+enum mode {
+ MODE_WHOLE_FILE,
+ MODE_FIXED_HUNK,
+ MODE_NO_HUNKS,
+ MODE_BAD_HUNK,
+ MODE_BAD_PARSE,
+ MODE_BAD_SYNC,
+ MODE_OVERLAP,
+ MODE_NO_CAP,
+ MODE_ERROR,
+ MODE_ABORT,
+ MODE_CRASH,
+};
+
+static enum mode parse_mode(const char *s)
+{
+ if (!strcmp(s, "whole-file"))
+ return MODE_WHOLE_FILE;
+ if (!strcmp(s, "fixed-hunk"))
+ return MODE_FIXED_HUNK;
+ if (!strcmp(s, "no-hunks"))
+ return MODE_NO_HUNKS;
+ if (!strcmp(s, "bad-hunk"))
+ return MODE_BAD_HUNK;
+ if (!strcmp(s, "bad-parse"))
+ return MODE_BAD_PARSE;
+ if (!strcmp(s, "bad-sync"))
+ return MODE_BAD_SYNC;
+ if (!strcmp(s, "overlap"))
+ return MODE_OVERLAP;
+ if (!strcmp(s, "no-cap"))
+ return MODE_NO_CAP;
+ if (!strcmp(s, "error"))
+ return MODE_ERROR;
+ if (!strcmp(s, "abort"))
+ return MODE_ABORT;
+ if (!strcmp(s, "crash"))
+ return MODE_CRASH;
+ die("unknown --mode=%s", s);
+}
+
+/*
+ * Read "key=value" packets up to a flush, capturing "command" and
+ * "pathname". Returns 1 if a request was read, 0 on EOF.
+ *
+ * The first packet uses the gentle variant so that a clean shutdown
+ * by Git (EOF) does not produce a spurious "the remote end hung up
+ * unexpectedly" on stderr. Subsequent packets use the non-gentle
+ * variant: once inside a request, truncation is a protocol violation
+ * and dying loudly is the correct response.
+ */
+static int read_request_header(char **command, char **pathname)
+{
+ int first = 1;
+ char *line;
+
+ *command = *pathname = NULL;
+ for (;;) {
+ const char *value;
+
+ if (first) {
+ if (packet_read_line_gently(0, NULL, &line) < 0)
+ return 0;
+ first = 0;
+ } else {
+ line = packet_read_line(0, NULL);
+ }
+ if (!line)
+ break;
+ if (skip_prefix(line, "command=", &value))
+ *command = xstrdup(value);
+ else if (skip_prefix(line, "pathname=", &value))
+ *pathname = xstrdup(value);
+ }
+ return 1;
+}
+
+static size_t count_lines(const struct strbuf *buf)
+{
+ size_t lines = 0;
+
+ for (size_t i = 0; i < buf->len; i++)
+ if (buf->buf[i] == '\n')
+ lines++;
+
+ return lines + (buf->len > 0 && buf->buf[buf->len - 1] != '\n');
+}
+
+static void send_status(const char *status)
+{
+ packet_flush(1);
+ packet_write_fmt(1, "%s\n", status);
+ packet_flush(1);
+}
+
+static void respond(enum mode mode,
+ const struct strbuf *old_buf,
+ const struct strbuf *new_buf)
+{
+ switch (mode) {
+ case MODE_ERROR:
+ send_status("status=error");
+ return;
+ case MODE_ABORT:
+ send_status("status=abort");
+ return;
+ case MODE_CRASH:
+ exit(1);
+ case MODE_FIXED_HUNK:
+ packet_write_fmt(1, "hunk 5 2 5 2\n");
+ break;
+ case MODE_BAD_HUNK:
+ packet_write_fmt(1, "hunk 999 1 999 1\n");
+ break;
+ case MODE_BAD_PARSE:
+ packet_write_fmt(1, "garbage not a hunk\n");
+ break;
+ case MODE_BAD_SYNC:
+ packet_write_fmt(1, "hunk 1 2 1 1\n");
+ break;
+ case MODE_OVERLAP:
+ packet_write_fmt(1, "hunk 1 5 1 5\n");
+ packet_write_fmt(1, "hunk 3 2 3 2\n");
+ break;
+ case MODE_NO_HUNKS:
+ break;
+ case MODE_NO_CAP:
+ case MODE_WHOLE_FILE: {
+ size_t old_lines = count_lines(old_buf);
+ size_t new_lines = count_lines(new_buf);
+ /*
+ * Match git diff output: start=0 when count=0
+ * (empty file side), 1 otherwise.
+ */
+ packet_write_fmt(1, "hunk %"PRIuMAX" %"PRIuMAX
+ " %"PRIuMAX" %"PRIuMAX"\n",
+ (uintmax_t)(old_lines ? 1 : 0),
+ (uintmax_t)old_lines,
+ (uintmax_t)(new_lines ? 1 : 0),
+ (uintmax_t)new_lines);
+ break;
+ }
+ }
+ send_status("status=success");
+}
+
+static void command_loop(enum mode mode)
+{
+ for (;;) {
+ char *command = NULL, *pathname = NULL;
+ struct strbuf obuf = STRBUF_INIT;
+ struct strbuf nbuf = STRBUF_INIT;
+
+ if (!read_request_header(&command, &pathname))
+ break; /* EOF: Git closed its end */
+
+ read_packetized_to_strbuf(0, &obuf, 0);
+ read_packetized_to_strbuf(0, &nbuf, 0);
+
+ if (logfile) {
+ fprintf(logfile,
+ "command=%s pathname=%s old=%.*s new=%.*s\n",
+ command ? command : "(none)",
+ pathname ? pathname : "(none)",
+ (int)(strchrnul(obuf.buf, '\n') - obuf.buf),
+ obuf.buf,
+ (int)(strchrnul(nbuf.buf, '\n') - nbuf.buf),
+ nbuf.buf);
+ fflush(logfile);
+ }
+
+ respond(mode, &obuf, &nbuf);
+
+ free(command);
+ free(pathname);
+ strbuf_release(&obuf);
+ strbuf_release(&nbuf);
+ }
+}
+
+static void handshake(enum mode mode)
+{
+ char *line;
+
+ line = packet_read_line(0, NULL);
+ if (!line || strcmp(line, "git-diff-client"))
+ die("bad welcome: '%s'", line ? line : "(eof)");
+ line = packet_read_line(0, NULL);
+ if (!line || strcmp(line, "version=1"))
+ die("bad version: '%s'", line ? line : "(eof)");
+ if (packet_read_line(0, NULL))
+ die("expected flush after version");
+
+ packet_write_fmt(1, "git-diff-server\n");
+ packet_write_fmt(1, "version=1\n");
+ packet_flush(1);
+
+ /* Drain capabilities advertised by Git */
+ while ((line = packet_read_line(0, NULL)))
+ ; /* drain */
+
+ /* Respond with our capabilities (or none for no-cap mode) */
+ if (mode != MODE_NO_CAP)
+ packet_write_fmt(1, "capability=hunks\n");
+ packet_flush(1);
+}
+
+static const char *const usage_str[] = {
+ "test-tool diff-process-backend --mode=<mode> [--log=<path>]",
+ NULL
+};
+
+int cmd__diff_process_backend(int argc, const char **argv)
+{
+ const char *mode_str = NULL, *log_path = NULL;
+ enum mode mode = MODE_WHOLE_FILE;
+ struct option options[] = {
+ OPT_STRING(0, "mode", &mode_str, "mode",
+ "response shape: whole-file (default), fixed-hunk,"
+ " no-hunks, bad-hunk, bad-sync, overlap, error,"
+ " abort, crash"),
+ OPT_STRING(0, "log", &log_path, "path",
+ "append per-request summary to this file"),
+ OPT_END()
+ };
+
+ argc = parse_options(argc, argv, NULL, options, usage_str, 0);
+ if (argc)
+ usage_with_options(usage_str, options);
+
+ if (mode_str)
+ mode = parse_mode(mode_str);
+
+ if (log_path) {
+ logfile = fopen(log_path, "a");
+ if (!logfile)
+ die_errno("failed to open log '%s'", log_path);
+ }
+
+ handshake(mode);
+ command_loop(mode);
+
+ if (logfile && fclose(logfile))
+ die_errno("error closing log");
+ return 0;
+}
diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c
index b71a22b43b..3c3f95269c 100644
--- a/t/helper/test-tool.c
+++ b/t/helper/test-tool.c
@@ -22,6 +22,7 @@ static struct test_cmd cmds[] = {
{ "date", cmd__date },
{ "delete-gpgsig", cmd__delete_gpgsig },
{ "delta", cmd__delta },
+ { "diff-process-backend", cmd__diff_process_backend },
{ "dir-iterator", cmd__dir_iterator },
{ "drop-caches", cmd__drop_caches },
{ "dump-cache-tree", cmd__dump_cache_tree },
diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h
index f2885b33d5..a5bb755516 100644
--- a/t/helper/test-tool.h
+++ b/t/helper/test-tool.h
@@ -15,6 +15,7 @@ int cmd__csprng(int argc, const char **argv);
int cmd__date(int argc, const char **argv);
int cmd__delta(int argc, const char **argv);
int cmd__delete_gpgsig(int argc, const char **argv);
+int cmd__diff_process_backend(int argc, const char **argv);
int cmd__dir_iterator(int argc, const char **argv);
int cmd__drop_caches(int argc, const char **argv);
int cmd__dump_cache_tree(int argc, const char **argv);
diff --git a/t/meson.build b/t/meson.build
index c5832fee05..027855ced7 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -512,6 +512,7 @@ integration_tests = [
't4072-diff-max-depth.sh',
't4073-diff-stat-name-width.sh',
't4074-diff-shifted-matched-group.sh',
+ 't4080-diff-process.sh',
't4100-apply-stat.sh',
't4101-apply-nonl.sh',
't4102-apply-rename.sh',
diff --git a/t/t4080-diff-process.sh b/t/t4080-diff-process.sh
new file mode 100755
index 0000000000..9bb579b564
--- /dev/null
+++ b/t/t4080-diff-process.sh
@@ -0,0 +1,432 @@
+#!/bin/sh
+
+test_description='diff process via long-running process'
+
+. ./test-lib.sh
+
+# See t/helper/test-diff-process-backend.c for the backend implementation
+# and available --mode= options.
+
+BACKEND="test-tool diff-process-backend"
+
+test_expect_success 'setup' '
+ echo "*.c diff=cdiff" >.gitattributes &&
+ git add .gitattributes &&
+
+ # boundary.c: 10 lines, changes at 5-6 and 9-10.
+ # Used by: hunk boundaries, error fallback, crash, bad hunks, overlap.
+ cat >boundary.c <<-\EOF &&
+ line1
+ line2
+ line3
+ line4
+ OLD5
+ OLD6
+ line7
+ line8
+ OLD9
+ OLD10
+ EOF
+ git add boundary.c &&
+
+ # worddiff.c: single-line function, value changes 1 -> 999.
+ # Used by: word-diff, --diff-algorithm, --no-ext-diff, --stat.
+ cat >worddiff.c <<-\EOF &&
+ int value(void) { return 1; }
+ EOF
+ git add worddiff.c &&
+
+ # newfile.c: single-line function, value changes 42 -> 99.
+ # Used by: modified file, --exit-code, multiple drivers.
+ cat >newfile.c <<-\EOF &&
+ int new_func(void) { return 42; }
+ EOF
+ git add newfile.c &&
+
+ # logtest.c: single-line function for log/format-patch tests.
+ # Needs two commits so log -1 has a diff.
+ cat >logtest.c <<-\EOF &&
+ int logfunc(void) { return 1; }
+ EOF
+ git add logtest.c &&
+
+ # two.c/one.c: two-file pair for error/abort/startup-failure tests.
+ cat >one.c <<-\EOF &&
+ int first(void) { return 1; }
+ EOF
+ cat >two.c <<-\EOF &&
+ int second(void) { return 2; }
+ EOF
+ git add one.c two.c &&
+
+ git commit -m "initial" &&
+
+ # Second commit for logtest.c (so log -1 has something to show).
+ cat >logtest.c <<-\EOF &&
+ int logfunc(void) { return 2; }
+ EOF
+ git add logtest.c &&
+ git commit -m "change logtest.c" &&
+
+ # Working tree modifications (not committed).
+ cat >boundary.c <<-\EOF &&
+ line1
+ line2
+ line3
+ line4
+ NEW5
+ NEW6
+ line7
+ line8
+ NEW9
+ NEW10
+ EOF
+
+ cat >worddiff.c <<-\EOF &&
+ int value(void) { return 999; }
+ EOF
+
+ cat >newfile.c <<-\EOF &&
+ int new_func(void) { return 99; }
+ EOF
+
+ cat >one.c <<-\EOF &&
+ int first(void) { return 10; }
+ EOF
+
+ cat >two.c <<-\EOF
+ int second(void) { return 20; }
+ EOF
+'
+
+#
+# Core behavior: the tool controls which lines are marked as changed.
+#
+
+test_expect_success 'diff process hunk boundaries affect output' '
+ # The file has changes at lines 5-6 and 9-10, but fixed-hunk
+ # only reports lines 5-6 as changed. Lines 9-10 should not
+ # appear as changed in the output.
+ git -c diff.cdiff.process="$BACKEND --mode=fixed-hunk" \
+ diff boundary.c >actual &&
+ test_grep "^-OLD5" actual &&
+ test_grep "^-OLD6" actual &&
+ test_grep "^+NEW5" actual &&
+ test_grep "^+NEW6" actual &&
+ test_grep ! "^-OLD9" actual &&
+ test_grep ! "^-OLD10" actual &&
+ test_grep ! "^+NEW9" actual &&
+ test_grep ! "^+NEW10" actual
+'
+
+test_expect_success 'diff process works with modified file' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff -- newfile.c >actual 2>stderr &&
+ test_grep "return 99" actual &&
+ test_grep "pathname=newfile.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process works with added file (empty old side)' '
+ cat >added.c <<-\EOF &&
+ int added(void) { return 1; }
+ EOF
+ git add added.c &&
+
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --cached -- added.c >actual 2>stderr &&
+ test_grep "added" actual &&
+ test_grep "pathname=added.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process works with deleted file (empty new side)' '
+ git add added.c &&
+ git commit -m "commit added.c" &&
+ git rm added.c &&
+
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --cached -- added.c >actual 2>stderr &&
+ test_grep "deleted file" actual &&
+ test_grep "pathname=added.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process skipped for binary files' '
+ printf "\\0binary" >binary.c &&
+ git add binary.c &&
+ git commit -m "add binary" &&
+ printf "\\0changed" >binary.c &&
+
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff -- binary.c >actual &&
+ test_grep "Binary files" actual &&
+ test_path_is_missing backend.log
+'
+
+test_expect_success 'diff process not consulted for unmatched driver' '
+ echo "not tracked by cdiff" >unmatched.txt &&
+ git add unmatched.txt &&
+ git commit -m "add unmatched.txt" &&
+
+ echo "modified" >unmatched.txt &&
+
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff -- unmatched.txt >actual &&
+ test_grep "modified" actual &&
+ test_path_is_missing backend.log
+'
+
+test_expect_success 'multiple drivers use separate processes' '
+ echo "*.h diff=hdiff" >>.gitattributes &&
+ git add .gitattributes &&
+
+ cat >multi.h <<-\EOF &&
+ int header(void) { return 1; }
+ EOF
+ git add multi.h &&
+ git commit -m "add multi.h" &&
+
+ cat >multi.h <<-\EOF &&
+ int header(void) { return 2; }
+ EOF
+
+ test_when_finished "rm -f backend-c.log backend-h.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend-c.log" \
+ -c diff.hdiff.process="$BACKEND --log=backend-h.log" \
+ diff -- newfile.c multi.h >actual 2>stderr &&
+ test_grep "pathname=newfile.c" backend-c.log &&
+ test_grep "pathname=multi.h" backend-h.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process works alongside textconv' '
+ write_script uppercase-filter <<-\EOF &&
+ tr "a-z" "A-Z" <"$1"
+ EOF
+
+ cat >textconv.c <<-\EOF &&
+ hello world
+ EOF
+ git add textconv.c &&
+ git commit -m "add textconv.c" &&
+
+ cat >textconv.c <<-\EOF &&
+ goodbye world
+ EOF
+
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.textconv="./uppercase-filter" \
+ -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff -- textconv.c >actual 2>stderr &&
+ # The diff process receives textconv-transformed (uppercase) content.
+ test_grep "pathname=textconv.c" backend.log &&
+ test_grep "old=HELLO WORLD" backend.log &&
+ test_grep "new=GOODBYE WORLD" backend.log &&
+ test_must_be_empty stderr
+'
+
+#
+# Downstream features: word diff, log, equivalent files, exit code.
+#
+
+test_expect_success 'diff process with --word-diff' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --word-diff worddiff.c >actual 2>stderr &&
+ test_grep "\[-1;-\]" actual &&
+ test_grep "{+999;+}" actual &&
+ test_grep "pathname=worddiff.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process works with git log -p' '
+ # With no-hunks mode, the tool says the files are equivalent,
+ # so log -p should show the commit but no diff content.
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=no-hunks --log=backend.log" \
+ log -1 -p -- logtest.c >actual 2>stderr &&
+ test_grep "change logtest.c" actual &&
+ test_grep ! "return 2" actual &&
+ test_grep "command=hunks pathname=logtest.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process no hunks suppresses diff output' '
+ cat >nohunks.c <<-\EOF &&
+ int zero(void) { return 0; }
+ EOF
+ git add nohunks.c &&
+ git commit -m "add nohunks.c" &&
+
+ cat >nohunks.c <<-\EOF &&
+ int zero(void) { return 999; }
+ EOF
+
+ git -c diff.cdiff.process="$BACKEND --mode=no-hunks" \
+ diff nohunks.c >actual &&
+ test_must_be_empty actual
+'
+
+test_expect_success 'diff process no hunks with --exit-code returns success' '
+ git -c diff.cdiff.process="$BACKEND --mode=no-hunks" \
+ diff --exit-code nohunks.c
+'
+
+test_expect_success 'diff process with --exit-code and hunks returns failure' '
+ test_expect_code 1 git -c diff.cdiff.process="$BACKEND" \
+ diff --exit-code newfile.c
+'
+
+#
+# Bypass mechanisms: flags and commands that skip the diff process.
+#
+
+test_expect_success 'diff process bypassed by --diff-algorithm' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --diff-algorithm=patience worddiff.c >actual &&
+ test_grep "return 999" actual &&
+ test_path_is_missing backend.log
+'
+
+test_expect_success 'diff process not used by --stat' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --stat worddiff.c >actual &&
+ test_grep "worddiff.c" actual &&
+ test_path_is_missing backend.log
+'
+
+#
+# Error handling and fallback.
+#
+
+test_expect_success 'diff process fallback on tool error status' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=error --log=backend.log" \
+ diff boundary.c >actual 2>stderr &&
+ # Fallback produces the full builtin diff (both change regions).
+ test_grep "^-OLD5" actual &&
+ test_grep "^+NEW5" actual &&
+ test_grep "^-OLD9" actual &&
+ test_grep "^+NEW9" actual &&
+ # Tool was contacted (it replied with error, not crash).
+ test_grep "command=hunks pathname=boundary.c" backend.log &&
+ test_grep "diff process.*failed" stderr
+'
+
+test_expect_success 'diff process error keeps tool available for next file' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=error --log=backend.log" \
+ diff -- one.c two.c >actual 2>stderr &&
+ # Unlike abort, error keeps the tool available: both files
+ # are sent to the tool (and both fall back).
+ test_grep "pathname=one.c" backend.log &&
+ test_grep "pathname=two.c" backend.log &&
+ test_grep "return 10" actual &&
+ test_grep "return 20" actual &&
+ test_grep "diff process.*failed" stderr
+'
+
+test_expect_success 'diff process abort disables for session' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=abort --log=backend.log" \
+ diff -- one.c two.c >actual 2>stderr &&
+ # Both files should still produce diff output via fallback.
+ test_grep "return 10" actual &&
+ test_grep "return 20" actual &&
+ # The tool aborts on the first file and git clears its
+ # capability. The second file never contacts the tool.
+ test_grep "pathname=one.c" backend.log &&
+ test_grep ! "pathname=two.c" backend.log &&
+ test_must_be_empty stderr
+'
+
+test_expect_success 'diff process fallback on tool crash' '
+ git -c diff.cdiff.process="$BACKEND --mode=crash" \
+ diff boundary.c >actual 2>stderr &&
+ test_grep "^-OLD5" actual &&
+ test_grep "^+NEW5" actual &&
+ test_grep "^-OLD9" actual &&
+ test_grep "^+NEW9" actual &&
+ # Crash is a communication failure, so a warning is emitted.
+ test_grep "diff process.*failed" stderr
+'
+
+test_expect_success 'diff process startup failure only warns once' '
+ git -c diff.cdiff.process="/nonexistent/tool" \
+ diff -- one.c two.c >actual 2>stderr &&
+ # Both files produce diff output via fallback.
+ test_grep "return 10" actual &&
+ test_grep "return 20" actual &&
+ # Sentinel prevents repeated warnings: only one, not one per file.
+ test_grep "diff process.*failed" stderr >warnings &&
+ test_line_count = 1 warnings
+'
+
+
+test_expect_success 'diff process fallback on bad hunks' '
+ git -c diff.cdiff.process="$BACKEND --mode=bad-hunk" \
+ diff boundary.c >actual 2>stderr &&
+ test_grep "^-OLD5" actual &&
+ test_grep "^+NEW5" actual &&
+ test_grep "^-OLD9" actual &&
+ test_grep "^+NEW9" actual &&
+ test_grep "exceeds.*lines" stderr
+'
+
+test_expect_success 'diff process fallback on mismatched unchanged totals' '
+ cat >synctest.c <<-\EOF &&
+ line1
+ line2
+ line3
+ EOF
+ git add synctest.c &&
+ git commit -m "add synctest.c" &&
+
+ cat >synctest.c <<-\EOF &&
+ line1
+ changed
+ line3
+ EOF
+
+ # bad-sync reports hunk 1 2 1 1: marks 2 old lines and 1 new
+ # line as changed, leaving 1 unchanged old vs 2 unchanged new.
+ # The synchronization invariant fails and git falls back.
+ git -c diff.cdiff.process="$BACKEND --mode=bad-sync" \
+ diff synctest.c >actual 2>stderr &&
+ test_grep "changed" actual &&
+ test_grep "unchanged line count mismatch" stderr
+'
+
+test_expect_success 'diff process fallback on overlapping hunks' '
+ # boundary.c has 10 lines, so both hunks are in bounds
+ # but they overlap at lines 3-5, triggering the ordering check.
+ git -c diff.cdiff.process="$BACKEND --mode=overlap" \
+ diff boundary.c >actual 2>stderr &&
+ test_grep "NEW5" actual &&
+ test_grep "overlaps with previous" stderr
+'
+
+test_expect_success 'diff process fallback on malformed hunk line' '
+ git -c diff.cdiff.process="$BACKEND --mode=bad-parse" \
+ diff boundary.c >actual 2>stderr &&
+ test_grep "^-OLD5" actual &&
+ test_grep "^+NEW5" actual
+'
+
+test_expect_success 'diff process skipped when tool omits capability' '
+ git -c diff.cdiff.process="$BACKEND --mode=no-cap" \
+ diff boundary.c >actual 2>stderr &&
+ test_grep "^-OLD5" actual &&
+ test_grep "^+NEW5" actual &&
+ test_must_be_empty stderr
+'
+
+test_done
diff --git a/userdiff.h b/userdiff.h
index 51c26e0d41..a98eabe377 100644
--- a/userdiff.h
+++ b/userdiff.h
@@ -3,6 +3,7 @@
#include "notes-cache.h"
+struct diff_subprocess;
struct index_state;
struct repository;
@@ -33,6 +34,8 @@ struct userdiff_driver {
int textconv_want_cache;
const char *process;
char *process_owned;
+ struct diff_subprocess *diff_subprocess;
+ unsigned diff_process_failed : 1;
};
enum userdiff_driver_type {
USERDIFF_DRIVER_TYPE_BUILTIN = 1<<0,
--
gitgitgadget
^ permalink raw reply related
* [PATCH v4 5/6] diff: bypass diff process with --no-ext-diff and in format-patch
From: Michael Montalbo via GitGitGadget @ 2026-06-14 18:59 UTC (permalink / raw)
To: git; +Cc: Johannes Schindelin, Michael Montalbo, Michael Montalbo
In-Reply-To: <pull.2120.v4.git.1781463564.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
Make --no-ext-diff disable diff.<driver>.process in addition to
diff.<driver>.command. Although the two mechanisms work differently
(command replaces Git's output, process feeds hunks back into the
pipeline), both invoke external tools and --no-ext-diff means
"no external tools."
Replace the OPT_BOOL for --ext-diff with an OPT_CALLBACK that
sets both allow_external and no_diff_process, so a single option
controls both. Passing --ext-diff explicitly clears
no_diff_process, so a later --ext-diff overrides an earlier
--no-ext-diff.
Disable the diff process unconditionally in format-patch so that
generated patches are always based on the builtin diff algorithm
and can be applied reliably by recipients who do not have the
external tool.
Document that --diff-algorithm also bypasses the diff process,
since it forces the builtin algorithm.
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
Documentation/diff-algorithm-option.adoc | 3 +++
Documentation/diff-options.adoc | 4 +++-
builtin/log.c | 7 +++++++
diff.c | 16 ++++++++++++++--
diff.h | 4 +++-
t/t4080-diff-process.sh | 16 ++++++++++++++++
6 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/Documentation/diff-algorithm-option.adoc b/Documentation/diff-algorithm-option.adoc
index 8e3a0b63d7..4d7e2ec35f 100644
--- a/Documentation/diff-algorithm-option.adoc
+++ b/Documentation/diff-algorithm-option.adoc
@@ -18,3 +18,6 @@
For instance, if you configured the `diff.algorithm` variable to a
non-default value and want to use the default one, then you
have to use `--diff-algorithm=default` option.
++
+If you explicitly choose a diff algorithm, it also bypasses
+`diff.<driver>.process` (see linkgit:gitattributes[5]).
diff --git a/Documentation/diff-options.adoc b/Documentation/diff-options.adoc
index c8242e2462..a884445211 100644
--- a/Documentation/diff-options.adoc
+++ b/Documentation/diff-options.adoc
@@ -833,7 +833,9 @@ endif::git-format-patch[]
to use this option with linkgit:git-log[1] and friends.
`--no-ext-diff`::
- Disallow external diff drivers.
+ Disallow external diff helpers, including
+ `diff.<driver>.command` and `diff.<driver>.process`
+ (see linkgit:gitattributes[5]).
`--textconv`::
`--no-textconv`::
diff --git a/builtin/log.c b/builtin/log.c
index e464b30af4..363052f468 100644
--- a/builtin/log.c
+++ b/builtin/log.c
@@ -2217,6 +2217,13 @@ int cmd_format_patch(int argc,
if (argc > 1)
die(_("unrecognized argument: %s"), argv[1]);
+ /*
+ * Disable diff.<driver>.process so that patches generated by
+ * format-patch are always based on the builtin diff algorithm
+ * and can be applied reliably.
+ */
+ rev.diffopt.flags.no_diff_process = 1;
+
if (rev.diffopt.output_format & DIFF_FORMAT_NAME)
die(_("--name-only does not make sense"));
if (rev.diffopt.output_format & DIFF_FORMAT_NAME_STATUS)
diff --git a/diff.c b/diff.c
index 3d97a188b9..4d9cb9b26b 100644
--- a/diff.c
+++ b/diff.c
@@ -5936,6 +5936,17 @@ static int diff_opt_submodule(const struct option *opt,
return 0;
}
+static int diff_opt_ext_diff(const struct option *opt,
+ const char *arg, int unset)
+{
+ struct diff_options *options = opt->value;
+
+ BUG_ON_OPT_ARG(arg);
+ options->flags.allow_external = !unset;
+ options->flags.no_diff_process = unset;
+ return 0;
+}
+
static int diff_opt_textconv(const struct option *opt,
const char *arg, int unset)
{
@@ -6266,8 +6277,9 @@ struct option *add_diff_options(const struct option *opts,
N_("exit with 1 if there were differences, 0 otherwise")),
OPT_BOOL(0, "quiet", &options->flags.quick,
N_("disable all output of the program")),
- OPT_BOOL(0, "ext-diff", &options->flags.allow_external,
- N_("allow an external diff helper to be executed")),
+ OPT_CALLBACK_F(0, "ext-diff", options, NULL,
+ N_("allow an external diff helper to be executed"),
+ PARSE_OPT_NOARG, diff_opt_ext_diff),
OPT_CALLBACK_F(0, "textconv", options, NULL,
N_("run external text conversion filters when comparing binary files"),
PARSE_OPT_NOARG, diff_opt_textconv),
diff --git a/diff.h b/diff.h
index 7dc157968d..bc7da6986a 100644
--- a/diff.h
+++ b/diff.h
@@ -173,7 +173,9 @@ struct diff_flags {
*/
unsigned allow_external;
- /** Disables diff.<driver>.process. */
+ /**
+ * Disables diff.<driver>.process. Set by --no-ext-diff.
+ */
unsigned no_diff_process;
/**
diff --git a/t/t4080-diff-process.sh b/t/t4080-diff-process.sh
index 9bb579b564..df4d08e31f 100755
--- a/t/t4080-diff-process.sh
+++ b/t/t4080-diff-process.sh
@@ -295,6 +295,22 @@ test_expect_success 'diff process bypassed by --diff-algorithm' '
test_path_is_missing backend.log
'
+test_expect_success 'diff process bypassed by --no-ext-diff' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ diff --no-ext-diff worddiff.c >actual &&
+ test_grep "return 999" actual &&
+ test_path_is_missing backend.log
+'
+
+test_expect_success 'diff process not used by format-patch' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --log=backend.log" \
+ format-patch -1 --stdout -- logtest.c >actual &&
+ test_grep "return 2" actual &&
+ test_path_is_missing backend.log
+'
+
test_expect_success 'diff process not used by --stat' '
test_when_finished "rm -f backend.log" &&
git -c diff.cdiff.process="$BACKEND --log=backend.log" \
--
gitgitgadget
^ permalink raw reply related
* [PATCH v4 6/6] blame: consult diff process for no-hunk detection
From: Michael Montalbo via GitGitGadget @ 2026-06-14 18:59 UTC (permalink / raw)
To: git; +Cc: Johannes Schindelin, Michael Montalbo, Michael Montalbo
In-Reply-To: <pull.2120.v4.git.1781463564.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
When a diff process is configured via diff.<driver>.process,
consult it during blame's per-commit diffing. If the process
returns no hunks for a commit's changes to a file, treat the
commit as having no changes, causing blame to attribute lines
to earlier commits.
The consultation happens at the pass_blame_to_parent() callsite
using diff_process_fill_hunks(), matching how builtin_diff() in
diff.c uses the same function. A new diff_hunks_xpp() variant
accepts a pre-populated xpparam_t so callers can pass external
hunks, while the existing diff_hunks() retains its original
signature and behavior. The copy-detection callsite is
unaffected since it does not use the diff process.
The subprocess is long-running (one startup cost amortized
across the blame traversal), but each commit in the file's
history incurs a round-trip to the tool.
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
blame.c | 40 +++++++++++----
t/t4080-diff-process.sh | 105 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 136 insertions(+), 9 deletions(-)
diff --git a/blame.c b/blame.c
index 977cbb7097..354e6c15f4 100644
--- a/blame.c
+++ b/blame.c
@@ -19,6 +19,8 @@
#include "tag.h"
#include "trace2.h"
#include "blame.h"
+#include "diff-process.h"
+#include "xdiff-interface.h"
#include "alloc.h"
#include "commit-slab.h"
#include "bloom.h"
@@ -314,17 +316,25 @@ static struct commit *fake_working_tree_commit(struct repository *r,
-static int diff_hunks(mmfile_t *file_a, mmfile_t *file_b,
- xdl_emit_hunk_consume_func_t hunk_func, void *cb_data, int xdl_opts)
+static int diff_hunks_xpp(mmfile_t *file_a, mmfile_t *file_b,
+ xdl_emit_hunk_consume_func_t hunk_func,
+ void *cb_data, xpparam_t *xpp)
{
- xpparam_t xpp = {0};
xdemitconf_t xecfg = {0};
xdemitcb_t ecb = {NULL};
- xpp.flags = xdl_opts;
xecfg.hunk_func = hunk_func;
ecb.priv = cb_data;
- return xdi_diff(file_a, file_b, &xpp, &xecfg, &ecb);
+ return xdi_diff(file_a, file_b, xpp, &xecfg, &ecb);
+}
+
+static int diff_hunks(mmfile_t *file_a, mmfile_t *file_b,
+ xdl_emit_hunk_consume_func_t hunk_func, void *cb_data, int xdl_opts)
+{
+ xpparam_t xpp = {0};
+
+ xpp.flags = xdl_opts;
+ return diff_hunks_xpp(file_a, file_b, hunk_func, cb_data, &xpp);
}
static const char *get_next_line(const char *start, const char *end)
@@ -1943,6 +1953,7 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb,
struct blame_origin *parent, int ignore_diffs)
{
mmfile_t file_p, file_o;
+ xpparam_t xpp = {0};
struct blame_chunk_cb_data d;
struct blame_entry *newdest = NULL;
@@ -1961,10 +1972,21 @@ static void pass_blame_to_parent(struct blame_scoreboard *sb,
&sb->num_read_blob, ignore_diffs);
sb->num_get_patch++;
- if (diff_hunks(&file_p, &file_o, blame_chunk_cb, &d, sb->xdl_opts))
- die("unable to generate diff (%s -> %s)",
- oid_to_hex(&parent->commit->object.oid),
- oid_to_hex(&target->commit->object.oid));
+ xpp.flags = sb->xdl_opts;
+ /*
+ * If the diff process considers the files equivalent,
+ * skip the diff so blame looks past this commit.
+ */
+ if (diff_process_fill_hunks(&sb->revs->diffopt, target->path,
+ &file_p, &file_o, &xpp)
+ != DIFF_PROCESS_EQUIVALENT) {
+ if (diff_hunks_xpp(&file_p, &file_o, blame_chunk_cb,
+ &d, &xpp))
+ die("unable to generate diff (%s -> %s)",
+ oid_to_hex(&parent->commit->object.oid),
+ oid_to_hex(&target->commit->object.oid));
+ }
+ free(xpp.external_hunks);
/* The rest are the same as the parent */
blame_chunk(&d.dstq, &d.srcq, INT_MAX, d.offset, INT_MAX, 0,
parent, target, 0);
diff --git a/t/t4080-diff-process.sh b/t/t4080-diff-process.sh
index df4d08e31f..9fc3c01eec 100755
--- a/t/t4080-diff-process.sh
+++ b/t/t4080-diff-process.sh
@@ -445,4 +445,109 @@ test_expect_success 'diff process skipped when tool omits capability' '
test_must_be_empty stderr
'
+#
+# Blame integration.
+#
+
+test_expect_success 'blame uses tool-provided hunks' '
+ cat >blame-hunk.c <<-\EOF &&
+ line1
+ line2
+ line3
+ line4
+ original5
+ original6
+ line7
+ line8
+ line9
+ line10
+ EOF
+ git add blame-hunk.c &&
+ git commit -m "add blame-hunk.c" &&
+ ORIG=$(git rev-parse --short HEAD) &&
+
+ cat >blame-hunk.c <<-\EOF &&
+ line1
+ line2
+ line3
+ line4
+ changed5
+ changed6
+ line7
+ line8
+ changed9
+ changed10
+ EOF
+ git add blame-hunk.c &&
+ git commit -m "change blame-hunk.c" &&
+ CHANGE=$(git rev-parse --short HEAD) &&
+
+ # With fixed-hunk mode the tool reports only lines 5-6 as changed,
+ # so blame should attribute lines 9-10 to the original commit
+ # even though the builtin diff would show them as changed.
+ git -c diff.cdiff.process="$BACKEND --mode=fixed-hunk" \
+ blame blame-hunk.c >actual &&
+ sed -n "9p" actual >line9 &&
+ sed -n "10p" actual >line10 &&
+ test_grep "$ORIG" line9 &&
+ test_grep "$ORIG" line10 &&
+ sed -n "5p" actual >line5 &&
+ sed -n "6p" actual >line6 &&
+ test_grep "$CHANGE" line5 &&
+ test_grep "$CHANGE" line6
+'
+
+test_expect_success 'blame skips commits with no hunks from diff process' '
+ cat >blame.c <<-\EOF &&
+ int main(void) {
+ return 0;
+ }
+ EOF
+ git add blame.c &&
+ git commit -m "add blame.c" &&
+ ORIG_COMMIT=$(git rev-parse --short HEAD) &&
+
+ cat >blame.c <<-\EOF &&
+ int main(void)
+ {
+ return 0;
+ }
+ EOF
+ git add blame.c &&
+ git commit -m "reformat blame.c" &&
+ BLAME_COMMIT=$(git rev-parse --short HEAD) &&
+
+ # Without no-hunks mode, blame attributes the change.
+ git blame blame.c >without &&
+ test_grep "$BLAME_COMMIT" without &&
+
+ # With no-hunks mode, the process considers the files equivalent
+ # and blame skips the reformat commit, attributing to the original.
+ git -c diff.cdiff.process="$BACKEND --mode=no-hunks" \
+ blame blame.c >with &&
+ test_grep ! "$BLAME_COMMIT" with &&
+ test_grep "$ORIG_COMMIT" with
+'
+
+test_expect_success 'blame --no-ext-diff bypasses diff process' '
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=no-hunks --log=backend.log" \
+ blame --no-ext-diff blame.c >actual &&
+ # Without the process, blame attributes the reformat commit normally.
+ test_grep "$BLAME_COMMIT" actual &&
+ test_path_is_missing backend.log
+'
+
+test_expect_success 'blame --no-ext-diff uses builtin hunks' '
+ # fixed-hunk mode would narrow blame to lines 5-6, but
+ # --no-ext-diff should bypass it and use the builtin diff.
+ test_when_finished "rm -f backend.log" &&
+ git -c diff.cdiff.process="$BACKEND --mode=fixed-hunk --log=backend.log" \
+ blame --no-ext-diff blame-hunk.c >actual &&
+ # Builtin diff attributes lines 9-10 to the change commit.
+ sed -n "9p" actual >line9 &&
+ test_grep "$CHANGE" line9 &&
+ test_path_is_missing backend.log
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
into its oldest one, reusing that commit's message.
Related idea: https://github.com/gitgitgadget/git/issues/1135
Harald Nordgren (2):
t3415: remove prepare-commit-msg hook after use
rebase: add --fixup-all to fold a range
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 13 ++++-
sequencer.c | 24 +++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 106 ++++++++++++++++++++++++++++++++++
5 files changed, 152 insertions(+), 4 deletions(-)
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1
Pull-Request: https://github.com/git/git/pull/2337
--
gitgitgadget
^ permalink raw reply
* [PATCH 1/2] t3415: remove prepare-commit-msg hook after use
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.git.git.1781465141.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
The "pick and fixup respect commit.cleanup" test left its
prepare-commit-msg hook in place, leaking it into later tests. Remove it
with test_when_finished.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
t/t3415-rebase-autosquash.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 5033411a43..8964d1cc88 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -490,6 +490,7 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
git reset --hard base &&
test_commit --no-tag "fixup! second commit" file1 fixup &&
test_commit something &&
+ test_when_finished "rm -f .git/hooks/prepare-commit-msg" &&
write_script .git/hooks/prepare-commit-msg <<-\EOF &&
printf "\n# Prepared\n" >> "$1"
EOF
--
gitgitgadget
^ permalink raw reply related
* [PATCH 2/2] rebase: add --fixup-all to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.git.git.1781465141.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git rebase --autosquash --fixup-all [<upstream>]" to do this
directly. It keeps the first commit in the range as a "pick" and turns
every later commit into a "fixup", so the whole range collapses into a
single commit that reuses the first commit's message. With no <upstream>
argument the range is "@{upstream}..HEAD", folding all unpushed commits
into one.
Fold the commits in their original order, so that any fixup!/squash!
commits already present in the range are folded in as well. Allow the
flag only together with --autosquash, and reject --rebase-merges since a
merge commit cannot be folded into another commit.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 13 ++++-
sequencer.c | 24 +++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 105 ++++++++++++++++++++++++++++++++++
5 files changed, 151 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc
index f6c22d1598..d1a3e4ef64 100644
--- a/Documentation/git-rebase.adoc
+++ b/Documentation/git-rebase.adoc
@@ -602,6 +602,16 @@ option can be used to override that setting.
+
See also INCOMPATIBLE OPTIONS below.
+--fixup-all::
+ Valid only when used with `--autosquash`. Keep the first commit in
+ the range as a `pick` and change every later commit to a `fixup`, so
+ the whole range is folded into a single commit that reuses the first
+ commit's message. With no `<upstream>` argument this folds all commits
+ since `@{upstream}` into one. The commits are folded in their original
+ order, so any `fixup!`/`squash!` commits already in the range are folded
+ in as well. Cannot be combined with `--rebase-merges`, as a merge
+ commit cannot be folded into another commit.
+
--autostash::
--no-autostash::
Automatically create a temporary stash entry before the operation
@@ -652,6 +662,7 @@ are incompatible with the following options:
* --strategy
* --strategy-option
* --autosquash
+ * --fixup-all
* --rebase-merges
* --interactive
* --exec
diff --git a/builtin/rebase.c b/builtin/rebase.c
index fa4f5d9306..a363fbc1f2 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -118,6 +118,7 @@ struct rebase_options {
int allow_rerere_autoupdate;
int keep_empty;
int autosquash;
+ int fixup_all;
char *gpg_sign_opt;
int autostash;
int committer_date_is_author_date;
@@ -329,7 +330,8 @@ static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)
ret = complete_action(the_repository, &replay, flags,
shortrevisions, opts->onto_name, opts->onto,
&opts->orig_head->object.oid, &opts->exec,
- opts->autosquash, opts->update_refs, &todo_list);
+ opts->autosquash, opts->fixup_all, opts->update_refs,
+ &todo_list);
cleanup:
replay_opts_release(&replay);
@@ -1205,6 +1207,8 @@ int cmd_rebase(int argc,
OPT_BOOL(0, "autosquash", &options.autosquash,
N_("move commits that begin with "
"squash!/fixup! under -i")),
+ OPT_BOOL(0, "fixup-all", &options.fixup_all,
+ N_("fold all commits in the range into the first one")),
OPT_BOOL(0, "update-refs", &options.update_refs,
N_("update branches that point to commits "
"that are being rebased")),
@@ -1594,6 +1598,13 @@ int cmd_rebase(int argc,
options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
+ if (options.fixup_all && options.autosquash != 1)
+ die(_("--fixup-all requires --autosquash"));
+
+ if (options.fixup_all && options.rebase_merges)
+ die(_("options '%s' and '%s' cannot be used together"),
+ "--fixup-all", "--rebase-merges");
+
if (options.autosquash == 1) {
imply_merge(&options, "--autosquash");
} else if (options.autosquash == -1) {
diff --git a/sequencer.c b/sequencer.c
index 57855b0066..eeaf6226fe 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -6554,11 +6554,29 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list)
return 0;
}
+static void todo_list_fixup_all_but_first(struct todo_list *todo_list)
+{
+ int i, seen_first = 0;
+
+ for (i = 0; i < todo_list->nr; i++) {
+ struct todo_item *item = todo_list->items + i;
+
+ if (!item->commit || item->command == TODO_DROP)
+ continue;
+ if (!seen_first) {
+ seen_first = 1;
+ item->command = TODO_PICK;
+ continue;
+ }
+ item->command = TODO_FIXUP;
+ }
+}
+
int complete_action(struct repository *r, struct replay_opts *opts, unsigned flags,
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned fixup_all, unsigned update_refs,
struct todo_list *todo_list)
{
char shortonto[GIT_MAX_HEXSZ + 1];
@@ -6581,7 +6599,9 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
if (update_refs && todo_list_add_update_ref_commands(todo_list))
return -1;
- if (autosquash && todo_list_rearrange_squash(todo_list))
+ if (fixup_all)
+ todo_list_fixup_all_but_first(todo_list);
+ else if (autosquash && todo_list_rearrange_squash(todo_list))
return -1;
if (commands->nr)
diff --git a/sequencer.h b/sequencer.h
index 3164bd437d..9bb6b42c94 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -196,7 +196,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned fixup_all, unsigned update_refs,
struct todo_list *todo_list);
int todo_list_rearrange_squash(struct todo_list *todo_list);
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 8964d1cc88..21d4159ebd 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -511,4 +511,109 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
test_commit_message HEAD -m "something"
'
+test_expect_success '--fixup-all folds the range into the first commit' '
+ git reset --hard base &&
+ test_commit --no-tag fold1 file_fold a &&
+ test_commit --no-tag fold2 file_fold b &&
+ test_commit --no-tag fold3 file_fold c &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fold1" &&
+ echo c >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '
+ git reset --hard base &&
+ test_commit --no-tag foldA file_fold a &&
+ test_commit --no-tag foldB file_fold b &&
+ git commit --allow-empty --fixup HEAD~1 &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "foldA" &&
+ echo b >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '
+ git reset --hard base &&
+ test_commit --no-tag fixupbase file_fix a &&
+ git commit --allow-empty --fixup HEAD &&
+ test_commit --no-tag fixuptail file_fix b &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ echo b >expect &&
+ test_cmp expect file_fix
+'
+
+test_expect_success '--fixup-all with a single commit in range is a no-op' '
+ git reset --hard base &&
+ test_commit --no-tag solo file_solo a &&
+ git rev-parse HEAD >expect &&
+ git rebase --autosquash --fixup-all HEAD~1 &&
+ git rev-parse HEAD >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--fixup-all with an empty range succeeds' '
+ git reset --hard base &&
+ git rebase --autosquash --fixup-all HEAD &&
+ test_cmp_rev base HEAD
+'
+
+test_expect_success '--fixup-all skips a dropped commit in the range' '
+ git reset --hard base &&
+ test_commit --no-tag fixdrop1 file_drop a &&
+ git commit --allow-empty -m "empty in the middle" &&
+ test_commit --no-tag fixdrop3 file_drop b &&
+ git rebase --autosquash --empty=drop --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fixdrop1" &&
+ echo b >expect &&
+ test_cmp expect file_drop
+'
+
+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '
+ git reset --hard base &&
+ test_commit --no-tag mid-first &&
+ git checkout -b mid-side &&
+ test_commit --no-tag mid-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge mid-side" mid-side &&
+ test_commit --no-tag mid-last &&
+ git rebase --autosquash --fixup-all base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "mid-first" &&
+ test_path_is_file mid-merged.t
+'
+
+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '
+ git reset --hard base &&
+ git checkout -b head-side &&
+ test_commit --no-tag head-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge head-side" head-side &&
+ test_commit --no-tag head-last &&
+ git rebase --autosquash --fixup-all base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "head-merged" &&
+ test_path_is_file head-merged.t
+'
+
+test_expect_success '--fixup-all requires --autosquash' '
+ git reset --hard base &&
+ test_must_fail git rebase --fixup-all HEAD~1 2>err &&
+ test_grep "fixup-all requires --autosquash" err &&
+ test_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&
+ test_grep "fixup-all requires --autosquash" err
+'
+
+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '
+ git reset --hard base &&
+ test_must_fail git rebase --autosquash --rebase-merges \
+ --fixup-all HEAD~1 2>err &&
+ test_grep "cannot be used together" err &&
+ test_path_is_missing .git/rebase-merge
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* Re: [PATCH] doc: fix a small, old release notes typo
From: Junio C Hamano @ 2026-06-14 21:52 UTC (permalink / raw)
To: D. Ben Knoble; +Cc: git, Elijah Newren
In-Reply-To: <645638cd87d6d919af6d4310be8176d49fba326e.1781456960.git.ben.knoble+github@gmail.com>
"D. Ben Knoble" <ben.knoble+github@gmail.com> writes:
> Signed-off-by: D. Ben Knoble <ben.knoble+github@gmail.com>
> ---
> No harm done if you choose not to keep this, I think. Stumbled upon it when
> trying to understand Elijah's message [1] about timestamp_t overflowing in 2106
> (I though 32-bit time_t overflowed in 2038, but timestamp_t is something
> different… except maybe when it's not? Anyway…)
Unless it fixes a glaring factual error that would harm end-users if
left unfixed, I would not very much be enthused to see fixes to
these ancient documents, quite honestly.
> separate and dedicated timestamp_t (so that we can distinguish
> - timestamps and a vanilla ulongs, which along is already a good
> + timestamps and a vanilla ulongs, which alone is already a good
"timestamps and vanilla ulongs", as both are plural?
^ permalink raw reply
* Re: [GSoC Patch v3 3/4] repo: add path.commondir with absolute and relative suffix formatting
From: Lucas Seiki Oshiro @ 2026-06-15 1:54 UTC (permalink / raw)
To: K Jayatheerth
Cc: a3205153416, git, gitster, jltobler, kristofferhaugsbakk,
kumarayushjha123, phillip.wood, sandals
In-Reply-To: <20260612182847.562816-4-jayatheerthkulkarni2005@gmail.com>
> + test_expect_success "absolute: $label" '
> + (
> + cd "$absolute_root/sub" &&
> + ROOT="$(test-tool path-utils real_path "..")" && export ROOT &&
Be carful with the quotes here. Actually, there's no need to use
quotes around `..`, and export can be used directly with the env
var:
export ROOT="$(test-tool path-utils real_path ..)" &&
>
> + test_expect_success "relative: $label" '
> + (
> + cd "$relative_root/sub" &&
> + ROOT="$(test-tool path-utils real_path "..")" && export ROOT &&
Same here.
> +test_repo_info_path 'commondir with GIT_COMMON_DIR and GIT_DIR' 'commondir' \
> + 'commondir-envs' 'custom-common' '../custom-common' \
> + 'GIT_COMMON_DIR="$ROOT/custom-common" && export GIT_COMMON_DIR &&
> + GIT_DIR="../.git" && export GIT_DIR &&
export GIT_COMMON_DIR="$ROOT/custom-common" &&
export GIT_DIR="../.git" &&
^ permalink raw reply
* Re: [GSoC Patch v3 4/4] repo: add path.gitdir with absolute and relative suffix formatting
From: Lucas Seiki Oshiro @ 2026-06-15 1:55 UTC (permalink / raw)
To: K Jayatheerth
Cc: a3205153416, git, gitster, jltobler, kristofferhaugsbakk,
kumarayushjha123, phillip.wood, sandals
In-Reply-To: <20260612182847.562816-5-jayatheerthkulkarni2005@gmail.com>
> +test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \
> + 'gitdir-env' '.git' '../.git' \
> + 'GIT_DIR="../.git" && export GIT_DIR'
'export GIT_DIR="../.git"
^ permalink raw reply
* Re: [GSoC Patch v3 0/4] teach git repo info to handle path keys
From: Lucas Seiki Oshiro @ 2026-06-15 1:59 UTC (permalink / raw)
To: K Jayatheerth
Cc: a3205153416, git, gitster, jltobler, kristofferhaugsbakk,
kumarayushjha123, phillip.wood, sandals
In-Reply-To: <20260612182847.562816-1-jayatheerthkulkarni2005@gmail.com>
Hi, Jayatheerth!
I've left some comments in your tests. From my side
you only need to fix them.
^ permalink raw reply
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
From: Junio C Hamano @ 2026-06-15 2:01 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <pull.2337.git.git.1781465141.gitgitgadget@gmail.com>
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
> into its oldest one, reusing that commit's message.
[2/2] seems to add "--fixup-all" but I agree with the "related idea"
that naming it and modelling it after "merge --squash" would be
easier to understand.
> Related idea: https://github.com/gitgitgadget/git/issues/1135
I also wonder if we can do something like this without adding any
new option or command. E.g., if you have four patch series, where
the initial implementation HEAD~3 is followed by "oops it was still
wrong" fix-up HEAD~2, HEAD~1 and HEAD, then
git reset --soft HEAD~3 && git commit --amend --no-edit
is what the user wants to do, no?
^ permalink raw reply
* [PATCH] gitattributes: fix eol attribute for Perl scripts
From: Koutian Wu via GitGitGadget @ 2026-06-15 4:25 UTC (permalink / raw)
To: git; +Cc: Koutian Wu, ktwu01
From: ktwu01 <ktwu01@gmail.com>
The *.pl pattern currently sets eof=lf, which is not a built-in
attribute used for line-ending normalization.
Use eol=lf instead, matching the neighboring *.perl and *.pm rules, so
Perl scripts are checked out with LF line endings.
Signed-off-by: ktwu01 <ktwu01@gmail.com>
---
gitattributes: fix eol attribute for Perl scripts
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2151%2Fktwu01%2Fkw%2Ffix-pl-eol-attribute-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2151/ktwu01/kw/fix-pl-eol-attribute-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/2151
.gitattributes | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitattributes b/.gitattributes
index 556322be01..26490ad60a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,7 +2,7 @@
*.[ch] whitespace=indent,trail,space,incomplete diff=cpp
*.sh whitespace=indent,trail,space,incomplete text eol=lf
*.perl text eol=lf diff=perl
-*.pl text eof=lf diff=perl
+*.pl text eol=lf diff=perl
*.pm text eol=lf diff=perl
*.py text eol=lf diff=python
*.bat text eol=crlf
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
--
gitgitgadget
^ permalink raw reply related
* [GSoC Patch v4 0/4] teach git repo info to handle path keys
From: K Jayatheerth @ 2026-06-15 4:51 UTC (permalink / raw)
To: git
Cc: a3205153416, gitster, jltobler, kumarayushjha123,
lucasseikioshiro, phillip.wood, sandals, kristofferhaugsbakk,
K Jayatheerth
In-Reply-To: <20260601151950.30686-1-jayatheerthkulkarni2005@gmail.com>
Hi!
This series teaches `git repo info` to handle `path.*`
keys, allowing scripts to reliably discover core
repository paths without resorting to `git rev-parse`.
The patches are structured as follows:
1. path: Extract the localized path-formatting logic
out of `rev-parse` and expose it globally via
`path.h` using clear append semantics.
2. rev-parse: Refactor the command to leverage the
newly shared path engine.
3. repo: Introduce `path.commondir.absolute` and
`path.commondir.relative` alongside a robust,
isolated test helper.
4. repo: Introduce `path.gitdir.absolute` and
`path.gitdir.relative` using the same standardized
formatting rules.
Since all the questions were answered
I have removed them from this cover letter.
Changes since v3:
* Removed unnecessary double quotes around the `..` argument when calling
`test-tool path-utils real_path` in the `test_repo_info_path` helper,
as suggested by Lucas.
* Retained the POSIX-compliant `ROOT="..." && export ROOT` syntax in the
test setup. Combining them into `export ROOT="..."` triggered Git's
strict `test-lint-shell-syntax` portability checks, so the separate
assignment and export remains.
K Jayatheerth (4):
path: introduce append_formatted_path() for shared path formatting
rev-parse: use append_formatted_path() for path formatting
repo: add path.commondir with absolute and relative suffix formatting
repo: add path.gitdir with absolute and relative suffix formatting
Documentation/git-repo.adoc | 15 ++++++
builtin/repo.c | 50 +++++++++++++++++
builtin/rev-parse.c | 103 ++++++++----------------------------
path.c | 70 ++++++++++++++++++++++++
path.h | 36 +++++++++++++
t/t1900-repo-info.sh | 68 ++++++++++++++++++++++++
6 files changed, 262 insertions(+), 80 deletions(-)
Range-diff against v3:
1: d276ac145e = 1: a396b4f8e6 path: introduce append_formatted_path() for shared path formatting
2: 5dba41bcb3 = 2: 16198f96d1 rev-parse: use append_formatted_path() for path formatting
3: b21c97f5d9 ! 3: b45c6f0d12 repo: add path.commondir with absolute and relative suffix formatting
@@ t/t1900-repo-info.sh: test_expect_success 'git repo info -h shows only repo info
+ test_expect_success "absolute: $label" '
+ (
+ cd "$absolute_root/sub" &&
-+ ROOT="$(test-tool path-utils real_path "..")" && export ROOT &&
++ ROOT="$(test-tool path-utils real_path ..)" && export ROOT &&
+ eval "$init_command" &&
+ expect_path="$ROOT${expect_absolute_suffix:+/$expect_absolute_suffix}" &&
+ echo "path.$field_name.absolute=$expect_path" >expect &&
@@ t/t1900-repo-info.sh: test_expect_success 'git repo info -h shows only repo info
+ test_expect_success "relative: $label" '
+ (
+ cd "$relative_root/sub" &&
-+ ROOT="$(test-tool path-utils real_path "..")" && export ROOT &&
++ ROOT="$(test-tool path-utils real_path ..)" && export ROOT &&
+ eval "$init_command" &&
+ echo "path.$field_name.relative=$expect_relative" >expect &&
+ git repo info "path.$field_name.relative" >actual &&
4: fd7a899788 = 4: b5234ffe3e repo: add path.gitdir with absolute and relative suffix formatting
--
2.54.0
^ permalink raw reply
* [GSoC Patch v4 1/4] path: introduce append_formatted_path() for shared path formatting
From: K Jayatheerth @ 2026-06-15 4:51 UTC (permalink / raw)
To: git
Cc: a3205153416, gitster, jltobler, kumarayushjha123,
lucasseikioshiro, phillip.wood, sandals, kristofferhaugsbakk,
K Jayatheerth
In-Reply-To: <20260615045112.50686-1-jayatheerthkulkarni2005@gmail.com>
The path-formatting logic in builtin/rev-parse.c is tightly coupled
to that command and writes directly to stdout, making it impossible
for other builtins to reuse.
Extract the core algorithm into append_formatted_path() in path.c
and expose a path_format enum in path.h so that any builtin can
format paths consistently without duplicating logic.
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
---
path.c | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
path.h | 36 ++++++++++++++++++++++++++++++
2 files changed, 106 insertions(+)
diff --git a/path.c b/path.c
index d7e17bf174..5e83e3e4f6 100644
--- a/path.c
+++ b/path.c
@@ -1579,6 +1579,76 @@ char *xdg_cache_home(const char *filename)
return NULL;
}
+void append_formatted_path(struct strbuf *dest, const char *path,
+ const char *prefix, enum path_format format)
+{
+ switch (format) {
+ case PATH_FORMAT_DEFAULT:
+ case PATH_FORMAT_UNMODIFIED:
+ strbuf_addstr(dest, path);
+ break;
+
+ case PATH_FORMAT_RELATIVE: {
+ struct strbuf relative_buf = STRBUF_INIT;
+ struct strbuf real_path = STRBUF_INIT;
+ struct strbuf real_prefix = STRBUF_INIT;
+ char *cwd = NULL;
+
+ /*
+ * We don't ever produce a relative path if prefix is NULL,
+ * so set the prefix to the current directory so that we can
+ * produce a relative path whenever possible.
+ */
+ if (!prefix)
+ prefix = cwd = xgetcwd();
+
+ if (!is_absolute_path(path)) {
+ strbuf_realpath_forgiving(&real_path, path, 1);
+ path = real_path.buf;
+ }
+ if (!is_absolute_path(prefix)) {
+ strbuf_realpath_forgiving(&real_prefix, prefix, 1);
+ prefix = real_prefix.buf;
+ }
+
+ strbuf_addstr(dest, relative_path(path, prefix, &relative_buf));
+
+ strbuf_release(&relative_buf);
+ strbuf_release(&real_path);
+ strbuf_release(&real_prefix);
+ free(cwd);
+ break;
+ }
+
+ case PATH_FORMAT_RELATIVE_IF_SHARED: {
+ struct strbuf relative_buf = STRBUF_INIT;
+
+ /*
+ * If we're using RELATIVE_IF_SHARED mode, then we want an
+ * absolute path unless the two share a common prefix, so don't
+ * default the prefix to the current working directory. Doing so
+ * would cause a relative path to always be produced if possible.
+ */
+ strbuf_addstr(dest, relative_path(path, prefix, &relative_buf));
+ strbuf_release(&relative_buf);
+ break;
+ }
+
+ case PATH_FORMAT_CANONICAL: {
+ struct strbuf canonical_buf = STRBUF_INIT;
+
+ strbuf_realpath_forgiving(&canonical_buf, path, 1);
+ strbuf_addbuf(dest, &canonical_buf);
+
+ strbuf_release(&canonical_buf);
+ break;
+ }
+
+ default:
+ BUG("unknown path_format value %d", format);
+ }
+}
+
REPO_GIT_PATH_FUNC(squash_msg, "SQUASH_MSG")
REPO_GIT_PATH_FUNC(merge_msg, "MERGE_MSG")
REPO_GIT_PATH_FUNC(merge_rr, "MERGE_RR")
diff --git a/path.h b/path.h
index 0434ba5e07..6aca53b100 100644
--- a/path.h
+++ b/path.h
@@ -262,6 +262,42 @@ enum scld_error safe_create_leading_directories_no_share(char *path);
int safe_create_file_with_leading_directories(struct repository *repo,
const char *path);
+/**
+ * The formatting strategy to apply when writing a path into a buffer.
+ */
+enum path_format {
+ /*
+ * Represents the default formatting behavior. Treated as
+ * PATH_FORMAT_UNMODIFIED by append_formatted_path().
+ */
+ PATH_FORMAT_DEFAULT,
+
+ /* Output the path exactly as-is without any modifications. */
+ PATH_FORMAT_UNMODIFIED,
+
+ /* Output a path relative to the provided directory prefix. */
+ PATH_FORMAT_RELATIVE,
+
+ /* Output a relative path only if the path shares a root with the prefix. */
+ PATH_FORMAT_RELATIVE_IF_SHARED,
+
+ /* Output a fully resolved, absolute canonical path. */
+ PATH_FORMAT_CANONICAL
+};
+
+/**
+ * Format a path according to the specified formatting strategy and append
+ * the result to the given strbuf.
+ *
+ * `dest` : The string buffer to append the formatted path to.
+ * `path` : The path string that needs to be formatted.
+ * `prefix` : The directory prefix to calculate relative offsets against.
+ * Pass NULL to default to the current working directory where applicable.
+ * `format` : The formatting behavior rule to execute.
+ */
+void append_formatted_path(struct strbuf *dest, const char *path,
+ const char *prefix, enum path_format format);
+
# ifdef USE_THE_REPOSITORY_VARIABLE
# include "strbuf.h"
# include "repository.h"
--
2.54.0
^ permalink raw reply related
* [GSoC Patch v4 2/4] rev-parse: use append_formatted_path() for path formatting
From: K Jayatheerth @ 2026-06-15 4:51 UTC (permalink / raw)
To: git
Cc: a3205153416, gitster, jltobler, kumarayushjha123,
lucasseikioshiro, phillip.wood, sandals, kristofferhaugsbakk,
K Jayatheerth
In-Reply-To: <20260615045112.50686-1-jayatheerthkulkarni2005@gmail.com>
Now that path formatting logic lives in a shared helper, keeping a
duplicate implementation in rev-parse is unnecessary and risks the
two diverging over time.
Replace the local format_type and default_type enums and the
hand-rolled formatting logic with a call to append_formatted_path().
Introduce PATH_FORMAT_DEFAULT as the initial value of arg_path_format
so that per-path fallback behavior is resolved in print_path() rather
than leaked into the shared helper.
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
---
builtin/rev-parse.c | 103 ++++++++++----------------------------------
1 file changed, 23 insertions(+), 80 deletions(-)
diff --git a/builtin/rev-parse.c b/builtin/rev-parse.c
index 218b5f34d6..2dd35361f3 100644
--- a/builtin/rev-parse.c
+++ b/builtin/rev-parse.c
@@ -632,73 +632,16 @@ static void handle_ref_opt(const char *pattern, const char *prefix)
clear_ref_exclusions(&ref_excludes);
}
-enum format_type {
- /* We would like a relative path. */
- FORMAT_RELATIVE,
- /* We would like a canonical absolute path. */
- FORMAT_CANONICAL,
- /* We would like the default behavior. */
- FORMAT_DEFAULT,
-};
-
-enum default_type {
- /* Our default is a relative path. */
- DEFAULT_RELATIVE,
- /* Our default is a relative path if there's a shared root. */
- DEFAULT_RELATIVE_IF_SHARED,
- /* Our default is a canonical absolute path. */
- DEFAULT_CANONICAL,
- /* Our default is not to modify the item. */
- DEFAULT_UNMODIFIED,
-};
-
-static void print_path(const char *path, const char *prefix, enum format_type format, enum default_type def)
+static void print_path(const char *path, const char *prefix,
+ enum path_format arg_path_format, enum path_format def_format)
{
- char *cwd = NULL;
- /*
- * We don't ever produce a relative path if prefix is NULL, so set the
- * prefix to the current directory so that we can produce a relative
- * path whenever possible. If we're using RELATIVE_IF_SHARED mode, then
- * we want an absolute path unless the two share a common prefix, so don't
- * set it in that case, since doing so causes a relative path to always
- * be produced if possible.
- */
- if (!prefix && (format != FORMAT_DEFAULT || def != DEFAULT_RELATIVE_IF_SHARED))
- prefix = cwd = xgetcwd();
- if (format == FORMAT_DEFAULT && def == DEFAULT_UNMODIFIED) {
- puts(path);
- } else if (format == FORMAT_RELATIVE ||
- (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE)) {
- /*
- * In order for relative_path to work as expected, we need to
- * make sure that both paths are absolute paths. If we don't,
- * we can end up with an unexpected absolute path that the user
- * didn't want.
- */
- struct strbuf buf = STRBUF_INIT, realbuf = STRBUF_INIT, prefixbuf = STRBUF_INIT;
- if (!is_absolute_path(path)) {
- strbuf_realpath_forgiving(&realbuf, path, 1);
- path = realbuf.buf;
- }
- if (!is_absolute_path(prefix)) {
- strbuf_realpath_forgiving(&prefixbuf, prefix, 1);
- prefix = prefixbuf.buf;
- }
- puts(relative_path(path, prefix, &buf));
- strbuf_release(&buf);
- strbuf_release(&realbuf);
- strbuf_release(&prefixbuf);
- } else if (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE_IF_SHARED) {
- struct strbuf buf = STRBUF_INIT;
- puts(relative_path(path, prefix, &buf));
- strbuf_release(&buf);
- } else {
- struct strbuf buf = STRBUF_INIT;
- strbuf_realpath_forgiving(&buf, path, 1);
- puts(buf.buf);
- strbuf_release(&buf);
- }
- free(cwd);
+ struct strbuf sb = STRBUF_INIT;
+ enum path_format fmt = (arg_path_format != PATH_FORMAT_DEFAULT) ? arg_path_format : def_format;
+
+ append_formatted_path(&sb, path, prefix, fmt);
+ puts(sb.buf);
+
+ strbuf_release(&sb);
}
int cmd_rev_parse(int argc,
@@ -717,7 +660,7 @@ int cmd_rev_parse(int argc,
const char *name = NULL;
struct strbuf buf = STRBUF_INIT;
int seen_end_of_options = 0;
- enum format_type format = FORMAT_DEFAULT;
+ enum path_format arg_path_format = PATH_FORMAT_DEFAULT;
show_usage_if_asked(argc, argv, builtin_rev_parse_usage);
@@ -797,8 +740,8 @@ int cmd_rev_parse(int argc,
die(_("--git-path requires an argument"));
print_path(repo_git_path_replace(the_repository, &buf,
"%s", argv[i + 1]), prefix,
- format,
- DEFAULT_RELATIVE_IF_SHARED);
+ arg_path_format,
+ PATH_FORMAT_RELATIVE_IF_SHARED);
i++;
continue;
}
@@ -820,9 +763,9 @@ int cmd_rev_parse(int argc,
if (!arg)
die(_("--path-format requires an argument"));
if (!strcmp(arg, "absolute")) {
- format = FORMAT_CANONICAL;
+ arg_path_format = PATH_FORMAT_CANONICAL;
} else if (!strcmp(arg, "relative")) {
- format = FORMAT_RELATIVE;
+ arg_path_format = PATH_FORMAT_RELATIVE;
} else {
die(_("unknown argument to --path-format: %s"), arg);
}
@@ -985,7 +928,7 @@ int cmd_rev_parse(int argc,
if (!strcmp(arg, "--show-toplevel")) {
const char *work_tree = repo_get_work_tree(the_repository);
if (work_tree)
- print_path(work_tree, prefix, format, DEFAULT_UNMODIFIED);
+ print_path(work_tree, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
else
die(_("this operation must be run in a work tree"));
continue;
@@ -993,7 +936,7 @@ int cmd_rev_parse(int argc,
if (!strcmp(arg, "--show-superproject-working-tree")) {
struct strbuf superproject = STRBUF_INIT;
if (get_superproject_working_tree(&superproject))
- print_path(superproject.buf, prefix, format, DEFAULT_UNMODIFIED);
+ print_path(superproject.buf, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
strbuf_release(&superproject);
continue;
}
@@ -1028,18 +971,18 @@ int cmd_rev_parse(int argc,
const char *gitdir = getenv(GIT_DIR_ENVIRONMENT);
char *cwd;
int len;
- enum format_type wanted = format;
+ enum path_format wanted = arg_path_format;
if (arg[2] == 'g') { /* --git-dir */
if (gitdir) {
- print_path(gitdir, prefix, format, DEFAULT_UNMODIFIED);
+ print_path(gitdir, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
continue;
}
if (!prefix) {
- print_path(".git", prefix, format, DEFAULT_UNMODIFIED);
+ print_path(".git", prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
continue;
}
} else { /* --absolute-git-dir */
- wanted = FORMAT_CANONICAL;
+ wanted = PATH_FORMAT_CANONICAL;
if (!gitdir && !prefix)
gitdir = ".git";
if (gitdir) {
@@ -1055,11 +998,11 @@ int cmd_rev_parse(int argc,
strbuf_reset(&buf);
strbuf_addf(&buf, "%s%s.git", cwd, len && cwd[len-1] != '/' ? "/" : "");
free(cwd);
- print_path(buf.buf, prefix, wanted, DEFAULT_CANONICAL);
+ print_path(buf.buf, prefix, wanted, PATH_FORMAT_CANONICAL);
continue;
}
if (!strcmp(arg, "--git-common-dir")) {
- print_path(repo_get_common_dir(the_repository), prefix, format, DEFAULT_RELATIVE_IF_SHARED);
+ print_path(repo_get_common_dir(the_repository), prefix, arg_path_format, PATH_FORMAT_RELATIVE_IF_SHARED);
continue;
}
if (!strcmp(arg, "--is-inside-git-dir")) {
@@ -1089,7 +1032,7 @@ int cmd_rev_parse(int argc,
if (the_repository->index->split_index) {
const struct object_id *oid = &the_repository->index->split_index->base_oid;
const char *path = repo_git_path_replace(the_repository, &buf, "sharedindex.%s", oid_to_hex(oid));
- print_path(path, prefix, format, DEFAULT_RELATIVE);
+ print_path(path, prefix, arg_path_format, PATH_FORMAT_RELATIVE);
}
continue;
}
--
2.54.0
^ permalink raw reply related
* [GSoC Patch v4 3/4] repo: add path.commondir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-15 4:51 UTC (permalink / raw)
To: git
Cc: a3205153416, gitster, jltobler, kumarayushjha123,
lucasseikioshiro, phillip.wood, sandals, kristofferhaugsbakk,
K Jayatheerth
In-Reply-To: <20260615045112.50686-1-jayatheerthkulkarni2005@gmail.com>
Scripts working with worktree setups need a reliable way to discover
the common directory, which diverges from the git directory when
multiple worktrees are in use. There is no way to retrieve this path
from git repo info today.
Introduce path.commondir.absolute and path.commondir.relative keys.
Exposing explicit format variants rather than a single key with a
default avoids ambiguity for scripts that require predictable output.
Add a test helper test_repo_info_path that creates isolated
repositories per test case to prevent state leaks, captures the repo
root before changing directories to avoid eval, and accepts an optional
init_command to cover environment variable overrides such as
GIT_COMMON_DIR and GIT_DIR.
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
---
Documentation/git-repo.adoc | 9 ++++++
builtin/repo.c | 26 ++++++++++++++++
t/t1900-repo-info.sh | 61 +++++++++++++++++++++++++++++++++++++
3 files changed, 96 insertions(+)
diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index 42262c1983..890c34051d 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -104,6 +104,15 @@ values that they return:
`object.format`::
The object format (hash algorithm) used in the repository.
+`path.commondir.absolute`::
+ The canonical absolute path to the Git repository's common
+ directory (the shared `.git` directory containing objects,
+ refs, and global configuration).
+
+`path.commondir.relative`::
+ The path to the Git repository's common directory relative to
+ the current working directory.
+
`references.format`::
The reference storage format. The valid values are:
+
diff --git a/builtin/repo.c b/builtin/repo.c
index 71a5c1c29c..c4cc3bf3fc 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -7,12 +7,14 @@
#include "hex.h"
#include "odb.h"
#include "parse-options.h"
+#include "path.h"
#include "path-walk.h"
#include "progress.h"
#include "quote.h"
#include "ref-filter.h"
#include "refs.h"
#include "revision.h"
+#include "setup.h"
#include "strbuf.h"
#include "string-list.h"
#include "shallow.h"
@@ -75,6 +77,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf)
return 0;
}
+static int get_path_commondir_absolute(struct repository *repo, struct strbuf *buf)
+{
+ const char *common_dir = repo_get_common_dir(repo);
+
+ if (!common_dir)
+ return error(_("unable to get common directory"));
+
+ append_formatted_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+ return 0;
+}
+
+static int get_path_commondir_relative(struct repository *repo, struct strbuf *buf)
+{
+ const char *common_dir = repo_get_common_dir(repo);
+
+ if (!common_dir)
+ return error(_("unable to get common directory"));
+
+ append_formatted_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+ return 0;
+}
+
static int get_references_format(struct repository *repo, struct strbuf *buf)
{
strbuf_addstr(buf,
@@ -87,6 +111,8 @@ static const struct repo_info_field repo_info_field[] = {
{ "layout.bare", get_layout_bare },
{ "layout.shallow", get_layout_shallow },
{ "object.format", get_object_format },
+ { "path.commondir.absolute", get_path_commondir_absolute },
+ { "path.commondir.relative", get_path_commondir_relative },
{ "references.format", get_references_format },
};
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 39bb77dda0..0c0228687f 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -155,4 +155,65 @@ test_expect_success 'git repo info -h shows only repo info usage' '
test_grep ! "git repo structure" actual
'
+# Helper function to test path keys in both absolute and relative formats.
+# $1: label for the test
+# $2: field_name (e.g., commondir)
+# $3: unique repo name for isolation
+# $4: expect_absolute (suffix appended to repo root)
+# $5: expect_relative (the relative path string expected)
+# $6: init_command (extra setup like exporting env vars)
+test_repo_info_path () {
+ label=$1
+ field_name=$2
+ repo_name=$3
+ expect_absolute_suffix=$4
+ expect_relative=$5
+ init_command=$6
+
+ absolute_root="$repo_name-absolute"
+ relative_root="$repo_name-relative"
+
+ test_expect_success "setup: $label" '
+ git init "$absolute_root" &&
+ git init "$relative_root" &&
+ mkdir -p "$absolute_root/sub" "$relative_root/sub"
+ '
+
+ test_expect_success "absolute: $label" '
+ (
+ cd "$absolute_root/sub" &&
+ ROOT="$(test-tool path-utils real_path ..)" && export ROOT &&
+ eval "$init_command" &&
+ expect_path="$ROOT${expect_absolute_suffix:+/$expect_absolute_suffix}" &&
+ echo "path.$field_name.absolute=$expect_path" >expect &&
+ git repo info "path.$field_name.absolute" >actual &&
+ test_cmp expect actual
+ )
+ '
+
+ test_expect_success "relative: $label" '
+ (
+ cd "$relative_root/sub" &&
+ ROOT="$(test-tool path-utils real_path ..)" && export ROOT &&
+ eval "$init_command" &&
+ echo "path.$field_name.relative=$expect_relative" >expect &&
+ git repo info "path.$field_name.relative" >actual &&
+ test_cmp expect actual
+ )
+ '
+}
+
+test_repo_info_path 'commondir standard' 'commondir' 'commondir-std' \
+ '.git' '../.git'
+
+test_repo_info_path 'commondir with GIT_COMMON_DIR and GIT_DIR' 'commondir' \
+ 'commondir-envs' 'custom-common' '../custom-common' \
+ 'GIT_COMMON_DIR="$ROOT/custom-common" && export GIT_COMMON_DIR &&
+ GIT_DIR="../.git" && export GIT_DIR &&
+ git init --bare "$ROOT/custom-common"'
+
+test_repo_info_path 'commondir with only GIT_DIR' 'commondir' \
+ 'commondir-only-gitdir' '.git' '../.git' \
+ 'GIT_DIR="../.git" && export GIT_DIR'
+
test_done
--
2.54.0
^ permalink raw reply related
* [GSoC Patch v4 4/4] repo: add path.gitdir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-15 4:51 UTC (permalink / raw)
To: git
Cc: a3205153416, gitster, jltobler, kumarayushjha123,
lucasseikioshiro, phillip.wood, sandals, kristofferhaugsbakk,
K Jayatheerth
In-Reply-To: <20260615045112.50686-1-jayatheerthkulkarni2005@gmail.com>
Scripts need a stable way to locate the git directory without
parsing rev-parse output or relying on its flag-driven path format
selection. There is no way to retrieve this path from git repo info
today.
Introduce path.gitdir.absolute and path.gitdir.relative keys,
consistent with the path.commondir keys added in the previous patch.
Reuse the test_repo_info_path helper introduced there to validate
both variants.
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
---
Documentation/git-repo.adoc | 6 ++++++
builtin/repo.c | 24 ++++++++++++++++++++++++
t/t1900-repo-info.sh | 7 +++++++
3 files changed, 37 insertions(+)
diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index 890c34051d..ed7d80c690 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -113,6 +113,12 @@ values that they return:
The path to the Git repository's common directory relative to
the current working directory.
+`path.gitdir.absolute`::
+ The canonical absolute path to the Git repository directory (the `.git` directory).
+
+`path.gitdir.relative`::
+ The path to the Git repository directory relative to the current working directory.
+
`references.format`::
The reference storage format. The valid values are:
+
diff --git a/builtin/repo.c b/builtin/repo.c
index c4cc3bf3fc..9a312d127a 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -99,6 +99,28 @@ static int get_path_commondir_relative(struct repository *repo, struct strbuf *b
return 0;
}
+static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf)
+{
+ const char *git_dir = repo_get_git_dir(repo);
+
+ if (!git_dir)
+ return error(_("unable to get git directory"));
+
+ append_formatted_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+ return 0;
+}
+
+static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf)
+{
+ const char *git_dir = repo_get_git_dir(repo);
+
+ if (!git_dir)
+ return error(_("unable to get git directory"));
+
+ append_formatted_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+ return 0;
+}
+
static int get_references_format(struct repository *repo, struct strbuf *buf)
{
strbuf_addstr(buf,
@@ -113,6 +135,8 @@ static const struct repo_info_field repo_info_field[] = {
{ "object.format", get_object_format },
{ "path.commondir.absolute", get_path_commondir_absolute },
{ "path.commondir.relative", get_path_commondir_relative },
+ { "path.gitdir.absolute", get_path_gitdir_absolute },
+ { "path.gitdir.relative", get_path_gitdir_relative },
{ "references.format", get_references_format },
};
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 0c0228687f..45741fc9f1 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -216,4 +216,11 @@ test_repo_info_path 'commondir with only GIT_DIR' 'commondir' \
'commondir-only-gitdir' '.git' '../.git' \
'GIT_DIR="../.git" && export GIT_DIR'
+test_repo_info_path 'gitdir standard' 'gitdir' 'gitdir-std' \
+ '.git' '../.git'
+
+test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \
+ 'gitdir-env' '.git' '../.git' \
+ 'GIT_DIR="../.git" && export GIT_DIR'
+
test_done
--
2.54.0
^ permalink raw reply related
* [PATCH v3] log: improve --follow following renames for non-linear history
From: Miklos Vajna @ 2026-06-15 6:22 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Jeff King, git
In-Reply-To: <xmqqo6hglncl.fsf@gitster.g>
Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',
the output only contains history in the outer repo, not commits that
were merged via a subtree merge.
What happens is that 'git log --follow' stores the followed path only in
opt->diffopt.pathspec, so in case the commit history is non-linear, and
multiple parents have renames to the followed path, then the end result
isn't really defined: the first commit that happens to be visited in one
of the parents update opt->diffopt.pathspec, and from that point, only
that updated path is visited.
Fix the problem by introducing a commit -> path map
(follow_pathspec_slab) that stores what will be a path to follow when
visiting that parent. At the top of log_tree_commit(), if the slab has
an entry for this commit, we replace opt->diffopt.pathspec with a path
from this entry, so the correct path is followed, even if an unrelated
sub-tree changed the path to be followed to something else. After
log_tree_diff() runs, we record each parent's path in the slab. As a
result, the walk order doesn't matter, which was exactly the source of
problems previously.
This helps with subtree merges (rename happens inside the merge commit),
but also fixes the general case when the rename happens in the history
of parents, not in the merge commit itself.
Signed-off-by: Miklos Vajna <vmiklos@collabora.com>
---
Hi Junio,
On Thu, Jun 11, 2026 at 03:32:42PM -0700, Junio C Hamano <gitster@pobox.com> wrote:
> Missing sign-off; omitting sign-off to say that this is primarily
> for requesting comments and not ready for application (often we see
> RFC on the Subject line when this is done) is fine, though.
I've fixed that, this is meant to be ready for application now.
> My answer to my (rhetorical) question (Can a "map" cut it?) actually
> was "we probably can", since our "rename following" code does not
> handle cases where two paths in a parent is merged into a single
> path in a child, or a single path in a parent is split to form
> multiple paths in a child.
This is what confused me. Seeing that the "rename following" code
doesn't handle splits, I can indeed go back to just track one path per
commit, which makes the patch simpler, so I'm quite happy with that.
> Are any of your test cases added by this patch behave differently
> with this version (vs the "single path assigned to each commit"
> version you had earlier)? If so, then obviously there is some hole
> in my above discussion.
Ignoring the setup ones, I had 3 tests in the patch:
1) The original subtree merge use-case, with unrelated histories, rename
happening in the merge commit itself.
2) Your unrelated histories use-case from
https://lore.kernel.org/git/xmqqjysz7r41.fsf@gitster.g/
which pointed out the design issue in the --follow feature.
3) A last one, which tried to handle splits, in retrospect not really
successfully.
So I suggest let's forget about the 3rd case, and the first two behave
the same when storing just one path in the slab, so that validates your
discussion.
Now that you pointed out a 3rd use-case, with related histories, I also
added a test for that, with a history like this:
B---X
/ \
A M---Z
\ /
C---Y
Where:
- A has path0
- B (child of A) modifies path0
- X (child of B) renames path0 to path1
- C (child of A) modifies path0
- Y (child of C) renames path0 to path2
- M merges path1 and path2 to just path
- Z modifies path
and 'git log --follow path' finds all 6 non-merge commits. I turned this
into a (new) 3rd testcase in the patch, since related histories were not
tested so far.
> Eek. That's a subtle workaround to break the built-in safety to
> ensure there is only one pathspec element while following.
I now took that out, since the slab now just has one path for each
commit.
> t4218 seems to be taken by another topic in-flight, so this needs
> renumbering.
OK, t4219 seems to be free in 'next', let me take that, then.
Thanks,
Miklos
Documentation/config/log.adoc | 3 +-
log-tree.c | 116 ++++++++++++++++++++++++++++++
log-tree.h | 1 +
revision.c | 2 +
revision.h | 4 ++
t/meson.build | 1 +
t/t4219-log-follow-merge.sh | 129 ++++++++++++++++++++++++++++++++++
7 files changed, 254 insertions(+), 2 deletions(-)
create mode 100755 t/t4219-log-follow-merge.sh
diff --git a/Documentation/config/log.adoc b/Documentation/config/log.adoc
index f20cc25cd7..757a7be196 100644
--- a/Documentation/config/log.adoc
+++ b/Documentation/config/log.adoc
@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.
`log.follow`::
If `true`, `git log` will act as if the `--follow` option was used when
a single <path> is given. This has the same limitations as `--follow`,
- i.e. it cannot be used to follow multiple files and does not work well
- on non-linear history.
+ i.e. it cannot be used to follow multiple files.
`log.graphColors`::
A list of colors, separated by commas, that can be used to draw
diff --git a/log-tree.c b/log-tree.c
index 7e048701d0..90f933063e 100644
--- a/log-tree.c
+++ b/log-tree.c
@@ -3,6 +3,7 @@
#include "git-compat-util.h"
#include "commit-reach.h"
+#include "commit-slab.h"
#include "config.h"
#include "diff.h"
#include "diffcore.h"
@@ -1089,6 +1090,96 @@ static int do_remerge_diff(struct rev_info *opt,
return !opt->loginfo;
}
+/* Per-commit path storage for --follow across merges */
+define_commit_slab(follow_pathspec_slab, char *);
+
+static const char *pathspec_single_path(const struct pathspec *ps)
+{
+ if (ps->nr != 1)
+ return NULL;
+ return ps->items[0].match;
+}
+
+static void set_pathspec_to_single_path(struct pathspec *ps, const char *path)
+{
+ const char *paths[2] = { path, NULL };
+
+ clear_pathspec(ps);
+ parse_pathspec(ps,
+ PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
+ PATHSPEC_LITERAL_PATH, "", paths);
+}
+
+static void remember_follow_pathspec(struct rev_info *opt,
+ struct commit *c, const char *path)
+{
+ char **slot;
+
+ if (!path)
+ return;
+ if (!opt->follow_pathspec_slab) {
+ opt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));
+ init_follow_pathspec_slab(opt->follow_pathspec_slab);
+ }
+ slot = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);
+ if (*slot && !strcmp(*slot, path))
+ return;
+ free(*slot);
+ *slot = xstrdup(path);
+}
+
+static const char *recall_follow_pathspec(struct rev_info *opt,
+ struct commit *c)
+{
+ char **slot;
+
+ if (!opt->follow_pathspec_slab)
+ return NULL;
+ slot = follow_pathspec_slab_peek(opt->follow_pathspec_slab, c);
+ return slot ? *slot : NULL;
+}
+
+static void free_follow_pathspec_slot(char **slot)
+{
+ FREE_AND_NULL(*slot);
+}
+
+void release_follow_pathspec_slab(struct rev_info *opt)
+{
+ if (!opt->follow_pathspec_slab)
+ return;
+ deep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,
+ free_follow_pathspec_slot);
+ FREE_AND_NULL(opt->follow_pathspec_slab);
+}
+
+/* Compute a path to follow in parent, if there is one */
+static void propagate_follow_pathspec_to_parent(struct rev_info *opt,
+ struct commit *commit,
+ struct commit *parent)
+{
+ struct diff_options diff_opts;
+ const char *path;
+
+ parse_commit_or_die(parent);
+ repo_diff_setup(opt->diffopt.repo, &diff_opts);
+ copy_pathspec(&diff_opts.pathspec, &opt->diffopt.pathspec);
+ diff_opts.flags.recursive = 1;
+ diff_opts.flags.follow_renames = 1;
+ diff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;
+ diff_setup_done(&diff_opts);
+ diff_tree_oid(get_commit_tree_oid(parent),
+ get_commit_tree_oid(commit),
+ "", &diff_opts);
+
+ path = pathspec_single_path(&diff_opts.pathspec);
+ if (path)
+ remember_follow_pathspec(opt, parent, path);
+
+ diff_queue_clear(&diff_queued_diff);
+ diff_free(&diff_opts);
+}
+
/*
* Show the diff of a commit.
*
@@ -1179,6 +1270,16 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)
opt->loginfo = &log;
opt->diffopt.no_free = 1;
+ /* Any recorded path for this commit? If so, restore it */
+ if (opt->diffopt.flags.follow_renames) {
+ const char *stored = recall_follow_pathspec(opt, commit);
+ if (stored) {
+ const char *current = pathspec_single_path(&opt->diffopt.pathspec);
+ if (!current || strcmp(current, stored))
+ set_pathspec_to_single_path(&opt->diffopt.pathspec, stored);
+ }
+ }
+
/* NEEDSWORK: no restoring of no_free? Why? */
if (opt->line_level_traverse)
return line_log_print(opt, commit);
@@ -1195,6 +1296,21 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)
fprintf(opt->diffopt.file, "\n%s\n", opt->break_bar);
if (shown)
show_diff_of_diff(opt);
+
+ /* Record what path each parent of this commit should use */
+ if (opt->diffopt.flags.follow_renames) {
+ struct commit_list *parents = get_saved_parents(opt, commit);
+ if (parents && parents->next) {
+ struct commit_list *p;
+ for (p = parents; p; p = p->next)
+ propagate_follow_pathspec_to_parent(opt, commit,
+ p->item);
+ } else if (parents) {
+ remember_follow_pathspec(opt, parents->item,
+ pathspec_single_path(&opt->diffopt.pathspec));
+ }
+ }
+
opt->loginfo = NULL;
maybe_flush_or_die(opt->diffopt.file, "stdout");
opt->diffopt.no_free = no_free;
diff --git a/log-tree.h b/log-tree.h
index 07924be8bc..e8679b6c4a 100644
--- a/log-tree.h
+++ b/log-tree.h
@@ -26,6 +26,7 @@ struct decoration_options {
int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);
int log_tree_diff_flush(struct rev_info *);
int log_tree_commit(struct rev_info *, struct commit *);
+void release_follow_pathspec_slab(struct rev_info *);
void show_log(struct rev_info *opt);
void format_decorations(struct strbuf *sb, const struct commit *commit,
enum git_colorbool use_color, const struct decoration_options *opts);
diff --git a/revision.c b/revision.c
index 5693618be4..caa85fb4c6 100644
--- a/revision.c
+++ b/revision.c
@@ -26,6 +26,7 @@
#include "decorate.h"
#include "string-list.h"
#include "line-log.h"
+#include "log-tree.h"
#include "mailmap.h"
#include "commit-slab.h"
#include "cache-tree.h"
@@ -3284,6 +3285,7 @@ void release_revisions(struct rev_info *revs)
line_log_free(revs);
oidset_clear(&revs->missing_commits);
release_revisions_bloom_keyvecs(revs);
+ release_follow_pathspec_slab(revs);
}
static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)
diff --git a/revision.h b/revision.h
index c9a11827cc..607113ca74 100644
--- a/revision.h
+++ b/revision.h
@@ -65,6 +65,7 @@ struct repository;
struct rev_info;
struct string_list;
struct saved_parents;
+struct follow_pathspec_slab;
struct bloom_keyvec;
struct bloom_filter_settings;
struct option;
@@ -354,6 +355,9 @@ struct rev_info {
/* copies of the parent lists, for --full-diff display */
struct saved_parents *saved_parents_slab;
+ /* per-commit pathspec for --follow across merges */
+ struct follow_pathspec_slab *follow_pathspec_slab;
+
struct commit_list *previous_parents;
struct commit_list *ancestry_path_bottoms;
const char *break_bar;
diff --git a/t/meson.build b/t/meson.build
index c5832fee05..8c4636565b 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -576,6 +576,7 @@ integration_tests = [
't4215-log-skewed-merges.sh',
't4216-log-bloom.sh',
't4217-log-limit.sh',
+ 't4219-log-follow-merge.sh',
't4252-am-options.sh',
't4253-am-keep-cr-dos.sh',
't4254-am-corrupt.sh',
diff --git a/t/t4219-log-follow-merge.sh b/t/t4219-log-follow-merge.sh
new file mode 100755
index 0000000000..e370f82955
--- /dev/null
+++ b/t/t4219-log-follow-merge.sh
@@ -0,0 +1,129 @@
+#!/bin/sh
+
+test_description='Test --follow follows renames across merges'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup subtree-merged repository' '
+ git init inner &&
+ echo inner >inner/inner.txt &&
+ git -C inner add inner.txt &&
+ git -C inner commit -m "inner init" &&
+
+ git init outer &&
+ echo outer >outer/outer.txt &&
+ git -C outer add outer.txt &&
+ git -C outer commit -m "outer init" &&
+
+ git -C outer fetch ../inner master &&
+ git -C outer merge -s ours --no-commit --allow-unrelated-histories \
+ FETCH_HEAD &&
+ git -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&
+ git -C outer commit -m "Merge inner repo into inner/ subdirectory"
+'
+
+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '
+ git -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&
+ echo "inner init" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'setup merge of two branches that both renamed a file to README' '
+ git init foo &&
+ mkdir foo/foo &&
+ echo "foo readme" >foo/foo/README &&
+ git -C foo add foo/README &&
+ git -C foo commit -m "add foo README" &&
+
+ git -C foo mv foo/README README &&
+ git -C foo commit -m "promote foo README to toplevel" &&
+
+ echo "foo c" >foo/foo.c &&
+ git -C foo add foo.c &&
+ git -C foo commit -m "add foo C impl" &&
+
+ git init bar &&
+ mkdir bar/bar &&
+ echo "bar readme" >bar/bar/README &&
+ git -C bar add bar/README &&
+ git -C bar commit -m "add bar README" &&
+
+ git -C bar mv bar/README README &&
+ git -C bar commit -m "promote bar README to toplevel" &&
+
+ echo "bar c" >bar/bar.c &&
+ git -C bar add bar.c &&
+ git -C bar commit -m "add bar C impl" &&
+
+ git -C foo fetch ../bar master &&
+ git -C foo merge -s ours --no-commit --allow-unrelated-histories \
+ FETCH_HEAD &&
+ git -C foo checkout FETCH_HEAD -- bar.c &&
+ git -C foo commit -m "merge bar into foo"
+'
+
+test_expect_success '--follow follows renames across both sides of a merge' '
+ git -C foo log --follow --pretty=tformat:%s README >actual &&
+ sort actual >actual.sorted &&
+ cat >expect <<-\EOF &&
+ add bar README
+ add foo README
+ promote bar README to toplevel
+ promote foo README to toplevel
+ EOF
+ test_cmp expect actual.sorted
+'
+
+test_expect_success 'setup diamond with renames on both sides of a fork' '
+ git init diamond &&
+ test_lines="line 1\nline 2\nline 3\nline 4\nline 5\n" &&
+
+ printf "$test_lines" >diamond/path0 &&
+ git -C diamond add path0 &&
+ git -C diamond commit -m "A: add path0" &&
+
+ git -C diamond checkout -b upper &&
+ printf "line 1\nline 2\nline 3 modified by B\nline 4\nline 5\n" \
+ >diamond/path0 &&
+ git -C diamond commit -am "B: modify path0 on upper" &&
+ git -C diamond mv path0 path1 &&
+ git -C diamond commit -m "X: rename path0 to path1" &&
+
+ git -C diamond checkout -b lower master &&
+ printf "line 1\nline 2\nline 3 modified by C\nline 4\nline 5\n" \
+ >diamond/path0 &&
+ git -C diamond commit -am "C: modify path0 on lower" &&
+ git -C diamond mv path0 path2 &&
+ git -C diamond commit -m "Y: rename path0 to path2" &&
+
+ git -C diamond checkout upper &&
+ git -C diamond merge -s ours --no-commit lower &&
+ git -C diamond rm path1 &&
+ printf "line 1\nline 2\nline 3 merged\nline 4\nline 5\n" \
+ >diamond/path &&
+ git -C diamond add path &&
+ git -C diamond commit -m "M: merge with rename to path" &&
+
+ printf "line 1\nline 2\nline 3 merged again\nline 4\nline 5\n" \
+ >diamond/path &&
+ git -C diamond commit -am "Z: modify path"
+'
+
+test_expect_success '--follow follows renames through a fork in a single history' '
+ git -C diamond log --follow --pretty=tformat:%s path >actual &&
+ sort actual >actual.sorted &&
+ cat >expect <<-\EOF &&
+ A: add path0
+ B: modify path0 on upper
+ C: modify path0 on lower
+ X: rename path0 to path1
+ Y: rename path0 to path2
+ Z: modify path
+ EOF
+ test_cmp expect actual.sorted
+'
+
+test_done
--
2.51.0
^ permalink raw reply related
* Re: [PATCH] gitattributes: fix eol attribute for Perl scripts
From: Patrick Steinhardt @ 2026-06-15 7:22 UTC (permalink / raw)
To: Koutian Wu via GitGitGadget; +Cc: git, Koutian Wu
In-Reply-To: <pull.2151.git.1781497525828.gitgitgadget@gmail.com>
On Mon, Jun 15, 2026 at 04:25:25AM +0000, Koutian Wu via GitGitGadget wrote:
> From: ktwu01 <ktwu01@gmail.com>
>
> The *.pl pattern currently sets eof=lf, which is not a built-in
> attribute used for line-ending normalization.
>
> Use eol=lf instead, matching the neighboring *.perl and *.pm rules, so
> Perl scripts are checked out with LF line endings.
>
> Signed-off-by: ktwu01 <ktwu01@gmail.com>
The Signed-off-by and commit author should use your real name, if
possible. See [1].
> diff --git a/.gitattributes b/.gitattributes
> index 556322be01..26490ad60a 100644
> --- a/.gitattributes
> +++ b/.gitattributes
> @@ -2,7 +2,7 @@
> *.[ch] whitespace=indent,trail,space,incomplete diff=cpp
> *.sh whitespace=indent,trail,space,incomplete text eol=lf
> *.perl text eol=lf diff=perl
> -*.pl text eof=lf diff=perl
> +*.pl text eol=lf diff=perl
> *.pm text eol=lf diff=perl
> *.py text eol=lf diff=python
> *.bat text eol=crlf
Yeah, this looks obviously correct to me. Thanks for the fix!
Patrick
[1]: https://git-scm.com/docs/SubmittingPatches#real-name
^ permalink raw reply
* Re: [PATCH] cat-file: speed up default format
From: Patrick Steinhardt @ 2026-06-15 7:27 UTC (permalink / raw)
To: René Scharfe; +Cc: Git List
In-Reply-To: <5a7ed929-6fe0-496c-83bd-65dee57c2241@web.de>
On Sun, Jun 14, 2026 at 06:28:34PM +0200, René Scharfe wrote:
> eb54a3391b (cat-file: skip expanding default format, 2022-03-15) added
> special handling for the default batch format. In the meantime it has
> fallen behind the code path for handling arbitrary formats. Bring it up
> to speed by using the new and more efficient strbuf_add_oid_hex() and
> strbuf_add_uint() instead of strbuf_addf():
>
> Benchmark 1: ./git_main cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)'
> Time (mean ± σ): 1.051 s ± 0.003 s [User: 1.027 s, System: 0.023 s]
> Range (min … max): 1.049 s … 1.058 s 10 runs
>
> Benchmark 2: ./git_main cat-file --batch-all-objects --batch-check='%(objectname)-%(objecttype)-%(objectsize)'
> Time (mean ± σ): 1.012 s ± 0.002 s [User: 0.988 s, System: 0.023 s]
> Range (min … max): 1.010 s … 1.018 s 10 runs
>
> Benchmark 3: ./git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)'
> Time (mean ± σ): 979.0 ms ± 1.1 ms [User: 954.1 ms, System: 23.2 ms]
> Range (min … max): 977.7 ms … 980.8 ms 10 runs
>
> Summary
> ./git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)' ran
> 1.03 ± 0.00 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectname)-%(objecttype)-%(objectsize)'
> 1.07 ± 0.00 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)'
This almost makes me wonder whether it even makes sense to keep around
the handler for the default format. Is a 3% speedup worth the additional
complexity and the need to keep those sites in sync?
> diff --git a/builtin/cat-file.c b/builtin/cat-file.c
> index 2b64f8f733..d7f7895e30 100644
> --- a/builtin/cat-file.c
> +++ b/builtin/cat-file.c
> @@ -461,9 +461,12 @@ static void print_object_or_die(struct batch_options *opt, struct expand_data *d
> static void print_default_format(struct strbuf *scratch, struct expand_data *data,
> struct batch_options *opt)
> {
> - strbuf_addf(scratch, "%s %s %"PRIuMAX"%c", oid_to_hex(&data->oid),
> - type_name(data->type),
> - (uintmax_t)data->size, opt->output_delim);
> + strbuf_add_oid_hex(scratch, &data->oid);
> + strbuf_addch(scratch, ' ');
> + strbuf_addstr(scratch, type_name(data->type));
> + strbuf_addch(scratch, ' ');
> + strbuf_add_uint(scratch, data->size);
> + strbuf_addch(scratch, opt->output_delim);
> }
The change itself looks obviously good to me though, thanks!
Patrick
^ permalink raw reply
* [PATCH v2] gitattributes: fix eol attribute for Perl scripts
From: Koutian Wu via GitGitGadget @ 2026-06-15 7:53 UTC (permalink / raw)
To: git; +Cc: Koutian Wu, Koutian Wu
In-Reply-To: <pull.2151.git.1781497525828.gitgitgadget@gmail.com>
From: Koutian Wu <ktwu01@gmail.com>
The *.pl pattern currently sets eof=lf, which is not a built-in
attribute used for line-ending normalization.
Use eol=lf instead, matching the neighboring *.perl and *.pm rules, so
Perl scripts are checked out with LF line endings.
Signed-off-by: Koutian Wu <ktwu01@gmail.com>
---
gitattributes: fix eol attribute for Perl scripts
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2151%2Fktwu01%2Fkw%2Ffix-pl-eol-attribute-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2151/ktwu01/kw/fix-pl-eol-attribute-v2
Pull-Request: https://github.com/gitgitgadget/git/pull/2151
Range-diff vs v1:
1: 92ba4d499d ! 1: f4b4ca30c7 gitattributes: fix eol attribute for Perl scripts
@@
## Metadata ##
-Author: ktwu01 <ktwu01@gmail.com>
+Author: Koutian Wu <ktwu01@gmail.com>
## Commit message ##
gitattributes: fix eol attribute for Perl scripts
@@ Commit message
Use eol=lf instead, matching the neighboring *.perl and *.pm rules, so
Perl scripts are checked out with LF line endings.
- Signed-off-by: ktwu01 <ktwu01@gmail.com>
+ Signed-off-by: Koutian Wu <ktwu01@gmail.com>
## .gitattributes ##
@@
.gitattributes | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitattributes b/.gitattributes
index 556322be01..26490ad60a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,7 +2,7 @@
*.[ch] whitespace=indent,trail,space,incomplete diff=cpp
*.sh whitespace=indent,trail,space,incomplete text eol=lf
*.perl text eol=lf diff=perl
-*.pl text eof=lf diff=perl
+*.pl text eol=lf diff=perl
*.pm text eol=lf diff=perl
*.py text eol=lf diff=python
*.bat text eol=crlf
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
--
gitgitgadget
^ permalink raw reply related
* Re: [PATCH] commit-graph: use timestamp_t for max parent generation accumulator
From: Patrick Steinhardt @ 2026-06-15 8:11 UTC (permalink / raw)
To: Elijah Newren via GitGitGadget; +Cc: git, Elijah Newren
In-Reply-To: <pull.2148.git.1781420271100.gitgitgadget@gmail.com>
On Sun, Jun 14, 2026 at 06:57:50AM +0000, Elijah Newren via GitGitGadget wrote:
> commit-graph: use timestamp_t for max parent generation accumulator
>
> We found a few repositories in the wild with commits whose authors were
> apparently on a computer in the year 2120 when they recorded their
> commits. Apparently, in a century from now, some folks are going to have
> a really weird timezone as well (-13068837), though the timezone doesn't
> factor into this patch at all.
I'd really be curious which other parts of Git will start to break once
we cross that threshold. Would it make sense if we maybe expanded our
linux-TEST-VARS job to create commits with a date beyond UINT32_MAX?
Something like the patch at the end of this mail. And yes, many tests
break with the patch applied. From all I've seen though many of those
failures are benign, even though I'd bet that there might even be some
"proper" failures in there.
Anyway, this is of course outside the scope of this patch series.
> diff --git a/commit-graph.c b/commit-graph.c
> index 9abe62bd5a..4b7156fd76 100644
> --- a/commit-graph.c
> +++ b/commit-graph.c
> @@ -1669,7 +1669,7 @@ static void compute_reachable_generation_numbers(
> struct commit *current = list->item;
> struct commit_list *parent;
> int all_parents_computed = 1;
> - uint32_t max_gen = 0;
> + timestamp_t max_gen = 0;
>
> for (parent = current->parents; parent; parent = parent->next) {
> repo_parse_commit(info->r, parent->item);
This looks obviously correct.
> diff --git a/t/t5328-commit-graph-64bit-time.sh b/t/t5328-commit-graph-64bit-time.sh
> index d8891e6a92..bc651b69de 100755
> --- a/t/t5328-commit-graph-64bit-time.sh
> +++ b/t/t5328-commit-graph-64bit-time.sh
> @@ -74,6 +74,15 @@ test_expect_success 'single commit with generation data exceeding UINT32_MAX' '
> git -C repo-uint32-max commit-graph verify
> '
>
> +test_expect_success 'descendant of commit with date exceeding UINT32_MAX' '
> + git init repo-uint32-max-descendant &&
> + test_commit -C repo-uint32-max-descendant \
> + --date "@4294967300 +0000" future-parent &&
> + test_commit -C repo-uint32-max-descendant present-day-child &&
> + git -C repo-uint32-max-descendant commit-graph write --reachable &&
> + git -C repo-uint32-max-descendant commit-graph verify
> +'
Makes sense. Thanks!
Patrick
diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh
index 809c662124..e78902b671 100644
--- a/t/test-lib-functions.sh
+++ b/t/test-lib-functions.sh
@@ -136,12 +136,19 @@ sane_unset () {
test_tick () {
if test -z "${test_tick+set}"
then
- test_tick=1112911993
+ if test_bool_env GIT_TEST_FUTURE false
+ then
+ test_tick=4294697600
+ test_tick_prefix=@
+ else
+ test_tick=1112911993
+ test_tick_prefix=
+ fi
else
test_tick=$(($test_tick + 60))
fi
- GIT_COMMITTER_DATE="$test_tick -0700"
- GIT_AUTHOR_DATE="$test_tick -0700"
+ GIT_COMMITTER_DATE="$test_tick_prefix$test_tick -0700"
+ GIT_AUTHOR_DATE="$test_tick_prefix$test_tick -0700"
export GIT_COMMITTER_DATE GIT_AUTHOR_DATE
}
diff --git a/t/test-lib.sh b/t/test-lib.sh
index 4a7357b547..54798fb3f1 100644
--- a/t/test-lib.sh
+++ b/t/test-lib.sh
@@ -558,12 +558,26 @@ TEST_AUTHOR_LOCALNAME=author
TEST_AUTHOR_DOMAIN=example.com
GIT_AUTHOR_EMAIL=${TEST_AUTHOR_LOCALNAME}@${TEST_AUTHOR_DOMAIN}
GIT_AUTHOR_NAME='A U Thor'
-GIT_AUTHOR_DATE='1112354055 +0200'
TEST_COMMITTER_LOCALNAME=committer
TEST_COMMITTER_DOMAIN=example.com
GIT_COMMITTER_EMAIL=${TEST_COMMITTER_LOCALNAME}@${TEST_COMMITTER_DOMAIN}
GIT_COMMITTER_NAME='C O Mitter'
-GIT_COMMITTER_DATE='1112354055 +0200'
+
+case "${GIT_TEST_FUTURE:-false}" in
+1|on|true|yes)
+ GIT_AUTHOR_DATE="${GIT_TEST_DATE:-@4294697300 +0200}"
+ GIT_COMMITTER_DATE="${GIT_TEST_DATE:-@4294697300 +0200}"
+ ;;
+0|off|false|no)
+ GIT_AUTHOR_DATE="${GIT_TEST_DATE:-1112354055 +0200}"
+ GIT_COMMITTER_DATE="${GIT_TEST_DATE:-1112354055 +0200}"
+ ;;
+*)
+ echo "GIT_TEST_FUTURE requires a boolean" >&2
+ exit 1
+ ;;
+esac
+
GIT_MERGE_VERBOSITY=5
GIT_MERGE_AUTOEDIT=no
export GIT_MERGE_VERBOSITY GIT_MERGE_AUTOEDIT
^ permalink raw reply related
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
From: Harald Nordgren @ 2026-06-15 8:18 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Harald Nordgren via GitGitGadget, git
In-Reply-To: <xmqqqzm8d0j7.fsf@gitster.g>
> > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
> > into its oldest one, reusing that commit's message.
>
> [2/2] seems to add "--fixup-all" but I agree with the "related idea"
> that naming it and modelling it after "merge --squash" would be
> easier to understand.
Sounds reasonable.
> I also wonder if we can do something like this without adding any
> new option or command. E.g., if you have four patch series, where
> the initial implementation HEAD~3 is followed by "oops it was still
> wrong" fix-up HEAD~2, HEAD~1 and HEAD, then
>
> git reset --soft HEAD~3 && git commit --amend --no-edit
>
> is what the user wants to do, no?
I don't think it's enough. First of all the user has to know the N for
HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend
--no-edit' is still quite ugly.
Harald
^ permalink raw reply
* Re: [PATCH 3/6] hash algorithms: use size_t for section lengths
From: Patrick Steinhardt @ 2026-06-15 8:35 UTC (permalink / raw)
To: Philip Oakley via GitGitGadget; +Cc: git, Johannes Schindelin, Philip Oakley
In-Reply-To: <253d6f8004e710d05b5de1f8279d67d2220f83de.1780593313.git.gitgitgadget@gmail.com>
On Thu, Jun 04, 2026 at 05:15:09PM +0000, Philip Oakley via GitGitGadget wrote:
> diff --git a/object-file.c b/object-file.c
> index 1f5f9daf24..c648cecd80 100644
> --- a/object-file.c
> +++ b/object-file.c
> @@ -581,7 +581,7 @@ static void write_object_file_prepare(const struct git_hash_algo *algo,
> /* Generate the header */
> *hdrlen = format_object_header(hdr, *hdrlen, type, len);
>
> - /* Sha1.. */
> + /* Hash (function pointers) computation */
> hash_object_body(algo, &c, buf, len, oid, hdr, hdrlen);
> }
>
Thanks for updating this comment while at it :)
> diff --git a/t/t1007-hash-object.sh b/t/t1007-hash-object.sh
> index 7867fd1dbf..10382a815e 100755
> --- a/t/t1007-hash-object.sh
> +++ b/t/t1007-hash-object.sh
> @@ -261,7 +261,7 @@ test_expect_success '--stdin outside of repository (uses default hash)' '
> test_cmp expect actual
> '
>
> -test_expect_failure EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> +test_expect_success EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> 'files over 4GB hash literally' '
> test-tool genzeros $((5*1024*1024*1024)) >big &&
> test_oid large5GB >expect &&
Previously we required `!LONG_IS_64BIT`, because the test would have
succeeded on platforms where it is 64 bit wide. But now that this test
works on all platforms I rather wonder whether we should completely drop
that prerequisite here, as we expect it to pass regardless of whether or
not long is 64 bit now.
Patrick
^ permalink raw reply
* Re: [PATCH 2/6] object-file.c: use size_t for header lengths
From: Patrick Steinhardt @ 2026-06-15 8:35 UTC (permalink / raw)
To: Philip Oakley via GitGitGadget; +Cc: git, Johannes Schindelin, Philip Oakley
In-Reply-To: <809d83e46fb46baeb5d0dfcd12eb7fc63580eec4.1780593313.git.gitgitgadget@gmail.com>
On Thu, Jun 04, 2026 at 05:15:08PM +0000, Philip Oakley via GitGitGadget wrote:
> From: Philip Oakley <philipoakley@iee.email>
>
> Continue walking the code path for the >4GB `hash-object --literally`
> test. The `hash_object_file_literally()` function internally uses both
> `hash_object_file()` and `write_object_file_prepare()`. Both function
> signatures use `unsigned long` rather than `size_t` for the mem buffer
> sizes. Use `size_t` instead, for LLP64 compatibility.
>
> While at it, convert those function's object's header buffer length to
> `size_t` for consistency. The value is already upcast to `uintmax_t` for
> print format compatibility.
One thing I was wondering is whether we should rather migrate to a size
that is consistent across different platforms. We could e.g. `typedef
uint64_t objsize_t` and then use that going forward.
I guess the question though is whether that'd buy us anything. In other
words, are there any platforms that we care about where `size_t` is only
32 bit wide? And would such platforms even be able to handle such large
objects?
Patrick
^ permalink raw reply
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox