{"thread":{"id":"65837","subject":"[PATCH 0/7] line-log: range-scope stat, check, and -G under -L","startedAt":"2026-06-18T18:16:36Z","lastAt":"2026-09-03T05:05:31Z","messageCount":27,"participants":["Michael Montalbo via GitGitGadget","Junio C Hamano","Michael Montalbo"],"isPatch":true,"patchVersion":1,"patchTotal":7},"messages":[{"id":"545877","messageId":"pull.2152.git.1781806593.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":null,"subject":"[PATCH 0/7] line-log: range-scope stat, check, and -G under -L","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:25Z","receivedAt":"2026-06-18T18:16:36Z","isPatch":true,"body":"This series extends git log -L so that more of its diff output and commit\nselection honor the tracked line ranges: the diff stat formats and --check\nare supported with range-scoped results, and the -G pickaxe is scoped to the\ntracked range.\n\nIt builds on top of the mm/line-log-cleanup topic [1], which integrated -L\nwith the standard log output pipeline and taught it the non-patch formats\n--raw, --name-only, --name-status, and --summary.\n\nWith these patches the following also honor the tracked range:\n\n * --stat, --numstat, --shortstat: counts cover only the lines inside the\n   tracked range, not the whole file.\n\n * --check: whitespace errors are reported only for added lines inside the\n   tracked range, with the correct file line numbers.\n\n * -G: a commit is selected only when the pattern appears on an\n   added/removed line inside the tracked range, rather than anywhere in the\n   file.\n\nThe --dirstat format is deliberately rejected. Its default mode reports each\ndirectory's share of the total churn as a percentage, computed from\nwhole-file byte damage (via diffcore_count_changes(), outside the line-based\npipeline that -L scopes), so bare --dirstat cannot honor the tracked range.\nThe --dirstat=lines mode could: it aggregates the same per-file line counts\nas --numstat, which -L already scopes. But supporting only that sub-mode\nwhile bare --dirstat still errors is a confusing split, so the whole format\nis left to a follow-up; --numstat already gives the exact range-scoped\nper-file counts.\n\n-S is left matching the whole file. Unlike -G, it counts needle occurrences\nper blob rather than grepping the diff, so scoping it to a range needs a\ndifferent approach; that is left to a follow-up. Patch 7, which scopes -G,\nalso updates the -L documentation to note the -S/-G distinction, so the\nwhole-file behavior of -S is not mistaken for the range-scoped behavior\naround it.\n\nPatches 1-3 are independent of the new formats: they fix two bugs in the\nexisting -L patch output (a leaked deletion and an off-by-one hunk header),\nbring its hunk headers in line with git diff's format, and clarify the\nline-range filter mm/line-log-cleanup added, whose names obscured its model\n(cryptic lno_ cursors conflating the pre/post-image and 0/1-based axes, a\nflat hunk-state struct, and a one-letter state pointer (s)). The two bugs\nmay be a hint that the model could use clarification, so patch 1 renames and\ngroups the filter state and patch 2 documents the model, before the fixes\nthat read against it. Patches 4-7 then build the new formats on top:\n\n * Patch 1: rename and group the filter for clarity. Spell the cryptic names\n   out to the file's own forms: the line-number cursors to\n   lno_in_preimage/lno_in_postimage (as in struct emit_callback) and the\n   range index to idx_in_postimage, while the hunk geometry stays old/new\n   (the xdiff_emit_hunk_fn convention) and moves into a sub-struct. Name the\n   filter pointer (filter) and rename the struct to line_range_filter and\n   the flush helper to flush_range_hunk. No behavior change.\n\n * Patch 2: simplify the filter by classifying removals as they arrive,\n   dropping the pending_rm buffer and a latent flush_range_hunk() bug that\n   leaked deletions just past the range. Make the buffered lines the hunk's\n   single source of truth: flush_range_hunk() derives the counts from them\n   rather than tracking them per line, dropping three more fields. Document\n   the model with a block comment and worked example, and add\n   begin_range_hunk() as the counterpart to flush_range_hunk(). (This\n   simplification was submitted by itself previously [2] but did not\n   advance, so it is re-included here.)\n\n * Patch 3: stop hand-rolling the synthetic hunk header and emit it through\n   xdiff's own formatter via a new xdiff_emit_hunk_header() helper. The\n   hand-rolled code put a count-0 side's begin one too high (the convention\n   is the line before the change); routing through xdl_emit_hunk_hdr() fixes\n   that by construction and, as a side effect, makes -L headers match git\n   diff exactly, including its omission of a count of 1. Regenerate the two\n   affected fixtures.\n\n * Patch 4: extract a line_range_filter_diff() helper that folds the\n   filter's two preconditions into one place: inflate ctxlen to the largest\n   range span so every change within a range lands in a single xdiff hunk,\n   and clear XDL_EMIT_NO_HUNK_HDR so the hunk headers the filter reads are\n   always emitted (its position tracking relies on both). It then runs an\n   initialized filter through xdiff, flushes the final range hunk, and\n   releases it; use it in builtin_diff(). The stat, check, and -G patches\n   that reuse it inherit both.\n\n * Patch 5: reuse the filter in builtin_diffstat() for the stat formats,\n   extend the -L output-format allowlist, and reject --dirstat.\n\n * Patch 6: reuse the filter in builtin_checkdiff() and extend the allowlist\n   for --check. The separate blank-at-eof pass scans the whole file, so\n   scope its report to the tracked ranges too.\n\n * Patch 7: scope -G to the tracked range. Expose the filter as\n   diff_emit_line_ranges() and grep only the tracked range's lines,\n   threading the filepair's line_ranges through the pickaxe callback. -S is\n   left whole-file, and the -L documentation is updated to note that -G is\n   range-scoped while -S still matches the whole file.\n\n[1]\nhttps://lore.kernel.org/git/pull.2094.v3.git.1780001267.gitgitgadget@gmail.com/\n[2]\nhttps://lore.kernel.org/git/pull.2099.git.1777230630020.gitgitgadget@gmail.com/\n\nMichael Montalbo (7):\n  diff: rename and group the line-range filter for clarity\n  diff: simplify the line-range filter by classifying removals\n    immediately\n  diff: emit -L hunk headers via xdiff's formatter\n  diff: extract a line-range diff helper for reuse\n  line-log: support diff stat formats with -L\n  diff: support --check with -L line ranges\n  diffcore-pickaxe: scope -G to the -L tracked range\n\n Documentation/line-range-options.adoc    |  17 +-\n diff.c                                   | 491 ++++++++++++++---------\n diffcore-pickaxe.c                       |  37 +-\n revision.c                               |   6 +-\n t/t4211-line-log.sh                      | 439 +++++++++++++++++++-\n t/t4211/sha1/expect.no-assertion-error   |   2 +-\n t/t4211/sha1/expect.vanishes-early       |   6 +-\n t/t4211/sha256/expect.no-assertion-error |   2 +-\n t/t4211/sha256/expect.vanishes-early     |   6 +-\n xdiff-interface.c                        |  19 +\n xdiff-interface.h                        |  28 ++\n 11 files changed, 826 insertions(+), 227 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2152%2Fmmontalbo%2Fmm%2Fline-log-stat-formats-followup-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2152/mmontalbo/mm/line-log-stat-formats-followup-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2152\n-- \ngitgitgadget\n"},{"id":"545878","messageId":"6cfaccab923694fae4c1f607c459af52dce9a021.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 1/7] diff: rename and group the line-range filter for clarity","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:26Z","receivedAt":"2026-06-18T18:16:37Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe line-range filter that mm/line-log-cleanup added uses names that\nobscure its model.  The cursors lno_post/lno_pre and the index lno_0\nshare an lno_ prefix but conflate the pre/post-image axis with the\n0-based/1-based axis, the hunk state is a flat set of rhunk_* fields,\nand the filter-state pointer is just s.\n\nThe filter bridges two layers of diff.c, and its fields already used\neach layer's vocabulary, but in cryptic abbreviations.  Spell them out\nto the form the rest of the file uses, so that the patches that follow\ncan simplify and fix it with those clearer names in place:\n\n  - lno_post/lno_pre -> lno_in_postimage/lno_in_preimage, the\n    line-number cursors, matching the counters in struct emit_callback\n  - lno_0 -> idx_in_postimage, the 0-based range index\n  - the hunk-header geometry stays old/new (old_begin, new_begin, and\n    counts) to match the xdiff_emit_hunk_fn callback and the\n    \"@@ -<old> +<new> @@\" header it feeds, but moves from flat rhunk_*\n    fields into a \"hunk\" sub-struct, so accesses read\n    filter->hunk.old_begin\n  - flush_rhunk -> flush_range_hunk\n  - the filter-state pointer in each callback: s -> filter\n\nAlso rename the struct line_range_callback to line_range_filter: it is\na filter over xdiff output, not merely a callback.\n\nNo behavior change.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 192 +++++++++++++++++++++++++++++----------------------------\n 1 file changed, 97 insertions(+), 95 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 5a584fa1d5..1e043c959f 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -623,15 +623,15 @@ struct emit_callback {\n  * reveals whether they precede an in-range line (flush into range hunk) or\n  * an out-of-range line (discard).\n  */\n-struct line_range_callback {\n+struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n \tvoid *orig_cb_data;\n \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n \tunsigned int cur_range;\t\t/* index into the range_set */\n \n \t/* Post/pre-image line counters (1-based, set from hunk headers) */\n-\tlong lno_post;\n-\tlong lno_pre;\n+\tlong lno_in_postimage;\n+\tlong lno_in_preimage;\n \n \t/*\n \t * Function name from most recent xdiff hunk header;\n@@ -640,12 +640,14 @@ struct line_range_callback {\n \tchar func[80];\n \tlong funclen;\n \n-\t/* Range hunk being accumulated for the current range */\n-\tstruct strbuf rhunk;\n-\tlong rhunk_old_begin, rhunk_old_count;\n-\tlong rhunk_new_begin, rhunk_new_count;\n-\tint rhunk_active;\n-\tint rhunk_has_changes;\t\t/* any '+' or '-' lines? */\n+\t/* The range hunk being accumulated for the current range. */\n+\tstruct {\n+\t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n+\t\tlong old_begin, old_count;\n+\t\tlong new_begin, new_count;\n+\t\tint active;\n+\t\tint has_changes;\t/* any '+' or '-' line? */\n+\t} hunk;\n \n \t/* Removal lines not yet known to be in-range */\n \tstruct strbuf pending_rm;\n@@ -2540,26 +2542,26 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_callback *s)\n+static void discard_pending_rm(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&s->pending_rm);\n-\ts->pending_rm_count = 0;\n+\tstrbuf_reset(&filter->pending_rm);\n+\tfilter->pending_rm_count = 0;\n }\n \n-static void flush_rhunk(struct line_range_callback *s)\n+static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n \tconst char *p, *end;\n \n-\tif (!s->rhunk_active || s->ret)\n+\tif (!filter->hunk.active || filter->ret)\n \t\treturn;\n \n \t/* Drain any pending removal lines into the range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n \t/*\n@@ -2568,22 +2570,22 @@ static void flush_rhunk(struct line_range_callback *s)\n \t * ctxlen causes xdiff to emit context covering a range that\n \t * has no changes in this commit.\n \t */\n-\tif (!s->rhunk_has_changes) {\n-\t\ts->rhunk_active = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->hunk.has_changes) {\n+\t\tfilter->hunk.active = 0;\n+\t\tstrbuf_reset(&filter->hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    s->rhunk_old_begin, s->rhunk_old_count,\n-\t\t    s->rhunk_new_begin, s->rhunk_new_count);\n-\tif (s->funclen > 0) {\n+\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n+\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n+\tif (filter->funclen > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, s->func, s->funclen);\n+\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n \t}\n \tstrbuf_addch(&hdr, '\\n');\n \n-\ts->ret = s->orig_line_fn(s->orig_cb_data, hdr.buf, hdr.len);\n+\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n \t/*\n@@ -2591,18 +2593,18 @@ static void flush_rhunk(struct line_range_callback *s)\n \t * The cast discards const because xdiff_emit_line_fn takes\n \t * char *, though fn_out_consume does not modify the buffer.\n \t */\n-\tp = s->rhunk.buf;\n-\tend = p + s->rhunk.len;\n-\twhile (!s->ret && p < end) {\n+\tp = filter->hunk.lines.buf;\n+\tend = p + filter->hunk.lines.len;\n+\twhile (!filter->ret && p < end) {\n \t\tconst char *eol = memchr(p, '\\n', end - p);\n \t\tunsigned long line_len = eol ? (unsigned long)(eol - p + 1)\n \t\t\t\t\t     : (unsigned long)(end - p);\n-\t\ts->ret = s->orig_line_fn(s->orig_cb_data, (char *)p, line_len);\n+\t\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, (char *)p, line_len);\n \t\tp += line_len;\n \t}\n \n-\ts->rhunk_active = 0;\n-\tstrbuf_reset(&s->rhunk);\n+\tfilter->hunk.active = 0;\n+\tstrbuf_reset(&filter->hunk.lines);\n }\n \n static void line_range_hunk_fn(void *data,\n@@ -2610,7 +2612,7 @@ static void line_range_hunk_fn(void *data,\n \t\t\t       long new_begin, long new_nr UNUSED,\n \t\t\t       const char *func, long funclen)\n {\n-\tstruct line_range_callback *s = data;\n+\tstruct line_range_filter *filter = data;\n \n \t/*\n \t * When count > 0, begin is 1-based.  When count == 0, begin is\n@@ -2622,104 +2624,104 @@ static void line_range_hunk_fn(void *data,\n \t * flush or discard them when the next content line reveals\n \t * whether the removals precede in-range content.\n \t */\n-\ts->lno_post = new_begin;\n-\ts->lno_pre = old_begin;\n+\tfilter->lno_in_postimage = new_begin;\n+\tfilter->lno_in_preimage = old_begin;\n \n \tif (funclen > 0) {\n-\t\tif (funclen > (long)sizeof(s->func))\n-\t\t\tfunclen = sizeof(s->func);\n-\t\tmemcpy(s->func, func, funclen);\n+\t\tif (funclen > (long)sizeof(filter->func))\n+\t\t\tfunclen = sizeof(filter->func);\n+\t\tmemcpy(filter->func, func, funclen);\n \t}\n-\ts->funclen = funclen;\n+\tfilter->funclen = funclen;\n }\n \n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n-\tstruct line_range_callback *s = priv;\n+\tstruct line_range_filter *filter = priv;\n \tconst struct range *cur;\n-\tlong lno_0, cur_pre;\n+\tlong idx_in_postimage, cur_pre;\n \n-\tif (s->ret)\n-\t\treturn s->ret;\n+\tif (filter->ret)\n+\t\treturn filter->ret;\n \n \tif (line[0] == '-') {\n-\t\tif (!s->pending_rm_count)\n-\t\t\ts->pending_rm_pre_begin = s->lno_pre;\n-\t\ts->lno_pre++;\n-\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\ts->pending_rm_count++;\n-\t\treturn s->ret;\n+\t\tif (!filter->pending_rm_count)\n+\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n+\t\tfilter->lno_in_preimage++;\n+\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\tfilter->pending_rm_count++;\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] == '\\\\') {\n-\t\tif (s->pending_rm_count)\n-\t\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\telse if (s->rhunk_active)\n-\t\t\tstrbuf_add(&s->rhunk, line, len);\n+\t\tif (filter->pending_rm_count)\n+\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\telse if (filter->hunk.active)\n+\t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n \t\t/* otherwise outside tracked range; drop silently */\n-\t\treturn s->ret;\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] != '+' && line[0] != ' ')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n-\tlno_0 = s->lno_post - 1;\n-\tcur_pre = s->lno_pre;\t/* save before advancing for context lines */\n-\ts->lno_post++;\n+\tidx_in_postimage = filter->lno_in_postimage - 1;\n+\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n+\tfilter->lno_in_postimage++;\n \tif (line[0] == ' ')\n-\t\ts->lno_pre++;\n+\t\tfilter->lno_in_preimage++;\n \n \t/* Advance past ranges we've passed */\n-\twhile (s->cur_range < s->ranges->nr &&\n-\t       lno_0 >= s->ranges->ranges[s->cur_range].end) {\n-\t\tif (s->rhunk_active)\n-\t\t\tflush_rhunk(s);\n-\t\tdiscard_pending_rm(s);\n-\t\ts->cur_range++;\n+\twhile (filter->cur_range < filter->ranges->nr &&\n+\t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n+\t\tif (filter->hunk.active)\n+\t\t\tflush_range_hunk(filter);\n+\t\tdiscard_pending_rm(filter);\n+\t\tfilter->cur_range++;\n \t}\n \n \t/* Past all ranges */\n-\tif (s->cur_range >= s->ranges->nr) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (filter->cur_range >= filter->ranges->nr) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n-\tcur = &s->ranges->ranges[s->cur_range];\n+\tcur = &filter->ranges->ranges[filter->cur_range];\n \n \t/* Before current range */\n-\tif (lno_0 < cur->start) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (idx_in_postimage < cur->start) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n \t/* In range so start a new range hunk if needed */\n-\tif (!s->rhunk_active) {\n-\t\ts->rhunk_active = 1;\n-\t\ts->rhunk_has_changes = 0;\n-\t\ts->rhunk_new_begin = lno_0 + 1;\n-\t\ts->rhunk_old_begin = s->pending_rm_count\n-\t\t\t? s->pending_rm_pre_begin : cur_pre;\n-\t\ts->rhunk_old_count = 0;\n-\t\ts->rhunk_new_count = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->hunk.active) {\n+\t\tfilter->hunk.active = 1;\n+\t\tfilter->hunk.has_changes = 0;\n+\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n+\t\tfilter->hunk.old_begin = filter->pending_rm_count\n+\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n+\t\tfilter->hunk.old_count = 0;\n+\t\tfilter->hunk.new_count = 0;\n+\t\tstrbuf_reset(&filter->hunk.lines);\n \t}\n \n \t/* Flush pending removals into range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n-\tstrbuf_add(&s->rhunk, line, len);\n-\ts->rhunk_new_count++;\n+\tstrbuf_add(&filter->hunk.lines, line, len);\n+\tfilter->hunk.new_count++;\n \tif (line[0] == '+')\n-\t\ts->rhunk_has_changes = 1;\n+\t\tfilter->hunk.has_changes = 1;\n \telse\n-\t\ts->rhunk_old_count++;\n+\t\tfilter->hunk.old_count++;\n \n-\treturn s->ret;\n+\treturn filter->ret;\n }\n \n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n@@ -4086,7 +4088,7 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_callback lr_state;\n+\t\t\tstruct line_range_filter lr_state;\n \t\t\tunsigned int i;\n \t\t\tlong max_span = 0;\n \n@@ -4094,7 +4096,7 @@ static void builtin_diff(const char *name_a,\n \t\t\tlr_state.orig_line_fn = fn_out_consume;\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n \t\t\tlr_state.ranges = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.rhunk, 0);\n+\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n \t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n@@ -4125,11 +4127,11 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \n-\t\t\tflush_rhunk(&lr_state);\n+\t\t\tflush_range_hunk(&lr_state);\n \t\t\tif (lr_state.ret)\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.rhunk);\n+\t\t\tstrbuf_release(&lr_state.hunk.lines);\n \t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n-- \ngitgitgadget\n\n"},{"id":"545879","messageId":"5602b7976a0fcb6eeb582d4cd88ca379f48dd98d.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 2/7] diff: simplify the line-range filter by classifying removals immediately","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:27Z","receivedAt":"2026-06-18T18:16:38Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe filter buffered '-' lines in a pending_rm strbuf, deferring their\nclassification until a '+' or ' ' line revealed the post-image\nposition.  That buffering is unnecessary: a removal occupies no\npost-image line, so it does not advance lno_in_postimage, and xdiff\nemits removals before additions within a change.  A '-' therefore\narrives while lno_in_postimage already holds the index the following\n'+'/' ' will occupy, and can be classified against the ranges as it\narrives.\n\nThe buffering also hid a bug: flush_range_hunk() drained pending_rm into\nthe range hunk whenever the hunk was active, even after lno_in_postimage\nhad advanced past the tracked range, so a deletion just after the\ntracked function leaked into the patch.  Classifying each line as it\narrives removes the pending_rm buffer, the discard_pending_rm() helper,\nthree struct fields, and makes that bug impossible by construction.\n\nWith every line classified on arrival, the buffered lines are the\nhunk's single source of truth, so the old/new counts need not be kept\nalongside them: flush_range_hunk() derives the counts (and whether the\nhunk holds any change) from the buffer when it builds the header.  Drop\nthe per-line counting and the old_count, new_count, and has_changes\nfields; there is no longer a second tally that could fall out of sync\nwith the buffer.\n\nAdd begin_range_hunk() to open the accumulator at the first in-range\nline, seeding both begins from the live image cursors, as the\ncounterpart to flush_range_hunk().  With the counting gone too,\nline_range_line_fn() now only appends an in-range line.\n\nDocument the coordinate model: a block comment on struct\nline_range_filter states it (the pre/post-image cursors, the 0-based\nidx_in_postimage, removals classified by the following line) with a\nworked example.\n\nAdd tests for the leaked trailing deletion this fixes, the symmetric\nleading-deletion case, and the filter's range boundaries (a change at\nthe first and last line of a range, and a pure in-range deletion).\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c              | 215 ++++++++++++++++++++++++--------------------\n t/t4211-line-log.sh | 125 ++++++++++++++++++++++++++\n 2 files changed, 243 insertions(+), 97 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 1e043c959f..ee765d7ac2 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -610,18 +610,58 @@ struct emit_callback {\n };\n \n /*\n- * State for the line-range callback wrappers that sit between\n- * xdi_diff_outf() and fn_out_consume().  xdiff produces a normal,\n- * unfiltered diff; the wrappers intercept each hunk header and line,\n- * track post-image position, and forward only lines that fall within\n- * the requested ranges.  Contiguous in-range lines are collected into\n- * range hunks and flushed with a synthetic @@ header so that\n- * fn_out_consume() sees well-formed unified-diff fragments.\n+ * Line-range filter: scopes \"git log -L\" output to the tracked ranges.\n  *\n- * Removal lines ('-') cannot be classified by post-image position, so\n- * they are buffered in pending_rm until the next '+' or ' ' line\n- * reveals whether they precede an in-range line (flush into range hunk) or\n- * an out-of-range line (discard).\n+ * It sits between xdi_diff_outf() and an output callback (fn_out_consume,\n+ * diffstat_consume, checkdiff_consume).  xdiff produces a normal diff; the\n+ * filter forwards only the lines inside the requested ranges, collecting\n+ * contiguous in-range lines into a \"range hunk\" emitted with a synthetic\n+ * @@ header so the callback sees well-formed unified-diff fragments.\n+ *\n+ * A diff describes the change from a pre-image to a post-image.  Each\n+ * line is context (' ', in both), a removal ('-', pre-image only), or\n+ * an addition ('+', post-image only).  -L tracks ranges in the\n+ * post-image, so a line is in range by its post-image position.\n+ *\n+ * Two 1-based cursors track the next line in each image, named as in\n+ * struct emit_callback and seeded from the xdiff hunk header:\n+ *\n+ *\tlno_in_postimage  advances on '+' and ' '   (lines in the post-image)\n+ *\tlno_in_preimage   advances on '-' and ' '   (lines in the pre-image)\n+ *\n+ * Ranges are 0-based half-open [start, end), so a line is tested at the\n+ * 0-based index idx_in_postimage = lno_in_postimage - 1.\n+ *\n+ * A '-' is not present in the post-image, so it has no post-image line\n+ * number of its own.  Since it does not advance lno_in_postimage, it is\n+ * classified at the idx_in_postimage that the following '+'/' ' will\n+ * occupy.  xdiff emits a change's removals before its additions, so that\n+ * index is already known when the '-' arrives.\n+ *\n+ * The synthetic \"@@ -<old> +<new> @@\" header has two sides, old (the\n+ * pre-image) and new (the post-image), matching the xdiff_emit_hunk_fn\n+ * callback; the hunk.old_begin / hunk.new_begin fields below hold those\n+ * begins, and flush_range_hunk() derives the counts from the buffered\n+ * lines.\n+ *\n+ * Example, tracking post-image line 2 (range [1, 2)) of:\n+ *\n+ *\tpre-image  post-image\n+ *\t1 a        1 a\n+ *\t2 b        2 X      (b -> X)\n+ *\t3 c        3 c\n+ *\n+ *\tclassify each line by idx_in_postimage.  The pre and post columns\n+ *\tare each cursor's value while that line is classified, i.e. before\n+ *\tthe line advances them (pre = lno_in_preimage,\n+ *\tpost = lno_in_postimage, idx = idx_in_postimage):\n+ *\t' a'  pre 1  post 1  idx 0  ->  before start, skip\n+ *\t'-b'  pre 2  post 2  idx 1  ->  keep (removal)\n+ *\t'+X'  pre 3  post 2  idx 1  ->  keep (addition)\n+ *\t' c'  pre 3  post 3  idx 2  ->  past end, flush\n+ *\n+ * -b and +X share idx = 1 because -b did not advance lno_in_postimage;\n+ * both land in the range hunk, flushed when ' c' crosses the range end.\n  */\n struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n@@ -640,20 +680,18 @@ struct line_range_filter {\n \tchar func[80];\n \tlong funclen;\n \n-\t/* The range hunk being accumulated for the current range. */\n+\t/*\n+\t * The range hunk being accumulated.  At most one is live at a time:\n+\t * it is flushed and reset as the cursor leaves each range (and once\n+\t * more at end of diff), then reused for the next range.\n+\t */\n \tstruct {\n \t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n-\t\tlong old_begin, old_count;\n-\t\tlong new_begin, new_count;\n+\t\tlong old_begin;\n+\t\tlong new_begin;\n \t\tint active;\n-\t\tint has_changes;\t/* any '+' or '-' line? */\n \t} hunk;\n \n-\t/* Removal lines not yet known to be in-range */\n-\tstruct strbuf pending_rm;\n-\tint pending_rm_count;\n-\tlong pending_rm_pre_begin;\t/* pre-image line of first pending */\n-\n \tint ret;\t\t\t/* latched error from orig_line_fn */\n };\n \n@@ -2542,26 +2580,48 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_filter *filter)\n+/*\n+ * Begin a range hunk at the first in-range line.  Its position fixes the\n+ * hunk's begins, taken from the two image cursors before they advance:\n+ * new_begin from the post-image, old_begin from the pre-image.  The line\n+ * counts are not tracked here; flush_range_hunk() derives them from the\n+ * buffered lines.\n+ */\n+static void begin_range_hunk(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&filter->pending_rm);\n-\tfilter->pending_rm_count = 0;\n+\tfilter->hunk.active = 1;\n+\tfilter->hunk.new_begin = filter->lno_in_postimage;\n+\tfilter->hunk.old_begin = filter->lno_in_preimage;\n+\tstrbuf_reset(&filter->hunk.lines);\n }\n \n static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n \tconst char *p, *end;\n+\tlong old_count = 0, new_count = 0;\n+\tint has_changes = 0;\n \n \tif (!filter->hunk.active || filter->ret)\n \t\treturn;\n \n-\t/* Drain any pending removal lines into the range hunk */\n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n-\t\tfilter->hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n+\t/*\n+\t * Derive the hunk's geometry from the buffered lines: a ' '\n+\t * counts on both sides, a '-' on the old side, a '+' on the new.\n+\t * A '-' or '+' marks a real change; the \"\\ No newline at end of\n+\t * file\" marker (line[0] == '\\\\') counts on neither side.\n+\t */\n+\tp = filter->hunk.lines.buf;\n+\tend = p + filter->hunk.lines.len;\n+\twhile (p < end) {\n+\t\tconst char *eol = memchr(p, '\\n', end - p);\n+\t\tif (*p == ' ' || *p == '-')\n+\t\t\told_count++;\n+\t\tif (*p == ' ' || *p == '+')\n+\t\t\tnew_count++;\n+\t\tif (*p == '-' || *p == '+')\n+\t\t\thas_changes = 1;\n+\t\tp = eol ? eol + 1 : end;\n \t}\n \n \t/*\n@@ -2570,15 +2630,15 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t * ctxlen causes xdiff to emit context covering a range that\n \t * has no changes in this commit.\n \t */\n-\tif (!filter->hunk.has_changes) {\n+\tif (!has_changes) {\n \t\tfilter->hunk.active = 0;\n \t\tstrbuf_reset(&filter->hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n-\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n+\t\t    filter->hunk.old_begin, old_count,\n+\t\t    filter->hunk.new_begin, new_count);\n \tif (filter->funclen > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n \t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n@@ -2618,11 +2678,6 @@ static void line_range_hunk_fn(void *data,\n \t * When count > 0, begin is 1-based.  When count == 0, begin is\n \t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n \t * that type will arrive, so the value is unused.\n-\t *\n-\t * Any pending removal lines from the previous xdiff hunk are\n-\t * intentionally left in pending_rm: the line callback will\n-\t * flush or discard them when the next content line reveals\n-\t * whether the removals precede in-range content.\n \t */\n \tfilter->lno_in_postimage = new_begin;\n \tfilter->lno_in_preimage = old_begin;\n@@ -2638,88 +2693,56 @@ static void line_range_hunk_fn(void *data,\n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n \tstruct line_range_filter *filter = priv;\n-\tconst struct range *cur;\n-\tlong idx_in_postimage, cur_pre;\n+\tlong idx_in_postimage;\n+\tint in_range;\n \n \tif (filter->ret)\n \t\treturn filter->ret;\n \n-\tif (line[0] == '-') {\n-\t\tif (!filter->pending_rm_count)\n-\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n-\t\tfilter->lno_in_preimage++;\n-\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\tfilter->pending_rm_count++;\n-\t\treturn filter->ret;\n-\t}\n-\n \tif (line[0] == '\\\\') {\n-\t\tif (filter->pending_rm_count)\n-\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\telse if (filter->hunk.active)\n+\t\tif (filter->hunk.active)\n \t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n-\t\t/* otherwise outside tracked range; drop silently */\n \t\treturn filter->ret;\n \t}\n \n-\tif (line[0] != '+' && line[0] != ' ')\n+\tif (line[0] != '+' && line[0] != ' ' && line[0] != '-')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n+\t/*\n+\t * idx_in_postimage is this line's 0-based post-image index (see the model on\n+\t * struct line_range_filter).  The cursors are advanced only after\n+\t * the line is classified, so a '-' is tested at the same idx_in_postimage as\n+\t * the '+'/' ' that follows it.\n+\t */\n \tidx_in_postimage = filter->lno_in_postimage - 1;\n-\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n-\tfilter->lno_in_postimage++;\n-\tif (line[0] == ' ')\n-\t\tfilter->lno_in_preimage++;\n \n-\t/* Advance past ranges we've passed */\n+\t/* Retire ranges we have passed, flushing the one we leave. */\n \twhile (filter->cur_range < filter->ranges->nr &&\n \t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n \t\tif (filter->hunk.active)\n \t\t\tflush_range_hunk(filter);\n-\t\tdiscard_pending_rm(filter);\n \t\tfilter->cur_range++;\n \t}\n \n-\t/* Past all ranges */\n-\tif (filter->cur_range >= filter->ranges->nr) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n-\t}\n+\tin_range = filter->cur_range < filter->ranges->nr &&\n+\t\t   idx_in_postimage >= filter->ranges->ranges[filter->cur_range].start &&\n+\t\t   idx_in_postimage < filter->ranges->ranges[filter->cur_range].end;\n \n-\tcur = &filter->ranges->ranges[filter->cur_range];\n+\tif (in_range) {\n+\t\tif (!filter->hunk.active)\n+\t\t\tbegin_range_hunk(filter);\n \n-\t/* Before current range */\n-\tif (idx_in_postimage < cur->start) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n+\t\tstrbuf_add(&filter->hunk.lines, line, len);\n \t}\n \n-\t/* In range so start a new range hunk if needed */\n-\tif (!filter->hunk.active) {\n-\t\tfilter->hunk.active = 1;\n-\t\tfilter->hunk.has_changes = 0;\n-\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n-\t\tfilter->hunk.old_begin = filter->pending_rm_count\n-\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n-\t\tfilter->hunk.old_count = 0;\n-\t\tfilter->hunk.new_count = 0;\n-\t\tstrbuf_reset(&filter->hunk.lines);\n-\t}\n-\n-\t/* Flush pending removals into range hunk */\n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n-\t\tfilter->hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n-\t}\n-\n-\tstrbuf_add(&filter->hunk.lines, line, len);\n-\tfilter->hunk.new_count++;\n-\tif (line[0] == '+')\n-\t\tfilter->hunk.has_changes = 1;\n-\telse\n-\t\tfilter->hunk.old_count++;\n+\t/*\n+\t * Advance each image's cursor: a line present in that image (see\n+\t * the model) consumes one of its line numbers.\n+\t */\n+\tif (line[0] != '-')\n+\t\tfilter->lno_in_postimage++;\n+\tif (line[0] != '+')\n+\t\tfilter->lno_in_preimage++;\n \n \treturn filter->ret;\n }\n@@ -4097,7 +4120,6 @@ static void builtin_diff(const char *name_a,\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n \t\t\tlr_state.ranges = line_ranges;\n \t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n-\t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n \t\t\t * Inflate ctxlen so that all changes within\n@@ -4132,7 +4154,6 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \t\t\tstrbuf_release(&lr_state.hunk.lines);\n-\t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex ca4eb7bbc7..e9691066de 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -738,6 +738,131 @@ test_expect_success '-L with -G filters to diff-text matches' '\n \tgrep \"F2 + 2\" actual\n '\n \n+test_expect_success 'setup for trailing deletion test' '\n+\tgit checkout --orphan trailing-del &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 1;\n+\t}\n+\t// trailing comment\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add file with trailing comment\" &&\n+\t# Modify tracked() AND delete the trailing comment in\n+\t# one commit, so the commit touches the tracked range\n+\t# and is not filtered out by the revision walker.\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 2;\n+\t}\n+\tEOF\n+\tgit commit -a -m \"modify tracked and delete trailing comment\"\n+'\n+\n+test_expect_success '-L does not include deletions past end of tracked range' '\n+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n+\t# The trailing comment deletion is outside the tracked\n+\t# range and should not appear in the patch output.\n+\ttest_grep \"return 2\" actual &&\n+\ttest_grep ! \"trailing comment\" actual\n+'\n+\n+test_expect_success '-L includes leading deletions resolved by in-range line' '\n+\tgit checkout --orphan leading-del &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\t// leading comment\n+\tvoid tracked()\n+\t{\n+\t    return 1;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add file with leading comment\" &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 2;\n+\t}\n+\tEOF\n+\tgit commit -a -m \"modify tracked and delete leading comment\" &&\n+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n+\t# The leading comment deletion is resolved by the next\n+\t# non-removal line (void tracked), which is in range: a\n+\t# removal is classified by the position of the following\n+\t# line, so it joins the range that line falls in.\n+\ttest_grep \"return 2\" actual &&\n+\ttest_grep \"leading comment\" actual\n+'\n+\n+test_expect_success 'setup for line-range filter edge cases' '\n+\tgit checkout --orphan filter-edge &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid before()\n+\t{\n+\t    return 0;\n+\t}\n+\n+\tvoid tracked()\n+\t{\n+\t    int a = 1;\n+\t    int b = 2;\n+\t    int c = 3;\n+\t    return a + b + c;\n+\t}\n+\n+\tvoid after()\n+\t{\n+\t    return 9;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"initial\"\n+'\n+\n+test_expect_success '-L change at exact first line of range' '\n+\tgit checkout filter-edge &&\n+\t# Change the function signature (first line of range)\n+\tsed \"s/void tracked/int tracked/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"change first line\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"change first line\" actual &&\n+\ttest_grep \"+int tracked\" actual &&\n+\ttest_grep \"\\\\-void tracked\" actual\n+'\n+\n+test_expect_success '-L change at exact last line of range' '\n+\tgit checkout filter-edge &&\n+\tgit reset --hard HEAD~1 &&\n+\t# Change the closing brace line (last line of range)\n+\tsed \"s/^}$/} \\/\\/ end tracked/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"change last line\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"change last line\" actual &&\n+\ttest_grep \"end tracked\" actual\n+'\n+\n+test_expect_success '-L pure deletion in range (no additions)' '\n+\tgit checkout filter-edge &&\n+\tgit reset --hard HEAD~1 &&\n+\t# Delete a line inside tracked() without adding anything\n+\tsed \"/int c/d\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"pure deletion\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"pure deletion\" actual &&\n+\ttest_grep \"\\\\-.*int c\" actual\n+'\n+\n test_expect_success '-L with --diff-filter=M excludes root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --diff-filter=M --format=%s --no-patch >actual &&\n-- \ngitgitgadget\n\n"},{"id":"545880","messageId":"d211c82e40446d1d0b33f117d817a03ad716eea0.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 3/7] diff: emit -L hunk headers via xdiff's formatter","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:28Z","receivedAt":"2026-06-18T18:16:40Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe line-range filter builds its own \"@@ -<old> +<new> @@\" header for\neach range hunk.  For a side with no lines (count 0, such as the old\nside of a pure insertion), the begin should be the number of the line\nbefore the change, per the convention git diff and xdl_emit_hunk_hdr()\nfollow.  The hand-rolled code's begin was one too high; in t4211 this\nproduced\n\n\t@@ -25,0 +18,9 @@\n\nan old begin of 25 in a 24-line file, where git diff would give 24.\n\nStop hand-rolling the header.  flush_range_hunk() now formats it through\nxdiff's own emitter: a new xdiff_emit_hunk_header() helper wraps\nxdl_emit_hunk_hdr(), the function that produces every other diff's hunk\nheaders.  The count-0 begin is then correct by construction, and as a\nside effect -L headers match git diff exactly, including its omission of\na count of 1 (\"@@ -22 +22 @@\" rather than \"@@ -22,1 +22,1 @@\").\n\nxdiff's hunk callback already hands line_range_hunk_fn() a count-0 begin\ndecremented, so undo that when seeding the cursors and let the formatter\nre-apply the convention once, at emit time.\n\nThe off-by-one predates this series, and the two regenerated fixtures\nreach it from different origins: no-assertion-error has carried it since\nits test was added in ab60c693a2 (line-log: fix assertion error,\n2025-08-18), while vanishes-early acquired it when 86e986f166 (line-log:\nroute -L output through the standard diff pipeline) reshaped its tracked\nline into a pure insertion.  vanishes-early also drops its count-1\ncounts.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c                                   | 27 +++++++++++-------------\n t/t4211/sha1/expect.no-assertion-error   |  2 +-\n t/t4211/sha1/expect.vanishes-early       |  6 +++---\n t/t4211/sha256/expect.no-assertion-error |  2 +-\n t/t4211/sha256/expect.vanishes-early     |  6 +++---\n xdiff-interface.c                        | 19 +++++++++++++++++\n xdiff-interface.h                        | 15 +++++++++++++\n 7 files changed, 54 insertions(+), 23 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex ee765d7ac2..9751bb6798 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2636,14 +2636,9 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\treturn;\n \t}\n \n-\tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->hunk.old_begin, old_count,\n-\t\t    filter->hunk.new_begin, new_count);\n-\tif (filter->funclen > 0) {\n-\t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n-\t}\n-\tstrbuf_addch(&hdr, '\\n');\n+\txdiff_emit_hunk_header(&hdr, filter->hunk.old_begin, old_count,\n+\t\t\t       filter->hunk.new_begin, new_count,\n+\t\t\t       filter->func, filter->funclen);\n \n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n@@ -2668,19 +2663,21 @@ static void flush_range_hunk(struct line_range_filter *filter)\n }\n \n static void line_range_hunk_fn(void *data,\n-\t\t\t       long old_begin, long old_nr UNUSED,\n-\t\t\t       long new_begin, long new_nr UNUSED,\n+\t\t\t       long old_begin, long old_nr,\n+\t\t\t       long new_begin, long new_nr,\n \t\t\t       const char *func, long funclen)\n {\n \tstruct line_range_filter *filter = data;\n \n \t/*\n-\t * When count > 0, begin is 1-based.  When count == 0, begin is\n-\t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n-\t * that type will arrive, so the value is unused.\n+\t * Seed the per-image line cursors from the hunk header's begins.  For\n+\t * a side with no lines (count 0), xdiff's callback has already moved\n+\t * its begin to the line before the change, so add one back to recover\n+\t * the true 1-based start.  xdiff_emit_hunk_header() reapplies that -1\n+\t * when the clipped hunk is emitted.\n \t */\n-\tfilter->lno_in_postimage = new_begin;\n-\tfilter->lno_in_preimage = old_begin;\n+\tfilter->lno_in_postimage = new_nr ? new_begin : new_begin + 1;\n+\tfilter->lno_in_preimage = old_nr ? old_begin : old_begin + 1;\n \n \tif (funclen > 0) {\n \t\tif (funclen > (long)sizeof(filter->func))\ndiff --git a/t/t4211/sha1/expect.no-assertion-error b/t/t4211/sha1/expect.no-assertion-error\nindex 54c568f273..95faf51a7b 100644\n--- a/t/t4211/sha1/expect.no-assertion-error\n+++ b/t/t4211/sha1/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index bf79c2f..27c829c 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha1/expect.vanishes-early b/t/t4211/sha1/expect.vanishes-early\nindex a413ad3659..e4b1a201d5 100644\n--- a/t/t4211/sha1/expect.vanishes-early\n+++ b/t/t4211/sha1/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index 0b9cae5..5de3ea4 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 100b61a6f2f720f812620a9d10afb3a960ccb73c\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index 5e709a1..0b9cae5 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..444e415\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/t/t4211/sha256/expect.no-assertion-error b/t/t4211/sha256/expect.no-assertion-error\nindex c25f2ce19c..815d27f7f1 100644\n--- a/t/t4211/sha256/expect.no-assertion-error\n+++ b/t/t4211/sha256/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index 69cb69c..a0d566e 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha256/expect.vanishes-early b/t/t4211/sha256/expect.vanishes-early\nindex bc33b963dc..263fc9eaac 100644\n--- a/t/t4211/sha256/expect.vanishes-early\n+++ b/t/t4211/sha256/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index e4fa1d8..62c1fc2 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 29f32ac3141c48b22803e5c4127b719917b67d0f8ca8c5248bebfa2a19f7da10\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index d325124..e4fa1d8 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..9f550c3\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/xdiff-interface.c b/xdiff-interface.c\nindex 5ee2b96d0a..32e04630ee 100644\n--- a/xdiff-interface.c\n+++ b/xdiff-interface.c\n@@ -91,6 +91,25 @@ static int xdiff_outf(void *priv_, mmbuffer_t *mb, int nbuf)\n \treturn 0;\n }\n \n+static int strbuf_out_line(void *priv, mmbuffer_t *mb, int nbuf)\n+{\n+\tstruct strbuf *out = priv;\n+\tint i;\n+\tfor (i = 0; i < nbuf; i++)\n+\t\tstrbuf_add(out, mb[i].ptr, mb[i].size);\n+\treturn 0;\n+}\n+\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_count,\n+\t\t\t    long new_begin, long new_count,\n+\t\t\t    const char *func, long funclen)\n+{\n+\txdemitcb_t ecb = { .priv = out, .out_line = strbuf_out_line };\n+\txdl_emit_hunk_hdr(old_begin, old_count, new_begin, new_count,\n+\t\t\t  func, funclen, &ecb);\n+}\n+\n /*\n  * Trim down common substring at the end of the buffers,\n  * but end on a complete line.\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex ce54e1c0e0..51c88296ed 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -76,4 +76,19 @@ int xdiff_compare_lines(const char *l1, long s1,\n  */\n unsigned long xdiff_hash_string(const char *s, size_t len, long flags);\n \n+struct strbuf;\n+\n+/*\n+ * Append a unified-diff hunk header to `out`, e.g.\n+ * \"@@ -<old> +<new> @@ func\\n\".  The header comes from wrapping xdiff's\n+ * own hunk-header emitter, so it matches what a normal diff would\n+ * produce for these begins and counts.  For a side with no lines\n+ * (count 0) the begin is the line before the change, and a count of 1\n+ * is omitted.\n+ */\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_count,\n+\t\t\t    long new_begin, long new_count,\n+\t\t\t    const char *func, long funclen);\n+\n #endif\n-- \ngitgitgadget\n\n"},{"id":"545881","messageId":"b82a997359b7e1acd16151439b1dabad4cfb20ea.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 4/7] diff: extract a line-range diff helper for reuse","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:29Z","receivedAt":"2026-06-18T18:16:41Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nbuiltin_diff() open-codes the line-range filter setup and teardown\naround its xdi_diff_outf() call: zero the struct, point it at the\noutput callback, inflate ctxlen to the largest range span so each range\nyields a single xdiff hunk, run the diff, flush the trailing range\nhunk, and release the buffer.  The upcoming -L stat and check formats\nneed the same sequence.\n\nExtract line_range_filter_init() for the setup and a\nline_range_filter_diff() helper that prepares the xdiff config the\nfilter needs, runs an initialized filter through xdi_diff_outf(),\nflushes the final range hunk, and releases it, returning the latched\nerror.  The helper inflates ctxlen to the largest range span so each\nrange yields a single xdiff hunk, and clears XDL_EMIT_NO_HUNK_HDR so\nthe hunk headers the filter seeds its position from are always emitted.\nFolding both into the helper keeps these invariants, which the filter's\nposition tracking relies on, in a single place for every consumer.\nbuiltin_diff() now does init + line_range_filter_diff(); the next two\npatches reuse them in builtin_diffstat() and builtin_checkdiff()\ninstead of repeating the boilerplate.\n\nNo behavior change: builtin_diff() leaves XDL_EMIT_NO_HUNK_HDR unset,\nso clearing it is a no-op until the suppressing consumers arrive.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 100 +++++++++++++++++++++++++++++++++++----------------------\n 1 file changed, 61 insertions(+), 39 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 9751bb6798..6233a96bf0 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2580,6 +2580,18 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n+static void line_range_filter_init(struct line_range_filter *filter,\n+\t\t\t\t   const struct range_set *ranges,\n+\t\t\t\t   xdiff_emit_line_fn line_fn,\n+\t\t\t\t   void *cb_data)\n+{\n+\tmemset(filter, 0, sizeof(*filter));\n+\tfilter->orig_line_fn = line_fn;\n+\tfilter->orig_cb_data = cb_data;\n+\tfilter->ranges = ranges;\n+\tstrbuf_init(&filter->hunk.lines, 0);\n+}\n+\n /*\n  * Begin a range hunk at the first in-range line.  Its position fixes the\n  * hunk's begins, taken from the two image cursors before they advance:\n@@ -2744,6 +2756,50 @@ static int line_range_line_fn(void *priv, char *line, unsigned long len)\n \treturn filter->ret;\n }\n \n+/*\n+ * Run an xdiff pass through an initialized line-range filter, flush the\n+ * final range hunk, and release the filter.  Inflates ctxlen to the largest\n+ * range span first, so that every change within a single range lands in one\n+ * xdiff hunk and the inter-change context is emitted; the filter then clips\n+ * back to range boundaries.  The optimal ctxlen depends on where changes fall\n+ * within the range, which is only known after xdiff runs, so the max span is\n+ * the upper bound that guarantees correctness in a single pass.  Every\n+ * consumer (patch, diffstat, check) relies on one xdiff hunk per range, so\n+ * this lives here rather than at each call site.  Also clears\n+ * XDL_EMIT_NO_HUNK_HDR: the filter seeds its per-image position from the hunk\n+ * headers, so a consumer that otherwise suppresses them (diffstat) still gets\n+ * them here.  Returns non-zero if xdiff or any forwarded callback failed.\n+ */\n+static int line_range_filter_diff(struct line_range_filter *filter,\n+\t\t\t\t  mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tconst struct range_set *ranges = filter->ranges;\n+\tlong max_span = 0;\n+\tunsigned int i;\n+\tint ret;\n+\n+\tfor (i = 0; i < ranges->nr; i++) {\n+\t\tlong span = ranges->ranges[i].end - ranges->ranges[i].start;\n+\t\tif (span > max_span)\n+\t\t\tmax_span = span;\n+\t}\n+\tif (max_span > xecfg->ctxlen)\n+\t\txecfg->ctxlen = max_span;\n+\n+\t/* the filter seeds its per-image position from hunk headers */\n+\txecfg->flags &= ~XDL_EMIT_NO_HUNK_HDR;\n+\n+\tret = xdi_diff_outf(mf1, mf2, line_range_hunk_fn,\n+\t\t\t    line_range_line_fn, filter, xpp, xecfg);\n+\tif (!ret) {\n+\t\tflush_range_hunk(filter);\n+\t\tret = filter->ret;\n+\t}\n+\tstrbuf_release(&filter->hunk.lines);\n+\treturn ret;\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\n@@ -4108,49 +4164,15 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_filter lr_state;\n-\t\t\tunsigned int i;\n-\t\t\tlong max_span = 0;\n+\t\t\tstruct line_range_filter lr_filter;\n \n-\t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n-\t\t\tlr_state.orig_line_fn = fn_out_consume;\n-\t\t\tlr_state.orig_cb_data = &ecbdata;\n-\t\t\tlr_state.ranges = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n-\n-\t\t\t/*\n-\t\t\t * Inflate ctxlen so that all changes within\n-\t\t\t * any single range are merged into one xdiff\n-\t\t\t * hunk and the inter-change context is emitted.\n-\t\t\t * The callback clips back to range boundaries.\n-\t\t\t *\n-\t\t\t * The optimal ctxlen depends on where changes\n-\t\t\t * fall within the range, which is only known\n-\t\t\t * after xdiff runs; the max range span is the\n-\t\t\t * upper bound that guarantees correctness in a\n-\t\t\t * single pass.\n-\t\t\t */\n-\t\t\tfor (i = 0; i < line_ranges->nr; i++) {\n-\t\t\t\tlong span = line_ranges->ranges[i].end -\n-\t\t\t\t\t    line_ranges->ranges[i].start;\n-\t\t\t\tif (span > max_span)\n-\t\t\t\t\tmax_span = span;\n-\t\t\t}\n-\t\t\tif (max_span > xecfg.ctxlen)\n-\t\t\t\txecfg.ctxlen = max_span;\n-\n-\t\t\tif (xdi_diff_outf(&mf1, &mf2,\n-\t\t\t\t\t  line_range_hunk_fn,\n-\t\t\t\t\t  line_range_line_fn,\n-\t\t\t\t\t  &lr_state, &xpp, &xecfg))\n-\t\t\t\tdie(\"unable to generate diff for %s\",\n-\t\t\t\t    one->path);\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       fn_out_consume, &ecbdata);\n \n-\t\t\tflush_range_hunk(&lr_state);\n-\t\t\tif (lr_state.ret)\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.hunk.lines);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\n-- \ngitgitgadget\n\n"},{"id":"545882","messageId":"a70d861d27a13459bab34f6681b3ccfe2f20d0d8.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 5/7] line-log: support diff stat formats with -L","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:30Z","receivedAt":"2026-06-18T18:16:43Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nReuse the line_range_filter in builtin_diffstat() to produce\nrange-scoped statistics.  When a filepair carries line_ranges, the\nfilter wraps diffstat_consume() as its output callback, forwarding only\nin-range lines for counting.  flush_range_hunk() replays buffered\ncontent through diffstat_consume(), which ignores synthetic @@ headers\nsince it only counts '+' and '-' lines.\n\nExpand the output format allowlist in setup_revisions() to accept\n--stat, --numstat, and --shortstat with -L.\n\nLeave --dirstat out of the allowlist so it is rejected like any other\nunsupported format.  Its default mode counts each file's whole-file\nbyte damage via diffcore_count_changes(), outside the line-based\npipeline that the -L filter scopes, so bare --dirstat cannot honor the\ntracked range.  The --dirstat=lines mode could: it aggregates the same\nper-file line counts as --numstat, which -L already scopes.  But\naccepting only that sub-mode while bare --dirstat keeps erroring is a\nconfusing split, so the whole format is deferred to a follow-up;\n--numstat already reports the exact range-scoped per-file counts.\n\nAlso drop \"yet\" from the generic -L rejection message (\"does not\nyet support the requested diff format\").  Some rejected formats do\nnot fit a line range at all, so \"yet\" wrongly implied they are all\njust awaiting support.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  12 ++-\n diff.c                                |  13 ++-\n revision.c                            |   6 +-\n t/t4211-line-log.sh                   | 150 ++++++++++++++++++++++----\n 4 files changed, 155 insertions(+), 26 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 72f639b5e7..1a25f55bb1 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -9,10 +9,14 @@\n \t_<start>_ and _<end>_ (or _<funcname>_) must exist in the starting revision.\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n-\tNon-patch diff formats `--raw`, `--name-only`, `--name-status`,\n-\tand `--summary` are supported.  Diff stat formats\n-\t(`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not\n-\tcurrently implemented.\n+\tThe following non-patch diff formats are supported: `--raw`,\n+\t`--name-only`, `--name-status`, `--summary`,\n+\t`--stat`, `--numstat`, and `--shortstat`.\n+\tThe stat formats show range-scoped counts: only lines within\n+\tthe tracked range are counted.  `--dirstat` is not supported\n+\twith `-L`: it summarizes change as each directory's share of\n+\tthe total churn, not as counts for the tracked lines.  Use\n+\t`--numstat` for exact per-file counts within the range.\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\ndiff --git a/diff.c b/diff.c\nindex 6233a96bf0..026fafeb90 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \t\txecfg.ctxlen = o->context;\n \t\txecfg.interhunkctxlen = o->interhunkcontext;\n \t\txecfg.flags = XDL_EMIT_NO_HUNK_HDR;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, NULL,\n+\n+\t\tif (p->line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, p->line_ranges,\n+\t\t\t\t\t       diffstat_consume, diffstat);\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate diffstat for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL,\n \t\t\t\t  diffstat_consume, diffstat, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diffstat for %s\", one->path);\n \ndiff --git a/revision.c b/revision.c\nindex 6a8101e8b7..2c76e15778 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3193,8 +3193,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t     (revs->diffopt.output_format &\n \t      ~(DIFF_FORMAT_PATCH | DIFF_FORMAT_NO_OUTPUT |\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n-\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY))))\n-\t\tdie(_(\"-L does not yet support the requested diff format\"));\n+\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n+\t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n+\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\n \t\trevs->expand_tabs_in_log = revs->expand_tabs_in_log_default;\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex e9691066de..af37bd532f 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -176,24 +176,15 @@ test_expect_success '--name-status shows status and path' '\n \ttest_grep ! \"^@@\" actual\n '\n \n-test_expect_success '--stat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --stat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--numstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --numstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--shortstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --shortstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--dirstat is not yet supported with -L' '\n+test_expect_success '--dirstat is not supported with -L' '\n+\t# --dirstat is not supported with -L: its default mode measures\n+\t# whole-file change, not the tracked lines, and the\n+\t# --dirstat=lines variant is deferred too, so both forms are\n+\t# rejected like any other unsupported format.\n \ttest_must_fail git log -L1,24:b.c --dirstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err &&\n+\ttest_must_fail git log -L1,24:b.c --dirstat=lines 2>err &&\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success 'setup for checking fancy rename following' '\n@@ -887,9 +878,9 @@ test_expect_success '-L with -S suppresses non-matching commits' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success '--full-diff is not yet supported with -L' '\n+test_expect_success '--full-diff is not supported with -L' '\n \ttest_must_fail git log -L1,24:b.c --full-diff 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success '-L --oneline has no extra blank line before diff' '\n@@ -900,6 +891,127 @@ test_expect_success '-L --oneline has no extra blank line before diff' '\n \ttest_grep \"^diff --git\" line2\n '\n \n+test_expect_success 'setup for stat range-scoping tests' '\n+\tgit checkout --orphan stat-scoping &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"Add func1() and func2()\" &&\n+\n+\t# Modify both functions in a single commit so that\n+\t# whole-file stats differ from range-scoped stats.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"Modify both functions\"\n+'\n+\n+test_expect_success '--numstat counts only lines in tracked range' '\n+\t# \"Modify both functions\" changes one line in func1 and one in\n+\t# func2.  Whole-file numstat would show 2 added, 2 deleted.\n+\t# Range-scoped numstat for func2 should show only 1 and 1.\n+\tgit log -L:func2:file.c --numstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"^1\t1\tfile.c$\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--numstat counts only additions for root commit' '\n+\t# Root commit creates both func1 (4 lines) and func2 (4 lines).\n+\t# Whole-file numstat would show 9 lines added.  Range-scoped\n+\t# numstat for func2 should show only 4.\n+\tgit log -L:func2:file.c --numstat --format=%s >actual &&\n+\ttest_grep \"Add func1() and func2()\" actual &&\n+\ttest_grep \"^4\t0\tfile.c$\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--stat counts only lines in tracked range' '\n+\tgit log -L:func2:file.c --stat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"file.c |\" actual &&\n+\ttest_grep \"1 insertion\" actual &&\n+\ttest_grep \"1 deletion\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--shortstat counts only lines in tracked range' '\n+\t# --shortstat prints only the summary line: no per-file \"file.c |\"\n+\t# line.  Counts are range-scoped as for --numstat above.\n+\tgit log -L:func2:file.c --shortstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"1 insertion\" actual &&\n+\ttest_grep \"1 deletion\" actual &&\n+\ttest_grep ! \"file.c |\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--numstat across renames and multiple commits' '\n+\t# parallel-change carries the tracked function f across an a.c -> b.c\n+\t# rename and a merge of two parallel histories.  With -M, --numstat\n+\t# follows the rename and reports range-scoped (not whole-file)\n+\t# added/removed counts for f per commit; the file column flips from\n+\t# b.c to a.c at the rename as the walk goes back in time.  Commits\n+\t# that do not change the range of f emit no row (the merge and the\n+\t# pure file-move produce nothing), so there are fewer rows than\n+\t# commits.\n+\tgit checkout parallel-change &&\n+\tgit log -M -L \":f:b.c\" --format= --numstat >actual &&\n+\tcat >expect <<-\\EOF &&\n+\t1\t1\tb.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t0\ta.c\n+\t13\t0\ta.c\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '-L multiple ranges with --numstat excludes untracked change' '\n+\tgit checkout --orphan multi-range &&\n+\tgit reset --hard &&\n+\tcat >m.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\n+\tint func3()\n+\t{\n+\t    return F3;\n+\t}\n+\tEOF\n+\tgit add m.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add m.c\" &&\n+\t# Change all three functions but track only func1 and func2.\n+\t# Whole-file numstat would be 3 3; a 2 2 result proves the\n+\t# untracked func3 change is excluded and the two ranges just sum.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" -e \"s/F3/F3 + 3/\" m.c >tmp &&\n+\tmv tmp m.c &&\n+\tgit commit -a -m \"Modify all three functions\" &&\n+\tgit log -L:func1:m.c -L:func2:m.c --numstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify all three functions\" actual &&\n+\ttest_grep \"^2\t2\tm.c$\" actual &&\n+\ttest_grep ! \"^3\t3\tm.c$\" actual\n+'\n+\n test_expect_success '--summary shows new file on root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --summary --format= >actual &&\n-- \ngitgitgadget\n\n"},{"id":"545883","messageId":"be0679a5a73337bad94e997687d0e889907c25b6.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 6/7] diff: support --check with -L line ranges","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:31Z","receivedAt":"2026-06-18T18:16:44Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nbuiltin_checkdiff() runs its own xdiff pass to detect whitespace\nerrors in newly added lines.  When -L is active, the check should\nbe scoped to the tracked line ranges rather than the whole file.\n\nReuse the line_range_filter to wrap checkdiff_consume(), the same\npattern already used for patch output and diffstat.  The filter\nforwards only in-range lines for whitespace checking.\n\ncheckdiff reports the file line number of each error, which it\nnormally learns from the hunk header via checkdiff_consume_hunk().\nThe filter synthesizes its own hunk headers, so give it an optional\nhunk callback and route checkdiff_consume_hunk() through it; this\nsets the post-image position before the in-range lines are replayed.\nWithout it the reported line numbers would count from the start of\nthe range hunk rather than the start of the file.\n\nThe trailing blank-at-eof check is a second pass that scans the whole\nfile via check_blank_at_eof(), so gate its report on the tracked\nranges as well; otherwise a blank line added at end of file is\nreported even when it lies outside the range.\n\nAdd DIFF_FORMAT_CHECKDIFF to the -L output format allowlist in\nsetup_revisions() so that -L --check is accepted, and list --check\namong the supported formats in the documentation.  Add tests covering\nthat whitespace errors are reported, scoped to the tracked range, and\nlabeled with the correct file line number, including when two errors\nin one range are separated by a gap that would otherwise split into\nmultiple xdiff hunks.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  2 +-\n diff.c                                | 65 ++++++++++++++++++-\n revision.c                            |  2 +-\n t/t4211-line-log.sh                   | 92 +++++++++++++++++++++++++++\n 4 files changed, 156 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 1a25f55bb1..ec92f43ae4 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -10,7 +10,7 @@\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n \tThe following non-patch diff formats are supported: `--raw`,\n-\t`--name-only`, `--name-status`, `--summary`,\n+\t`--name-only`, `--name-status`, `--summary`, `--check`,\n \t`--stat`, `--numstat`, and `--shortstat`.\n \tThe stat formats show range-scoped counts: only lines within\n \tthe tracked range are counted.  `--dirstat` is not supported\ndiff --git a/diff.c b/diff.c\nindex 026fafeb90..519c513356 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -665,6 +665,12 @@ struct emit_callback {\n  */\n struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n+\t/*\n+\t * Optional; consumers that report file line numbers (e.g.\n+\t * checkdiff) need the synthetic hunk header to set their\n+\t * post-image position before in-range lines are replayed.\n+\t */\n+\txdiff_emit_hunk_fn orig_hunk_fn;\n \tvoid *orig_cb_data;\n \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n \tunsigned int cur_range;\t\t/* index into the range_set */\n@@ -2652,6 +2658,17 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\t\t       filter->hunk.new_begin, new_count,\n \t\t\t       filter->func, filter->funclen);\n \n+\t/*\n+\t * Inform a line-numbering consumer of the post-image position\n+\t * before replaying lines, mirroring the hunk callback xdiff\n+\t * would have issued for a non-scoped diff.\n+\t */\n+\tif (filter->orig_hunk_fn)\n+\t\tfilter->orig_hunk_fn(filter->orig_cb_data,\n+\t\t\t\tfilter->hunk.old_begin, old_count,\n+\t\t\t\tfilter->hunk.new_begin, new_count,\n+\t\t\t\tfilter->func, filter->funclen);\n+\n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n@@ -4330,11 +4347,29 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \tdiff_free_filespec_data(two);\n }\n \n+/*\n+ * Is the 0-based line index within any of the tracked ranges?\n+ * (range_set ranges are 0-based, half-open [start, end).)  This is a\n+ * one-shot query for a single line and scans; the streaming filter\n+ * (line_range_line_fn) uses a forward cursor instead.\n+ */\n+static int idx_in_ranges(const struct range_set *ranges, long idx)\n+{\n+\tunsigned int i;\n+\n+\tfor (i = 0; i < ranges->nr; i++)\n+\t\tif (idx >= ranges->ranges[i].start &&\n+\t\t    idx < ranges->ranges[i].end)\n+\t\t\treturn 1;\n+\treturn 0;\n+}\n+\n static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\t      const char *attr_path,\n \t\t\t      struct diff_filespec *one,\n \t\t\t      struct diff_filespec *two,\n-\t\t\t      struct diff_options *o)\n+\t\t\t      struct diff_options *o,\n+\t\t\t      const struct range_set *line_ranges)\n {\n \tmmfile_t mf1, mf2;\n \tstruct checkdiff_t data;\n@@ -4374,7 +4409,19 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\tmemset(&xecfg, 0, sizeof(xecfg));\n \t\txecfg.ctxlen = 1; /* at least one context line */\n \t\txpp.flags = 0;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n+\n+\t\tif (line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       checkdiff_consume, &data);\n+\t\t\tlr_filter.orig_hunk_fn = checkdiff_consume_hunk;\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate checkdiff for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n \t\t\t\t  checkdiff_consume, &data,\n \t\t\t\t  &xpp, &xecfg))\n \t\t\tdie(\"unable to generate checkdiff for %s\", one->path);\n@@ -4387,6 +4434,17 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\tcheck_blank_at_eof(&mf1, &mf2, &ecbdata);\n \t\t\tblank_at_eof = ecbdata.blank_at_eof_in_postimage;\n \n+\t\t\t/*\n+\t\t\t * check_blank_at_eof() scans the whole file; with -L,\n+\t\t\t * keep the report only when its line is in a tracked\n+\t\t\t * range.  The error's location is the first trailing\n+\t\t\t * blank line (blank_at_eof, 1-based; ranges 0-based), so\n+\t\t\t * we scope by that line.\n+\t\t\t */\n+\t\t\tif (blank_at_eof && line_ranges &&\n+\t\t\t    !idx_in_ranges(line_ranges, blank_at_eof - 1))\n+\t\t\t\tblank_at_eof = 0;\n+\n \t\t\tif (blank_at_eof) {\n \t\t\t\tstatic char *err;\n \t\t\t\tif (!err)\n@@ -5179,7 +5237,8 @@ static void run_checkdiff(struct diff_filepair *p, struct diff_options *o)\n \tdiff_fill_oid_info(p->one, o->repo->index);\n \tdiff_fill_oid_info(p->two, o->repo->index);\n \n-\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o);\n+\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o,\n+\t\t\t  p->line_ranges);\n }\n \n void repo_diff_setup(struct repository *r, struct diff_options *options)\ndiff --git a/revision.c b/revision.c\nindex 2c76e15778..7abb287451 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3195,7 +3195,7 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n \t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n \t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n-\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tDIFF_FORMAT_SHORTSTAT | DIFF_FORMAT_CHECKDIFF))))\n \t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex af37bd532f..9d351aa05f 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -1018,4 +1018,96 @@ test_expect_success '--summary shows new file on root commit' '\n \ttest_grep \"create mode 100644 file.c\" actual\n '\n \n+test_expect_success 'setup for --check test' '\n+\tgit checkout --orphan check-test &&\n+\tgit reset --hard &&\n+\tcat >check.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return;\n+\t}\n+\n+\tvoid other()\n+\t{\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add check.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add check.c\" &&\n+\t# Introduce trailing whitespace errors in both functions\n+\tsed \"s/return;/return; /\" check.c >check.c.tmp &&\n+\tmv check.c.tmp check.c &&\n+\tgit commit -a -m \"introduce trailing whitespace\"\n+'\n+\n+test_expect_success '--check scoped to tracked range with correct file line' '\n+\t# tracked() trailing whitespace is at check.c:3; report it with the\n+\t# real file line number, not a count from the start of the range\n+\t# hunk.  other() at check.c:8 is outside the range and is excluded.\n+\ttest_must_fail git log -L:tracked:check.c --check --format= >actual &&\n+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n+\ttest_grep ! \"check.c:8:\" actual\n+'\n+\n+test_expect_success '--check reports each of several tracked ranges' '\n+\t# Track both functions as separate ranges.  Each range is flushed\n+\t# as its own hunk, so the second error must report its real file\n+\t# line (check.c:8), not continue the numbering from the first\n+\t# range (check.c:3).\n+\ttest_must_fail git log -L:tracked:check.c -L:other:check.c \\\n+\t\t--check --format= >actual &&\n+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n+\ttest_grep \"check.c:8: trailing whitespace\" actual\n+'\n+\n+test_expect_success '--check line numbers stay correct across a gap in one range' '\n+\tgit checkout --orphan check-gap &&\n+\tgit reset --hard &&\n+\tcat >gap.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    int a = 1;\n+\t    int b = 2;\n+\t    int c = 3;\n+\t    int d = 4;\n+\t    int e = 5;\n+\t    int g = 7;\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add gap.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gap.c\" &&\n+\t# Two trailing-whitespace errors within one tracked range,\n+\t# separated by clean lines.  ctxlen is inflated to the range span,\n+\t# so they land in a single xdiff hunk with the gap as context;\n+\t# both must report their real file line number, with the context\n+\t# lines between them counted.\n+\tsed -e \"s/int a = 1;/int a = 1; /\" -e \"s/int g = 7;/int g = 7; /\" gap.c >tmp &&\n+\tmv tmp gap.c &&\n+\tgit commit -a -m \"ws errors with a gap\" &&\n+\ttest_must_fail git log -L:tracked:gap.c --check --format= >actual &&\n+\ttest_grep \"gap.c:3: trailing whitespace\" actual &&\n+\ttest_grep \"gap.c:8: trailing whitespace\" actual\n+'\n+\n+test_expect_success '--check does not report blank-at-eof outside the range' '\n+\tgit checkout --orphan check-eof &&\n+\tgit reset --hard &&\n+\tprintf \"void tracked()\\n{\\n    return;\\n}\\n\\nint tail = 1;\\n\" >eof.c &&\n+\tgit add eof.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add eof.c\" &&\n+\t# One commit introduces a trailing-whitespace error inside tracked()\n+\t# (line 3) and a blank line at end of file (line 7, outside the\n+\t# range).  The blank-at-eof check scans the whole file, so it must be\n+\t# scoped: report the in-range error, not the out-of-range EOF blank.\n+\tprintf \"void tracked()\\n{\\n    return; \\n}\\n\\nint tail = 1;\\n\\n\" >eof.c &&\n+\tgit commit -a -m \"ws in range, blank at eof out of range\" &&\n+\ttest_must_fail git log -L:tracked:eof.c --check --format= >actual &&\n+\ttest_grep \"eof.c:3: trailing whitespace\" actual &&\n+\ttest_grep ! \"blank line at EOF\" actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545884","messageId":"f69ccfbc8c4ffafa9bd2c6efcab3bafd6d94330f.1781806593.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH 7/7] diffcore-pickaxe: scope -G to the -L tracked range","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T18:16:32Z","receivedAt":"2026-06-18T18:16:46Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\ngit log -L scopes its diff output to the tracked range, but pickaxe\n(-S, -G) still runs in diffcore over the whole-file change, so -L -G\nselects a commit whenever the pattern appears in any added or removed\nline of the file, even outside the tracked range.\n\nTeach -G to honor the range.  diff_grep() already runs an xdiff pass\nand greps the +/- lines; route that pass through the line-range filter\nso only the tracked range's lines are grepped.  Expose the filter as\ndiff_emit_line_ranges(), a line-range-scoped xdi_diff_outf(), thread\nthe filepair's line_ranges through the pickaxe callback, and pass it\nfrom pickaxe_match().  Skip scoping under textconv, whose output is not\nin the original file's line coordinates.\n\n-G needs only a hit/no-hit answer, so the line-number concerns the\nfilter handles for patch and check output do not apply here.\n\n-S is left matching the whole file: it counts needle occurrences per\nblob rather than grepping the diff, so scoping it needs a different\napproach, left to a follow-up.  has_changes() takes the range parameter\nbut ignores it for now.\n\nDocument the resulting -L pickaxe scoping: -G is range-scoped, while -S\nstill matches the whole file.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  5 +-\n diff.c                                | 15 ++++++\n diffcore-pickaxe.c                    | 37 +++++++++++---\n t/t4211-line-log.sh                   | 72 +++++++++++++++++++++++++--\n xdiff-interface.h                     | 13 +++++\n 5 files changed, 130 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex ec92f43ae4..7cfae1499c 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -20,6 +20,9 @@\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n-as are pickaxe options (`-S`, `-G`) and `--diff-filter`.\n+as are pickaxe options (`-S`, `-G`) and `--diff-filter`.  `-G` is\n+scoped to the tracked range; `-S` is still evaluated over the whole\n+file, so an `-S` query may select a commit for a change outside the\n+range.\n +\n include::line-range-format.adoc[]\ndiff --git a/diff.c b/diff.c\nindex 519c513356..a8f346621b 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2817,6 +2817,21 @@ static int line_range_filter_diff(struct line_range_filter *filter,\n \treturn ret;\n }\n \n+/*\n+ * Expose the in-file line-range filter to callers outside diff.c (e.g.\n+ * pickaxe -G); see xdiff-interface.h for the contract.\n+ */\n+int diff_emit_line_ranges(mmfile_t *one, mmfile_t *two,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tstruct line_range_filter filter;\n+\n+\tline_range_filter_init(&filter, ranges, line_fn, cb_data);\n+\treturn line_range_filter_diff(&filter, one, two, xpp, xecfg);\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\ndiff --git a/diffcore-pickaxe.c b/diffcore-pickaxe.c\nindex a52d569911..047b2bf7ac 100644\n--- a/diffcore-pickaxe.c\n+++ b/diffcore-pickaxe.c\n@@ -16,7 +16,8 @@\n \n typedef int (*pickaxe_fn)(mmfile_t *one, mmfile_t *two,\n \t\t\t  struct diff_options *o,\n-\t\t\t  regex_t *regexp, kwset_t kws);\n+\t\t\t  regex_t *regexp, kwset_t kws,\n+\t\t\t  const struct range_set *ranges);\n \n struct diffgrep_cb {\n \tregex_t *regexp;\n@@ -42,7 +43,8 @@ static int diffgrep_consume(void *priv, char *line, unsigned long len)\n \n static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t\t     struct diff_options *o,\n-\t\t     regex_t *regexp, kwset_t kws UNUSED)\n+\t\t     regex_t *regexp, kwset_t kws UNUSED,\n+\t\t     const struct range_set *ranges)\n {\n \tstruct diffgrep_cb ecbdata;\n \txpparam_t xpp;\n@@ -50,8 +52,11 @@ static int diff_grep(mmfile_t *one, mmfile_t *two,\n \tint ret;\n \n \t/*\n-\t * We have both sides; need to run textual diff and see if\n-\t * the pattern appears on added/deleted lines.\n+\t * We have both sides; need to run textual diff and see if the\n+\t * pattern appears on added/deleted lines.  Under -L (ranges set),\n+\t * forward only the tracked range's lines so the match is scoped.\n+\t * -G needs only a hit/no-hit answer, so the line-number bookkeeping\n+\t * the filter does for -L patch and check output is irrelevant here.\n \t */\n \tmemset(&xpp, 0, sizeof(xpp));\n \tmemset(&xecfg, 0, sizeof(xecfg));\n@@ -65,8 +70,12 @@ static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t * An xdiff error might be our \"data->hit\" from above. See the\n \t * comment for xdiff_emit_line_fn in xdiff-interface.h\n \t */\n-\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n-\t\t\t    &ecbdata, &xpp, &xecfg);\n+\tif (ranges)\n+\t\tret = diff_emit_line_ranges(one, two, ranges, diffgrep_consume,\n+\t\t\t\t\t    &ecbdata, &xpp, &xecfg);\n+\telse\n+\t\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n+\t\t\t\t    &ecbdata, &xpp, &xecfg);\n \tif (ecbdata.hit)\n \t\treturn 1;\n \tif (ret)\n@@ -119,8 +128,13 @@ static unsigned int contains(mmfile_t *mf, regex_t *regexp, kwset_t kws,\n \n static int has_changes(mmfile_t *one, mmfile_t *two,\n \t\t       struct diff_options *o UNUSED,\n-\t\t       regex_t *regexp, kwset_t kws)\n+\t\t       regex_t *regexp, kwset_t kws,\n+\t\t       const struct range_set *ranges UNUSED)\n {\n+\t/*\n+\t * -S counts needle occurrences in each whole blob.  Scoping this to\n+\t * a -L range is left to a follow-up; for now -S ignores the range.\n+\t */\n \tunsigned int c1 = one ? contains(one, regexp, kws, 0) : 0;\n \tunsigned int c2 = two ? contains(two, regexp, kws, c1 + 1) : 0;\n \treturn c1 != c2;\n@@ -132,6 +146,7 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tstruct userdiff_driver *textconv_one = NULL;\n \tstruct userdiff_driver *textconv_two = NULL;\n \tmmfile_t mf1, mf2;\n+\tconst struct range_set *ranges;\n \tint ret;\n \n \t/* ignore unmerged */\n@@ -169,7 +184,13 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tmf1.size = fill_textconv(o->repo, textconv_one, p->one, &mf1.ptr);\n \tmf2.size = fill_textconv(o->repo, textconv_two, p->two, &mf2.ptr);\n \n-\tret = fn(&mf1, &mf2, o, regexp, kws);\n+\t/*\n+\t * -L scopes the search to the tracked range, but the range is in\n+\t * original-file line coordinates that do not map onto textconv\n+\t * output, so search the whole file when textconv is in play.\n+\t */\n+\tranges = (textconv_one || textconv_two) ? NULL : p->line_ranges;\n+\tret = fn(&mf1, &mf2, o, regexp, kws, ranges);\n \n \tif (textconv_one)\n \t\tfree(mf1.ptr);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex 9d351aa05f..3d35b20178 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -722,9 +722,9 @@ test_expect_success '-L with -S filters to string-count changes' '\n test_expect_success '-L with -G filters to diff-text matches' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c -G \"F2 [+] 2\" --format= >actual &&\n-\t# -G greps the whole-file diff text, not just the tracked range;\n-\t# combined with -L, this selects commits that both touch func2\n-\t# and have \"F2 + 2\" in their diff.\n+\t# -G greps the diff text, and under -L only the lines in the\n+\t# tracked range (unlike -S above, which searches the whole file);\n+\t# this selects commits whose change to func2 contains \"F2 + 2\".\n \ttest $(grep -c \"^diff --git\" actual) = 1 &&\n \tgrep \"F2 + 2\" actual\n '\n@@ -1110,4 +1110,70 @@ test_expect_success '--check does not report blank-at-eof outside the range' '\n \ttest_grep ! \"blank line at EOF\" actual\n '\n \n+test_expect_success '-L -G is scoped to the tracked range' '\n+\tgit checkout --orphan grep-scope &&\n+\tgit reset --hard &&\n+\tcat >gp.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return ALPHA;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return BETA;\n+\t}\n+\tEOF\n+\tgit add gp.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gp.c\" &&\n+\tsed -e \"s/ALPHA/ALPHA2/\" -e \"s/BETA/BETA2/\" gp.c >tmp &&\n+\tmv tmp gp.c &&\n+\tgit commit -a -m \"touch both functions\" &&\n+\t# The commit changes ALPHA (func1) and BETA (func2).  Tracking func2,\n+\t# -G BETA matches its in-range change; -G ALPHA must not, since ALPHA\n+\t# changes only outside the tracked range.\n+\tgit log -L:func2:gp.c -G BETA --format=%s >actual &&\n+\ttest_grep \"touch both functions\" actual &&\n+\tgit log -L:func2:gp.c -G ALPHA --format=%s >actual &&\n+\ttest_grep ! \"touch both functions\" actual\n+'\n+\n+test_expect_success '-L -G searches the whole file under textconv' '\n+\tgit checkout --orphan grep-textconv &&\n+\tgit reset --hard &&\n+\tcat >tc.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add tc.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add tc.c\" &&\n+\t# One commit changes func1 and func2; MAGIC lands only in the\n+\t# func2 change, outside func1.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/return F2/return MAGIC/\" tc.c >tmp &&\n+\tmv tmp tc.c &&\n+\tgit commit -a -m \"change both funcs\" &&\n+\techo \"tc.c diff=tc\" >.gitattributes &&\n+\n+\t# Without a textconv driver, -G is scoped to func1, so MAGIC (only\n+\t# in the func2 change) does not select the commit.\n+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n+\ttest_must_be_empty actual &&\n+\n+\t# A textconv driver makes the range (original-file line numbers)\n+\t# meaningless against the driver output, so -G falls back to the\n+\t# whole file and MAGIC now selects the commit.\n+\tgit config diff.tc.textconv cat &&\n+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n+\ttest_grep \"change both funcs\" actual\n+'\n+\n test_done\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex 51c88296ed..71e5dffefb 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -46,6 +46,19 @@ int xdi_diff_outf(mmfile_t *mf1, mmfile_t *mf2,\n \t\t  xdiff_emit_line_fn line_fn,\n \t\t  void *consume_callback_data,\n \t\t  xpparam_t const *xpp, xdemitconf_t const *xecfg);\n+\n+struct range_set;\n+/*\n+ * Like xdi_diff_outf(), but forwards only the lines within the given\n+ * (post-image) line ranges to line_fn, as \"git log -L\" scopes its output.\n+ * Returns line_fn's latched return value (so a consumer can signal a hit\n+ * with a non-zero return), or non-zero on xdiff failure.  Defined in\n+ * diff.c (it reuses the line-range filter there).\n+ */\n+int diff_emit_line_ranges(mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg);\n int read_mmfile(mmfile_t *ptr, const char *filename);\n void read_mmblob(mmfile_t *ptr, struct object_database *odb,\n \t\t const struct object_id *oid);\n-- \ngitgitgadget\n"},{"id":"545904","messageId":"xmqq8q8bpl03.fsf@gitster.g","threadId":"65837","inReplyTo":"a70d861d27a13459bab34f6681b3ccfe2f20d0d8.1781806593.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 5/7] line-log: support diff stat formats with -L","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-18T22:00:12Z","receivedAt":"2026-06-18T22:00:14Z","isPatch":true,"body":"\"Michael Montalbo via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\n> index 72f639b5e7..1a25f55bb1 100644\n> --- a/Documentation/line-range-options.adoc\n> +++ b/Documentation/line-range-options.adoc\n> @@ -9,10 +9,14 @@\n>  \t_<start>_ and _<end>_ (or _<funcname>_) must exist in the starting revision.\n>  \tYou can specify this option more than once. Implies `--patch`.\n>  \tPatch output can be suppressed using `--no-patch`.\n> -\tNon-patch diff formats `--raw`, `--name-only`, `--name-status`,\n> -\tand `--summary` are supported.  Diff stat formats\n> -\t(`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not\n> -\tcurrently implemented.\n> +\tThe following non-patch diff formats are supported: `--raw`,\n> +\t`--name-only`, `--name-status`, `--summary`,\n> +\t`--stat`, `--numstat`, and `--shortstat`.\n> +\tThe stat formats show range-scoped counts: only lines within\n> +\tthe tracked range are counted.  `--dirstat` is not supported\n\nIf \"range-scoped\" is a widely known term (as opposed to a new word\ninvented only during the introduction of this topic), the above\nreads well with a nice rhythm, but otherwise it may be easier to\nread, i.e., something like\n\n\tThe stat formats counts only lines within the tracked range.\n\nwithout having readers learn yet another new term that is only used\nhere.\n\n> diff --git a/diff.c b/diff.c\n> index 6233a96bf0..026fafeb90 100644\n> --- a/diff.c\n> +++ b/diff.c\n> @@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n>  \t\txecfg.ctxlen = o->context;\n>  \t\txecfg.interhunkctxlen = o->interhunkcontext;\n>  \t\txecfg.flags = XDL_EMIT_NO_HUNK_HDR;\n> -\t\tif (xdi_diff_outf(&mf1, &mf2, NULL,\n> +\n> +\t\tif (p->line_ranges) {\n> +\t\t\tstruct line_range_filter lr_filter;\n> +\n> +\t\t\tline_range_filter_init(&lr_filter, p->line_ranges,\n> +\t\t\t\t\t       diffstat_consume, diffstat);\n> +\n> +\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n> +\t\t\t\t\t\t   &xpp, &xecfg))\n> +\t\t\t\tdie(\"unable to generate diffstat for %s\",\n> +\t\t\t\t    one->path);\n> +\t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL,\n>  \t\t\t\t  diffstat_consume, diffstat, &xpp, &xecfg))\n>  \t\t\tdie(\"unable to generate diffstat for %s\", one->path);\n\nIt is pleasing to see that this can be done with such a surprisingly\nsmall change.\n\n"},{"id":"546221","messageId":"CAC2QwmKEHb+LL4ZkQwq+Rw8eyDXzdBp_nxa_d+Ecx0K1icNqQA@mail.gmail.com","threadId":"65837","inReplyTo":"xmqq8q8bpl03.fsf@gitster.g","subject":"Re: [PATCH 5/7] line-log: support diff stat formats with -L","fromName":"Michael Montalbo","fromEmail":"mmontalbo@gmail.com","sentAt":"2026-06-23T02:25:54Z","receivedAt":"2026-06-23T02:26:06Z","isPatch":true,"body":"On Thu, Jun 18, 2026 at 3:00 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> If \"range-scoped\" is a widely known term (as opposed to a new word\n> invented only during the introduction of this topic), the above\n> reads well with a nice rhythm, but otherwise it may be easier to\n> read, i.e., something like\n>\n>         The stat formats counts only lines within the tracked range.\n>\n> without having readers learn yet another new term that is only used\n> here.\n>\n\nIt was something I invented for the topic, but I agree it is better to\navoid coining a new term, especially since it ends up being spelled\nout anyway in the blurb that follows. Will replace the term.\n\n> > diff --git a/diff.c b/diff.c\n> > index 6233a96bf0..026fafeb90 100644\n> > --- a/diff.c\n> > +++ b/diff.c\n> > @@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n> >               xecfg.ctxlen = o->context;\n> >               xecfg.interhunkctxlen = o->interhunkcontext;\n> >               xecfg.flags = XDL_EMIT_NO_HUNK_HDR;\n> > -             if (xdi_diff_outf(&mf1, &mf2, NULL,\n> > +\n> > +             if (p->line_ranges) {\n> > +                     struct line_range_filter lr_filter;\n> > +\n> > +                     line_range_filter_init(&lr_filter, p->line_ranges,\n> > +                                            diffstat_consume, diffstat);\n> > +\n> > +                     if (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n> > +                                                &xpp, &xecfg))\n> > +                             die(\"unable to generate diffstat for %s\",\n> > +                                 one->path);\n> > +             } else if (xdi_diff_outf(&mf1, &mf2, NULL,\n> >                                 diffstat_consume, diffstat, &xpp, &xecfg))\n> >                       die(\"unable to generate diffstat for %s\", one->path);\n>\n> It is pleasing to see that this can be done with such a surprisingly\n> small change.\n>\n\nAgreed! I didn't initially plan it out that way during the first series,\nbut things fell into place nicely by the end.\n"},{"id":"546548","messageId":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH v2 0/7] line-log: scope stat, check, and -G to -L line ranges","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:54Z","receivedAt":"2026-06-27T17:29:04Z","isPatch":true,"body":"This series extends git log -L so that more of its diff output and commit\nselection honor the tracked line ranges: the diff stat formats and --check\nreport only the lines inside the tracked range, and the -G pickaxe is scoped\nto the tracked range.\n\nIt builds on top of the mm/line-log-cleanup topic [1], which integrated -L\nwith the standard log output pipeline and taught it the non-patch formats\n--raw, --name-only, --name-status, and --summary.\n\nWith these patches the following also honor the tracked range:\n\n * --stat, --numstat, --shortstat: counts cover only the lines inside the\n   tracked range, not the whole file.\n\n * --check: whitespace errors are reported only for added lines inside the\n   tracked range, with the correct file line numbers.\n\n * -G: a commit is selected only when the pattern appears on an\n   added/removed line inside the tracked range, rather than anywhere in the\n   file.\n\nThe --dirstat format is deliberately rejected. Its default mode reports each\ndirectory's share of the total churn as a percentage, computed from\nwhole-file byte damage (via diffcore_count_changes(), outside the line-based\npipeline that -L scopes), so bare --dirstat cannot honor the tracked range.\nThe --dirstat=lines mode could: it aggregates the same per-file line counts\nas --numstat, which -L already scopes. But supporting only that sub-mode\nwhile bare --dirstat still errors is a confusing split, so the whole format\nis left to a follow-up; --numstat already gives the exact per-file counts\nwithin the tracked range.\n\n-S is left matching the whole file. Unlike -G, it counts needle occurrences\nper blob rather than grepping the diff, so scoping it to a range needs a\ndifferent approach; that is left to a follow-up. Patch 7, which scopes -G,\nalso updates the -L documentation to note the -S/-G distinction, so the\nwhole-file behavior of -S is not mistaken for the way -G is scoped to the\ntracked range.\n\nPatches 1-3 are independent of the new formats: they fix two bugs in the\nexisting -L patch output (a leaked deletion and an off-by-one hunk header),\nbring its hunk headers in line with git diff's format, and clarify the\nline-range filter mm/line-log-cleanup added, whose names obscured its model\n(cryptic lno_ cursors conflating the pre/post-image and 0/1-based axes, a\nflat hunk-state struct, and a one-letter state pointer (s)). The two bugs\nmay be a hint that the model could use clarification, so patch 1 renames and\ngroups the filter state and patch 2 documents the model, before the fixes\nthat read against it. Patches 4-7 then build the new formats on top:\n\n * Patch 1: rename and group the filter for clarity. Spell the cryptic names\n   out to the file's own forms: the line-number cursors to\n   lno_in_preimage/lno_in_postimage (as in struct emit_callback) and the\n   range index to idx_in_postimage, while the hunk geometry stays old/new\n   (the xdiff_emit_hunk_fn convention) and moves into a sub-struct. Name the\n   filter pointer (filter) and rename the struct to line_range_filter and\n   the flush helper to flush_range_hunk. No behavior change.\n\n * Patch 2: simplify the filter by classifying removals as they arrive,\n   dropping the pending_rm buffer and a latent flush_range_hunk() bug that\n   leaked deletions just past the range. Make the buffered lines the hunk's\n   single source of truth: flush_range_hunk() derives the counts from them\n   rather than tracking them per line, dropping three more fields. Document\n   the model with a block comment and worked example, and add\n   begin_range_hunk() as the counterpart to flush_range_hunk(). (This\n   simplification was submitted by itself previously [2] but did not\n   advance, so it is re-included here.)\n\n * Patch 3: stop hand-rolling the synthetic hunk header and emit it through\n   xdiff's own formatter via a new xdiff_emit_hunk_header() helper. The\n   hand-rolled code put a count-0 side's begin one too high (the convention\n   is the line before the change); routing through xdl_emit_hunk_hdr() fixes\n   that by construction and, as a side effect, makes -L headers match git\n   diff exactly, including its omission of a count of 1. Regenerate the two\n   affected fixtures.\n\n * Patch 4: extract a line_range_filter_diff() helper that folds the\n   filter's two preconditions into one place: inflate ctxlen to the largest\n   range span so every change within a range lands in a single xdiff hunk,\n   and clear XDL_EMIT_NO_HUNK_HDR so the hunk headers the filter reads are\n   always emitted (its position tracking relies on both). It then runs an\n   initialized filter through xdiff, flushes the final range hunk, and\n   releases it; use it in builtin_diff(). The stat, check, and -G patches\n   that reuse it inherit both.\n\n * Patch 5: reuse the filter in builtin_diffstat() for the stat formats,\n   extend the -L output-format allowlist, and reject --dirstat.\n\n * Patch 6: reuse the filter in builtin_checkdiff() and extend the allowlist\n   for --check. The separate blank-at-eof pass scans the whole file, so\n   scope its report to the tracked ranges too.\n\n * Patch 7: scope -G to the tracked range. Expose the filter as\n   diff_emit_line_ranges() and grep only the tracked range's lines,\n   threading the filepair's line_ranges through the pickaxe callback. -S is\n   left whole-file, and the -L documentation is updated to note that -G is\n   scoped to the tracked range while -S still matches the whole file.\n\nChanges since v1:\n\n * Replace the term \"range-scoped\" with explicit descriptions that refer to\n   \"tracked line ranges\" instead.\n\n[1]\nhttps://lore.kernel.org/git/pull.2094.v3.git.1780001267.gitgitgadget@gmail.com/\n[2]\nhttps://lore.kernel.org/git/pull.2099.git.1777230630020.gitgitgadget@gmail.com/\n\nMichael Montalbo (7):\n  diff: rename and group the line-range filter for clarity\n  diff: simplify the line-range filter by classifying removals\n    immediately\n  diff: emit -L hunk headers via xdiff's formatter\n  diff: extract a line-range diff helper for reuse\n  line-log: support diff stat formats with -L\n  diff: support --check with -L line ranges\n  diffcore-pickaxe: scope -G to the -L tracked range\n\n Documentation/line-range-options.adoc    |  17 +-\n diff.c                                   | 491 ++++++++++++++---------\n diffcore-pickaxe.c                       |  37 +-\n revision.c                               |   6 +-\n t/t4211-line-log.sh                      | 439 +++++++++++++++++++-\n t/t4211/sha1/expect.no-assertion-error   |   2 +-\n t/t4211/sha1/expect.vanishes-early       |   6 +-\n t/t4211/sha256/expect.no-assertion-error |   2 +-\n t/t4211/sha256/expect.vanishes-early     |   6 +-\n xdiff-interface.c                        |  19 +\n xdiff-interface.h                        |  28 ++\n 11 files changed, 826 insertions(+), 227 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2152%2Fmmontalbo%2Fmm%2Fline-log-stat-formats-followup-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2152/mmontalbo/mm/line-log-stat-formats-followup-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2152\n\nRange-diff vs v1:\n\n 1:  6cfaccab92 = 1:  6cfaccab92 diff: rename and group the line-range filter for clarity\n 2:  5602b7976a = 2:  5602b7976a diff: simplify the line-range filter by classifying removals immediately\n 3:  d211c82e40 = 3:  d211c82e40 diff: emit -L hunk headers via xdiff's formatter\n 4:  b82a997359 = 4:  b82a997359 diff: extract a line-range diff helper for reuse\n 5:  a70d861d27 ! 5:  3d0091b549 line-log: support diff stat formats with -L\n     @@ Metadata\n       ## Commit message ##\n          line-log: support diff stat formats with -L\n      \n     -    Reuse the line_range_filter in builtin_diffstat() to produce\n     -    range-scoped statistics.  When a filepair carries line_ranges, the\n     -    filter wraps diffstat_consume() as its output callback, forwarding only\n     -    in-range lines for counting.  flush_range_hunk() replays buffered\n     -    content through diffstat_consume(), which ignores synthetic @@ headers\n     -    since it only counts '+' and '-' lines.\n     +    Reuse the line_range_filter in builtin_diffstat() so the stat formats\n     +    count only the lines within the tracked range.  When a filepair carries\n     +    line_ranges, the filter wraps diffstat_consume() as its output callback,\n     +    forwarding only the lines inside the range for counting.\n     +    flush_range_hunk() replays buffered content through diffstat_consume(),\n     +    which ignores synthetic @@ headers since it only counts '+' and '-'\n     +    lines.\n      \n          Expand the output format allowlist in setup_revisions() to accept\n          --stat, --numstat, and --shortstat with -L.\n     @@ Commit message\n          per-file line counts as --numstat, which -L already scopes.  But\n          accepting only that sub-mode while bare --dirstat keeps erroring is a\n          confusing split, so the whole format is deferred to a follow-up;\n     -    --numstat already reports the exact range-scoped per-file counts.\n     +    --numstat already reports the exact per-file counts within the tracked\n     +    range.\n      \n          Also drop \"yet\" from the generic -L rejection message (\"does not\n          yet support the requested diff format\").  Some rejected formats do\n     @@ Documentation/line-range-options.adoc\n      +\tThe following non-patch diff formats are supported: `--raw`,\n      +\t`--name-only`, `--name-status`, `--summary`,\n      +\t`--stat`, `--numstat`, and `--shortstat`.\n     -+\tThe stat formats show range-scoped counts: only lines within\n     -+\tthe tracked range are counted.  `--dirstat` is not supported\n     ++\tThe stat formats count only lines within the tracked range.\n     ++\t`--dirstat` is not supported\n      +\twith `-L`: it summarizes change as each directory's share of\n      +\tthe total churn, not as counts for the tracked lines.  Use\n      +\t`--numstat` for exact per-file counts within the range.\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\tgit commit -m \"Add func1() and func2()\" &&\n      +\n      +\t# Modify both functions in a single commit so that\n     -+\t# whole-file stats differ from range-scoped stats.\n     ++\t# whole-file stats differ from the counts for the tracked range.\n      +\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" file.c >tmp &&\n      +\tmv tmp file.c &&\n      +\tgit commit -a -m \"Modify both functions\"\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +test_expect_success '--numstat counts only lines in tracked range' '\n      +\t# \"Modify both functions\" changes one line in func1 and one in\n      +\t# func2.  Whole-file numstat would show 2 added, 2 deleted.\n     -+\t# Range-scoped numstat for func2 should show only 1 and 1.\n     ++\t# numstat for func2 within the tracked range should show only 1 and 1.\n      +\tgit log -L:func2:file.c --numstat --format=%s -1 >actual &&\n      +\ttest_grep \"Modify both functions\" actual &&\n      +\ttest_grep \"^1\t1\tfile.c$\" actual &&\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\n      +test_expect_success '--numstat counts only additions for root commit' '\n      +\t# Root commit creates both func1 (4 lines) and func2 (4 lines).\n     -+\t# Whole-file numstat would show 9 lines added.  Range-scoped\n     -+\t# numstat for func2 should show only 4.\n     ++\t# Whole-file numstat would show 9 lines added.  numstat for func2\n     ++\t# within the tracked range should show only 4.\n      +\tgit log -L:func2:file.c --numstat --format=%s >actual &&\n      +\ttest_grep \"Add func1() and func2()\" actual &&\n      +\ttest_grep \"^4\t0\tfile.c$\" actual &&\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\n      +test_expect_success '--shortstat counts only lines in tracked range' '\n      +\t# --shortstat prints only the summary line: no per-file \"file.c |\"\n     -+\t# line.  Counts are range-scoped as for --numstat above.\n     ++\t# line.  Counts cover only the tracked range, as for --numstat above.\n      +\tgit log -L:func2:file.c --shortstat --format=%s -1 >actual &&\n      +\ttest_grep \"Modify both functions\" actual &&\n      +\ttest_grep \"1 insertion\" actual &&\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +test_expect_success '--numstat across renames and multiple commits' '\n      +\t# parallel-change carries the tracked function f across an a.c -> b.c\n      +\t# rename and a merge of two parallel histories.  With -M, --numstat\n     -+\t# follows the rename and reports range-scoped (not whole-file)\n     -+\t# added/removed counts for f per commit; the file column flips from\n     ++\t# follows the rename and reports added/removed counts for f within\n     ++\t# the tracked range (not whole-file) per commit; the file column flips from\n      +\t# b.c to a.c at the rename as the walk goes back in time.  Commits\n      +\t# that do not change the range of f emit no row (the merge and the\n      +\t# pure file-move produce nothing), so there are fewer rows than\n 6:  be0679a5a7 ! 6:  36ed52d831 diff: support --check with -L line ranges\n     @@ Documentation/line-range-options.adoc\n      -\t`--name-only`, `--name-status`, `--summary`,\n      +\t`--name-only`, `--name-status`, `--summary`, `--check`,\n       \t`--stat`, `--numstat`, and `--shortstat`.\n     - \tThe stat formats show range-scoped counts: only lines within\n     - \tthe tracked range are counted.  `--dirstat` is not supported\n     + \tThe stat formats count only lines within the tracked range.\n     + \t`--dirstat` is not supported\n      \n       ## diff.c ##\n      @@ diff.c: struct emit_callback {\n 7:  f69ccfbc8c ! 7:  df83e6275b diffcore-pickaxe: scope -G to the -L tracked range\n     @@ Commit message\n          Teach -G to honor the range.  diff_grep() already runs an xdiff pass\n          and greps the +/- lines; route that pass through the line-range filter\n          so only the tracked range's lines are grepped.  Expose the filter as\n     -    diff_emit_line_ranges(), a line-range-scoped xdi_diff_outf(), thread\n     -    the filepair's line_ranges through the pickaxe callback, and pass it\n     -    from pickaxe_match().  Skip scoping under textconv, whose output is not\n     -    in the original file's line coordinates.\n     +    diff_emit_line_ranges(), an xdi_diff_outf() that emits only the tracked\n     +    range's lines, thread the filepair's line_ranges through the pickaxe\n     +    callback, and pass it from pickaxe_match().  Skip scoping under\n     +    textconv, whose output is not in the original file's line coordinates.\n      \n          -G needs only a hit/no-hit answer, so the line-number concerns the\n          filter handles for patch and check output do not apply here.\n     @@ Commit message\n          approach, left to a follow-up.  has_changes() takes the range parameter\n          but ignores it for now.\n      \n     -    Document the resulting -L pickaxe scoping: -G is range-scoped, while -S\n     -    still matches the whole file.\n     +    Document the resulting -L pickaxe scoping: -G is scoped to the tracked\n     +    range, while -S still matches the whole file.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n\n-- \ngitgitgadget\n"},{"id":"546549","messageId":"6cfaccab923694fae4c1f607c459af52dce9a021.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 1/7] diff: rename and group the line-range filter for clarity","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:55Z","receivedAt":"2026-06-27T17:29:06Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe line-range filter that mm/line-log-cleanup added uses names that\nobscure its model.  The cursors lno_post/lno_pre and the index lno_0\nshare an lno_ prefix but conflate the pre/post-image axis with the\n0-based/1-based axis, the hunk state is a flat set of rhunk_* fields,\nand the filter-state pointer is just s.\n\nThe filter bridges two layers of diff.c, and its fields already used\neach layer's vocabulary, but in cryptic abbreviations.  Spell them out\nto the form the rest of the file uses, so that the patches that follow\ncan simplify and fix it with those clearer names in place:\n\n  - lno_post/lno_pre -> lno_in_postimage/lno_in_preimage, the\n    line-number cursors, matching the counters in struct emit_callback\n  - lno_0 -> idx_in_postimage, the 0-based range index\n  - the hunk-header geometry stays old/new (old_begin, new_begin, and\n    counts) to match the xdiff_emit_hunk_fn callback and the\n    \"@@ -<old> +<new> @@\" header it feeds, but moves from flat rhunk_*\n    fields into a \"hunk\" sub-struct, so accesses read\n    filter->hunk.old_begin\n  - flush_rhunk -> flush_range_hunk\n  - the filter-state pointer in each callback: s -> filter\n\nAlso rename the struct line_range_callback to line_range_filter: it is\na filter over xdiff output, not merely a callback.\n\nNo behavior change.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 192 +++++++++++++++++++++++++++++----------------------------\n 1 file changed, 97 insertions(+), 95 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 5a584fa1d5..1e043c959f 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -623,15 +623,15 @@ struct emit_callback {\n  * reveals whether they precede an in-range line (flush into range hunk) or\n  * an out-of-range line (discard).\n  */\n-struct line_range_callback {\n+struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n \tvoid *orig_cb_data;\n \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n \tunsigned int cur_range;\t\t/* index into the range_set */\n \n \t/* Post/pre-image line counters (1-based, set from hunk headers) */\n-\tlong lno_post;\n-\tlong lno_pre;\n+\tlong lno_in_postimage;\n+\tlong lno_in_preimage;\n \n \t/*\n \t * Function name from most recent xdiff hunk header;\n@@ -640,12 +640,14 @@ struct line_range_callback {\n \tchar func[80];\n \tlong funclen;\n \n-\t/* Range hunk being accumulated for the current range */\n-\tstruct strbuf rhunk;\n-\tlong rhunk_old_begin, rhunk_old_count;\n-\tlong rhunk_new_begin, rhunk_new_count;\n-\tint rhunk_active;\n-\tint rhunk_has_changes;\t\t/* any '+' or '-' lines? */\n+\t/* The range hunk being accumulated for the current range. */\n+\tstruct {\n+\t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n+\t\tlong old_begin, old_count;\n+\t\tlong new_begin, new_count;\n+\t\tint active;\n+\t\tint has_changes;\t/* any '+' or '-' line? */\n+\t} hunk;\n \n \t/* Removal lines not yet known to be in-range */\n \tstruct strbuf pending_rm;\n@@ -2540,26 +2542,26 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_callback *s)\n+static void discard_pending_rm(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&s->pending_rm);\n-\ts->pending_rm_count = 0;\n+\tstrbuf_reset(&filter->pending_rm);\n+\tfilter->pending_rm_count = 0;\n }\n \n-static void flush_rhunk(struct line_range_callback *s)\n+static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n \tconst char *p, *end;\n \n-\tif (!s->rhunk_active || s->ret)\n+\tif (!filter->hunk.active || filter->ret)\n \t\treturn;\n \n \t/* Drain any pending removal lines into the range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n \t/*\n@@ -2568,22 +2570,22 @@ static void flush_rhunk(struct line_range_callback *s)\n \t * ctxlen causes xdiff to emit context covering a range that\n \t * has no changes in this commit.\n \t */\n-\tif (!s->rhunk_has_changes) {\n-\t\ts->rhunk_active = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->hunk.has_changes) {\n+\t\tfilter->hunk.active = 0;\n+\t\tstrbuf_reset(&filter->hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    s->rhunk_old_begin, s->rhunk_old_count,\n-\t\t    s->rhunk_new_begin, s->rhunk_new_count);\n-\tif (s->funclen > 0) {\n+\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n+\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n+\tif (filter->funclen > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, s->func, s->funclen);\n+\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n \t}\n \tstrbuf_addch(&hdr, '\\n');\n \n-\ts->ret = s->orig_line_fn(s->orig_cb_data, hdr.buf, hdr.len);\n+\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n \t/*\n@@ -2591,18 +2593,18 @@ static void flush_rhunk(struct line_range_callback *s)\n \t * The cast discards const because xdiff_emit_line_fn takes\n \t * char *, though fn_out_consume does not modify the buffer.\n \t */\n-\tp = s->rhunk.buf;\n-\tend = p + s->rhunk.len;\n-\twhile (!s->ret && p < end) {\n+\tp = filter->hunk.lines.buf;\n+\tend = p + filter->hunk.lines.len;\n+\twhile (!filter->ret && p < end) {\n \t\tconst char *eol = memchr(p, '\\n', end - p);\n \t\tunsigned long line_len = eol ? (unsigned long)(eol - p + 1)\n \t\t\t\t\t     : (unsigned long)(end - p);\n-\t\ts->ret = s->orig_line_fn(s->orig_cb_data, (char *)p, line_len);\n+\t\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, (char *)p, line_len);\n \t\tp += line_len;\n \t}\n \n-\ts->rhunk_active = 0;\n-\tstrbuf_reset(&s->rhunk);\n+\tfilter->hunk.active = 0;\n+\tstrbuf_reset(&filter->hunk.lines);\n }\n \n static void line_range_hunk_fn(void *data,\n@@ -2610,7 +2612,7 @@ static void line_range_hunk_fn(void *data,\n \t\t\t       long new_begin, long new_nr UNUSED,\n \t\t\t       const char *func, long funclen)\n {\n-\tstruct line_range_callback *s = data;\n+\tstruct line_range_filter *filter = data;\n \n \t/*\n \t * When count > 0, begin is 1-based.  When count == 0, begin is\n@@ -2622,104 +2624,104 @@ static void line_range_hunk_fn(void *data,\n \t * flush or discard them when the next content line reveals\n \t * whether the removals precede in-range content.\n \t */\n-\ts->lno_post = new_begin;\n-\ts->lno_pre = old_begin;\n+\tfilter->lno_in_postimage = new_begin;\n+\tfilter->lno_in_preimage = old_begin;\n \n \tif (funclen > 0) {\n-\t\tif (funclen > (long)sizeof(s->func))\n-\t\t\tfunclen = sizeof(s->func);\n-\t\tmemcpy(s->func, func, funclen);\n+\t\tif (funclen > (long)sizeof(filter->func))\n+\t\t\tfunclen = sizeof(filter->func);\n+\t\tmemcpy(filter->func, func, funclen);\n \t}\n-\ts->funclen = funclen;\n+\tfilter->funclen = funclen;\n }\n \n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n-\tstruct line_range_callback *s = priv;\n+\tstruct line_range_filter *filter = priv;\n \tconst struct range *cur;\n-\tlong lno_0, cur_pre;\n+\tlong idx_in_postimage, cur_pre;\n \n-\tif (s->ret)\n-\t\treturn s->ret;\n+\tif (filter->ret)\n+\t\treturn filter->ret;\n \n \tif (line[0] == '-') {\n-\t\tif (!s->pending_rm_count)\n-\t\t\ts->pending_rm_pre_begin = s->lno_pre;\n-\t\ts->lno_pre++;\n-\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\ts->pending_rm_count++;\n-\t\treturn s->ret;\n+\t\tif (!filter->pending_rm_count)\n+\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n+\t\tfilter->lno_in_preimage++;\n+\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\tfilter->pending_rm_count++;\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] == '\\\\') {\n-\t\tif (s->pending_rm_count)\n-\t\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\telse if (s->rhunk_active)\n-\t\t\tstrbuf_add(&s->rhunk, line, len);\n+\t\tif (filter->pending_rm_count)\n+\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\telse if (filter->hunk.active)\n+\t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n \t\t/* otherwise outside tracked range; drop silently */\n-\t\treturn s->ret;\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] != '+' && line[0] != ' ')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n-\tlno_0 = s->lno_post - 1;\n-\tcur_pre = s->lno_pre;\t/* save before advancing for context lines */\n-\ts->lno_post++;\n+\tidx_in_postimage = filter->lno_in_postimage - 1;\n+\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n+\tfilter->lno_in_postimage++;\n \tif (line[0] == ' ')\n-\t\ts->lno_pre++;\n+\t\tfilter->lno_in_preimage++;\n \n \t/* Advance past ranges we've passed */\n-\twhile (s->cur_range < s->ranges->nr &&\n-\t       lno_0 >= s->ranges->ranges[s->cur_range].end) {\n-\t\tif (s->rhunk_active)\n-\t\t\tflush_rhunk(s);\n-\t\tdiscard_pending_rm(s);\n-\t\ts->cur_range++;\n+\twhile (filter->cur_range < filter->ranges->nr &&\n+\t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n+\t\tif (filter->hunk.active)\n+\t\t\tflush_range_hunk(filter);\n+\t\tdiscard_pending_rm(filter);\n+\t\tfilter->cur_range++;\n \t}\n \n \t/* Past all ranges */\n-\tif (s->cur_range >= s->ranges->nr) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (filter->cur_range >= filter->ranges->nr) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n-\tcur = &s->ranges->ranges[s->cur_range];\n+\tcur = &filter->ranges->ranges[filter->cur_range];\n \n \t/* Before current range */\n-\tif (lno_0 < cur->start) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (idx_in_postimage < cur->start) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n \t/* In range so start a new range hunk if needed */\n-\tif (!s->rhunk_active) {\n-\t\ts->rhunk_active = 1;\n-\t\ts->rhunk_has_changes = 0;\n-\t\ts->rhunk_new_begin = lno_0 + 1;\n-\t\ts->rhunk_old_begin = s->pending_rm_count\n-\t\t\t? s->pending_rm_pre_begin : cur_pre;\n-\t\ts->rhunk_old_count = 0;\n-\t\ts->rhunk_new_count = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->hunk.active) {\n+\t\tfilter->hunk.active = 1;\n+\t\tfilter->hunk.has_changes = 0;\n+\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n+\t\tfilter->hunk.old_begin = filter->pending_rm_count\n+\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n+\t\tfilter->hunk.old_count = 0;\n+\t\tfilter->hunk.new_count = 0;\n+\t\tstrbuf_reset(&filter->hunk.lines);\n \t}\n \n \t/* Flush pending removals into range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n-\tstrbuf_add(&s->rhunk, line, len);\n-\ts->rhunk_new_count++;\n+\tstrbuf_add(&filter->hunk.lines, line, len);\n+\tfilter->hunk.new_count++;\n \tif (line[0] == '+')\n-\t\ts->rhunk_has_changes = 1;\n+\t\tfilter->hunk.has_changes = 1;\n \telse\n-\t\ts->rhunk_old_count++;\n+\t\tfilter->hunk.old_count++;\n \n-\treturn s->ret;\n+\treturn filter->ret;\n }\n \n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n@@ -4086,7 +4088,7 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_callback lr_state;\n+\t\t\tstruct line_range_filter lr_state;\n \t\t\tunsigned int i;\n \t\t\tlong max_span = 0;\n \n@@ -4094,7 +4096,7 @@ static void builtin_diff(const char *name_a,\n \t\t\tlr_state.orig_line_fn = fn_out_consume;\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n \t\t\tlr_state.ranges = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.rhunk, 0);\n+\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n \t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n@@ -4125,11 +4127,11 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \n-\t\t\tflush_rhunk(&lr_state);\n+\t\t\tflush_range_hunk(&lr_state);\n \t\t\tif (lr_state.ret)\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.rhunk);\n+\t\t\tstrbuf_release(&lr_state.hunk.lines);\n \t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n-- \ngitgitgadget\n\n"},{"id":"546550","messageId":"5602b7976a0fcb6eeb582d4cd88ca379f48dd98d.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 2/7] diff: simplify the line-range filter by classifying removals immediately","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:56Z","receivedAt":"2026-06-27T17:29:08Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe filter buffered '-' lines in a pending_rm strbuf, deferring their\nclassification until a '+' or ' ' line revealed the post-image\nposition.  That buffering is unnecessary: a removal occupies no\npost-image line, so it does not advance lno_in_postimage, and xdiff\nemits removals before additions within a change.  A '-' therefore\narrives while lno_in_postimage already holds the index the following\n'+'/' ' will occupy, and can be classified against the ranges as it\narrives.\n\nThe buffering also hid a bug: flush_range_hunk() drained pending_rm into\nthe range hunk whenever the hunk was active, even after lno_in_postimage\nhad advanced past the tracked range, so a deletion just after the\ntracked function leaked into the patch.  Classifying each line as it\narrives removes the pending_rm buffer, the discard_pending_rm() helper,\nthree struct fields, and makes that bug impossible by construction.\n\nWith every line classified on arrival, the buffered lines are the\nhunk's single source of truth, so the old/new counts need not be kept\nalongside them: flush_range_hunk() derives the counts (and whether the\nhunk holds any change) from the buffer when it builds the header.  Drop\nthe per-line counting and the old_count, new_count, and has_changes\nfields; there is no longer a second tally that could fall out of sync\nwith the buffer.\n\nAdd begin_range_hunk() to open the accumulator at the first in-range\nline, seeding both begins from the live image cursors, as the\ncounterpart to flush_range_hunk().  With the counting gone too,\nline_range_line_fn() now only appends an in-range line.\n\nDocument the coordinate model: a block comment on struct\nline_range_filter states it (the pre/post-image cursors, the 0-based\nidx_in_postimage, removals classified by the following line) with a\nworked example.\n\nAdd tests for the leaked trailing deletion this fixes, the symmetric\nleading-deletion case, and the filter's range boundaries (a change at\nthe first and last line of a range, and a pure in-range deletion).\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c              | 215 ++++++++++++++++++++++++--------------------\n t/t4211-line-log.sh | 125 ++++++++++++++++++++++++++\n 2 files changed, 243 insertions(+), 97 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 1e043c959f..ee765d7ac2 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -610,18 +610,58 @@ struct emit_callback {\n };\n \n /*\n- * State for the line-range callback wrappers that sit between\n- * xdi_diff_outf() and fn_out_consume().  xdiff produces a normal,\n- * unfiltered diff; the wrappers intercept each hunk header and line,\n- * track post-image position, and forward only lines that fall within\n- * the requested ranges.  Contiguous in-range lines are collected into\n- * range hunks and flushed with a synthetic @@ header so that\n- * fn_out_consume() sees well-formed unified-diff fragments.\n+ * Line-range filter: scopes \"git log -L\" output to the tracked ranges.\n  *\n- * Removal lines ('-') cannot be classified by post-image position, so\n- * they are buffered in pending_rm until the next '+' or ' ' line\n- * reveals whether they precede an in-range line (flush into range hunk) or\n- * an out-of-range line (discard).\n+ * It sits between xdi_diff_outf() and an output callback (fn_out_consume,\n+ * diffstat_consume, checkdiff_consume).  xdiff produces a normal diff; the\n+ * filter forwards only the lines inside the requested ranges, collecting\n+ * contiguous in-range lines into a \"range hunk\" emitted with a synthetic\n+ * @@ header so the callback sees well-formed unified-diff fragments.\n+ *\n+ * A diff describes the change from a pre-image to a post-image.  Each\n+ * line is context (' ', in both), a removal ('-', pre-image only), or\n+ * an addition ('+', post-image only).  -L tracks ranges in the\n+ * post-image, so a line is in range by its post-image position.\n+ *\n+ * Two 1-based cursors track the next line in each image, named as in\n+ * struct emit_callback and seeded from the xdiff hunk header:\n+ *\n+ *\tlno_in_postimage  advances on '+' and ' '   (lines in the post-image)\n+ *\tlno_in_preimage   advances on '-' and ' '   (lines in the pre-image)\n+ *\n+ * Ranges are 0-based half-open [start, end), so a line is tested at the\n+ * 0-based index idx_in_postimage = lno_in_postimage - 1.\n+ *\n+ * A '-' is not present in the post-image, so it has no post-image line\n+ * number of its own.  Since it does not advance lno_in_postimage, it is\n+ * classified at the idx_in_postimage that the following '+'/' ' will\n+ * occupy.  xdiff emits a change's removals before its additions, so that\n+ * index is already known when the '-' arrives.\n+ *\n+ * The synthetic \"@@ -<old> +<new> @@\" header has two sides, old (the\n+ * pre-image) and new (the post-image), matching the xdiff_emit_hunk_fn\n+ * callback; the hunk.old_begin / hunk.new_begin fields below hold those\n+ * begins, and flush_range_hunk() derives the counts from the buffered\n+ * lines.\n+ *\n+ * Example, tracking post-image line 2 (range [1, 2)) of:\n+ *\n+ *\tpre-image  post-image\n+ *\t1 a        1 a\n+ *\t2 b        2 X      (b -> X)\n+ *\t3 c        3 c\n+ *\n+ *\tclassify each line by idx_in_postimage.  The pre and post columns\n+ *\tare each cursor's value while that line is classified, i.e. before\n+ *\tthe line advances them (pre = lno_in_preimage,\n+ *\tpost = lno_in_postimage, idx = idx_in_postimage):\n+ *\t' a'  pre 1  post 1  idx 0  ->  before start, skip\n+ *\t'-b'  pre 2  post 2  idx 1  ->  keep (removal)\n+ *\t'+X'  pre 3  post 2  idx 1  ->  keep (addition)\n+ *\t' c'  pre 3  post 3  idx 2  ->  past end, flush\n+ *\n+ * -b and +X share idx = 1 because -b did not advance lno_in_postimage;\n+ * both land in the range hunk, flushed when ' c' crosses the range end.\n  */\n struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n@@ -640,20 +680,18 @@ struct line_range_filter {\n \tchar func[80];\n \tlong funclen;\n \n-\t/* The range hunk being accumulated for the current range. */\n+\t/*\n+\t * The range hunk being accumulated.  At most one is live at a time:\n+\t * it is flushed and reset as the cursor leaves each range (and once\n+\t * more at end of diff), then reused for the next range.\n+\t */\n \tstruct {\n \t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n-\t\tlong old_begin, old_count;\n-\t\tlong new_begin, new_count;\n+\t\tlong old_begin;\n+\t\tlong new_begin;\n \t\tint active;\n-\t\tint has_changes;\t/* any '+' or '-' line? */\n \t} hunk;\n \n-\t/* Removal lines not yet known to be in-range */\n-\tstruct strbuf pending_rm;\n-\tint pending_rm_count;\n-\tlong pending_rm_pre_begin;\t/* pre-image line of first pending */\n-\n \tint ret;\t\t\t/* latched error from orig_line_fn */\n };\n \n@@ -2542,26 +2580,48 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_filter *filter)\n+/*\n+ * Begin a range hunk at the first in-range line.  Its position fixes the\n+ * hunk's begins, taken from the two image cursors before they advance:\n+ * new_begin from the post-image, old_begin from the pre-image.  The line\n+ * counts are not tracked here; flush_range_hunk() derives them from the\n+ * buffered lines.\n+ */\n+static void begin_range_hunk(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&filter->pending_rm);\n-\tfilter->pending_rm_count = 0;\n+\tfilter->hunk.active = 1;\n+\tfilter->hunk.new_begin = filter->lno_in_postimage;\n+\tfilter->hunk.old_begin = filter->lno_in_preimage;\n+\tstrbuf_reset(&filter->hunk.lines);\n }\n \n static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n \tconst char *p, *end;\n+\tlong old_count = 0, new_count = 0;\n+\tint has_changes = 0;\n \n \tif (!filter->hunk.active || filter->ret)\n \t\treturn;\n \n-\t/* Drain any pending removal lines into the range hunk */\n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n-\t\tfilter->hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n+\t/*\n+\t * Derive the hunk's geometry from the buffered lines: a ' '\n+\t * counts on both sides, a '-' on the old side, a '+' on the new.\n+\t * A '-' or '+' marks a real change; the \"\\ No newline at end of\n+\t * file\" marker (line[0] == '\\\\') counts on neither side.\n+\t */\n+\tp = filter->hunk.lines.buf;\n+\tend = p + filter->hunk.lines.len;\n+\twhile (p < end) {\n+\t\tconst char *eol = memchr(p, '\\n', end - p);\n+\t\tif (*p == ' ' || *p == '-')\n+\t\t\told_count++;\n+\t\tif (*p == ' ' || *p == '+')\n+\t\t\tnew_count++;\n+\t\tif (*p == '-' || *p == '+')\n+\t\t\thas_changes = 1;\n+\t\tp = eol ? eol + 1 : end;\n \t}\n \n \t/*\n@@ -2570,15 +2630,15 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t * ctxlen causes xdiff to emit context covering a range that\n \t * has no changes in this commit.\n \t */\n-\tif (!filter->hunk.has_changes) {\n+\tif (!has_changes) {\n \t\tfilter->hunk.active = 0;\n \t\tstrbuf_reset(&filter->hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n-\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n+\t\t    filter->hunk.old_begin, old_count,\n+\t\t    filter->hunk.new_begin, new_count);\n \tif (filter->funclen > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n \t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n@@ -2618,11 +2678,6 @@ static void line_range_hunk_fn(void *data,\n \t * When count > 0, begin is 1-based.  When count == 0, begin is\n \t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n \t * that type will arrive, so the value is unused.\n-\t *\n-\t * Any pending removal lines from the previous xdiff hunk are\n-\t * intentionally left in pending_rm: the line callback will\n-\t * flush or discard them when the next content line reveals\n-\t * whether the removals precede in-range content.\n \t */\n \tfilter->lno_in_postimage = new_begin;\n \tfilter->lno_in_preimage = old_begin;\n@@ -2638,88 +2693,56 @@ static void line_range_hunk_fn(void *data,\n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n \tstruct line_range_filter *filter = priv;\n-\tconst struct range *cur;\n-\tlong idx_in_postimage, cur_pre;\n+\tlong idx_in_postimage;\n+\tint in_range;\n \n \tif (filter->ret)\n \t\treturn filter->ret;\n \n-\tif (line[0] == '-') {\n-\t\tif (!filter->pending_rm_count)\n-\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n-\t\tfilter->lno_in_preimage++;\n-\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\tfilter->pending_rm_count++;\n-\t\treturn filter->ret;\n-\t}\n-\n \tif (line[0] == '\\\\') {\n-\t\tif (filter->pending_rm_count)\n-\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\telse if (filter->hunk.active)\n+\t\tif (filter->hunk.active)\n \t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n-\t\t/* otherwise outside tracked range; drop silently */\n \t\treturn filter->ret;\n \t}\n \n-\tif (line[0] != '+' && line[0] != ' ')\n+\tif (line[0] != '+' && line[0] != ' ' && line[0] != '-')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n+\t/*\n+\t * idx_in_postimage is this line's 0-based post-image index (see the model on\n+\t * struct line_range_filter).  The cursors are advanced only after\n+\t * the line is classified, so a '-' is tested at the same idx_in_postimage as\n+\t * the '+'/' ' that follows it.\n+\t */\n \tidx_in_postimage = filter->lno_in_postimage - 1;\n-\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n-\tfilter->lno_in_postimage++;\n-\tif (line[0] == ' ')\n-\t\tfilter->lno_in_preimage++;\n \n-\t/* Advance past ranges we've passed */\n+\t/* Retire ranges we have passed, flushing the one we leave. */\n \twhile (filter->cur_range < filter->ranges->nr &&\n \t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n \t\tif (filter->hunk.active)\n \t\t\tflush_range_hunk(filter);\n-\t\tdiscard_pending_rm(filter);\n \t\tfilter->cur_range++;\n \t}\n \n-\t/* Past all ranges */\n-\tif (filter->cur_range >= filter->ranges->nr) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n-\t}\n+\tin_range = filter->cur_range < filter->ranges->nr &&\n+\t\t   idx_in_postimage >= filter->ranges->ranges[filter->cur_range].start &&\n+\t\t   idx_in_postimage < filter->ranges->ranges[filter->cur_range].end;\n \n-\tcur = &filter->ranges->ranges[filter->cur_range];\n+\tif (in_range) {\n+\t\tif (!filter->hunk.active)\n+\t\t\tbegin_range_hunk(filter);\n \n-\t/* Before current range */\n-\tif (idx_in_postimage < cur->start) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n+\t\tstrbuf_add(&filter->hunk.lines, line, len);\n \t}\n \n-\t/* In range so start a new range hunk if needed */\n-\tif (!filter->hunk.active) {\n-\t\tfilter->hunk.active = 1;\n-\t\tfilter->hunk.has_changes = 0;\n-\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n-\t\tfilter->hunk.old_begin = filter->pending_rm_count\n-\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n-\t\tfilter->hunk.old_count = 0;\n-\t\tfilter->hunk.new_count = 0;\n-\t\tstrbuf_reset(&filter->hunk.lines);\n-\t}\n-\n-\t/* Flush pending removals into range hunk */\n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n-\t\tfilter->hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n-\t}\n-\n-\tstrbuf_add(&filter->hunk.lines, line, len);\n-\tfilter->hunk.new_count++;\n-\tif (line[0] == '+')\n-\t\tfilter->hunk.has_changes = 1;\n-\telse\n-\t\tfilter->hunk.old_count++;\n+\t/*\n+\t * Advance each image's cursor: a line present in that image (see\n+\t * the model) consumes one of its line numbers.\n+\t */\n+\tif (line[0] != '-')\n+\t\tfilter->lno_in_postimage++;\n+\tif (line[0] != '+')\n+\t\tfilter->lno_in_preimage++;\n \n \treturn filter->ret;\n }\n@@ -4097,7 +4120,6 @@ static void builtin_diff(const char *name_a,\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n \t\t\tlr_state.ranges = line_ranges;\n \t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n-\t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n \t\t\t * Inflate ctxlen so that all changes within\n@@ -4132,7 +4154,6 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \t\t\tstrbuf_release(&lr_state.hunk.lines);\n-\t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex ca4eb7bbc7..e9691066de 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -738,6 +738,131 @@ test_expect_success '-L with -G filters to diff-text matches' '\n \tgrep \"F2 + 2\" actual\n '\n \n+test_expect_success 'setup for trailing deletion test' '\n+\tgit checkout --orphan trailing-del &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 1;\n+\t}\n+\t// trailing comment\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add file with trailing comment\" &&\n+\t# Modify tracked() AND delete the trailing comment in\n+\t# one commit, so the commit touches the tracked range\n+\t# and is not filtered out by the revision walker.\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 2;\n+\t}\n+\tEOF\n+\tgit commit -a -m \"modify tracked and delete trailing comment\"\n+'\n+\n+test_expect_success '-L does not include deletions past end of tracked range' '\n+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n+\t# The trailing comment deletion is outside the tracked\n+\t# range and should not appear in the patch output.\n+\ttest_grep \"return 2\" actual &&\n+\ttest_grep ! \"trailing comment\" actual\n+'\n+\n+test_expect_success '-L includes leading deletions resolved by in-range line' '\n+\tgit checkout --orphan leading-del &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\t// leading comment\n+\tvoid tracked()\n+\t{\n+\t    return 1;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add file with leading comment\" &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 2;\n+\t}\n+\tEOF\n+\tgit commit -a -m \"modify tracked and delete leading comment\" &&\n+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n+\t# The leading comment deletion is resolved by the next\n+\t# non-removal line (void tracked), which is in range: a\n+\t# removal is classified by the position of the following\n+\t# line, so it joins the range that line falls in.\n+\ttest_grep \"return 2\" actual &&\n+\ttest_grep \"leading comment\" actual\n+'\n+\n+test_expect_success 'setup for line-range filter edge cases' '\n+\tgit checkout --orphan filter-edge &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid before()\n+\t{\n+\t    return 0;\n+\t}\n+\n+\tvoid tracked()\n+\t{\n+\t    int a = 1;\n+\t    int b = 2;\n+\t    int c = 3;\n+\t    return a + b + c;\n+\t}\n+\n+\tvoid after()\n+\t{\n+\t    return 9;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"initial\"\n+'\n+\n+test_expect_success '-L change at exact first line of range' '\n+\tgit checkout filter-edge &&\n+\t# Change the function signature (first line of range)\n+\tsed \"s/void tracked/int tracked/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"change first line\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"change first line\" actual &&\n+\ttest_grep \"+int tracked\" actual &&\n+\ttest_grep \"\\\\-void tracked\" actual\n+'\n+\n+test_expect_success '-L change at exact last line of range' '\n+\tgit checkout filter-edge &&\n+\tgit reset --hard HEAD~1 &&\n+\t# Change the closing brace line (last line of range)\n+\tsed \"s/^}$/} \\/\\/ end tracked/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"change last line\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"change last line\" actual &&\n+\ttest_grep \"end tracked\" actual\n+'\n+\n+test_expect_success '-L pure deletion in range (no additions)' '\n+\tgit checkout filter-edge &&\n+\tgit reset --hard HEAD~1 &&\n+\t# Delete a line inside tracked() without adding anything\n+\tsed \"/int c/d\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"pure deletion\" &&\n+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n+\ttest_grep \"pure deletion\" actual &&\n+\ttest_grep \"\\\\-.*int c\" actual\n+'\n+\n test_expect_success '-L with --diff-filter=M excludes root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --diff-filter=M --format=%s --no-patch >actual &&\n-- \ngitgitgadget\n\n"},{"id":"546551","messageId":"d211c82e40446d1d0b33f117d817a03ad716eea0.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 3/7] diff: emit -L hunk headers via xdiff's formatter","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:57Z","receivedAt":"2026-06-27T17:29:10Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nThe line-range filter builds its own \"@@ -<old> +<new> @@\" header for\neach range hunk.  For a side with no lines (count 0, such as the old\nside of a pure insertion), the begin should be the number of the line\nbefore the change, per the convention git diff and xdl_emit_hunk_hdr()\nfollow.  The hand-rolled code's begin was one too high; in t4211 this\nproduced\n\n\t@@ -25,0 +18,9 @@\n\nan old begin of 25 in a 24-line file, where git diff would give 24.\n\nStop hand-rolling the header.  flush_range_hunk() now formats it through\nxdiff's own emitter: a new xdiff_emit_hunk_header() helper wraps\nxdl_emit_hunk_hdr(), the function that produces every other diff's hunk\nheaders.  The count-0 begin is then correct by construction, and as a\nside effect -L headers match git diff exactly, including its omission of\na count of 1 (\"@@ -22 +22 @@\" rather than \"@@ -22,1 +22,1 @@\").\n\nxdiff's hunk callback already hands line_range_hunk_fn() a count-0 begin\ndecremented, so undo that when seeding the cursors and let the formatter\nre-apply the convention once, at emit time.\n\nThe off-by-one predates this series, and the two regenerated fixtures\nreach it from different origins: no-assertion-error has carried it since\nits test was added in ab60c693a2 (line-log: fix assertion error,\n2025-08-18), while vanishes-early acquired it when 86e986f166 (line-log:\nroute -L output through the standard diff pipeline) reshaped its tracked\nline into a pure insertion.  vanishes-early also drops its count-1\ncounts.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c                                   | 27 +++++++++++-------------\n t/t4211/sha1/expect.no-assertion-error   |  2 +-\n t/t4211/sha1/expect.vanishes-early       |  6 +++---\n t/t4211/sha256/expect.no-assertion-error |  2 +-\n t/t4211/sha256/expect.vanishes-early     |  6 +++---\n xdiff-interface.c                        | 19 +++++++++++++++++\n xdiff-interface.h                        | 15 +++++++++++++\n 7 files changed, 54 insertions(+), 23 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex ee765d7ac2..9751bb6798 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2636,14 +2636,9 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\treturn;\n \t}\n \n-\tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->hunk.old_begin, old_count,\n-\t\t    filter->hunk.new_begin, new_count);\n-\tif (filter->funclen > 0) {\n-\t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n-\t}\n-\tstrbuf_addch(&hdr, '\\n');\n+\txdiff_emit_hunk_header(&hdr, filter->hunk.old_begin, old_count,\n+\t\t\t       filter->hunk.new_begin, new_count,\n+\t\t\t       filter->func, filter->funclen);\n \n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n@@ -2668,19 +2663,21 @@ static void flush_range_hunk(struct line_range_filter *filter)\n }\n \n static void line_range_hunk_fn(void *data,\n-\t\t\t       long old_begin, long old_nr UNUSED,\n-\t\t\t       long new_begin, long new_nr UNUSED,\n+\t\t\t       long old_begin, long old_nr,\n+\t\t\t       long new_begin, long new_nr,\n \t\t\t       const char *func, long funclen)\n {\n \tstruct line_range_filter *filter = data;\n \n \t/*\n-\t * When count > 0, begin is 1-based.  When count == 0, begin is\n-\t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n-\t * that type will arrive, so the value is unused.\n+\t * Seed the per-image line cursors from the hunk header's begins.  For\n+\t * a side with no lines (count 0), xdiff's callback has already moved\n+\t * its begin to the line before the change, so add one back to recover\n+\t * the true 1-based start.  xdiff_emit_hunk_header() reapplies that -1\n+\t * when the clipped hunk is emitted.\n \t */\n-\tfilter->lno_in_postimage = new_begin;\n-\tfilter->lno_in_preimage = old_begin;\n+\tfilter->lno_in_postimage = new_nr ? new_begin : new_begin + 1;\n+\tfilter->lno_in_preimage = old_nr ? old_begin : old_begin + 1;\n \n \tif (funclen > 0) {\n \t\tif (funclen > (long)sizeof(filter->func))\ndiff --git a/t/t4211/sha1/expect.no-assertion-error b/t/t4211/sha1/expect.no-assertion-error\nindex 54c568f273..95faf51a7b 100644\n--- a/t/t4211/sha1/expect.no-assertion-error\n+++ b/t/t4211/sha1/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index bf79c2f..27c829c 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha1/expect.vanishes-early b/t/t4211/sha1/expect.vanishes-early\nindex a413ad3659..e4b1a201d5 100644\n--- a/t/t4211/sha1/expect.vanishes-early\n+++ b/t/t4211/sha1/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index 0b9cae5..5de3ea4 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 100b61a6f2f720f812620a9d10afb3a960ccb73c\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index 5e709a1..0b9cae5 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..444e415\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/t/t4211/sha256/expect.no-assertion-error b/t/t4211/sha256/expect.no-assertion-error\nindex c25f2ce19c..815d27f7f1 100644\n--- a/t/t4211/sha256/expect.no-assertion-error\n+++ b/t/t4211/sha256/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index 69cb69c..a0d566e 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha256/expect.vanishes-early b/t/t4211/sha256/expect.vanishes-early\nindex bc33b963dc..263fc9eaac 100644\n--- a/t/t4211/sha256/expect.vanishes-early\n+++ b/t/t4211/sha256/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index e4fa1d8..62c1fc2 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 29f32ac3141c48b22803e5c4127b719917b67d0f8ca8c5248bebfa2a19f7da10\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index d325124..e4fa1d8 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..9f550c3\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/xdiff-interface.c b/xdiff-interface.c\nindex 5ee2b96d0a..32e04630ee 100644\n--- a/xdiff-interface.c\n+++ b/xdiff-interface.c\n@@ -91,6 +91,25 @@ static int xdiff_outf(void *priv_, mmbuffer_t *mb, int nbuf)\n \treturn 0;\n }\n \n+static int strbuf_out_line(void *priv, mmbuffer_t *mb, int nbuf)\n+{\n+\tstruct strbuf *out = priv;\n+\tint i;\n+\tfor (i = 0; i < nbuf; i++)\n+\t\tstrbuf_add(out, mb[i].ptr, mb[i].size);\n+\treturn 0;\n+}\n+\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_count,\n+\t\t\t    long new_begin, long new_count,\n+\t\t\t    const char *func, long funclen)\n+{\n+\txdemitcb_t ecb = { .priv = out, .out_line = strbuf_out_line };\n+\txdl_emit_hunk_hdr(old_begin, old_count, new_begin, new_count,\n+\t\t\t  func, funclen, &ecb);\n+}\n+\n /*\n  * Trim down common substring at the end of the buffers,\n  * but end on a complete line.\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex ce54e1c0e0..51c88296ed 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -76,4 +76,19 @@ int xdiff_compare_lines(const char *l1, long s1,\n  */\n unsigned long xdiff_hash_string(const char *s, size_t len, long flags);\n \n+struct strbuf;\n+\n+/*\n+ * Append a unified-diff hunk header to `out`, e.g.\n+ * \"@@ -<old> +<new> @@ func\\n\".  The header comes from wrapping xdiff's\n+ * own hunk-header emitter, so it matches what a normal diff would\n+ * produce for these begins and counts.  For a side with no lines\n+ * (count 0) the begin is the line before the change, and a count of 1\n+ * is omitted.\n+ */\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_count,\n+\t\t\t    long new_begin, long new_count,\n+\t\t\t    const char *func, long funclen);\n+\n #endif\n-- \ngitgitgadget\n\n"},{"id":"546552","messageId":"b82a997359b7e1acd16151439b1dabad4cfb20ea.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 4/7] diff: extract a line-range diff helper for reuse","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:58Z","receivedAt":"2026-06-27T17:29:11Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nbuiltin_diff() open-codes the line-range filter setup and teardown\naround its xdi_diff_outf() call: zero the struct, point it at the\noutput callback, inflate ctxlen to the largest range span so each range\nyields a single xdiff hunk, run the diff, flush the trailing range\nhunk, and release the buffer.  The upcoming -L stat and check formats\nneed the same sequence.\n\nExtract line_range_filter_init() for the setup and a\nline_range_filter_diff() helper that prepares the xdiff config the\nfilter needs, runs an initialized filter through xdi_diff_outf(),\nflushes the final range hunk, and releases it, returning the latched\nerror.  The helper inflates ctxlen to the largest range span so each\nrange yields a single xdiff hunk, and clears XDL_EMIT_NO_HUNK_HDR so\nthe hunk headers the filter seeds its position from are always emitted.\nFolding both into the helper keeps these invariants, which the filter's\nposition tracking relies on, in a single place for every consumer.\nbuiltin_diff() now does init + line_range_filter_diff(); the next two\npatches reuse them in builtin_diffstat() and builtin_checkdiff()\ninstead of repeating the boilerplate.\n\nNo behavior change: builtin_diff() leaves XDL_EMIT_NO_HUNK_HDR unset,\nso clearing it is a no-op until the suppressing consumers arrive.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 100 +++++++++++++++++++++++++++++++++++----------------------\n 1 file changed, 61 insertions(+), 39 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 9751bb6798..6233a96bf0 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2580,6 +2580,18 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n+static void line_range_filter_init(struct line_range_filter *filter,\n+\t\t\t\t   const struct range_set *ranges,\n+\t\t\t\t   xdiff_emit_line_fn line_fn,\n+\t\t\t\t   void *cb_data)\n+{\n+\tmemset(filter, 0, sizeof(*filter));\n+\tfilter->orig_line_fn = line_fn;\n+\tfilter->orig_cb_data = cb_data;\n+\tfilter->ranges = ranges;\n+\tstrbuf_init(&filter->hunk.lines, 0);\n+}\n+\n /*\n  * Begin a range hunk at the first in-range line.  Its position fixes the\n  * hunk's begins, taken from the two image cursors before they advance:\n@@ -2744,6 +2756,50 @@ static int line_range_line_fn(void *priv, char *line, unsigned long len)\n \treturn filter->ret;\n }\n \n+/*\n+ * Run an xdiff pass through an initialized line-range filter, flush the\n+ * final range hunk, and release the filter.  Inflates ctxlen to the largest\n+ * range span first, so that every change within a single range lands in one\n+ * xdiff hunk and the inter-change context is emitted; the filter then clips\n+ * back to range boundaries.  The optimal ctxlen depends on where changes fall\n+ * within the range, which is only known after xdiff runs, so the max span is\n+ * the upper bound that guarantees correctness in a single pass.  Every\n+ * consumer (patch, diffstat, check) relies on one xdiff hunk per range, so\n+ * this lives here rather than at each call site.  Also clears\n+ * XDL_EMIT_NO_HUNK_HDR: the filter seeds its per-image position from the hunk\n+ * headers, so a consumer that otherwise suppresses them (diffstat) still gets\n+ * them here.  Returns non-zero if xdiff or any forwarded callback failed.\n+ */\n+static int line_range_filter_diff(struct line_range_filter *filter,\n+\t\t\t\t  mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tconst struct range_set *ranges = filter->ranges;\n+\tlong max_span = 0;\n+\tunsigned int i;\n+\tint ret;\n+\n+\tfor (i = 0; i < ranges->nr; i++) {\n+\t\tlong span = ranges->ranges[i].end - ranges->ranges[i].start;\n+\t\tif (span > max_span)\n+\t\t\tmax_span = span;\n+\t}\n+\tif (max_span > xecfg->ctxlen)\n+\t\txecfg->ctxlen = max_span;\n+\n+\t/* the filter seeds its per-image position from hunk headers */\n+\txecfg->flags &= ~XDL_EMIT_NO_HUNK_HDR;\n+\n+\tret = xdi_diff_outf(mf1, mf2, line_range_hunk_fn,\n+\t\t\t    line_range_line_fn, filter, xpp, xecfg);\n+\tif (!ret) {\n+\t\tflush_range_hunk(filter);\n+\t\tret = filter->ret;\n+\t}\n+\tstrbuf_release(&filter->hunk.lines);\n+\treturn ret;\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\n@@ -4108,49 +4164,15 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_filter lr_state;\n-\t\t\tunsigned int i;\n-\t\t\tlong max_span = 0;\n+\t\t\tstruct line_range_filter lr_filter;\n \n-\t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n-\t\t\tlr_state.orig_line_fn = fn_out_consume;\n-\t\t\tlr_state.orig_cb_data = &ecbdata;\n-\t\t\tlr_state.ranges = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n-\n-\t\t\t/*\n-\t\t\t * Inflate ctxlen so that all changes within\n-\t\t\t * any single range are merged into one xdiff\n-\t\t\t * hunk and the inter-change context is emitted.\n-\t\t\t * The callback clips back to range boundaries.\n-\t\t\t *\n-\t\t\t * The optimal ctxlen depends on where changes\n-\t\t\t * fall within the range, which is only known\n-\t\t\t * after xdiff runs; the max range span is the\n-\t\t\t * upper bound that guarantees correctness in a\n-\t\t\t * single pass.\n-\t\t\t */\n-\t\t\tfor (i = 0; i < line_ranges->nr; i++) {\n-\t\t\t\tlong span = line_ranges->ranges[i].end -\n-\t\t\t\t\t    line_ranges->ranges[i].start;\n-\t\t\t\tif (span > max_span)\n-\t\t\t\t\tmax_span = span;\n-\t\t\t}\n-\t\t\tif (max_span > xecfg.ctxlen)\n-\t\t\t\txecfg.ctxlen = max_span;\n-\n-\t\t\tif (xdi_diff_outf(&mf1, &mf2,\n-\t\t\t\t\t  line_range_hunk_fn,\n-\t\t\t\t\t  line_range_line_fn,\n-\t\t\t\t\t  &lr_state, &xpp, &xecfg))\n-\t\t\t\tdie(\"unable to generate diff for %s\",\n-\t\t\t\t    one->path);\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       fn_out_consume, &ecbdata);\n \n-\t\t\tflush_range_hunk(&lr_state);\n-\t\t\tif (lr_state.ret)\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.hunk.lines);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\n-- \ngitgitgadget\n\n"},{"id":"546553","messageId":"3d0091b549114f6bf32c9fb7626da9fc2583e88b.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 5/7] line-log: support diff stat formats with -L","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:28:59Z","receivedAt":"2026-06-27T17:29:13Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nReuse the line_range_filter in builtin_diffstat() so the stat formats\ncount only the lines within the tracked range.  When a filepair carries\nline_ranges, the filter wraps diffstat_consume() as its output callback,\nforwarding only the lines inside the range for counting.\nflush_range_hunk() replays buffered content through diffstat_consume(),\nwhich ignores synthetic @@ headers since it only counts '+' and '-'\nlines.\n\nExpand the output format allowlist in setup_revisions() to accept\n--stat, --numstat, and --shortstat with -L.\n\nLeave --dirstat out of the allowlist so it is rejected like any other\nunsupported format.  Its default mode counts each file's whole-file\nbyte damage via diffcore_count_changes(), outside the line-based\npipeline that the -L filter scopes, so bare --dirstat cannot honor the\ntracked range.  The --dirstat=lines mode could: it aggregates the same\nper-file line counts as --numstat, which -L already scopes.  But\naccepting only that sub-mode while bare --dirstat keeps erroring is a\nconfusing split, so the whole format is deferred to a follow-up;\n--numstat already reports the exact per-file counts within the tracked\nrange.\n\nAlso drop \"yet\" from the generic -L rejection message (\"does not\nyet support the requested diff format\").  Some rejected formats do\nnot fit a line range at all, so \"yet\" wrongly implied they are all\njust awaiting support.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  12 ++-\n diff.c                                |  13 ++-\n revision.c                            |   6 +-\n t/t4211-line-log.sh                   | 150 ++++++++++++++++++++++----\n 4 files changed, 155 insertions(+), 26 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 72f639b5e7..a111a492b4 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -9,10 +9,14 @@\n \t_<start>_ and _<end>_ (or _<funcname>_) must exist in the starting revision.\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n-\tNon-patch diff formats `--raw`, `--name-only`, `--name-status`,\n-\tand `--summary` are supported.  Diff stat formats\n-\t(`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not\n-\tcurrently implemented.\n+\tThe following non-patch diff formats are supported: `--raw`,\n+\t`--name-only`, `--name-status`, `--summary`,\n+\t`--stat`, `--numstat`, and `--shortstat`.\n+\tThe stat formats count only lines within the tracked range.\n+\t`--dirstat` is not supported\n+\twith `-L`: it summarizes change as each directory's share of\n+\tthe total churn, not as counts for the tracked lines.  Use\n+\t`--numstat` for exact per-file counts within the range.\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\ndiff --git a/diff.c b/diff.c\nindex 6233a96bf0..026fafeb90 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \t\txecfg.ctxlen = o->context;\n \t\txecfg.interhunkctxlen = o->interhunkcontext;\n \t\txecfg.flags = XDL_EMIT_NO_HUNK_HDR;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, NULL,\n+\n+\t\tif (p->line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, p->line_ranges,\n+\t\t\t\t\t       diffstat_consume, diffstat);\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate diffstat for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL,\n \t\t\t\t  diffstat_consume, diffstat, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diffstat for %s\", one->path);\n \ndiff --git a/revision.c b/revision.c\nindex 6a8101e8b7..2c76e15778 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3193,8 +3193,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t     (revs->diffopt.output_format &\n \t      ~(DIFF_FORMAT_PATCH | DIFF_FORMAT_NO_OUTPUT |\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n-\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY))))\n-\t\tdie(_(\"-L does not yet support the requested diff format\"));\n+\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n+\t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n+\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\n \t\trevs->expand_tabs_in_log = revs->expand_tabs_in_log_default;\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex e9691066de..b9ca336dbc 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -176,24 +176,15 @@ test_expect_success '--name-status shows status and path' '\n \ttest_grep ! \"^@@\" actual\n '\n \n-test_expect_success '--stat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --stat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--numstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --numstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--shortstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --shortstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--dirstat is not yet supported with -L' '\n+test_expect_success '--dirstat is not supported with -L' '\n+\t# --dirstat is not supported with -L: its default mode measures\n+\t# whole-file change, not the tracked lines, and the\n+\t# --dirstat=lines variant is deferred too, so both forms are\n+\t# rejected like any other unsupported format.\n \ttest_must_fail git log -L1,24:b.c --dirstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err &&\n+\ttest_must_fail git log -L1,24:b.c --dirstat=lines 2>err &&\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success 'setup for checking fancy rename following' '\n@@ -887,9 +878,9 @@ test_expect_success '-L with -S suppresses non-matching commits' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success '--full-diff is not yet supported with -L' '\n+test_expect_success '--full-diff is not supported with -L' '\n \ttest_must_fail git log -L1,24:b.c --full-diff 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success '-L --oneline has no extra blank line before diff' '\n@@ -900,6 +891,127 @@ test_expect_success '-L --oneline has no extra blank line before diff' '\n \ttest_grep \"^diff --git\" line2\n '\n \n+test_expect_success 'setup for stat range-scoping tests' '\n+\tgit checkout --orphan stat-scoping &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"Add func1() and func2()\" &&\n+\n+\t# Modify both functions in a single commit so that\n+\t# whole-file stats differ from the counts for the tracked range.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"Modify both functions\"\n+'\n+\n+test_expect_success '--numstat counts only lines in tracked range' '\n+\t# \"Modify both functions\" changes one line in func1 and one in\n+\t# func2.  Whole-file numstat would show 2 added, 2 deleted.\n+\t# numstat for func2 within the tracked range should show only 1 and 1.\n+\tgit log -L:func2:file.c --numstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"^1\t1\tfile.c$\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--numstat counts only additions for root commit' '\n+\t# Root commit creates both func1 (4 lines) and func2 (4 lines).\n+\t# Whole-file numstat would show 9 lines added.  numstat for func2\n+\t# within the tracked range should show only 4.\n+\tgit log -L:func2:file.c --numstat --format=%s >actual &&\n+\ttest_grep \"Add func1() and func2()\" actual &&\n+\ttest_grep \"^4\t0\tfile.c$\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--stat counts only lines in tracked range' '\n+\tgit log -L:func2:file.c --stat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"file.c |\" actual &&\n+\ttest_grep \"1 insertion\" actual &&\n+\ttest_grep \"1 deletion\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--shortstat counts only lines in tracked range' '\n+\t# --shortstat prints only the summary line: no per-file \"file.c |\"\n+\t# line.  Counts cover only the tracked range, as for --numstat above.\n+\tgit log -L:func2:file.c --shortstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify both functions\" actual &&\n+\ttest_grep \"1 insertion\" actual &&\n+\ttest_grep \"1 deletion\" actual &&\n+\ttest_grep ! \"file.c |\" actual &&\n+\ttest_grep ! \"^diff --git\" actual\n+'\n+\n+test_expect_success '--numstat across renames and multiple commits' '\n+\t# parallel-change carries the tracked function f across an a.c -> b.c\n+\t# rename and a merge of two parallel histories.  With -M, --numstat\n+\t# follows the rename and reports added/removed counts for f within\n+\t# the tracked range (not whole-file) per commit; the file column flips from\n+\t# b.c to a.c at the rename as the walk goes back in time.  Commits\n+\t# that do not change the range of f emit no row (the merge and the\n+\t# pure file-move produce nothing), so there are fewer rows than\n+\t# commits.\n+\tgit checkout parallel-change &&\n+\tgit log -M -L \":f:b.c\" --format= --numstat >actual &&\n+\tcat >expect <<-\\EOF &&\n+\t1\t1\tb.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t0\ta.c\n+\t13\t0\ta.c\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '-L multiple ranges with --numstat excludes untracked change' '\n+\tgit checkout --orphan multi-range &&\n+\tgit reset --hard &&\n+\tcat >m.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\n+\tint func3()\n+\t{\n+\t    return F3;\n+\t}\n+\tEOF\n+\tgit add m.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add m.c\" &&\n+\t# Change all three functions but track only func1 and func2.\n+\t# Whole-file numstat would be 3 3; a 2 2 result proves the\n+\t# untracked func3 change is excluded and the two ranges just sum.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" -e \"s/F3/F3 + 3/\" m.c >tmp &&\n+\tmv tmp m.c &&\n+\tgit commit -a -m \"Modify all three functions\" &&\n+\tgit log -L:func1:m.c -L:func2:m.c --numstat --format=%s -1 >actual &&\n+\ttest_grep \"Modify all three functions\" actual &&\n+\ttest_grep \"^2\t2\tm.c$\" actual &&\n+\ttest_grep ! \"^3\t3\tm.c$\" actual\n+'\n+\n test_expect_success '--summary shows new file on root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --summary --format= >actual &&\n-- \ngitgitgadget\n\n"},{"id":"546554","messageId":"36ed52d831d3712a404db80382f094b8c34e6ec7.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 6/7] diff: support --check with -L line ranges","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:29:00Z","receivedAt":"2026-06-27T17:29:14Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nbuiltin_checkdiff() runs its own xdiff pass to detect whitespace\nerrors in newly added lines.  When -L is active, the check should\nbe scoped to the tracked line ranges rather than the whole file.\n\nReuse the line_range_filter to wrap checkdiff_consume(), the same\npattern already used for patch output and diffstat.  The filter\nforwards only in-range lines for whitespace checking.\n\ncheckdiff reports the file line number of each error, which it\nnormally learns from the hunk header via checkdiff_consume_hunk().\nThe filter synthesizes its own hunk headers, so give it an optional\nhunk callback and route checkdiff_consume_hunk() through it; this\nsets the post-image position before the in-range lines are replayed.\nWithout it the reported line numbers would count from the start of\nthe range hunk rather than the start of the file.\n\nThe trailing blank-at-eof check is a second pass that scans the whole\nfile via check_blank_at_eof(), so gate its report on the tracked\nranges as well; otherwise a blank line added at end of file is\nreported even when it lies outside the range.\n\nAdd DIFF_FORMAT_CHECKDIFF to the -L output format allowlist in\nsetup_revisions() so that -L --check is accepted, and list --check\namong the supported formats in the documentation.  Add tests covering\nthat whitespace errors are reported, scoped to the tracked range, and\nlabeled with the correct file line number, including when two errors\nin one range are separated by a gap that would otherwise split into\nmultiple xdiff hunks.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  2 +-\n diff.c                                | 65 ++++++++++++++++++-\n revision.c                            |  2 +-\n t/t4211-line-log.sh                   | 92 +++++++++++++++++++++++++++\n 4 files changed, 156 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex a111a492b4..33b4e94881 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -10,7 +10,7 @@\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n \tThe following non-patch diff formats are supported: `--raw`,\n-\t`--name-only`, `--name-status`, `--summary`,\n+\t`--name-only`, `--name-status`, `--summary`, `--check`,\n \t`--stat`, `--numstat`, and `--shortstat`.\n \tThe stat formats count only lines within the tracked range.\n \t`--dirstat` is not supported\ndiff --git a/diff.c b/diff.c\nindex 026fafeb90..519c513356 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -665,6 +665,12 @@ struct emit_callback {\n  */\n struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n+\t/*\n+\t * Optional; consumers that report file line numbers (e.g.\n+\t * checkdiff) need the synthetic hunk header to set their\n+\t * post-image position before in-range lines are replayed.\n+\t */\n+\txdiff_emit_hunk_fn orig_hunk_fn;\n \tvoid *orig_cb_data;\n \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n \tunsigned int cur_range;\t\t/* index into the range_set */\n@@ -2652,6 +2658,17 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\t\t       filter->hunk.new_begin, new_count,\n \t\t\t       filter->func, filter->funclen);\n \n+\t/*\n+\t * Inform a line-numbering consumer of the post-image position\n+\t * before replaying lines, mirroring the hunk callback xdiff\n+\t * would have issued for a non-scoped diff.\n+\t */\n+\tif (filter->orig_hunk_fn)\n+\t\tfilter->orig_hunk_fn(filter->orig_cb_data,\n+\t\t\t\tfilter->hunk.old_begin, old_count,\n+\t\t\t\tfilter->hunk.new_begin, new_count,\n+\t\t\t\tfilter->func, filter->funclen);\n+\n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n@@ -4330,11 +4347,29 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \tdiff_free_filespec_data(two);\n }\n \n+/*\n+ * Is the 0-based line index within any of the tracked ranges?\n+ * (range_set ranges are 0-based, half-open [start, end).)  This is a\n+ * one-shot query for a single line and scans; the streaming filter\n+ * (line_range_line_fn) uses a forward cursor instead.\n+ */\n+static int idx_in_ranges(const struct range_set *ranges, long idx)\n+{\n+\tunsigned int i;\n+\n+\tfor (i = 0; i < ranges->nr; i++)\n+\t\tif (idx >= ranges->ranges[i].start &&\n+\t\t    idx < ranges->ranges[i].end)\n+\t\t\treturn 1;\n+\treturn 0;\n+}\n+\n static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\t      const char *attr_path,\n \t\t\t      struct diff_filespec *one,\n \t\t\t      struct diff_filespec *two,\n-\t\t\t      struct diff_options *o)\n+\t\t\t      struct diff_options *o,\n+\t\t\t      const struct range_set *line_ranges)\n {\n \tmmfile_t mf1, mf2;\n \tstruct checkdiff_t data;\n@@ -4374,7 +4409,19 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\tmemset(&xecfg, 0, sizeof(xecfg));\n \t\txecfg.ctxlen = 1; /* at least one context line */\n \t\txpp.flags = 0;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n+\n+\t\tif (line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       checkdiff_consume, &data);\n+\t\t\tlr_filter.orig_hunk_fn = checkdiff_consume_hunk;\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate checkdiff for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n \t\t\t\t  checkdiff_consume, &data,\n \t\t\t\t  &xpp, &xecfg))\n \t\t\tdie(\"unable to generate checkdiff for %s\", one->path);\n@@ -4387,6 +4434,17 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\tcheck_blank_at_eof(&mf1, &mf2, &ecbdata);\n \t\t\tblank_at_eof = ecbdata.blank_at_eof_in_postimage;\n \n+\t\t\t/*\n+\t\t\t * check_blank_at_eof() scans the whole file; with -L,\n+\t\t\t * keep the report only when its line is in a tracked\n+\t\t\t * range.  The error's location is the first trailing\n+\t\t\t * blank line (blank_at_eof, 1-based; ranges 0-based), so\n+\t\t\t * we scope by that line.\n+\t\t\t */\n+\t\t\tif (blank_at_eof && line_ranges &&\n+\t\t\t    !idx_in_ranges(line_ranges, blank_at_eof - 1))\n+\t\t\t\tblank_at_eof = 0;\n+\n \t\t\tif (blank_at_eof) {\n \t\t\t\tstatic char *err;\n \t\t\t\tif (!err)\n@@ -5179,7 +5237,8 @@ static void run_checkdiff(struct diff_filepair *p, struct diff_options *o)\n \tdiff_fill_oid_info(p->one, o->repo->index);\n \tdiff_fill_oid_info(p->two, o->repo->index);\n \n-\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o);\n+\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o,\n+\t\t\t  p->line_ranges);\n }\n \n void repo_diff_setup(struct repository *r, struct diff_options *options)\ndiff --git a/revision.c b/revision.c\nindex 2c76e15778..7abb287451 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3195,7 +3195,7 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n \t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n \t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n-\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tDIFF_FORMAT_SHORTSTAT | DIFF_FORMAT_CHECKDIFF))))\n \t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex b9ca336dbc..68576418f4 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -1018,4 +1018,96 @@ test_expect_success '--summary shows new file on root commit' '\n \ttest_grep \"create mode 100644 file.c\" actual\n '\n \n+test_expect_success 'setup for --check test' '\n+\tgit checkout --orphan check-test &&\n+\tgit reset --hard &&\n+\tcat >check.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return;\n+\t}\n+\n+\tvoid other()\n+\t{\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add check.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add check.c\" &&\n+\t# Introduce trailing whitespace errors in both functions\n+\tsed \"s/return;/return; /\" check.c >check.c.tmp &&\n+\tmv check.c.tmp check.c &&\n+\tgit commit -a -m \"introduce trailing whitespace\"\n+'\n+\n+test_expect_success '--check scoped to tracked range with correct file line' '\n+\t# tracked() trailing whitespace is at check.c:3; report it with the\n+\t# real file line number, not a count from the start of the range\n+\t# hunk.  other() at check.c:8 is outside the range and is excluded.\n+\ttest_must_fail git log -L:tracked:check.c --check --format= >actual &&\n+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n+\ttest_grep ! \"check.c:8:\" actual\n+'\n+\n+test_expect_success '--check reports each of several tracked ranges' '\n+\t# Track both functions as separate ranges.  Each range is flushed\n+\t# as its own hunk, so the second error must report its real file\n+\t# line (check.c:8), not continue the numbering from the first\n+\t# range (check.c:3).\n+\ttest_must_fail git log -L:tracked:check.c -L:other:check.c \\\n+\t\t--check --format= >actual &&\n+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n+\ttest_grep \"check.c:8: trailing whitespace\" actual\n+'\n+\n+test_expect_success '--check line numbers stay correct across a gap in one range' '\n+\tgit checkout --orphan check-gap &&\n+\tgit reset --hard &&\n+\tcat >gap.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    int a = 1;\n+\t    int b = 2;\n+\t    int c = 3;\n+\t    int d = 4;\n+\t    int e = 5;\n+\t    int g = 7;\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add gap.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gap.c\" &&\n+\t# Two trailing-whitespace errors within one tracked range,\n+\t# separated by clean lines.  ctxlen is inflated to the range span,\n+\t# so they land in a single xdiff hunk with the gap as context;\n+\t# both must report their real file line number, with the context\n+\t# lines between them counted.\n+\tsed -e \"s/int a = 1;/int a = 1; /\" -e \"s/int g = 7;/int g = 7; /\" gap.c >tmp &&\n+\tmv tmp gap.c &&\n+\tgit commit -a -m \"ws errors with a gap\" &&\n+\ttest_must_fail git log -L:tracked:gap.c --check --format= >actual &&\n+\ttest_grep \"gap.c:3: trailing whitespace\" actual &&\n+\ttest_grep \"gap.c:8: trailing whitespace\" actual\n+'\n+\n+test_expect_success '--check does not report blank-at-eof outside the range' '\n+\tgit checkout --orphan check-eof &&\n+\tgit reset --hard &&\n+\tprintf \"void tracked()\\n{\\n    return;\\n}\\n\\nint tail = 1;\\n\" >eof.c &&\n+\tgit add eof.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add eof.c\" &&\n+\t# One commit introduces a trailing-whitespace error inside tracked()\n+\t# (line 3) and a blank line at end of file (line 7, outside the\n+\t# range).  The blank-at-eof check scans the whole file, so it must be\n+\t# scoped: report the in-range error, not the out-of-range EOF blank.\n+\tprintf \"void tracked()\\n{\\n    return; \\n}\\n\\nint tail = 1;\\n\\n\" >eof.c &&\n+\tgit commit -a -m \"ws in range, blank at eof out of range\" &&\n+\ttest_must_fail git log -L:tracked:eof.c --check --format= >actual &&\n+\ttest_grep \"eof.c:3: trailing whitespace\" actual &&\n+\ttest_grep ! \"blank line at EOF\" actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546555","messageId":"df83e6275b93f2e22c8f7ab17b542343332c58d2.1782581342.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v2.git.1782581342.gitgitgadget@gmail.com","subject":"[PATCH v2 7/7] diffcore-pickaxe: scope -G to the -L tracked range","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-27T17:29:01Z","receivedAt":"2026-06-27T17:29:15Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\ngit log -L scopes its diff output to the tracked range, but pickaxe\n(-S, -G) still runs in diffcore over the whole-file change, so -L -G\nselects a commit whenever the pattern appears in any added or removed\nline of the file, even outside the tracked range.\n\nTeach -G to honor the range.  diff_grep() already runs an xdiff pass\nand greps the +/- lines; route that pass through the line-range filter\nso only the tracked range's lines are grepped.  Expose the filter as\ndiff_emit_line_ranges(), an xdi_diff_outf() that emits only the tracked\nrange's lines, thread the filepair's line_ranges through the pickaxe\ncallback, and pass it from pickaxe_match().  Skip scoping under\ntextconv, whose output is not in the original file's line coordinates.\n\n-G needs only a hit/no-hit answer, so the line-number concerns the\nfilter handles for patch and check output do not apply here.\n\n-S is left matching the whole file: it counts needle occurrences per\nblob rather than grepping the diff, so scoping it needs a different\napproach, left to a follow-up.  has_changes() takes the range parameter\nbut ignores it for now.\n\nDocument the resulting -L pickaxe scoping: -G is scoped to the tracked\nrange, while -S still matches the whole file.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  5 +-\n diff.c                                | 15 ++++++\n diffcore-pickaxe.c                    | 37 +++++++++++---\n t/t4211-line-log.sh                   | 72 +++++++++++++++++++++++++--\n xdiff-interface.h                     | 13 +++++\n 5 files changed, 130 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 33b4e94881..d619ffa633 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -20,6 +20,9 @@\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n-as are pickaxe options (`-S`, `-G`) and `--diff-filter`.\n+as are pickaxe options (`-S`, `-G`) and `--diff-filter`.  `-G` is\n+scoped to the tracked range; `-S` is still evaluated over the whole\n+file, so an `-S` query may select a commit for a change outside the\n+range.\n +\n include::line-range-format.adoc[]\ndiff --git a/diff.c b/diff.c\nindex 519c513356..a8f346621b 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2817,6 +2817,21 @@ static int line_range_filter_diff(struct line_range_filter *filter,\n \treturn ret;\n }\n \n+/*\n+ * Expose the in-file line-range filter to callers outside diff.c (e.g.\n+ * pickaxe -G); see xdiff-interface.h for the contract.\n+ */\n+int diff_emit_line_ranges(mmfile_t *one, mmfile_t *two,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tstruct line_range_filter filter;\n+\n+\tline_range_filter_init(&filter, ranges, line_fn, cb_data);\n+\treturn line_range_filter_diff(&filter, one, two, xpp, xecfg);\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\ndiff --git a/diffcore-pickaxe.c b/diffcore-pickaxe.c\nindex a52d569911..047b2bf7ac 100644\n--- a/diffcore-pickaxe.c\n+++ b/diffcore-pickaxe.c\n@@ -16,7 +16,8 @@\n \n typedef int (*pickaxe_fn)(mmfile_t *one, mmfile_t *two,\n \t\t\t  struct diff_options *o,\n-\t\t\t  regex_t *regexp, kwset_t kws);\n+\t\t\t  regex_t *regexp, kwset_t kws,\n+\t\t\t  const struct range_set *ranges);\n \n struct diffgrep_cb {\n \tregex_t *regexp;\n@@ -42,7 +43,8 @@ static int diffgrep_consume(void *priv, char *line, unsigned long len)\n \n static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t\t     struct diff_options *o,\n-\t\t     regex_t *regexp, kwset_t kws UNUSED)\n+\t\t     regex_t *regexp, kwset_t kws UNUSED,\n+\t\t     const struct range_set *ranges)\n {\n \tstruct diffgrep_cb ecbdata;\n \txpparam_t xpp;\n@@ -50,8 +52,11 @@ static int diff_grep(mmfile_t *one, mmfile_t *two,\n \tint ret;\n \n \t/*\n-\t * We have both sides; need to run textual diff and see if\n-\t * the pattern appears on added/deleted lines.\n+\t * We have both sides; need to run textual diff and see if the\n+\t * pattern appears on added/deleted lines.  Under -L (ranges set),\n+\t * forward only the tracked range's lines so the match is scoped.\n+\t * -G needs only a hit/no-hit answer, so the line-number bookkeeping\n+\t * the filter does for -L patch and check output is irrelevant here.\n \t */\n \tmemset(&xpp, 0, sizeof(xpp));\n \tmemset(&xecfg, 0, sizeof(xecfg));\n@@ -65,8 +70,12 @@ static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t * An xdiff error might be our \"data->hit\" from above. See the\n \t * comment for xdiff_emit_line_fn in xdiff-interface.h\n \t */\n-\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n-\t\t\t    &ecbdata, &xpp, &xecfg);\n+\tif (ranges)\n+\t\tret = diff_emit_line_ranges(one, two, ranges, diffgrep_consume,\n+\t\t\t\t\t    &ecbdata, &xpp, &xecfg);\n+\telse\n+\t\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n+\t\t\t\t    &ecbdata, &xpp, &xecfg);\n \tif (ecbdata.hit)\n \t\treturn 1;\n \tif (ret)\n@@ -119,8 +128,13 @@ static unsigned int contains(mmfile_t *mf, regex_t *regexp, kwset_t kws,\n \n static int has_changes(mmfile_t *one, mmfile_t *two,\n \t\t       struct diff_options *o UNUSED,\n-\t\t       regex_t *regexp, kwset_t kws)\n+\t\t       regex_t *regexp, kwset_t kws,\n+\t\t       const struct range_set *ranges UNUSED)\n {\n+\t/*\n+\t * -S counts needle occurrences in each whole blob.  Scoping this to\n+\t * a -L range is left to a follow-up; for now -S ignores the range.\n+\t */\n \tunsigned int c1 = one ? contains(one, regexp, kws, 0) : 0;\n \tunsigned int c2 = two ? contains(two, regexp, kws, c1 + 1) : 0;\n \treturn c1 != c2;\n@@ -132,6 +146,7 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tstruct userdiff_driver *textconv_one = NULL;\n \tstruct userdiff_driver *textconv_two = NULL;\n \tmmfile_t mf1, mf2;\n+\tconst struct range_set *ranges;\n \tint ret;\n \n \t/* ignore unmerged */\n@@ -169,7 +184,13 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tmf1.size = fill_textconv(o->repo, textconv_one, p->one, &mf1.ptr);\n \tmf2.size = fill_textconv(o->repo, textconv_two, p->two, &mf2.ptr);\n \n-\tret = fn(&mf1, &mf2, o, regexp, kws);\n+\t/*\n+\t * -L scopes the search to the tracked range, but the range is in\n+\t * original-file line coordinates that do not map onto textconv\n+\t * output, so search the whole file when textconv is in play.\n+\t */\n+\tranges = (textconv_one || textconv_two) ? NULL : p->line_ranges;\n+\tret = fn(&mf1, &mf2, o, regexp, kws, ranges);\n \n \tif (textconv_one)\n \t\tfree(mf1.ptr);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex 68576418f4..bac74d4479 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -722,9 +722,9 @@ test_expect_success '-L with -S filters to string-count changes' '\n test_expect_success '-L with -G filters to diff-text matches' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c -G \"F2 [+] 2\" --format= >actual &&\n-\t# -G greps the whole-file diff text, not just the tracked range;\n-\t# combined with -L, this selects commits that both touch func2\n-\t# and have \"F2 + 2\" in their diff.\n+\t# -G greps the diff text, and under -L only the lines in the\n+\t# tracked range (unlike -S above, which searches the whole file);\n+\t# this selects commits whose change to func2 contains \"F2 + 2\".\n \ttest $(grep -c \"^diff --git\" actual) = 1 &&\n \tgrep \"F2 + 2\" actual\n '\n@@ -1110,4 +1110,70 @@ test_expect_success '--check does not report blank-at-eof outside the range' '\n \ttest_grep ! \"blank line at EOF\" actual\n '\n \n+test_expect_success '-L -G is scoped to the tracked range' '\n+\tgit checkout --orphan grep-scope &&\n+\tgit reset --hard &&\n+\tcat >gp.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return ALPHA;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return BETA;\n+\t}\n+\tEOF\n+\tgit add gp.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gp.c\" &&\n+\tsed -e \"s/ALPHA/ALPHA2/\" -e \"s/BETA/BETA2/\" gp.c >tmp &&\n+\tmv tmp gp.c &&\n+\tgit commit -a -m \"touch both functions\" &&\n+\t# The commit changes ALPHA (func1) and BETA (func2).  Tracking func2,\n+\t# -G BETA matches its in-range change; -G ALPHA must not, since ALPHA\n+\t# changes only outside the tracked range.\n+\tgit log -L:func2:gp.c -G BETA --format=%s >actual &&\n+\ttest_grep \"touch both functions\" actual &&\n+\tgit log -L:func2:gp.c -G ALPHA --format=%s >actual &&\n+\ttest_grep ! \"touch both functions\" actual\n+'\n+\n+test_expect_success '-L -G searches the whole file under textconv' '\n+\tgit checkout --orphan grep-textconv &&\n+\tgit reset --hard &&\n+\tcat >tc.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add tc.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add tc.c\" &&\n+\t# One commit changes func1 and func2; MAGIC lands only in the\n+\t# func2 change, outside func1.\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/return F2/return MAGIC/\" tc.c >tmp &&\n+\tmv tmp tc.c &&\n+\tgit commit -a -m \"change both funcs\" &&\n+\techo \"tc.c diff=tc\" >.gitattributes &&\n+\n+\t# Without a textconv driver, -G is scoped to func1, so MAGIC (only\n+\t# in the func2 change) does not select the commit.\n+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n+\ttest_must_be_empty actual &&\n+\n+\t# A textconv driver makes the range (original-file line numbers)\n+\t# meaningless against the driver output, so -G falls back to the\n+\t# whole file and MAGIC now selects the commit.\n+\tgit config diff.tc.textconv cat &&\n+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n+\ttest_grep \"change both funcs\" actual\n+'\n+\n test_done\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex 51c88296ed..71e5dffefb 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -46,6 +46,19 @@ int xdi_diff_outf(mmfile_t *mf1, mmfile_t *mf2,\n \t\t  xdiff_emit_line_fn line_fn,\n \t\t  void *consume_callback_data,\n \t\t  xpparam_t const *xpp, xdemitconf_t const *xecfg);\n+\n+struct range_set;\n+/*\n+ * Like xdi_diff_outf(), but forwards only the lines within the given\n+ * (post-image) line ranges to line_fn, as \"git log -L\" scopes its output.\n+ * Returns line_fn's latched return value (so a consumer can signal a hit\n+ * with a non-zero return), or non-zero on xdiff failure.  Defined in\n+ * diff.c (it reuses the line-range filter there).\n+ */\n+int diff_emit_line_ranges(mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg);\n int read_mmfile(mmfile_t *ptr, const char *filename);\n void read_mmblob(mmfile_t *ptr, struct object_database *odb,\n \t\t const struct object_id *oid);\n-- \ngitgitgadget\n"},{"id":"548334","messageId":"CAC2Qwm+MPs9Pyf6q-MAt9U2fQGOxrnHf=dSEhdVZOV6LYX1p7A@mail.gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"Re: [PATCH 0/7] line-log: range-scope stat, check, and -G under -L","fromName":"Michael Montalbo","fromEmail":"mmontalbo@gmail.com","sentAt":"2026-07-15T21:05:44Z","receivedAt":"2026-07-15T21:05:57Z","isPatch":true,"body":"Friendly ping.\n"},{"id":"551819","messageId":"331c6c92f3b54f7ee95ef5fe4db375b99b9be146.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 1/7] diff: rename line-range filter struct and clarify fields","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:13Z","receivedAt":"2026-09-03T05:05:24Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\ndiff's line-range filtering logic uses the line_range_callback\nstruct to represent filtering state. However, this name does not\nclearly reflect the role it plays. This is especially relevant as\nwe expand diff's line-range filtering to work with more options,\nincluding --stat and -G.\n\nAlso, line_range_callback's fields are terse, while the comment\nexplaining line_range_callback is verbose and out of place compared to\nits surroundings.\n\nRename line_range_callback to line_range_filter, and replace the verbose\ncomment with a concise one, instead preferring descriptive field and\nvariable names that are self-explanatory over comments.\n\nNo logical behavior change. Some fields are grouped under a new struct\nin the newly renamed line_range_filter. Everything else is just a\nrename.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 270 ++++++++++++++++++++++++---------------------------------\n 1 file changed, 114 insertions(+), 156 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 414532d09f..679a0e27d4 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -610,49 +610,31 @@ struct emit_callback {\n };\n \n /*\n- * State for the line-range callback wrappers that sit between\n- * xdi_diff_outf() and fn_out_consume().  xdiff produces a normal,\n- * unfiltered diff; the wrappers intercept each hunk header and line,\n- * track post-image position, and forward only lines that fall within\n- * the requested ranges.  Contiguous in-range lines are collected into\n- * range hunks and flushed with a synthetic @@ header so that\n- * fn_out_consume() sees well-formed unified-diff fragments.\n- *\n- * Removal lines ('-') cannot be classified by post-image position, so\n- * they are buffered in pending_rm until the next '+' or ' ' line\n- * reveals whether they precede an in-range line (flush into range hunk) or\n- * an out-of-range line (discard).\n+ * Filter the line ranges that are emitted by diff.\n  */\n-struct line_range_callback {\n+struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n \tvoid *orig_cb_data;\n-\tconst struct range_set *ranges;\t/* 0-based [start, end) */\n-\tunsigned int cur_range;\t\t/* index into the range_set */\n-\n-\t/* Post/pre-image line counters (1-based, set from hunk headers) */\n-\tlong lno_post;\n-\tlong lno_pre;\n+\tconst struct range_set *range_sets_to_filter_by;\n+\tunsigned int range_set_idx;\n+\n+\tstruct {\n+\t\tchar func_name[80];\n+\t\tlong func_name_len;\n+\t\tlong old_begin, old_count;\n+\t\tlong new_begin, new_count;\n+\t\tlong lno_in_preimage;\n+\t\tlong lno_in_postimage;\n+\t\tstruct strbuf lines;\n+\t\tint active;\n+\t\tint has_changes;\n+\t} accumulating_hunk;\n \n-\t/*\n-\t * Function name from most recent xdiff hunk header;\n-\t * size matches struct func_line.buf in xdiff/xemit.c.\n-\t */\n-\tchar func[80];\n-\tlong funclen;\n-\n-\t/* Range hunk being accumulated for the current range */\n-\tstruct strbuf rhunk;\n-\tlong rhunk_old_begin, rhunk_old_count;\n-\tlong rhunk_new_begin, rhunk_new_count;\n-\tint rhunk_active;\n-\tint rhunk_has_changes;\t\t/* any '+' or '-' lines? */\n-\n-\t/* Removal lines not yet known to be in-range */\n \tstruct strbuf pending_rm;\n \tint pending_rm_count;\n-\tlong pending_rm_pre_begin;\t/* pre-image line of first pending */\n+\tlong pending_rm_pre_begin;\n \n-\tint ret;\t\t\t/* latched error from orig_line_fn */\n+\tint ret;\n };\n \n static int count_lines(const char *data, int size)\n@@ -2540,69 +2522,59 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_callback *s)\n+static void discard_pending_rm(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&s->pending_rm);\n-\ts->pending_rm_count = 0;\n+\tstrbuf_reset(&filter->pending_rm);\n+\tfilter->pending_rm_count = 0;\n }\n \n-static void flush_rhunk(struct line_range_callback *s)\n+static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n-\tconst char *p, *end;\n+\tconst char *line_buf, *line_buf_end;\n \n-\tif (!s->rhunk_active || s->ret)\n+\tif (!filter->accumulating_hunk.active || filter->ret)\n \t\treturn;\n \n-\t/* Drain any pending removal lines into the range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n+\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->accumulating_hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n-\t/*\n-\t * Suppress context-only hunks: they contain no actual changes\n-\t * and would just be noise.  This can happen when the inflated\n-\t * ctxlen causes xdiff to emit context covering a range that\n-\t * has no changes in this commit.\n-\t */\n-\tif (!s->rhunk_has_changes) {\n-\t\ts->rhunk_active = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->accumulating_hunk.has_changes) {\n+\t\tfilter->accumulating_hunk.active = 0;\n+\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    s->rhunk_old_begin, s->rhunk_old_count,\n-\t\t    s->rhunk_new_begin, s->rhunk_new_count);\n-\tif (s->funclen > 0) {\n+\t\t    filter->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count,\n+\t\t    filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count);\n+\tif (filter->accumulating_hunk.func_name_len > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, s->func, s->funclen);\n+\t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n+\t     filter->accumulating_hunk.func_name_len);\n \t}\n \tstrbuf_addch(&hdr, '\\n');\n \n-\ts->ret = s->orig_line_fn(s->orig_cb_data, hdr.buf, hdr.len);\n+\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n-\t/*\n-\t * Replay buffered lines one at a time through fn_out_consume.\n-\t * The cast discards const because xdiff_emit_line_fn takes\n-\t * char *, though fn_out_consume does not modify the buffer.\n-\t */\n-\tp = s->rhunk.buf;\n-\tend = p + s->rhunk.len;\n-\twhile (!s->ret && p < end) {\n-\t\tconst char *eol = memchr(p, '\\n', end - p);\n-\t\tunsigned long line_len = eol ? (unsigned long)(eol - p + 1)\n-\t\t\t\t\t     : (unsigned long)(end - p);\n-\t\ts->ret = s->orig_line_fn(s->orig_cb_data, (char *)p, line_len);\n-\t\tp += line_len;\n+\tline_buf = filter->accumulating_hunk.lines.buf;\n+\tline_buf_end = line_buf + filter->accumulating_hunk.lines.len;\n+\twhile (!filter->ret && line_buf < line_buf_end) {\n+\t\tconst char *eol = memchr(line_buf, '\\n', line_buf_end - line_buf);\n+\t\tunsigned long line_len = eol ? (unsigned long)(eol - line_buf + 1)\n+\t\t\t\t\t     : (unsigned long)(line_buf_end - line_buf);\n+\t\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data,\n+\t\t\t\t     (char *)line_buf, line_len);\n+\t\tline_buf += line_len;\n \t}\n \n-\ts->rhunk_active = 0;\n-\tstrbuf_reset(&s->rhunk);\n+\tfilter->accumulating_hunk.active = 0;\n+\tstrbuf_reset(&filter->accumulating_hunk.lines);\n }\n \n static void line_range_hunk_fn(void *data,\n@@ -2610,116 +2582,102 @@ static void line_range_hunk_fn(void *data,\n \t\t\t       long new_begin, long new_nr UNUSED,\n \t\t\t       const char *func, long funclen)\n {\n-\tstruct line_range_callback *s = data;\n+\tstruct line_range_filter *filter = data;\n \n-\t/*\n-\t * When count > 0, begin is 1-based.  When count == 0, begin is\n-\t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n-\t * that type will arrive, so the value is unused.\n-\t *\n-\t * Any pending removal lines from the previous xdiff hunk are\n-\t * intentionally left in pending_rm: the line callback will\n-\t * flush or discard them when the next content line reveals\n-\t * whether the removals precede in-range content.\n-\t */\n-\ts->lno_post = new_begin;\n-\ts->lno_pre = old_begin;\n+\tfilter->accumulating_hunk.lno_in_postimage = new_begin;\n+\tfilter->accumulating_hunk.lno_in_preimage = old_begin;\n \n \tif (funclen > 0) {\n-\t\tif (funclen > (long)sizeof(s->func))\n-\t\t\tfunclen = sizeof(s->func);\n-\t\tmemcpy(s->func, func, funclen);\n+\t\tif (funclen > (long)sizeof(filter->accumulating_hunk.func_name))\n+\t\t\tfunclen = sizeof(filter->accumulating_hunk.func_name);\n+\t\tmemcpy(filter->accumulating_hunk.func_name, func, funclen);\n \t}\n-\ts->funclen = funclen;\n+\tfilter->accumulating_hunk.func_name_len = funclen;\n }\n \n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n-\tstruct line_range_callback *s = priv;\n+\tstruct line_range_filter *filter = priv;\n \tconst struct range *cur;\n-\tlong lno_0, cur_pre;\n+\tlong idx_in_postimage, cur_pre;\n \n-\tif (s->ret)\n-\t\treturn s->ret;\n+\tif (filter->ret)\n+\t\treturn filter->ret;\n \n \tif (line[0] == '-') {\n-\t\tif (!s->pending_rm_count)\n-\t\t\ts->pending_rm_pre_begin = s->lno_pre;\n-\t\ts->lno_pre++;\n-\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\ts->pending_rm_count++;\n-\t\treturn s->ret;\n+\t\tif (!filter->pending_rm_count)\n+\t\t\tfilter->pending_rm_pre_begin =\n+\t\t\t\tfilter->accumulating_hunk.lno_in_preimage;\n+\t\tfilter->accumulating_hunk.lno_in_preimage++;\n+\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\tfilter->pending_rm_count++;\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] == '\\\\') {\n-\t\tif (s->pending_rm_count)\n-\t\t\tstrbuf_add(&s->pending_rm, line, len);\n-\t\telse if (s->rhunk_active)\n-\t\t\tstrbuf_add(&s->rhunk, line, len);\n-\t\t/* otherwise outside tracked range; drop silently */\n-\t\treturn s->ret;\n+\t\tif (filter->pending_rm_count)\n+\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n+\t\telse if (filter->accumulating_hunk.active)\n+\t\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n+\t\treturn filter->ret;\n \t}\n \n \tif (line[0] != '+' && line[0] != ' ')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n-\tlno_0 = s->lno_post - 1;\n-\tcur_pre = s->lno_pre;\t/* save before advancing for context lines */\n-\ts->lno_post++;\n+\tidx_in_postimage = filter->accumulating_hunk.lno_in_postimage - 1;\n+\tcur_pre = filter->accumulating_hunk.lno_in_preimage;\n+\tfilter->accumulating_hunk.lno_in_postimage++;\n \tif (line[0] == ' ')\n-\t\ts->lno_pre++;\n+\t\tfilter->accumulating_hunk.lno_in_preimage++;\n \n-\t/* Advance past ranges we've passed */\n-\twhile (s->cur_range < s->ranges->nr &&\n-\t       lno_0 >= s->ranges->ranges[s->cur_range].end) {\n-\t\tif (s->rhunk_active)\n-\t\t\tflush_rhunk(s);\n-\t\tdiscard_pending_rm(s);\n-\t\ts->cur_range++;\n+\twhile (filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n+\t       idx_in_postimage >=\n+\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) {\n+\t\tif (filter->accumulating_hunk.active)\n+\t\t\tflush_range_hunk(filter);\n+\t\tdiscard_pending_rm(filter);\n+\t\tfilter->range_set_idx++;\n \t}\n \n-\t/* Past all ranges */\n-\tif (s->cur_range >= s->ranges->nr) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n-\tcur = &s->ranges->ranges[s->cur_range];\n+\tcur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx];\n \n-\t/* Before current range */\n-\tif (lno_0 < cur->start) {\n-\t\tdiscard_pending_rm(s);\n-\t\treturn s->ret;\n+\tif (idx_in_postimage < cur->start) {\n+\t\tdiscard_pending_rm(filter);\n+\t\treturn filter->ret;\n \t}\n \n-\t/* In range so start a new range hunk if needed */\n-\tif (!s->rhunk_active) {\n-\t\ts->rhunk_active = 1;\n-\t\ts->rhunk_has_changes = 0;\n-\t\ts->rhunk_new_begin = lno_0 + 1;\n-\t\ts->rhunk_old_begin = s->pending_rm_count\n-\t\t\t? s->pending_rm_pre_begin : cur_pre;\n-\t\ts->rhunk_old_count = 0;\n-\t\ts->rhunk_new_count = 0;\n-\t\tstrbuf_reset(&s->rhunk);\n+\tif (!filter->accumulating_hunk.active) {\n+\t\tfilter->accumulating_hunk.active = 1;\n+\t\tfilter->accumulating_hunk.has_changes = 0;\n+\t\tfilter->accumulating_hunk.new_begin = idx_in_postimage + 1;\n+\t\tfilter->accumulating_hunk.old_begin = filter->pending_rm_count\n+\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n+\t\tfilter->accumulating_hunk.old_count = 0;\n+\t\tfilter->accumulating_hunk.new_count = 0;\n+\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n \t}\n \n-\t/* Flush pending removals into range hunk */\n-\tif (s->pending_rm_count) {\n-\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n-\t\ts->rhunk_old_count += s->pending_rm_count;\n-\t\ts->rhunk_has_changes = 1;\n-\t\tdiscard_pending_rm(s);\n+\tif (filter->pending_rm_count) {\n+\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n+\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n+\t\tfilter->accumulating_hunk.has_changes = 1;\n+\t\tdiscard_pending_rm(filter);\n \t}\n \n-\tstrbuf_add(&s->rhunk, line, len);\n-\ts->rhunk_new_count++;\n+\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n+\tfilter->accumulating_hunk.new_count++;\n \tif (line[0] == '+')\n-\t\ts->rhunk_has_changes = 1;\n+\t\tfilter->accumulating_hunk.has_changes = 1;\n \telse\n-\t\ts->rhunk_old_count++;\n+\t\tfilter->accumulating_hunk.old_count++;\n \n-\treturn s->ret;\n+\treturn filter->ret;\n }\n \n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n@@ -4066,15 +4024,15 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_callback lr_state;\n+\t\t\tstruct line_range_filter lr_state;\n \t\t\tunsigned int i;\n \t\t\tlong max_span = 0;\n \n \t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n \t\t\tlr_state.orig_line_fn = fn_out_consume;\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n-\t\t\tlr_state.ranges = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.rhunk, 0);\n+\t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n+\t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n \t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n@@ -4105,11 +4063,11 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \n-\t\t\tflush_rhunk(&lr_state);\n+\t\t\tflush_range_hunk(&lr_state);\n \t\t\tif (lr_state.ret)\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.rhunk);\n+\t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n \t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n-- \ngitgitgadget\n\n"},{"id":"551823","messageId":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.git.1781806593.gitgitgadget@gmail.com","subject":"[PATCH v3 0/7] line-log: scope stat, check, and -G to -L line ranges","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:12Z","receivedAt":"2026-09-03T05:05:24Z","isPatch":true,"body":"Teach -L to work with the --stat family of options and --check. Also, teach\n-G to search within the line ranges specified by -L. This series continues\nthe previous work[1][2] extending -L to work with more diff options, and,\nfor example, enables:\n\nReviewing the --stat history of a single function:\n\n$ git log --oneline -L:strbuf_grow:strbuf.c --stat\nc13d0f7bd4 strbuf: use st_add3() in strbuf_grow()\n strbuf.c | 6 ++----\n 1 file changed, 2 insertions(+), 4 deletions(-)\n...\nb449f4cfc9 Rework strbuf API and semantics.\n strbuf.c | 6 ++++++\n 1 file changed, 6 insertions(+)\n\n\nFinding where a pattern changed inside one function with -G, while ignoring\nmatches elsewhere in the file:\n\n$ git log --oneline -L:run_builtin:git.c -G RUN_SETUP\ne5b17bda8b git: ensure correct git directory setup with -h\n27bd38d4e5 git.c: treat RUN_SETUP_GENTLY and RUN_SETUP as mutually exclusive\n...\n47d0b4ff57 Clean up internal command handling\n\n\nThe first 4 commits are preparatory cleanup for the main feature changes and\ncould potentially be separated. The last 3 commits implement the new\nfeatures.\n\nPatches:\n\n 1. Rename line_range_callback to line_range_filter to more accurately\n    reflect its role as a general filter for other diff features. Also,\n    replace verbose comments with descriptive field and variable names.\n 2. Simplify line-range filtering logic, which fixes a bug as a result.\n 3. Make -L diff hunk header format consistent with default mode's header\n    format for all cases by exposing xdiff's header generating function.\n 4. Extract a line-range diff helper so the --stat, --check, and -G support\n    can reuse it.\n 5. Enable support for --stat family of options.\n 6. Enable support for --check.\n 7. Teach -G to search within -L line ranges.\n\nChanges since v2:\n\n * Rewrite all prose in series from scratch to remove significant verbosity\n   and extraneous detail that makes the series hard to reason about. Replace\n   block comments with descriptive field and variable names.\n * Pare down and simplify some test implementations.\n * No other logical behavior changes.\n\n[1]\nhttps://lore.kernel.org/git/pull.2094.v3.git.1780001267.gitgitgadget@gmail.com/\n[2]\nhttps://lore.kernel.org/git/pull.2099.git.1777230630020.gitgitgadget@gmail.com/\n\nCc: \"D. Ben Knoble\" ben.knoble@gmail.com\n\nMichael Montalbo (7):\n  diff: rename line-range filter struct and clarify fields\n  diff: simplify the line-range filter by classifying removals\n    immediately\n  diff: emit -L hunk headers via xdiff's formatter\n  diff: extract a line-range diff helper for reuse\n  diff: support stat formats with -L\n  diff: support --check with -L line ranges\n  diffcore-pickaxe: limit -G to the -L tracked range\n\n Documentation/line-range-options.adoc    |  15 +-\n diff.c                                   | 422 +++++++++++------------\n diffcore-pickaxe.c                       |  30 +-\n revision.c                               |   6 +-\n t/t4211-line-log.sh                      | 325 +++++++++++++++--\n t/t4211/sha1/expect.no-assertion-error   |   2 +-\n t/t4211/sha1/expect.vanishes-early       |   6 +-\n t/t4211/sha256/expect.no-assertion-error |   2 +-\n t/t4211/sha256/expect.vanishes-early     |   6 +-\n xdiff-interface.c                        |  19 +\n xdiff-interface.h                        |  23 ++\n 11 files changed, 588 insertions(+), 268 deletions(-)\n\n\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2152%2Fmmontalbo%2Fmm%2Fline-log-stat-formats-followup-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2152/mmontalbo/mm/line-log-stat-formats-followup-v3\nPull-Request: https://github.com/gitgitgadget/git/pull/2152\n\nRange-diff vs v2:\n\n 1:  6cfaccab92 ! 1:  331c6c92f3 diff: rename and group the line-range filter for clarity\n     @@ Metadata\n      Author: Michael Montalbo <mmontalbo@gmail.com>\n      \n       ## Commit message ##\n     -    diff: rename and group the line-range filter for clarity\n     +    diff: rename line-range filter struct and clarify fields\n      \n     -    The line-range filter that mm/line-log-cleanup added uses names that\n     -    obscure its model.  The cursors lno_post/lno_pre and the index lno_0\n     -    share an lno_ prefix but conflate the pre/post-image axis with the\n     -    0-based/1-based axis, the hunk state is a flat set of rhunk_* fields,\n     -    and the filter-state pointer is just s.\n     +    diff's line-range filtering logic uses the line_range_callback\n     +    struct to represent filtering state. However, this name does not\n     +    clearly reflect the role it plays. This is especially relevant as\n     +    we expand diff's line-range filtering to work with more options,\n     +    including --stat and -G.\n      \n     -    The filter bridges two layers of diff.c, and its fields already used\n     -    each layer's vocabulary, but in cryptic abbreviations.  Spell them out\n     -    to the form the rest of the file uses, so that the patches that follow\n     -    can simplify and fix it with those clearer names in place:\n     +    Also, line_range_callback's fields are terse, while the comment\n     +    explaining line_range_callback is verbose and out of place compared to\n     +    its surroundings.\n      \n     -      - lno_post/lno_pre -> lno_in_postimage/lno_in_preimage, the\n     -        line-number cursors, matching the counters in struct emit_callback\n     -      - lno_0 -> idx_in_postimage, the 0-based range index\n     -      - the hunk-header geometry stays old/new (old_begin, new_begin, and\n     -        counts) to match the xdiff_emit_hunk_fn callback and the\n     -        \"@@ -<old> +<new> @@\" header it feeds, but moves from flat rhunk_*\n     -        fields into a \"hunk\" sub-struct, so accesses read\n     -        filter->hunk.old_begin\n     -      - flush_rhunk -> flush_range_hunk\n     -      - the filter-state pointer in each callback: s -> filter\n     +    Rename line_range_callback to line_range_filter, and replace the verbose\n     +    comment with a concise one, instead preferring descriptive field and\n     +    variable names that are self-explanatory over comments.\n      \n     -    Also rename the struct line_range_callback to line_range_filter: it is\n     -    a filter over xdiff output, not merely a callback.\n     -\n     -    No behavior change.\n     +    No logical behavior change. Some fields are grouped under a new struct\n     +    in the newly renamed line_range_filter. Everything else is just a\n     +    rename.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n       ## diff.c ##\n      @@ diff.c: struct emit_callback {\n     -  * reveals whether they precede an in-range line (flush into range hunk) or\n     -  * an out-of-range line (discard).\n     + };\n     + \n     + /*\n     +- * State for the line-range callback wrappers that sit between\n     +- * xdi_diff_outf() and fn_out_consume().  xdiff produces a normal,\n     +- * unfiltered diff; the wrappers intercept each hunk header and line,\n     +- * track post-image position, and forward only lines that fall within\n     +- * the requested ranges.  Contiguous in-range lines are collected into\n     +- * range hunks and flushed with a synthetic @@ header so that\n     +- * fn_out_consume() sees well-formed unified-diff fragments.\n     +- *\n     +- * Removal lines ('-') cannot be classified by post-image position, so\n     +- * they are buffered in pending_rm until the next '+' or ' ' line\n     +- * reveals whether they precede an in-range line (flush into range hunk) or\n     +- * an out-of-range line (discard).\n     ++ * Filter the line ranges that are emitted by diff.\n        */\n      -struct line_range_callback {\n      +struct line_range_filter {\n       \txdiff_emit_line_fn orig_line_fn;\n       \tvoid *orig_cb_data;\n     - \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n     - \tunsigned int cur_range;\t\t/* index into the range_set */\n     - \n     - \t/* Post/pre-image line counters (1-based, set from hunk headers) */\n     +-\tconst struct range_set *ranges;\t/* 0-based [start, end) */\n     +-\tunsigned int cur_range;\t\t/* index into the range_set */\n     +-\n     +-\t/* Post/pre-image line counters (1-based, set from hunk headers) */\n      -\tlong lno_post;\n      -\tlong lno_pre;\n     -+\tlong lno_in_postimage;\n     -+\tlong lno_in_preimage;\n     - \n     - \t/*\n     - \t * Function name from most recent xdiff hunk header;\n     -@@ diff.c: struct line_range_callback {\n     - \tchar func[80];\n     - \tlong funclen;\n     - \n     ++\tconst struct range_set *range_sets_to_filter_by;\n     ++\tunsigned int range_set_idx;\n     ++\n     ++\tstruct {\n     ++\t\tchar func_name[80];\n     ++\t\tlong func_name_len;\n     ++\t\tlong old_begin, old_count;\n     ++\t\tlong new_begin, new_count;\n     ++\t\tlong lno_in_preimage;\n     ++\t\tlong lno_in_postimage;\n     ++\t\tstruct strbuf lines;\n     ++\t\tint active;\n     ++\t\tint has_changes;\n     ++\t} accumulating_hunk;\n     + \n     +-\t/*\n     +-\t * Function name from most recent xdiff hunk header;\n     +-\t * size matches struct func_line.buf in xdiff/xemit.c.\n     +-\t */\n     +-\tchar func[80];\n     +-\tlong funclen;\n     +-\n      -\t/* Range hunk being accumulated for the current range */\n      -\tstruct strbuf rhunk;\n      -\tlong rhunk_old_begin, rhunk_old_count;\n      -\tlong rhunk_new_begin, rhunk_new_count;\n      -\tint rhunk_active;\n      -\tint rhunk_has_changes;\t\t/* any '+' or '-' lines? */\n     -+\t/* The range hunk being accumulated for the current range. */\n     -+\tstruct {\n     -+\t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n     -+\t\tlong old_begin, old_count;\n     -+\t\tlong new_begin, new_count;\n     -+\t\tint active;\n     -+\t\tint has_changes;\t/* any '+' or '-' line? */\n     -+\t} hunk;\n     - \n     - \t/* Removal lines not yet known to be in-range */\n     +-\n     +-\t/* Removal lines not yet known to be in-range */\n       \tstruct strbuf pending_rm;\n     + \tint pending_rm_count;\n     +-\tlong pending_rm_pre_begin;\t/* pre-image line of first pending */\n     ++\tlong pending_rm_pre_begin;\n     + \n     +-\tint ret;\t\t\t/* latched error from orig_line_fn */\n     ++\tint ret;\n     + };\n     + \n     + static int count_lines(const char *data, int size)\n      @@ diff.c: static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n       \treturn 1;\n       }\n     @@ diff.c: static int quick_consume(void *priv, char *line UNUSED, unsigned long le\n      +static void flush_range_hunk(struct line_range_filter *filter)\n       {\n       \tstruct strbuf hdr = STRBUF_INIT;\n     - \tconst char *p, *end;\n     +-\tconst char *p, *end;\n     ++\tconst char *line_buf, *line_buf_end;\n       \n      -\tif (!s->rhunk_active || s->ret)\n     -+\tif (!filter->hunk.active || filter->ret)\n     ++\tif (!filter->accumulating_hunk.active || filter->ret)\n       \t\treturn;\n       \n     - \t/* Drain any pending removal lines into the range hunk */\n     +-\t/* Drain any pending removal lines into the range hunk */\n      -\tif (s->pending_rm_count) {\n      -\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n      -\t\ts->rhunk_old_count += s->pending_rm_count;\n      -\t\ts->rhunk_has_changes = 1;\n      -\t\tdiscard_pending_rm(s);\n      +\tif (filter->pending_rm_count) {\n     -+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n     -+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n     -+\t\tfilter->hunk.has_changes = 1;\n     ++\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n     ++\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n     ++\t\tfilter->accumulating_hunk.has_changes = 1;\n      +\t\tdiscard_pending_rm(filter);\n       \t}\n       \n     - \t/*\n     -@@ diff.c: static void flush_rhunk(struct line_range_callback *s)\n     - \t * ctxlen causes xdiff to emit context covering a range that\n     - \t * has no changes in this commit.\n     - \t */\n     +-\t/*\n     +-\t * Suppress context-only hunks: they contain no actual changes\n     +-\t * and would just be noise.  This can happen when the inflated\n     +-\t * ctxlen causes xdiff to emit context covering a range that\n     +-\t * has no changes in this commit.\n     +-\t */\n      -\tif (!s->rhunk_has_changes) {\n      -\t\ts->rhunk_active = 0;\n      -\t\tstrbuf_reset(&s->rhunk);\n     -+\tif (!filter->hunk.has_changes) {\n     -+\t\tfilter->hunk.active = 0;\n     -+\t\tstrbuf_reset(&filter->hunk.lines);\n     ++\tif (!filter->accumulating_hunk.has_changes) {\n     ++\t\tfilter->accumulating_hunk.active = 0;\n     ++\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n       \t\treturn;\n       \t}\n       \n     @@ diff.c: static void flush_rhunk(struct line_range_callback *s)\n      -\t\t    s->rhunk_old_begin, s->rhunk_old_count,\n      -\t\t    s->rhunk_new_begin, s->rhunk_new_count);\n      -\tif (s->funclen > 0) {\n     -+\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n     -+\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n     -+\tif (filter->funclen > 0) {\n     ++\t\t    filter->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count,\n     ++\t\t    filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count);\n     ++\tif (filter->accumulating_hunk.func_name_len > 0) {\n       \t\tstrbuf_addch(&hdr, ' ');\n      -\t\tstrbuf_add(&hdr, s->func, s->funclen);\n     -+\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n     ++\t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n     ++\t     filter->accumulating_hunk.func_name_len);\n       \t}\n       \tstrbuf_addch(&hdr, '\\n');\n       \n     @@ diff.c: static void flush_rhunk(struct line_range_callback *s)\n      +\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n       \tstrbuf_release(&hdr);\n       \n     - \t/*\n     -@@ diff.c: static void flush_rhunk(struct line_range_callback *s)\n     - \t * The cast discards const because xdiff_emit_line_fn takes\n     - \t * char *, though fn_out_consume does not modify the buffer.\n     - \t */\n     +-\t/*\n     +-\t * Replay buffered lines one at a time through fn_out_consume.\n     +-\t * The cast discards const because xdiff_emit_line_fn takes\n     +-\t * char *, though fn_out_consume does not modify the buffer.\n     +-\t */\n      -\tp = s->rhunk.buf;\n      -\tend = p + s->rhunk.len;\n      -\twhile (!s->ret && p < end) {\n     -+\tp = filter->hunk.lines.buf;\n     -+\tend = p + filter->hunk.lines.len;\n     -+\twhile (!filter->ret && p < end) {\n     - \t\tconst char *eol = memchr(p, '\\n', end - p);\n     - \t\tunsigned long line_len = eol ? (unsigned long)(eol - p + 1)\n     - \t\t\t\t\t     : (unsigned long)(end - p);\n     +-\t\tconst char *eol = memchr(p, '\\n', end - p);\n     +-\t\tunsigned long line_len = eol ? (unsigned long)(eol - p + 1)\n     +-\t\t\t\t\t     : (unsigned long)(end - p);\n      -\t\ts->ret = s->orig_line_fn(s->orig_cb_data, (char *)p, line_len);\n     -+\t\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, (char *)p, line_len);\n     - \t\tp += line_len;\n     +-\t\tp += line_len;\n     ++\tline_buf = filter->accumulating_hunk.lines.buf;\n     ++\tline_buf_end = line_buf + filter->accumulating_hunk.lines.len;\n     ++\twhile (!filter->ret && line_buf < line_buf_end) {\n     ++\t\tconst char *eol = memchr(line_buf, '\\n', line_buf_end - line_buf);\n     ++\t\tunsigned long line_len = eol ? (unsigned long)(eol - line_buf + 1)\n     ++\t\t\t\t\t     : (unsigned long)(line_buf_end - line_buf);\n     ++\t\tfilter->ret = filter->orig_line_fn(filter->orig_cb_data,\n     ++\t\t\t\t     (char *)line_buf, line_len);\n     ++\t\tline_buf += line_len;\n       \t}\n       \n      -\ts->rhunk_active = 0;\n      -\tstrbuf_reset(&s->rhunk);\n     -+\tfilter->hunk.active = 0;\n     -+\tstrbuf_reset(&filter->hunk.lines);\n     ++\tfilter->accumulating_hunk.active = 0;\n     ++\tstrbuf_reset(&filter->accumulating_hunk.lines);\n       }\n       \n       static void line_range_hunk_fn(void *data,\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      -\tstruct line_range_callback *s = data;\n      +\tstruct line_range_filter *filter = data;\n       \n     - \t/*\n     - \t * When count > 0, begin is 1-based.  When count == 0, begin is\n     -@@ diff.c: static void line_range_hunk_fn(void *data,\n     - \t * flush or discard them when the next content line reveals\n     - \t * whether the removals precede in-range content.\n     - \t */\n     +-\t/*\n     +-\t * When count > 0, begin is 1-based.  When count == 0, begin is\n     +-\t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n     +-\t * that type will arrive, so the value is unused.\n     +-\t *\n     +-\t * Any pending removal lines from the previous xdiff hunk are\n     +-\t * intentionally left in pending_rm: the line callback will\n     +-\t * flush or discard them when the next content line reveals\n     +-\t * whether the removals precede in-range content.\n     +-\t */\n      -\ts->lno_post = new_begin;\n      -\ts->lno_pre = old_begin;\n     -+\tfilter->lno_in_postimage = new_begin;\n     -+\tfilter->lno_in_preimage = old_begin;\n     ++\tfilter->accumulating_hunk.lno_in_postimage = new_begin;\n     ++\tfilter->accumulating_hunk.lno_in_preimage = old_begin;\n       \n       \tif (funclen > 0) {\n      -\t\tif (funclen > (long)sizeof(s->func))\n      -\t\t\tfunclen = sizeof(s->func);\n      -\t\tmemcpy(s->func, func, funclen);\n     -+\t\tif (funclen > (long)sizeof(filter->func))\n     -+\t\t\tfunclen = sizeof(filter->func);\n     -+\t\tmemcpy(filter->func, func, funclen);\n     ++\t\tif (funclen > (long)sizeof(filter->accumulating_hunk.func_name))\n     ++\t\t\tfunclen = sizeof(filter->accumulating_hunk.func_name);\n     ++\t\tmemcpy(filter->accumulating_hunk.func_name, func, funclen);\n       \t}\n      -\ts->funclen = funclen;\n     -+\tfilter->funclen = funclen;\n     ++\tfilter->accumulating_hunk.func_name_len = funclen;\n       }\n       \n       static int line_range_line_fn(void *priv, char *line, unsigned long len)\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      -\t\ts->pending_rm_count++;\n      -\t\treturn s->ret;\n      +\t\tif (!filter->pending_rm_count)\n     -+\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n     -+\t\tfilter->lno_in_preimage++;\n     ++\t\t\tfilter->pending_rm_pre_begin =\n     ++\t\t\t\tfilter->accumulating_hunk.lno_in_preimage;\n     ++\t\tfilter->accumulating_hunk.lno_in_preimage++;\n      +\t\tstrbuf_add(&filter->pending_rm, line, len);\n      +\t\tfilter->pending_rm_count++;\n      +\t\treturn filter->ret;\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      -\t\t\tstrbuf_add(&s->pending_rm, line, len);\n      -\t\telse if (s->rhunk_active)\n      -\t\t\tstrbuf_add(&s->rhunk, line, len);\n     +-\t\t/* otherwise outside tracked range; drop silently */\n     +-\t\treturn s->ret;\n      +\t\tif (filter->pending_rm_count)\n      +\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n     -+\t\telse if (filter->hunk.active)\n     -+\t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n     - \t\t/* otherwise outside tracked range; drop silently */\n     --\t\treturn s->ret;\n     ++\t\telse if (filter->accumulating_hunk.active)\n     ++\t\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n      +\t\treturn filter->ret;\n       \t}\n       \n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      -\tlno_0 = s->lno_post - 1;\n      -\tcur_pre = s->lno_pre;\t/* save before advancing for context lines */\n      -\ts->lno_post++;\n     -+\tidx_in_postimage = filter->lno_in_postimage - 1;\n     -+\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n     -+\tfilter->lno_in_postimage++;\n     ++\tidx_in_postimage = filter->accumulating_hunk.lno_in_postimage - 1;\n     ++\tcur_pre = filter->accumulating_hunk.lno_in_preimage;\n     ++\tfilter->accumulating_hunk.lno_in_postimage++;\n       \tif (line[0] == ' ')\n      -\t\ts->lno_pre++;\n     -+\t\tfilter->lno_in_preimage++;\n     ++\t\tfilter->accumulating_hunk.lno_in_preimage++;\n       \n     - \t/* Advance past ranges we've passed */\n     +-\t/* Advance past ranges we've passed */\n      -\twhile (s->cur_range < s->ranges->nr &&\n      -\t       lno_0 >= s->ranges->ranges[s->cur_range].end) {\n      -\t\tif (s->rhunk_active)\n      -\t\t\tflush_rhunk(s);\n      -\t\tdiscard_pending_rm(s);\n      -\t\ts->cur_range++;\n     -+\twhile (filter->cur_range < filter->ranges->nr &&\n     -+\t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n     -+\t\tif (filter->hunk.active)\n     ++\twhile (filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n     ++\t       idx_in_postimage >=\n     ++\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) {\n     ++\t\tif (filter->accumulating_hunk.active)\n      +\t\t\tflush_range_hunk(filter);\n      +\t\tdiscard_pending_rm(filter);\n     -+\t\tfilter->cur_range++;\n     ++\t\tfilter->range_set_idx++;\n       \t}\n       \n     - \t/* Past all ranges */\n     +-\t/* Past all ranges */\n      -\tif (s->cur_range >= s->ranges->nr) {\n      -\t\tdiscard_pending_rm(s);\n      -\t\treturn s->ret;\n     -+\tif (filter->cur_range >= filter->ranges->nr) {\n     ++\tif (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) {\n      +\t\tdiscard_pending_rm(filter);\n      +\t\treturn filter->ret;\n       \t}\n       \n      -\tcur = &s->ranges->ranges[s->cur_range];\n     -+\tcur = &filter->ranges->ranges[filter->cur_range];\n     ++\tcur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx];\n       \n     - \t/* Before current range */\n     +-\t/* Before current range */\n      -\tif (lno_0 < cur->start) {\n      -\t\tdiscard_pending_rm(s);\n      -\t\treturn s->ret;\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      +\t\treturn filter->ret;\n       \t}\n       \n     - \t/* In range so start a new range hunk if needed */\n     +-\t/* In range so start a new range hunk if needed */\n      -\tif (!s->rhunk_active) {\n      -\t\ts->rhunk_active = 1;\n      -\t\ts->rhunk_has_changes = 0;\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      -\t\ts->rhunk_old_count = 0;\n      -\t\ts->rhunk_new_count = 0;\n      -\t\tstrbuf_reset(&s->rhunk);\n     -+\tif (!filter->hunk.active) {\n     -+\t\tfilter->hunk.active = 1;\n     -+\t\tfilter->hunk.has_changes = 0;\n     -+\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n     -+\t\tfilter->hunk.old_begin = filter->pending_rm_count\n     ++\tif (!filter->accumulating_hunk.active) {\n     ++\t\tfilter->accumulating_hunk.active = 1;\n     ++\t\tfilter->accumulating_hunk.has_changes = 0;\n     ++\t\tfilter->accumulating_hunk.new_begin = idx_in_postimage + 1;\n     ++\t\tfilter->accumulating_hunk.old_begin = filter->pending_rm_count\n      +\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n     -+\t\tfilter->hunk.old_count = 0;\n     -+\t\tfilter->hunk.new_count = 0;\n     -+\t\tstrbuf_reset(&filter->hunk.lines);\n     ++\t\tfilter->accumulating_hunk.old_count = 0;\n     ++\t\tfilter->accumulating_hunk.new_count = 0;\n     ++\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n       \t}\n       \n     - \t/* Flush pending removals into range hunk */\n     +-\t/* Flush pending removals into range hunk */\n      -\tif (s->pending_rm_count) {\n      -\t\tstrbuf_addbuf(&s->rhunk, &s->pending_rm);\n      -\t\ts->rhunk_old_count += s->pending_rm_count;\n      -\t\ts->rhunk_has_changes = 1;\n      -\t\tdiscard_pending_rm(s);\n      +\tif (filter->pending_rm_count) {\n     -+\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n     -+\t\tfilter->hunk.old_count += filter->pending_rm_count;\n     -+\t\tfilter->hunk.has_changes = 1;\n     ++\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n     ++\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n     ++\t\tfilter->accumulating_hunk.has_changes = 1;\n      +\t\tdiscard_pending_rm(filter);\n       \t}\n       \n      -\tstrbuf_add(&s->rhunk, line, len);\n      -\ts->rhunk_new_count++;\n     -+\tstrbuf_add(&filter->hunk.lines, line, len);\n     -+\tfilter->hunk.new_count++;\n     ++\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n     ++\tfilter->accumulating_hunk.new_count++;\n       \tif (line[0] == '+')\n      -\t\ts->rhunk_has_changes = 1;\n     -+\t\tfilter->hunk.has_changes = 1;\n     ++\t\tfilter->accumulating_hunk.has_changes = 1;\n       \telse\n      -\t\ts->rhunk_old_count++;\n     -+\t\tfilter->hunk.old_count++;\n     ++\t\tfilter->accumulating_hunk.old_count++;\n       \n      -\treturn s->ret;\n      +\treturn filter->ret;\n     @@ diff.c: static void builtin_diff(const char *name_a,\n       \t\t\tunsigned int i;\n       \t\t\tlong max_span = 0;\n       \n     -@@ diff.c: static void builtin_diff(const char *name_a,\n     + \t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n       \t\t\tlr_state.orig_line_fn = fn_out_consume;\n       \t\t\tlr_state.orig_cb_data = &ecbdata;\n     - \t\t\tlr_state.ranges = line_ranges;\n     +-\t\t\tlr_state.ranges = line_ranges;\n      -\t\t\tstrbuf_init(&lr_state.rhunk, 0);\n     -+\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n     ++\t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n     ++\t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n       \t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n       \n       \t\t\t/*\n     @@ diff.c: static void builtin_diff(const char *name_a,\n       \t\t\t\tdie(\"unable to generate diff for %s\",\n       \t\t\t\t    one->path);\n      -\t\t\tstrbuf_release(&lr_state.rhunk);\n     -+\t\t\tstrbuf_release(&lr_state.hunk.lines);\n     ++\t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n       \t\t\tstrbuf_release(&lr_state.pending_rm);\n       \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n       \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n 2:  5602b7976a ! 2:  020e07c0ea diff: simplify the line-range filter by classifying removals immediately\n     @@ Metadata\n       ## Commit message ##\n          diff: simplify the line-range filter by classifying removals immediately\n      \n     -    The filter buffered '-' lines in a pending_rm strbuf, deferring their\n     -    classification until a '+' or ' ' line revealed the post-image\n     -    position.  That buffering is unnecessary: a removal occupies no\n     -    post-image line, so it does not advance lno_in_postimage, and xdiff\n     -    emits removals before additions within a change.  A '-' therefore\n     -    arrives while lno_in_postimage already holds the index the following\n     -    '+'/' ' will occupy, and can be classified against the ranges as it\n     -    arrives.\n     +    Currently, the diff line-range filter buffers preimage removal lines\n     +    until a postimage line arrives. That line's number confirms whether the\n     +    preimage line falls in a relevant range. However, storing preimage\n     +    lines in a separate buffer is unnecessary. Worse, the logic has a bug:\n     +    a preimage line outside the target range is included when it\n     +    immediately follows an in-range postimage line.\n      \n     -    The buffering also hid a bug: flush_range_hunk() drained pending_rm into\n     -    the range hunk whenever the hunk was active, even after lno_in_postimage\n     -    had advanced past the tracked range, so a deletion just after the\n     -    tracked function leaked into the patch.  Classifying each line as it\n     -    arrives removes the pending_rm buffer, the discard_pending_rm() helper,\n     -    three struct fields, and makes that bug impossible by construction.\n     +    Preimage lines will always precede their postimage counterpart both in\n     +    content line number and emission order from xdiff's line callback\n     +    function. So preimage lines can share the postimage buffer. The filter\n     +    flushes them based on whether the postimage lines fall within the\n     +    target range.\n      \n     -    With every line classified on arrival, the buffered lines are the\n     -    hunk's single source of truth, so the old/new counts need not be kept\n     -    alongside them: flush_range_hunk() derives the counts (and whether the\n     -    hunk holds any change) from the buffer when it builds the header.  Drop\n     -    the per-line counting and the old_count, new_count, and has_changes\n     -    fields; there is no longer a second tally that could fall out of sync\n     -    with the buffer.\n     +    Remove logic related to storing preimage lines in a separate \"removal\"\n     +    buffer and prepending them to the accumulating_hunk's line buffer.\n     +    Instead, store those lines in the accumulating_hunk's line_buffer\n     +    immediately and flush everything as appropriate based on postimage\n     +    line numbers that arrive. This resolves the bug by construction.\n      \n     -    Add begin_range_hunk() to open the accumulator at the first in-range\n     -    line, seeding both begins from the live image cursors, as the\n     -    counterpart to flush_range_hunk().  With the counting gone too,\n     -    line_range_line_fn() now only appends an in-range line.\n     +    Also, calculate the old and new line counts for the diff hunk header\n     +    when flushing rather than storing counters in line_range_filter to\n     +    simplify state management further.\n      \n     -    Document the coordinate model: a block comment on struct\n     -    line_range_filter states it (the pre/post-image cursors, the 0-based\n     -    idx_in_postimage, removals classified by the following line) with a\n     -    worked example.\n     -\n     -    Add tests for the leaked trailing deletion this fixes, the symmetric\n     -    leading-deletion case, and the filter's range boundaries (a change at\n     -    the first and last line of a range, and a pure in-range deletion).\n     +    Add a test to t/t4211-line-log.sh that verifies the preimage line\n     +    emission bug is fixed.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n       ## diff.c ##\n     -@@ diff.c: struct emit_callback {\n     - };\n     - \n     - /*\n     -- * State for the line-range callback wrappers that sit between\n     -- * xdi_diff_outf() and fn_out_consume().  xdiff produces a normal,\n     -- * unfiltered diff; the wrappers intercept each hunk header and line,\n     -- * track post-image position, and forward only lines that fall within\n     -- * the requested ranges.  Contiguous in-range lines are collected into\n     -- * range hunks and flushed with a synthetic @@ header so that\n     -- * fn_out_consume() sees well-formed unified-diff fragments.\n     -+ * Line-range filter: scopes \"git log -L\" output to the tracked ranges.\n     -  *\n     -- * Removal lines ('-') cannot be classified by post-image position, so\n     -- * they are buffered in pending_rm until the next '+' or ' ' line\n     -- * reveals whether they precede an in-range line (flush into range hunk) or\n     -- * an out-of-range line (discard).\n     -+ * It sits between xdi_diff_outf() and an output callback (fn_out_consume,\n     -+ * diffstat_consume, checkdiff_consume).  xdiff produces a normal diff; the\n     -+ * filter forwards only the lines inside the requested ranges, collecting\n     -+ * contiguous in-range lines into a \"range hunk\" emitted with a synthetic\n     -+ * @@ header so the callback sees well-formed unified-diff fragments.\n     -+ *\n     -+ * A diff describes the change from a pre-image to a post-image.  Each\n     -+ * line is context (' ', in both), a removal ('-', pre-image only), or\n     -+ * an addition ('+', post-image only).  -L tracks ranges in the\n     -+ * post-image, so a line is in range by its post-image position.\n     -+ *\n     -+ * Two 1-based cursors track the next line in each image, named as in\n     -+ * struct emit_callback and seeded from the xdiff hunk header:\n     -+ *\n     -+ *\tlno_in_postimage  advances on '+' and ' '   (lines in the post-image)\n     -+ *\tlno_in_preimage   advances on '-' and ' '   (lines in the pre-image)\n     -+ *\n     -+ * Ranges are 0-based half-open [start, end), so a line is tested at the\n     -+ * 0-based index idx_in_postimage = lno_in_postimage - 1.\n     -+ *\n     -+ * A '-' is not present in the post-image, so it has no post-image line\n     -+ * number of its own.  Since it does not advance lno_in_postimage, it is\n     -+ * classified at the idx_in_postimage that the following '+'/' ' will\n     -+ * occupy.  xdiff emits a change's removals before its additions, so that\n     -+ * index is already known when the '-' arrives.\n     -+ *\n     -+ * The synthetic \"@@ -<old> +<new> @@\" header has two sides, old (the\n     -+ * pre-image) and new (the post-image), matching the xdiff_emit_hunk_fn\n     -+ * callback; the hunk.old_begin / hunk.new_begin fields below hold those\n     -+ * begins, and flush_range_hunk() derives the counts from the buffered\n     -+ * lines.\n     -+ *\n     -+ * Example, tracking post-image line 2 (range [1, 2)) of:\n     -+ *\n     -+ *\tpre-image  post-image\n     -+ *\t1 a        1 a\n     -+ *\t2 b        2 X      (b -> X)\n     -+ *\t3 c        3 c\n     -+ *\n     -+ *\tclassify each line by idx_in_postimage.  The pre and post columns\n     -+ *\tare each cursor's value while that line is classified, i.e. before\n     -+ *\tthe line advances them (pre = lno_in_preimage,\n     -+ *\tpost = lno_in_postimage, idx = idx_in_postimage):\n     -+ *\t' a'  pre 1  post 1  idx 0  ->  before start, skip\n     -+ *\t'-b'  pre 2  post 2  idx 1  ->  keep (removal)\n     -+ *\t'+X'  pre 3  post 2  idx 1  ->  keep (addition)\n     -+ *\t' c'  pre 3  post 3  idx 2  ->  past end, flush\n     -+ *\n     -+ * -b and +X share idx = 1 because -b did not advance lno_in_postimage;\n     -+ * both land in the range hunk, flushed when ' c' crosses the range end.\n     -  */\n     - struct line_range_filter {\n     - \txdiff_emit_line_fn orig_line_fn;\n      @@ diff.c: struct line_range_filter {\n     - \tchar func[80];\n     - \tlong funclen;\n     - \n     --\t/* The range hunk being accumulated for the current range. */\n     -+\t/*\n     -+\t * The range hunk being accumulated.  At most one is live at a time:\n     -+\t * it is flushed and reset as the cursor leaves each range (and once\n     -+\t * more at end of diff), then reused for the next range.\n     -+\t */\n       \tstruct {\n     - \t\tstruct strbuf lines;\t/* buffered in-range diff lines */\n     + \t\tchar func_name[80];\n     + \t\tlong func_name_len;\n      -\t\tlong old_begin, old_count;\n      -\t\tlong new_begin, new_count;\n      +\t\tlong old_begin;\n      +\t\tlong new_begin;\n     + \t\tlong lno_in_preimage;\n     + \t\tlong lno_in_postimage;\n     + \t\tstruct strbuf lines;\n       \t\tint active;\n     --\t\tint has_changes;\t/* any '+' or '-' line? */\n     - \t} hunk;\n     +-\t\tint has_changes;\n     + \t} accumulating_hunk;\n       \n     --\t/* Removal lines not yet known to be in-range */\n      -\tstruct strbuf pending_rm;\n      -\tint pending_rm_count;\n     --\tlong pending_rm_pre_begin;\t/* pre-image line of first pending */\n     +-\tlong pending_rm_pre_begin;\n      -\n     - \tint ret;\t\t\t/* latched error from orig_line_fn */\n     + \tint ret;\n       };\n       \n      @@ diff.c: static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n     @@ diff.c: static int quick_consume(void *priv, char *line UNUSED, unsigned long le\n       }\n       \n      -static void discard_pending_rm(struct line_range_filter *filter)\n     -+/*\n     -+ * Begin a range hunk at the first in-range line.  Its position fixes the\n     -+ * hunk's begins, taken from the two image cursors before they advance:\n     -+ * new_begin from the post-image, old_begin from the pre-image.  The line\n     -+ * counts are not tracked here; flush_range_hunk() derives them from the\n     -+ * buffered lines.\n     -+ */\n      +static void begin_range_hunk(struct line_range_filter *filter)\n       {\n      -\tstrbuf_reset(&filter->pending_rm);\n      -\tfilter->pending_rm_count = 0;\n     -+\tfilter->hunk.active = 1;\n     -+\tfilter->hunk.new_begin = filter->lno_in_postimage;\n     -+\tfilter->hunk.old_begin = filter->lno_in_preimage;\n     -+\tstrbuf_reset(&filter->hunk.lines);\n     ++\tfilter->accumulating_hunk.active = 1;\n     ++\tfilter->accumulating_hunk.new_begin = filter->accumulating_hunk.lno_in_postimage;\n     ++\tfilter->accumulating_hunk.old_begin = filter->accumulating_hunk.lno_in_preimage;\n     ++\tstrbuf_reset(&filter->accumulating_hunk.lines);\n       }\n       \n       static void flush_range_hunk(struct line_range_filter *filter)\n       {\n       \tstruct strbuf hdr = STRBUF_INIT;\n     - \tconst char *p, *end;\n     + \tconst char *line_buf, *line_buf_end;\n      +\tlong old_count = 0, new_count = 0;\n      +\tint has_changes = 0;\n       \n     - \tif (!filter->hunk.active || filter->ret)\n     + \tif (!filter->accumulating_hunk.active || filter->ret)\n       \t\treturn;\n       \n     --\t/* Drain any pending removal lines into the range hunk */\n      -\tif (filter->pending_rm_count) {\n     --\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n     --\t\tfilter->hunk.old_count += filter->pending_rm_count;\n     --\t\tfilter->hunk.has_changes = 1;\n     +-\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n     +-\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n     +-\t\tfilter->accumulating_hunk.has_changes = 1;\n      -\t\tdiscard_pending_rm(filter);\n     -+\t/*\n     -+\t * Derive the hunk's geometry from the buffered lines: a ' '\n     -+\t * counts on both sides, a '-' on the old side, a '+' on the new.\n     -+\t * A '-' or '+' marks a real change; the \"\\ No newline at end of\n     -+\t * file\" marker (line[0] == '\\\\') counts on neither side.\n     -+\t */\n     -+\tp = filter->hunk.lines.buf;\n     -+\tend = p + filter->hunk.lines.len;\n     -+\twhile (p < end) {\n     -+\t\tconst char *eol = memchr(p, '\\n', end - p);\n     -+\t\tif (*p == ' ' || *p == '-')\n     ++\tline_buf = filter->accumulating_hunk.lines.buf;\n     ++\tline_buf_end = line_buf + filter->accumulating_hunk.lines.len;\n     ++\twhile (line_buf < line_buf_end) {\n     ++\t\tconst char *eol = memchr(line_buf, '\\n', line_buf_end - line_buf);\n     ++\t\tif (*line_buf == ' ') {\n      +\t\t\told_count++;\n     -+\t\tif (*p == ' ' || *p == '+')\n      +\t\t\tnew_count++;\n     -+\t\tif (*p == '-' || *p == '+')\n     ++\t\t}\n     ++\t\telse if (*line_buf == '-') {\n     ++\t\t\told_count++;\n      +\t\t\thas_changes = 1;\n     -+\t\tp = eol ? eol + 1 : end;\n     ++\t\t}\n     ++\t\telse if (*line_buf == '+') {\n     ++\t\t\tnew_count++;\n     ++\t\t\thas_changes = 1;\n     ++\t\t}\n     ++\t\tline_buf = eol ? eol + 1 : line_buf_end;\n       \t}\n       \n     - \t/*\n     -@@ diff.c: static void flush_range_hunk(struct line_range_filter *filter)\n     - \t * ctxlen causes xdiff to emit context covering a range that\n     - \t * has no changes in this commit.\n     - \t */\n     --\tif (!filter->hunk.has_changes) {\n     +-\tif (!filter->accumulating_hunk.has_changes) {\n      +\tif (!has_changes) {\n     - \t\tfilter->hunk.active = 0;\n     - \t\tstrbuf_reset(&filter->hunk.lines);\n     + \t\tfilter->accumulating_hunk.active = 0;\n     + \t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n       \t\treturn;\n       \t}\n       \n       \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n     --\t\t    filter->hunk.old_begin, filter->hunk.old_count,\n     --\t\t    filter->hunk.new_begin, filter->hunk.new_count);\n     -+\t\t    filter->hunk.old_begin, old_count,\n     -+\t\t    filter->hunk.new_begin, new_count);\n     - \tif (filter->funclen > 0) {\n     +-\t\t    filter->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count,\n     +-\t\t    filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count);\n     ++\t\t    filter->accumulating_hunk.old_begin, old_count,\n     ++\t\t    filter->accumulating_hunk.new_begin, new_count);\n     + \tif (filter->accumulating_hunk.func_name_len > 0) {\n       \t\tstrbuf_addch(&hdr, ' ');\n     - \t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n     -@@ diff.c: static void line_range_hunk_fn(void *data,\n     - \t * When count > 0, begin is 1-based.  When count == 0, begin is\n     - \t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n     - \t * that type will arrive, so the value is unused.\n     --\t *\n     --\t * Any pending removal lines from the previous xdiff hunk are\n     --\t * intentionally left in pending_rm: the line callback will\n     --\t * flush or discard them when the next content line reveals\n     --\t * whether the removals precede in-range content.\n     - \t */\n     - \tfilter->lno_in_postimage = new_begin;\n     - \tfilter->lno_in_preimage = old_begin;\n     + \t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n     +-\t     filter->accumulating_hunk.func_name_len);\n     ++\t\t\t   filter->accumulating_hunk.func_name_len);\n     + \t}\n     + \tstrbuf_addch(&hdr, '\\n');\n     + \n      @@ diff.c: static void line_range_hunk_fn(void *data,\n       static int line_range_line_fn(void *priv, char *line, unsigned long len)\n       {\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n       \n      -\tif (line[0] == '-') {\n      -\t\tif (!filter->pending_rm_count)\n     --\t\t\tfilter->pending_rm_pre_begin = filter->lno_in_preimage;\n     --\t\tfilter->lno_in_preimage++;\n     +-\t\t\tfilter->pending_rm_pre_begin =\n     +-\t\t\t\tfilter->accumulating_hunk.lno_in_preimage;\n     +-\t\tfilter->accumulating_hunk.lno_in_preimage++;\n      -\t\tstrbuf_add(&filter->pending_rm, line, len);\n      -\t\tfilter->pending_rm_count++;\n      -\t\treturn filter->ret;\n     @@ diff.c: static void line_range_hunk_fn(void *data,\n       \tif (line[0] == '\\\\') {\n      -\t\tif (filter->pending_rm_count)\n      -\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n     --\t\telse if (filter->hunk.active)\n     -+\t\tif (filter->hunk.active)\n     - \t\t\tstrbuf_add(&filter->hunk.lines, line, len);\n     --\t\t/* otherwise outside tracked range; drop silently */\n     +-\t\telse if (filter->accumulating_hunk.active)\n     ++\t\tif (filter->accumulating_hunk.active)\n     + \t\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n       \t\treturn filter->ret;\n       \t}\n       \n     @@ diff.c: static void line_range_hunk_fn(void *data,\n      +\tif (line[0] != '+' && line[0] != ' ' && line[0] != '-')\n       \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n       \n     -+\t/*\n     -+\t * idx_in_postimage is this line's 0-based post-image index (see the model on\n     -+\t * struct line_range_filter).  The cursors are advanced only after\n     -+\t * the line is classified, so a '-' is tested at the same idx_in_postimage as\n     -+\t * the '+'/' ' that follows it.\n     -+\t */\n     - \tidx_in_postimage = filter->lno_in_postimage - 1;\n     --\tcur_pre = filter->lno_in_preimage;\t/* save before advancing for context lines */\n     --\tfilter->lno_in_postimage++;\n     + \tidx_in_postimage = filter->accumulating_hunk.lno_in_postimage - 1;\n     +-\tcur_pre = filter->accumulating_hunk.lno_in_preimage;\n     +-\tfilter->accumulating_hunk.lno_in_postimage++;\n      -\tif (line[0] == ' ')\n     --\t\tfilter->lno_in_preimage++;\n     +-\t\tfilter->accumulating_hunk.lno_in_preimage++;\n       \n     --\t/* Advance past ranges we've passed */\n     -+\t/* Retire ranges we have passed, flushing the one we leave. */\n     - \twhile (filter->cur_range < filter->ranges->nr &&\n     - \t       idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) {\n     - \t\tif (filter->hunk.active)\n     + \twhile (filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n     + \t       idx_in_postimage >=\n     + \t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) {\n     + \t\tif (filter->accumulating_hunk.active)\n       \t\t\tflush_range_hunk(filter);\n      -\t\tdiscard_pending_rm(filter);\n     - \t\tfilter->cur_range++;\n     + \t\tfilter->range_set_idx++;\n       \t}\n       \n     --\t/* Past all ranges */\n     --\tif (filter->cur_range >= filter->ranges->nr) {\n     +-\tif (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) {\n      -\t\tdiscard_pending_rm(filter);\n      -\t\treturn filter->ret;\n      -\t}\n     -+\tin_range = filter->cur_range < filter->ranges->nr &&\n     -+\t\t   idx_in_postimage >= filter->ranges->ranges[filter->cur_range].start &&\n     -+\t\t   idx_in_postimage < filter->ranges->ranges[filter->cur_range].end;\n     - \n     --\tcur = &filter->ranges->ranges[filter->cur_range];\n     -+\tif (in_range) {\n     -+\t\tif (!filter->hunk.active)\n     -+\t\t\tbegin_range_hunk(filter);\n     - \n     --\t/* Before current range */\n     +-\n     +-\tcur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx];\n     +-\n      -\tif (idx_in_postimage < cur->start) {\n      -\t\tdiscard_pending_rm(filter);\n      -\t\treturn filter->ret;\n     -+\t\tstrbuf_add(&filter->hunk.lines, line, len);\n     - \t}\n     +-\t}\n     ++\tin_range = filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n     ++\t\t   idx_in_postimage >=\n     ++\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].start &&\n     ++\t\t   idx_in_postimage <\n     ++\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end;\n       \n     --\t/* In range so start a new range hunk if needed */\n     --\tif (!filter->hunk.active) {\n     --\t\tfilter->hunk.active = 1;\n     --\t\tfilter->hunk.has_changes = 0;\n     --\t\tfilter->hunk.new_begin = idx_in_postimage + 1;\n     --\t\tfilter->hunk.old_begin = filter->pending_rm_count\n     +-\tif (!filter->accumulating_hunk.active) {\n     +-\t\tfilter->accumulating_hunk.active = 1;\n     +-\t\tfilter->accumulating_hunk.has_changes = 0;\n     +-\t\tfilter->accumulating_hunk.new_begin = idx_in_postimage + 1;\n     +-\t\tfilter->accumulating_hunk.old_begin = filter->pending_rm_count\n      -\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n     --\t\tfilter->hunk.old_count = 0;\n     --\t\tfilter->hunk.new_count = 0;\n     --\t\tstrbuf_reset(&filter->hunk.lines);\n     +-\t\tfilter->accumulating_hunk.old_count = 0;\n     +-\t\tfilter->accumulating_hunk.new_count = 0;\n     +-\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n      -\t}\n     --\n     --\t/* Flush pending removals into range hunk */\n     ++\tif (in_range) {\n     ++\t\tif (!filter->accumulating_hunk.active)\n     ++\t\t\tbegin_range_hunk(filter);\n     + \n      -\tif (filter->pending_rm_count) {\n     --\t\tstrbuf_addbuf(&filter->hunk.lines, &filter->pending_rm);\n     --\t\tfilter->hunk.old_count += filter->pending_rm_count;\n     --\t\tfilter->hunk.has_changes = 1;\n     +-\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n     +-\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n     +-\t\tfilter->accumulating_hunk.has_changes = 1;\n      -\t\tdiscard_pending_rm(filter);\n     --\t}\n     --\n     --\tstrbuf_add(&filter->hunk.lines, line, len);\n     --\tfilter->hunk.new_count++;\n     ++\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n     + \t}\n     + \n     +-\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n     +-\tfilter->accumulating_hunk.new_count++;\n      -\tif (line[0] == '+')\n     --\t\tfilter->hunk.has_changes = 1;\n     +-\t\tfilter->accumulating_hunk.has_changes = 1;\n      -\telse\n     --\t\tfilter->hunk.old_count++;\n     -+\t/*\n     -+\t * Advance each image's cursor: a line present in that image (see\n     -+\t * the model) consumes one of its line numbers.\n     -+\t */\n     -+\tif (line[0] != '-')\n     -+\t\tfilter->lno_in_postimage++;\n     -+\tif (line[0] != '+')\n     -+\t\tfilter->lno_in_preimage++;\n     +-\t\tfilter->accumulating_hunk.old_count++;\n     ++\tif (line[0] == ' ' || line[0] == '+')\n     ++\t\tfilter->accumulating_hunk.lno_in_postimage++;\n     ++\tif (line[0] == ' ' || line[0] == '-')\n     ++\t\tfilter->accumulating_hunk.lno_in_preimage++;\n       \n       \treturn filter->ret;\n       }\n      @@ diff.c: static void builtin_diff(const char *name_a,\n       \t\t\tlr_state.orig_cb_data = &ecbdata;\n     - \t\t\tlr_state.ranges = line_ranges;\n     - \t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n     + \t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n     + \t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n      -\t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n       \n       \t\t\t/*\n     @@ diff.c: static void builtin_diff(const char *name_a,\n      @@ diff.c: static void builtin_diff(const char *name_a,\n       \t\t\t\tdie(\"unable to generate diff for %s\",\n       \t\t\t\t    one->path);\n     - \t\t\tstrbuf_release(&lr_state.hunk.lines);\n     + \t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n      -\t\t\tstrbuf_release(&lr_state.pending_rm);\n       \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n       \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n     @@ diff.c: static void builtin_diff(const char *name_a,\n      \n       ## t/t4211-line-log.sh ##\n      @@ t/t4211-line-log.sh: test_expect_success '-L with -G filters to diff-text matches' '\n     - \tgrep \"F2 + 2\" actual\n     + \ttest_grep \"F2 + 2\" actual\n       '\n       \n      +test_expect_success 'setup for trailing deletion test' '\n     @@ t/t4211-line-log.sh: test_expect_success '-L with -G filters to diff-text matche\n      +\t{\n      +\t    return 1;\n      +\t}\n     -+\t// trailing comment\n     ++\t// trailing comment outside tracked range\n      +\tEOF\n      +\tgit add file.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add file with trailing comment\" &&\n     -+\t# Modify tracked() AND delete the trailing comment in\n     -+\t# one commit, so the commit touches the tracked range\n     -+\t# and is not filtered out by the revision walker.\n     ++\t# Remove the trailing comment AND modify tracked() so there\n     ++\t# is a modification to the line range we track and a\n     ++\t# modification to the following line, which we do not track.\n      +\tcat >file.c <<-\\EOF &&\n      +\tvoid tracked()\n      +\t{\n     @@ t/t4211-line-log.sh: test_expect_success '-L with -G filters to diff-text matche\n      +\n      +test_expect_success '-L does not include deletions past end of tracked range' '\n      +\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n     -+\t# The trailing comment deletion is outside the tracked\n     -+\t# range and should not appear in the patch output.\n      +\ttest_grep \"return 2\" actual &&\n      +\ttest_grep ! \"trailing comment\" actual\n      +'\n     -+\n     -+test_expect_success '-L includes leading deletions resolved by in-range line' '\n     -+\tgit checkout --orphan leading-del &&\n     -+\tgit reset --hard &&\n     -+\tcat >file.c <<-\\EOF &&\n     -+\t// leading comment\n     -+\tvoid tracked()\n     -+\t{\n     -+\t    return 1;\n     -+\t}\n     -+\tEOF\n     -+\tgit add file.c &&\n     -+\ttest_tick &&\n     -+\tgit commit -m \"add file with leading comment\" &&\n     -+\tcat >file.c <<-\\EOF &&\n     -+\tvoid tracked()\n     -+\t{\n     -+\t    return 2;\n     -+\t}\n     -+\tEOF\n     -+\tgit commit -a -m \"modify tracked and delete leading comment\" &&\n     -+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n     -+\t# The leading comment deletion is resolved by the next\n     -+\t# non-removal line (void tracked), which is in range: a\n     -+\t# removal is classified by the position of the following\n     -+\t# line, so it joins the range that line falls in.\n     -+\ttest_grep \"return 2\" actual &&\n     -+\ttest_grep \"leading comment\" actual\n     -+'\n     -+\n     -+test_expect_success 'setup for line-range filter edge cases' '\n     -+\tgit checkout --orphan filter-edge &&\n     -+\tgit reset --hard &&\n     -+\tcat >file.c <<-\\EOF &&\n     -+\tvoid before()\n     -+\t{\n     -+\t    return 0;\n     -+\t}\n     -+\n     -+\tvoid tracked()\n     -+\t{\n     -+\t    int a = 1;\n     -+\t    int b = 2;\n     -+\t    int c = 3;\n     -+\t    return a + b + c;\n     -+\t}\n     -+\n     -+\tvoid after()\n     -+\t{\n     -+\t    return 9;\n     -+\t}\n     -+\tEOF\n     -+\tgit add file.c &&\n     -+\ttest_tick &&\n     -+\tgit commit -m \"initial\"\n     -+'\n     -+\n     -+test_expect_success '-L change at exact first line of range' '\n     -+\tgit checkout filter-edge &&\n     -+\t# Change the function signature (first line of range)\n     -+\tsed \"s/void tracked/int tracked/\" file.c >tmp &&\n     -+\tmv tmp file.c &&\n     -+\tgit commit -a -m \"change first line\" &&\n     -+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n     -+\ttest_grep \"change first line\" actual &&\n     -+\ttest_grep \"+int tracked\" actual &&\n     -+\ttest_grep \"\\\\-void tracked\" actual\n     -+'\n     -+\n     -+test_expect_success '-L change at exact last line of range' '\n     -+\tgit checkout filter-edge &&\n     -+\tgit reset --hard HEAD~1 &&\n     -+\t# Change the closing brace line (last line of range)\n     -+\tsed \"s/^}$/} \\/\\/ end tracked/\" file.c >tmp &&\n     -+\tmv tmp file.c &&\n     -+\tgit commit -a -m \"change last line\" &&\n     -+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n     -+\ttest_grep \"change last line\" actual &&\n     -+\ttest_grep \"end tracked\" actual\n     -+'\n     -+\n     -+test_expect_success '-L pure deletion in range (no additions)' '\n     -+\tgit checkout filter-edge &&\n     -+\tgit reset --hard HEAD~1 &&\n     -+\t# Delete a line inside tracked() without adding anything\n     -+\tsed \"/int c/d\" file.c >tmp &&\n     -+\tmv tmp file.c &&\n     -+\tgit commit -a -m \"pure deletion\" &&\n     -+\tgit log -L:tracked:file.c -p --format=%s -1 >actual &&\n     -+\ttest_grep \"pure deletion\" actual &&\n     -+\ttest_grep \"\\\\-.*int c\" actual\n     -+'\n      +\n       test_expect_success '-L with --diff-filter=M excludes root commit' '\n       \tgit checkout parent-oids &&\n 3:  d211c82e40 ! 3:  2081d5d257 diff: emit -L hunk headers via xdiff's formatter\n     @@ Metadata\n       ## Commit message ##\n          diff: emit -L hunk headers via xdiff's formatter\n      \n     -    The line-range filter builds its own \"@@ -<old> +<new> @@\" header for\n     -    each range hunk.  For a side with no lines (count 0, such as the old\n     -    side of a pure insertion), the begin should be the number of the line\n     -    before the change, per the convention git diff and xdl_emit_hunk_hdr()\n     -    follow.  The hand-rolled code's begin was one too high; in t4211 this\n     -    produced\n     +    Currently, diff's line-range filter implements its own method for\n     +    emitting diff hunk headers. This mostly matches what xdiff itself\n     +    outputs, but there is a discrepancy for postimage or preimage sides\n     +    with 0 line changes. For a side with no lines (count 0), the begin is\n     +    the line before the change. The header omits the line count of 1.\n      \n     -            @@ -25,0 +18,9 @@\n     +    Rather than fix this case in the line-range implementation, expose the\n     +    function xdiff uses to emit its headers. Reusing it keeps the header\n     +    format consistent with and without -L.\n      \n     -    an old begin of 25 in a 24-line file, where git diff would give 24.\n     -\n     -    Stop hand-rolling the header.  flush_range_hunk() now formats it through\n     -    xdiff's own emitter: a new xdiff_emit_hunk_header() helper wraps\n     -    xdl_emit_hunk_hdr(), the function that produces every other diff's hunk\n     -    headers.  The count-0 begin is then correct by construction, and as a\n     -    side effect -L headers match git diff exactly, including its omission of\n     -    a count of 1 (\"@@ -22 +22 @@\" rather than \"@@ -22,1 +22,1 @@\").\n     -\n     -    xdiff's hunk callback already hands line_range_hunk_fn() a count-0 begin\n     -    decremented, so undo that when seeding the cursors and let the formatter\n     -    re-apply the convention once, at emit time.\n     -\n     -    The off-by-one predates this series, and the two regenerated fixtures\n     -    reach it from different origins: no-assertion-error has carried it since\n     -    its test was added in ab60c693a2 (line-log: fix assertion error,\n     -    2025-08-18), while vanishes-early acquired it when 86e986f166 (line-log:\n     -    route -L output through the standard diff pipeline) reshaped its tracked\n     -    line into a pure insertion.  vanishes-early also drops its count-1\n     -    counts.\n     +    Update test scenarios and fixtures to reflect the now consistent header\n     +    format.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n     @@ diff.c: static void flush_range_hunk(struct line_range_filter *filter)\n       \t}\n       \n      -\tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n     --\t\t    filter->hunk.old_begin, old_count,\n     --\t\t    filter->hunk.new_begin, new_count);\n     --\tif (filter->funclen > 0) {\n     +-\t\t    filter->accumulating_hunk.old_begin, old_count,\n     +-\t\t    filter->accumulating_hunk.new_begin, new_count);\n     +-\tif (filter->accumulating_hunk.func_name_len > 0) {\n      -\t\tstrbuf_addch(&hdr, ' ');\n     --\t\tstrbuf_add(&hdr, filter->func, filter->funclen);\n     +-\t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n     +-\t\t\t   filter->accumulating_hunk.func_name_len);\n      -\t}\n      -\tstrbuf_addch(&hdr, '\\n');\n     -+\txdiff_emit_hunk_header(&hdr, filter->hunk.old_begin, old_count,\n     -+\t\t\t       filter->hunk.new_begin, new_count,\n     -+\t\t\t       filter->func, filter->funclen);\n     ++\txdiff_emit_hunk_header(&hdr, filter->accumulating_hunk.old_begin, old_count,\n     ++\t\t\t       filter->accumulating_hunk.new_begin, new_count,\n     ++\t\t\t       filter->accumulating_hunk.func_name,\n     ++\t\t\tfilter->accumulating_hunk.func_name_len);\n       \n       \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n       \tstrbuf_release(&hdr);\n     @@ diff.c: static void flush_range_hunk(struct line_range_filter *filter)\n       {\n       \tstruct line_range_filter *filter = data;\n       \n     - \t/*\n     --\t * When count > 0, begin is 1-based.  When count == 0, begin is\n     --\t * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of\n     --\t * that type will arrive, so the value is unused.\n     -+\t * Seed the per-image line cursors from the hunk header's begins.  For\n     -+\t * a side with no lines (count 0), xdiff's callback has already moved\n     -+\t * its begin to the line before the change, so add one back to recover\n     -+\t * the true 1-based start.  xdiff_emit_hunk_header() reapplies that -1\n     -+\t * when the clipped hunk is emitted.\n     - \t */\n     --\tfilter->lno_in_postimage = new_begin;\n     --\tfilter->lno_in_preimage = old_begin;\n     -+\tfilter->lno_in_postimage = new_nr ? new_begin : new_begin + 1;\n     -+\tfilter->lno_in_preimage = old_nr ? old_begin : old_begin + 1;\n     +-\tfilter->accumulating_hunk.lno_in_postimage = new_begin;\n     +-\tfilter->accumulating_hunk.lno_in_preimage = old_begin;\n     ++\tfilter->accumulating_hunk.lno_in_postimage = new_nr ? new_begin : new_begin + 1;\n     ++\tfilter->accumulating_hunk.lno_in_preimage = old_nr ? old_begin : old_begin + 1;\n       \n       \tif (funclen > 0) {\n     - \t\tif (funclen > (long)sizeof(filter->func))\n     + \t\tif (funclen > (long)sizeof(filter->accumulating_hunk.func_name))\n      \n       ## t/t4211/sha1/expect.no-assertion-error ##\n      @@ t/t4211/sha1/expect.no-assertion-error: diff --git a/b.c b/b.c\n     @@ xdiff-interface.h: int xdiff_compare_lines(const char *l1, long s1,\n      +\n      +/*\n      + * Append a unified-diff hunk header to `out`, e.g.\n     -+ * \"@@ -<old> +<new> @@ func\\n\".  The header comes from wrapping xdiff's\n     ++ * \"@@ -<old> +<new> @@ func\\n\". The header comes from wrapping xdiff's\n      + * own hunk-header emitter, so it matches what a normal diff would\n     -+ * produce for these begins and counts.  For a side with no lines\n     -+ * (count 0) the begin is the line before the change, and a count of 1\n     -+ * is omitted.\n     ++ * produce for the given line number begins and line counts.\n      + */\n      +void xdiff_emit_hunk_header(struct strbuf *out,\n     -+\t\t\t    long old_begin, long old_count,\n     -+\t\t\t    long new_begin, long new_count,\n     ++\t\t\t    long old_begin, long old_nr,\n     ++\t\t\t    long new_begin, long new_nr,\n      +\t\t\t    const char *func, long funclen);\n      +\n       #endif\n 4:  b82a997359 ! 4:  6b13c13ae7 diff: extract a line-range diff helper for reuse\n     @@ Metadata\n       ## Commit message ##\n          diff: extract a line-range diff helper for reuse\n      \n     -    builtin_diff() open-codes the line-range filter setup and teardown\n     -    around its xdi_diff_outf() call: zero the struct, point it at the\n     -    output callback, inflate ctxlen to the largest range span so each range\n     -    yields a single xdiff hunk, run the diff, flush the trailing range\n     -    hunk, and release the buffer.  The upcoming -L stat and check formats\n     -    need the same sequence.\n     +    Extract logic for initializing the line-range filter and running a diff\n     +    for a specific line range. This logic is needed for any diff that\n     +    targets a line range independent of the current patch display path.\n      \n     -    Extract line_range_filter_init() for the setup and a\n     -    line_range_filter_diff() helper that prepares the xdiff config the\n     -    filter needs, runs an initialized filter through xdi_diff_outf(),\n     -    flushes the final range hunk, and releases it, returning the latched\n     -    error.  The helper inflates ctxlen to the largest range span so each\n     -    range yields a single xdiff hunk, and clears XDL_EMIT_NO_HUNK_HDR so\n     -    the hunk headers the filter seeds its position from are always emitted.\n     -    Folding both into the helper keeps these invariants, which the filter's\n     -    position tracking relies on, in a single place for every consumer.\n     -    builtin_diff() now does init + line_range_filter_diff(); the next two\n     -    patches reuse them in builtin_diffstat() and builtin_checkdiff()\n     -    instead of repeating the boilerplate.\n     +    The subsequent commits use this logic to enable additional line range\n     +    targeted diff modes.\n      \n     -    No behavior change: builtin_diff() leaves XDL_EMIT_NO_HUNK_HDR unset,\n     -    so clearing it is a no-op until the suppressing consumers arrive.\n     +    No logical behavior change.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n     @@ diff.c: static int quick_consume(void *priv, char *line UNUSED, unsigned long le\n      +\tmemset(filter, 0, sizeof(*filter));\n      +\tfilter->orig_line_fn = line_fn;\n      +\tfilter->orig_cb_data = cb_data;\n     -+\tfilter->ranges = ranges;\n     -+\tstrbuf_init(&filter->hunk.lines, 0);\n     ++\tfilter->range_sets_to_filter_by = ranges;\n     ++\tstrbuf_init(&filter->accumulating_hunk.lines, 0);\n      +}\n      +\n     - /*\n     -  * Begin a range hunk at the first in-range line.  Its position fixes the\n     -  * hunk's begins, taken from the two image cursors before they advance:\n     + static void begin_range_hunk(struct line_range_filter *filter)\n     + {\n     + \tfilter->accumulating_hunk.active = 1;\n      @@ diff.c: static int line_range_line_fn(void *priv, char *line, unsigned long len)\n       \treturn filter->ret;\n       }\n       \n     -+/*\n     -+ * Run an xdiff pass through an initialized line-range filter, flush the\n     -+ * final range hunk, and release the filter.  Inflates ctxlen to the largest\n     -+ * range span first, so that every change within a single range lands in one\n     -+ * xdiff hunk and the inter-change context is emitted; the filter then clips\n     -+ * back to range boundaries.  The optimal ctxlen depends on where changes fall\n     -+ * within the range, which is only known after xdiff runs, so the max span is\n     -+ * the upper bound that guarantees correctness in a single pass.  Every\n     -+ * consumer (patch, diffstat, check) relies on one xdiff hunk per range, so\n     -+ * this lives here rather than at each call site.  Also clears\n     -+ * XDL_EMIT_NO_HUNK_HDR: the filter seeds its per-image position from the hunk\n     -+ * headers, so a consumer that otherwise suppresses them (diffstat) still gets\n     -+ * them here.  Returns non-zero if xdiff or any forwarded callback failed.\n     -+ */\n     ++\n      +static int line_range_filter_diff(struct line_range_filter *filter,\n      +\t\t\t\t  mmfile_t *mf1, mmfile_t *mf2,\n      +\t\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n      +{\n     -+\tconst struct range_set *ranges = filter->ranges;\n     ++\tconst struct range_set *ranges = filter->range_sets_to_filter_by;\n      +\tlong max_span = 0;\n      +\tunsigned int i;\n      +\tint ret;\n     @@ diff.c: static int line_range_line_fn(void *priv, char *line, unsigned long len)\n      +\t\tflush_range_hunk(filter);\n      +\t\tret = filter->ret;\n      +\t}\n     -+\tstrbuf_release(&filter->hunk.lines);\n     ++\tstrbuf_release(&filter->accumulating_hunk.lines);\n      +\treturn ret;\n      +}\n      +\n     @@ diff.c: static void builtin_diff(const char *name_a,\n      -\t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n      -\t\t\tlr_state.orig_line_fn = fn_out_consume;\n      -\t\t\tlr_state.orig_cb_data = &ecbdata;\n     --\t\t\tlr_state.ranges = line_ranges;\n     --\t\t\tstrbuf_init(&lr_state.hunk.lines, 0);\n     +-\t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n     +-\t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n      -\n      -\t\t\t/*\n      -\t\t\t * Inflate ctxlen so that all changes within\n     @@ diff.c: static void builtin_diff(const char *name_a,\n      +\t\t\t\t\t\t   &xpp, &xecfg))\n       \t\t\t\tdie(\"unable to generate diff for %s\",\n       \t\t\t\t    one->path);\n     --\t\t\tstrbuf_release(&lr_state.hunk.lines);\n     +-\t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n       \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n       \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n       \t\t\tdie(\"unable to generate diff for %s\", one->path);\n 5:  3d0091b549 ! 5:  f12b61b9a4 line-log: support diff stat formats with -L\n     @@ Metadata\n      Author: Michael Montalbo <mmontalbo@gmail.com>\n      \n       ## Commit message ##\n     -    line-log: support diff stat formats with -L\n     +    diff: support stat formats with -L\n      \n     -    Reuse the line_range_filter in builtin_diffstat() so the stat formats\n     -    count only the lines within the tracked range.  When a filepair carries\n     -    line_ranges, the filter wraps diffstat_consume() as its output callback,\n     -    forwarding only the lines inside the range for counting.\n     -    flush_range_hunk() replays buffered content through diffstat_consume(),\n     -    which ignores synthetic @@ headers since it only counts '+' and '-'\n     -    lines.\n     +    Reuse the line_range_filter in builtin_diffstat() so -L supports\n     +    the stat formats and add tests verifying the new behavior.\n      \n     -    Expand the output format allowlist in setup_revisions() to accept\n     -    --stat, --numstat, and --shortstat with -L.\n     -\n     -    Leave --dirstat out of the allowlist so it is rejected like any other\n     -    unsupported format.  Its default mode counts each file's whole-file\n     -    byte damage via diffcore_count_changes(), outside the line-based\n     -    pipeline that the -L filter scopes, so bare --dirstat cannot honor the\n     -    tracked range.  The --dirstat=lines mode could: it aggregates the same\n     -    per-file line counts as --numstat, which -L already scopes.  But\n     -    accepting only that sub-mode while bare --dirstat keeps erroring is a\n     -    confusing split, so the whole format is deferred to a follow-up;\n     -    --numstat already reports the exact per-file counts within the tracked\n     -    range.\n     -\n     -    Also drop \"yet\" from the generic -L rejection message (\"does not\n     -    yet support the requested diff format\").  Some rejected formats do\n     -    not fit a line range at all, so \"yet\" wrongly implied they are all\n     -    just awaiting support.\n     +    Ungate the newly enabled options and drop \"yet\" from the generic\n     +    -L rejection message (\"does not yet support the requested diff\n     +    format\"). Some rejected formats do not fit -L at all, so \"yet\"\n     +    wrongly implies they are all awaiting support.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n     @@ Documentation/line-range-options.adoc\n      -\t(`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not\n      -\tcurrently implemented.\n      +\tThe following non-patch diff formats are supported: `--raw`,\n     -+\t`--name-only`, `--name-status`, `--summary`,\n     -+\t`--stat`, `--numstat`, and `--shortstat`.\n     -+\tThe stat formats count only lines within the tracked range.\n     -+\t`--dirstat` is not supported\n     -+\twith `-L`: it summarizes change as each directory's share of\n     -+\tthe total churn, not as counts for the tracked lines.  Use\n     -+\t`--numstat` for exact per-file counts within the range.\n     ++\t`--name-only`, `--name-status`, `--summary`, `--stat`, `--numstat`,\n     ++\tand `--shortstat`. The stat formats count only lines within the tracked\n     ++\trange. `--dirstat` is not supported with `-L`: it summarizes change as each\n     ++\tdirectory's share of the total churn, not as counts for the tracked lines.\n     ++\tUse `--numstat` for exact per-file counts within the range.\n       +\n       Patch formatting options such as `--word-diff`, `--color-moved`,\n       `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n     @@ t/t4211-line-log.sh: test_expect_success '--name-status shows status and path' '\n      -\n      -test_expect_success '--dirstat is not yet supported with -L' '\n      +test_expect_success '--dirstat is not supported with -L' '\n     -+\t# --dirstat is not supported with -L: its default mode measures\n     -+\t# whole-file change, not the tracked lines, and the\n     -+\t# --dirstat=lines variant is deferred too, so both forms are\n     -+\t# rejected like any other unsupported format.\n       \ttest_must_fail git log -L1,24:b.c --dirstat 2>err &&\n      -\ttest_grep \"does not yet support\" err\n     -+\ttest_grep \"does not support\" err &&\n     -+\ttest_must_fail git log -L1,24:b.c --dirstat=lines 2>err &&\n      +\ttest_grep \"does not support\" err\n       '\n       \n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n       \ttest_grep \"^diff --git\" line2\n       '\n       \n     -+test_expect_success 'setup for stat range-scoping tests' '\n     -+\tgit checkout --orphan stat-scoping &&\n     ++test_expect_success 'setup for -L stat tests' '\n     ++\tgit checkout --orphan stat-range &&\n      +\tgit reset --hard &&\n      +\tcat >file.c <<-\\EOF &&\n      +\tint func1()\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\t    return F1;\n      +\t}\n      +\n     -+\tint func2()\n     ++\tint tracked_fn()\n      +\t{\n      +\t    return F2;\n      +\t}\n      +\tEOF\n      +\tgit add file.c &&\n      +\ttest_tick &&\n     -+\tgit commit -m \"Add func1() and func2()\" &&\n     ++\tgit commit -m \"Add func1() and tracked_fn()\" &&\n      +\n     -+\t# Modify both functions in a single commit so that\n     -+\t# whole-file stats differ from the counts for the tracked range.\n     ++\t# Modify both functions so whole-file stats (2 added, 2 deleted)\n     ++\t# differ from the tracked range of tracked_fn (1 and 1).\n      +\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" file.c >tmp &&\n      +\tmv tmp file.c &&\n      +\tgit commit -a -m \"Modify both functions\"\n      +'\n      +\n     -+test_expect_success '--numstat counts only lines in tracked range' '\n     -+\t# \"Modify both functions\" changes one line in func1 and one in\n     -+\t# func2.  Whole-file numstat would show 2 added, 2 deleted.\n     -+\t# numstat for func2 within the tracked range should show only 1 and 1.\n     -+\tgit log -L:func2:file.c --numstat --format=%s -1 >actual &&\n     -+\ttest_grep \"Modify both functions\" actual &&\n     -+\ttest_grep \"^1\t1\tfile.c$\" actual &&\n     -+\ttest_grep ! \"^diff --git\" actual\n     -+'\n     ++test_expect_success '-L --numstat limits counts to the tracked range' '\n     ++\tgit log -L:tracked_fn:file.c --numstat --format=%s >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tModify both functions\n      +\n     -+test_expect_success '--numstat counts only additions for root commit' '\n     -+\t# Root commit creates both func1 (4 lines) and func2 (4 lines).\n     -+\t# Whole-file numstat would show 9 lines added.  numstat for func2\n     -+\t# within the tracked range should show only 4.\n     -+\tgit log -L:func2:file.c --numstat --format=%s >actual &&\n     -+\ttest_grep \"Add func1() and func2()\" actual &&\n     -+\ttest_grep \"^4\t0\tfile.c$\" actual &&\n     -+\ttest_grep ! \"^diff --git\" actual\n     -+'\n     ++\t1\t1\tfile.c\n     ++\tAdd func1() and tracked_fn()\n      +\n     -+test_expect_success '--stat counts only lines in tracked range' '\n     -+\tgit log -L:func2:file.c --stat --format=%s -1 >actual &&\n     -+\ttest_grep \"Modify both functions\" actual &&\n     -+\ttest_grep \"file.c |\" actual &&\n     -+\ttest_grep \"1 insertion\" actual &&\n     -+\ttest_grep \"1 deletion\" actual &&\n     -+\ttest_grep ! \"^diff --git\" actual\n     ++\t4\t0\tfile.c\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--shortstat counts only lines in tracked range' '\n     -+\t# --shortstat prints only the summary line: no per-file \"file.c |\"\n     -+\t# line.  Counts cover only the tracked range, as for --numstat above.\n     -+\tgit log -L:func2:file.c --shortstat --format=%s -1 >actual &&\n     -+\ttest_grep \"Modify both functions\" actual &&\n     -+\ttest_grep \"1 insertion\" actual &&\n     -+\ttest_grep \"1 deletion\" actual &&\n     -+\ttest_grep ! \"file.c |\" actual &&\n     -+\ttest_grep ! \"^diff --git\" actual\n     ++test_expect_success '-L --stat and --shortstat limit counts to the tracked range' '\n     ++\tgit log -L:tracked_fn:file.c --stat --format=%s -1 >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tModify both functions\n     ++\n     ++\t file.c | 2 +-\n     ++\t 1 file changed, 1 insertion(+), 1 deletion(-)\n     ++\tEOF\n     ++\ttest_cmp expect actual &&\n     ++\n     ++\tgit log -L:tracked_fn:file.c --shortstat --format=%s -1 >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tModify both functions\n     ++\n     ++\t 1 file changed, 1 insertion(+), 1 deletion(-)\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--numstat across renames and multiple commits' '\n      +\t# parallel-change carries the tracked function f across an a.c -> b.c\n     -+\t# rename and a merge of two parallel histories.  With -M, --numstat\n     -+\t# follows the rename and reports added/removed counts for f within\n     -+\t# the tracked range (not whole-file) per commit; the file column flips from\n     -+\t# b.c to a.c at the rename as the walk goes back in time.  Commits\n     -+\t# that do not change the range of f emit no row (the merge and the\n     -+\t# pure file-move produce nothing), so there are fewer rows than\n     -+\t# commits.\n     ++\t# rename and a merge of two parallel histories.\n      +\tgit checkout parallel-change &&\n      +\tgit log -M -L \":f:b.c\" --format= --numstat >actual &&\n      +\tcat >expect <<-\\EOF &&\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\tgit checkout --orphan multi-range &&\n      +\tgit reset --hard &&\n      +\tcat >m.c <<-\\EOF &&\n     -+\tint func1()\n     ++\tint tracked_func1()\n      +\t{\n      +\t    return F1;\n      +\t}\n      +\n     -+\tint func2()\n     ++\tint tracked_func2()\n      +\t{\n      +\t    return F2;\n      +\t}\n     @@ t/t4211-line-log.sh: test_expect_success '-L --oneline has no extra blank line b\n      +\tgit add m.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add m.c\" &&\n     -+\t# Change all three functions but track only func1 and func2.\n     -+\t# Whole-file numstat would be 3 3; a 2 2 result proves the\n     -+\t# untracked func3 change is excluded and the two ranges just sum.\n      +\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" -e \"s/F3/F3 + 3/\" m.c >tmp &&\n      +\tmv tmp m.c &&\n      +\tgit commit -a -m \"Modify all three functions\" &&\n     -+\tgit log -L:func1:m.c -L:func2:m.c --numstat --format=%s -1 >actual &&\n     -+\ttest_grep \"Modify all three functions\" actual &&\n     -+\ttest_grep \"^2\t2\tm.c$\" actual &&\n     -+\ttest_grep ! \"^3\t3\tm.c$\" actual\n     ++\tgit log -L:tracked_func1:m.c -L:tracked_func2:m.c --numstat --format=%s -1 >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tModify all three functions\n     ++\n     ++\t2\t2\tm.c\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n       test_expect_success '--summary shows new file on root commit' '\n 6:  36ed52d831 ! 6:  f1f5af8f77 diff: support --check with -L line ranges\n     @@ Metadata\n       ## Commit message ##\n          diff: support --check with -L line ranges\n      \n     -    builtin_checkdiff() runs its own xdiff pass to detect whitespace\n     -    errors in newly added lines.  When -L is active, the check should\n     -    be scoped to the tracked line ranges rather than the whole file.\n     +    Reuse the line_range_filter in builtin_checkdiff() so -L supports\n     +    the --check option.\n      \n     -    Reuse the line_range_filter to wrap checkdiff_consume(), the same\n     -    pattern already used for patch output and diffstat.  The filter\n     -    forwards only in-range lines for whitespace checking.\n     +    Add orig_hunk_fn field similar to orig_line_fn that forwards\n     +    xdiff_emit_hunk_fn calls when we flush filtered hunks. This is necessary\n     +    because --check relies on receiving calls to its checkdiff_consume_hunk\n     +    function for managing state.\n      \n     -    checkdiff reports the file line number of each error, which it\n     -    normally learns from the hunk header via checkdiff_consume_hunk().\n     -    The filter synthesizes its own hunk headers, so give it an optional\n     -    hunk callback and route checkdiff_consume_hunk() through it; this\n     -    sets the post-image position before the in-range lines are replayed.\n     -    Without it the reported line numbers would count from the start of\n     -    the range hunk rather than the start of the file.\n     -\n     -    The trailing blank-at-eof check is a second pass that scans the whole\n     -    file via check_blank_at_eof(), so gate its report on the tracked\n     -    ranges as well; otherwise a blank line added at end of file is\n     -    reported even when it lies outside the range.\n     -\n     -    Add DIFF_FORMAT_CHECKDIFF to the -L output format allowlist in\n     -    setup_revisions() so that -L --check is accepted, and list --check\n     -    among the supported formats in the documentation.  Add tests covering\n     -    that whitespace errors are reported, scoped to the tracked range, and\n     -    labeled with the correct file line number, including when two errors\n     -    in one range are separated by a gap that would otherwise split into\n     -    multiple xdiff hunks.\n     +    Document and ungate the newly enabled option, and add tests verifying\n     +    the new behavior.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n     @@ Documentation/line-range-options.adoc\n       \tYou can specify this option more than once. Implies `--patch`.\n       \tPatch output can be suppressed using `--no-patch`.\n       \tThe following non-patch diff formats are supported: `--raw`,\n     --\t`--name-only`, `--name-status`, `--summary`,\n     -+\t`--name-only`, `--name-status`, `--summary`, `--check`,\n     - \t`--stat`, `--numstat`, and `--shortstat`.\n     - \tThe stat formats count only lines within the tracked range.\n     - \t`--dirstat` is not supported\n     +-\t`--name-only`, `--name-status`, `--summary`, `--stat`, `--numstat`,\n     +-\tand `--shortstat`. The stat formats count only lines within the tracked\n     +-\trange. `--dirstat` is not supported with `-L`: it summarizes change as each\n     +-\tdirectory's share of the total churn, not as counts for the tracked lines.\n     +-\tUse `--numstat` for exact per-file counts within the range.\n     ++\t`--name-only`, `--name-status`, `--summary`, `--check`, `--stat`,\n     ++\t`--numstat`, and `--shortstat`. The stat formats count only lines\n     ++\twithin the tracked range. `--dirstat` is not supported with `-L`: it\n     ++\treports how change is distributed across directories over whole files,\n     ++\twhich is not meaningful for line ranges within a file. Use `--numstat`\n     ++\tfor exact per-file counts within the range.\n     + +\n     + Patch formatting options such as `--word-diff`, `--color-moved`,\n     + `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n      \n       ## diff.c ##\n      @@ diff.c: struct emit_callback {\n        */\n       struct line_range_filter {\n       \txdiff_emit_line_fn orig_line_fn;\n     -+\t/*\n     -+\t * Optional; consumers that report file line numbers (e.g.\n     -+\t * checkdiff) need the synthetic hunk header to set their\n     -+\t * post-image position before in-range lines are replayed.\n     -+\t */\n      +\txdiff_emit_hunk_fn orig_hunk_fn;\n       \tvoid *orig_cb_data;\n     - \tconst struct range_set *ranges;\t/* 0-based [start, end) */\n     - \tunsigned int cur_range;\t\t/* index into the range_set */\n     + \tconst struct range_set *range_sets_to_filter_by;\n     + \tunsigned int range_set_idx;\n      @@ diff.c: static void flush_range_hunk(struct line_range_filter *filter)\n     - \t\t\t       filter->hunk.new_begin, new_count,\n     - \t\t\t       filter->func, filter->funclen);\n     + \t\t\t       filter->accumulating_hunk.func_name,\n     + \t\t\tfilter->accumulating_hunk.func_name_len);\n       \n     -+\t/*\n     -+\t * Inform a line-numbering consumer of the post-image position\n     -+\t * before replaying lines, mirroring the hunk callback xdiff\n     -+\t * would have issued for a non-scoped diff.\n     -+\t */\n      +\tif (filter->orig_hunk_fn)\n      +\t\tfilter->orig_hunk_fn(filter->orig_cb_data,\n     -+\t\t\t\tfilter->hunk.old_begin, old_count,\n     -+\t\t\t\tfilter->hunk.new_begin, new_count,\n     -+\t\t\t\tfilter->func, filter->funclen);\n     ++\t\t\t\tfilter->accumulating_hunk.old_begin, old_count,\n     ++\t\t\t\tfilter->accumulating_hunk.new_begin, new_count,\n     ++\t\t\t\tfilter->accumulating_hunk.func_name,\n     ++\t\t       filter->accumulating_hunk.func_name_len);\n      +\n       \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n       \tstrbuf_release(&hdr);\n     @@ diff.c: static void builtin_diffstat(const char *name_a, const char *name_b,\n       \tdiff_free_filespec_data(two);\n       }\n       \n     -+/*\n     -+ * Is the 0-based line index within any of the tracked ranges?\n     -+ * (range_set ranges are 0-based, half-open [start, end).)  This is a\n     -+ * one-shot query for a single line and scans; the streaming filter\n     -+ * (line_range_line_fn) uses a forward cursor instead.\n     -+ */\n      +static int idx_in_ranges(const struct range_set *ranges, long idx)\n      +{\n      +\tunsigned int i;\n     @@ diff.c: static void builtin_checkdiff(const char *name_a, const char *name_b,\n       \t\t\tcheck_blank_at_eof(&mf1, &mf2, &ecbdata);\n       \t\t\tblank_at_eof = ecbdata.blank_at_eof_in_postimage;\n       \n     -+\t\t\t/*\n     -+\t\t\t * check_blank_at_eof() scans the whole file; with -L,\n     -+\t\t\t * keep the report only when its line is in a tracked\n     -+\t\t\t * range.  The error's location is the first trailing\n     -+\t\t\t * blank line (blank_at_eof, 1-based; ranges 0-based), so\n     -+\t\t\t * we scope by that line.\n     -+\t\t\t */\n      +\t\t\tif (blank_at_eof && line_ranges &&\n      +\t\t\t    !idx_in_ranges(line_ranges, blank_at_eof - 1))\n      +\t\t\t\tblank_at_eof = 0;\n     @@ revision.c: int setup_revisions(int argc, const char **argv, struct rev_info *re\n       \tif (revs->expand_tabs_in_log < 0)\n      \n       ## t/t4211-line-log.sh ##\n     -@@ t/t4211-line-log.sh: test_expect_success '--summary shows new file on root commit' '\n     - \ttest_grep \"create mode 100644 file.c\" actual\n     +@@ t/t4211-line-log.sh: test_expect_success 'get_commit_action() does not mutate a not-yet-walked commit\n     + \t)\n       '\n       \n      +test_expect_success 'setup for --check test' '\n     @@ t/t4211-line-log.sh: test_expect_success '--summary shows new file on root commi\n      +\tgit add check.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add check.c\" &&\n     -+\t# Introduce trailing whitespace errors in both functions\n      +\tsed \"s/return;/return; /\" check.c >check.c.tmp &&\n      +\tmv check.c.tmp check.c &&\n      +\tgit commit -a -m \"introduce trailing whitespace\"\n      +'\n      +\n     -+test_expect_success '--check scoped to tracked range with correct file line' '\n     -+\t# tracked() trailing whitespace is at check.c:3; report it with the\n     -+\t# real file line number, not a count from the start of the range\n     -+\t# hunk.  other() at check.c:8 is outside the range and is excluded.\n     -+\ttest_must_fail git log -L:tracked:check.c --check --format= >actual &&\n     -+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n     -+\ttest_grep ! \"check.c:8:\" actual\n     -+'\n     ++test_expect_success '--check is limited to tracked ranges and reports real file line numbers' '\n     ++\ttest_must_fail git log -L:tracked:check.c --check --format= >raw &&\n     ++\tgrep -E \":[0-9]+:\" raw >actual &&\n     ++\techo \"check.c:3: trailing whitespace.\" >expect &&\n     ++\ttest_cmp expect actual &&\n      +\n     -+test_expect_success '--check reports each of several tracked ranges' '\n     -+\t# Track both functions as separate ranges.  Each range is flushed\n     -+\t# as its own hunk, so the second error must report its real file\n     -+\t# line (check.c:8), not continue the numbering from the first\n     -+\t# range (check.c:3).\n      +\ttest_must_fail git log -L:tracked:check.c -L:other:check.c \\\n     -+\t\t--check --format= >actual &&\n     -+\ttest_grep \"check.c:3: trailing whitespace\" actual &&\n     -+\ttest_grep \"check.c:8: trailing whitespace\" actual\n     ++\t\t--check --format= >raw &&\n     ++\tgrep -E \":[0-9]+:\" raw >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tcheck.c:3: trailing whitespace.\n     ++\tcheck.c:8: trailing whitespace.\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--check line numbers stay correct across a gap in one range' '\n     ++test_expect_success '--check reports each error at its real line across a gap in one range' '\n      +\tgit checkout --orphan check-gap &&\n      +\tgit reset --hard &&\n      +\tcat >gap.c <<-\\EOF &&\n     @@ t/t4211-line-log.sh: test_expect_success '--summary shows new file on root commi\n      +\tgit add gap.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add gap.c\" &&\n     -+\t# Two trailing-whitespace errors within one tracked range,\n     -+\t# separated by clean lines.  ctxlen is inflated to the range span,\n     -+\t# so they land in a single xdiff hunk with the gap as context;\n     -+\t# both must report their real file line number, with the context\n     -+\t# lines between them counted.\n      +\tsed -e \"s/int a = 1;/int a = 1; /\" -e \"s/int g = 7;/int g = 7; /\" gap.c >tmp &&\n      +\tmv tmp gap.c &&\n      +\tgit commit -a -m \"ws errors with a gap\" &&\n     -+\ttest_must_fail git log -L:tracked:gap.c --check --format= >actual &&\n     -+\ttest_grep \"gap.c:3: trailing whitespace\" actual &&\n     -+\ttest_grep \"gap.c:8: trailing whitespace\" actual\n     ++\ttest_must_fail git log -L:tracked:gap.c --check --format= >raw &&\n     ++\tgrep -E \":[0-9]+:\" raw >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tgap.c:3: trailing whitespace.\n     ++\tgap.c:8: trailing whitespace.\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--check does not report blank-at-eof outside the range' '\n     @@ t/t4211-line-log.sh: test_expect_success '--summary shows new file on root commi\n      +\tgit add eof.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add eof.c\" &&\n     -+\t# One commit introduces a trailing-whitespace error inside tracked()\n     -+\t# (line 3) and a blank line at end of file (line 7, outside the\n     -+\t# range).  The blank-at-eof check scans the whole file, so it must be\n     -+\t# scoped: report the in-range error, not the out-of-range EOF blank.\n      +\tprintf \"void tracked()\\n{\\n    return; \\n}\\n\\nint tail = 1;\\n\\n\" >eof.c &&\n      +\tgit commit -a -m \"ws in range, blank at eof out of range\" &&\n     -+\ttest_must_fail git log -L:tracked:eof.c --check --format= >actual &&\n     -+\ttest_grep \"eof.c:3: trailing whitespace\" actual &&\n     -+\ttest_grep ! \"blank line at EOF\" actual\n     ++\ttest_must_fail git log -L:tracked:eof.c --check --format= >raw &&\n     ++\tgrep -E \":[0-9]+:\" raw >actual &&\n     ++\techo \"eof.c:3: trailing whitespace.\" >expect &&\n     ++\ttest_cmp expect actual\n      +'\n      +\n       test_done\n 7:  df83e6275b ! 7:  cff5c124ec diffcore-pickaxe: scope -G to the -L tracked range\n     @@ Metadata\n      Author: Michael Montalbo <mmontalbo@gmail.com>\n      \n       ## Commit message ##\n     -    diffcore-pickaxe: scope -G to the -L tracked range\n     +    diffcore-pickaxe: limit -G to the -L tracked range\n      \n     -    git log -L scopes its diff output to the tracked range, but pickaxe\n     -    (-S, -G) still runs in diffcore over the whole-file change, so -L -G\n     -    selects a commit whenever the pattern appears in any added or removed\n     -    line of the file, even outside the tracked range.\n     +    Teach -G to only search the line ranges specified by -L.\n     +    Teaching -S is left as future work, so it still matches the entire\n     +    file even if -L is specified.\n      \n     -    Teach -G to honor the range.  diff_grep() already runs an xdiff pass\n     -    and greps the +/- lines; route that pass through the line-range filter\n     -    so only the tracked range's lines are grepped.  Expose the filter as\n     -    diff_emit_line_ranges(), an xdi_diff_outf() that emits only the tracked\n     -    range's lines, thread the filepair's line_ranges through the pickaxe\n     -    callback, and pass it from pickaxe_match().  Skip scoping under\n     -    textconv, whose output is not in the original file's line coordinates.\n     +    Rather than being part of diff.c's builtin implementations, the\n     +    diffcore-pickaxe functionality interacts with xdiff-interface as a\n     +    separate component. Add a sibling to xdi_diff_outf(), called\n     +    diff_emit_line_ranges(), that limits emitted lines to the given line\n     +    ranges.\n      \n     -    -G needs only a hit/no-hit answer, so the line-number concerns the\n     -    filter handles for patch and check output do not apply here.\n     +    Use diff_emit_line_ranges() when searching text if line ranges have\n     +    been specified. If textconv is enabled, use normal diffing instead of\n     +    diff_emit_line_ranges() since line range tracking relies on the line\n     +    coordinates of the original, pre-textconv file.\n      \n     -    -S is left matching the whole file: it counts needle occurrences per\n     -    blob rather than grepping the diff, so scoping it needs a different\n     -    approach, left to a follow-up.  has_changes() takes the range parameter\n     -    but ignores it for now.\n     -\n     -    Document the resulting -L pickaxe scoping: -G is scoped to the tracked\n     -    range, while -S still matches the whole file.\n     +    Update documentation and add tests accordingly.\n      \n          Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>\n      \n     @@ Documentation/line-range-options.adoc\n       Patch formatting options such as `--word-diff`, `--color-moved`,\n       `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n      -as are pickaxe options (`-S`, `-G`) and `--diff-filter`.\n     -+as are pickaxe options (`-S`, `-G`) and `--diff-filter`.  `-G` is\n     -+scoped to the tracked range; `-S` is still evaluated over the whole\n     -+file, so an `-S` query may select a commit for a change outside the\n     -+range.\n     ++as are pickaxe options (`-S`, `-G`) and `--diff-filter`. `-G` is\n     ++limited to the tracked range. In contrast, `-S` is evaluated over the whole\n     ++file and may select a commit with a change outside the tracked range.\n       +\n       include::line-range-format.adoc[]\n      \n     @@ diff.c: static int line_range_filter_diff(struct line_range_filter *filter,\n       \treturn ret;\n       }\n       \n     -+/*\n     -+ * Expose the in-file line-range filter to callers outside diff.c (e.g.\n     -+ * pickaxe -G); see xdiff-interface.h for the contract.\n     -+ */\n      +int diff_emit_line_ranges(mmfile_t *one, mmfile_t *two,\n      +\t\t\t  const struct range_set *ranges,\n      +\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n     @@ diffcore-pickaxe.c: static int diffgrep_consume(void *priv, char *line, unsigned\n       {\n       \tstruct diffgrep_cb ecbdata;\n       \txpparam_t xpp;\n     -@@ diffcore-pickaxe.c: static int diff_grep(mmfile_t *one, mmfile_t *two,\n     - \tint ret;\n     - \n     - \t/*\n     --\t * We have both sides; need to run textual diff and see if\n     --\t * the pattern appears on added/deleted lines.\n     -+\t * We have both sides; need to run textual diff and see if the\n     -+\t * pattern appears on added/deleted lines.  Under -L (ranges set),\n     -+\t * forward only the tracked range's lines so the match is scoped.\n     -+\t * -G needs only a hit/no-hit answer, so the line-number bookkeeping\n     -+\t * the filter does for -L patch and check output is irrelevant here.\n     - \t */\n     - \tmemset(&xpp, 0, sizeof(xpp));\n     - \tmemset(&xecfg, 0, sizeof(xecfg));\n      @@ diffcore-pickaxe.c: static int diff_grep(mmfile_t *one, mmfile_t *two,\n       \t * An xdiff error might be our \"data->hit\" from above. See the\n       \t * comment for xdiff_emit_line_fn in xdiff-interface.h\n     @@ diffcore-pickaxe.c: static unsigned int contains(mmfile_t *mf, regex_t *regexp,\n      +\t\t       const struct range_set *ranges UNUSED)\n       {\n      +\t/*\n     -+\t * -S counts needle occurrences in each whole blob.  Scoping this to\n     -+\t * a -L range is left to a follow-up; for now -S ignores the range.\n     ++\t * -S counts needle occurrences in each whole blob. Limiting this to\n     ++\t * an -L range is left as a follow-up; for now -S ignores the range.\n      +\t */\n       \tunsigned int c1 = one ? contains(one, regexp, kws, 0) : 0;\n       \tunsigned int c2 = two ? contains(two, regexp, kws, c1 + 1) : 0;\n     @@ diffcore-pickaxe.c: static int pickaxe_match(struct diff_filepair *p, struct dif\n       \n      -\tret = fn(&mf1, &mf2, o, regexp, kws);\n      +\t/*\n     -+\t * -L scopes the search to the tracked range, but the range is in\n     -+\t * original-file line coordinates that do not map onto textconv\n     -+\t * output, so search the whole file when textconv is in play.\n     ++\t * -L limits the search to the tracked range, but the range is in\n     ++\t * pre-textconv line coordinates that do not map onto textconv\n     ++\t * output, so search the whole file when textconv is enabled.\n      +\t */\n      +\tranges = (textconv_one || textconv_two) ? NULL : p->line_ranges;\n      +\tret = fn(&mf1, &mf2, o, regexp, kws, ranges);\n     @@ diffcore-pickaxe.c: static int pickaxe_match(struct diff_filepair *p, struct dif\n       \t\tfree(mf1.ptr);\n      \n       ## t/t4211-line-log.sh ##\n     -@@ t/t4211-line-log.sh: test_expect_success '-L with -S filters to string-count changes' '\n     - test_expect_success '-L with -G filters to diff-text matches' '\n     +@@ t/t4211-line-log.sh: test_expect_success '-L suppresses deletions outside tracked range' '\n     + \ttest $(grep -c \"^diff --git\" actual) = 1\n     + '\n     + \n     +-test_expect_success '-L with -S filters to string-count changes' '\n     ++test_expect_success '-L with -S selects only the matching commit' '\n     + \tgit checkout parent-oids &&\n     +-\tgit log -L:func2:file.c -S \"F2 + 2\" --format= >actual &&\n     +-\t# -S searches the whole file, not just the tracked range;\n     +-\t# combined with the -L range walk, this selects commits that\n     +-\t# both touch func2 and change the count of \"F2 + 2\" in the file.\n     +-\ttest $(grep -c \"^diff --git\" actual) = 1 &&\n     +-\ttest_grep \"F2 + 2\" actual\n     ++\tgit log -L:func2:file.c -S \"F2 + 2\" --format=%s --no-patch >actual &&\n     ++\techo \"Modify func2() in file.c\" >expect &&\n     ++\ttest_cmp expect actual\n     + '\n     + \n     +-test_expect_success '-L with -G filters to diff-text matches' '\n     ++test_expect_success '-L with -G selects only the matching commit' '\n       \tgit checkout parent-oids &&\n     - \tgit log -L:func2:file.c -G \"F2 [+] 2\" --format= >actual &&\n     +-\tgit log -L:func2:file.c -G \"F2 [+] 2\" --format= >actual &&\n      -\t# -G greps the whole-file diff text, not just the tracked range;\n      -\t# combined with -L, this selects commits that both touch func2\n      -\t# and have \"F2 + 2\" in their diff.\n     -+\t# -G greps the diff text, and under -L only the lines in the\n     -+\t# tracked range (unlike -S above, which searches the whole file);\n     -+\t# this selects commits whose change to func2 contains \"F2 + 2\".\n     - \ttest $(grep -c \"^diff --git\" actual) = 1 &&\n     - \tgrep \"F2 + 2\" actual\n     +-\ttest $(grep -c \"^diff --git\" actual) = 1 &&\n     +-\ttest_grep \"F2 + 2\" actual\n     ++\tgit log -L:func2:file.c -G \"F2 [+] 2\" --format=%s --no-patch >actual &&\n     ++\techo \"Modify func2() in file.c\" >expect &&\n     ++\ttest_cmp expect actual\n       '\n     + \n     + test_expect_success 'setup for trailing deletion test' '\n      @@ t/t4211-line-log.sh: test_expect_success '--check does not report blank-at-eof outside the range' '\n     - \ttest_grep ! \"blank line at EOF\" actual\n     + \ttest_cmp expect actual\n       '\n       \n     -+test_expect_success '-L -G is scoped to the tracked range' '\n     -+\tgit checkout --orphan grep-scope &&\n     ++test_expect_success '-L -G is limited to the tracked range' '\n     ++\tgit checkout --orphan grep-range &&\n      +\tgit reset --hard &&\n      +\tcat >gp.c <<-\\EOF &&\n      +\tint func1()\n     @@ t/t4211-line-log.sh: test_expect_success '--check does not report blank-at-eof o\n      +\tsed -e \"s/ALPHA/ALPHA2/\" -e \"s/BETA/BETA2/\" gp.c >tmp &&\n      +\tmv tmp gp.c &&\n      +\tgit commit -a -m \"touch both functions\" &&\n     -+\t# The commit changes ALPHA (func1) and BETA (func2).  Tracking func2,\n     -+\t# -G BETA matches its in-range change; -G ALPHA must not, since ALPHA\n     -+\t# changes only outside the tracked range.\n     -+\tgit log -L:func2:gp.c -G BETA --format=%s >actual &&\n     -+\ttest_grep \"touch both functions\" actual &&\n     -+\tgit log -L:func2:gp.c -G ALPHA --format=%s >actual &&\n     -+\ttest_grep ! \"touch both functions\" actual\n     ++\tgit log -L:func2:gp.c -G BETA --format=%s --no-patch >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\ttouch both functions\n     ++\tadd gp.c\n     ++\tEOF\n     ++\ttest_cmp expect actual &&\n     ++\tgit log -L:func2:gp.c -G ALPHA --format=%s --no-patch >actual &&\n     ++\ttest_must_be_empty actual\n      +'\n      +\n      +test_expect_success '-L -G searches the whole file under textconv' '\n     @@ t/t4211-line-log.sh: test_expect_success '--check does not report blank-at-eof o\n      +\tgit add tc.c &&\n      +\ttest_tick &&\n      +\tgit commit -m \"add tc.c\" &&\n     -+\t# One commit changes func1 and func2; MAGIC lands only in the\n     -+\t# func2 change, outside func1.\n     -+\tsed -e \"s/F1/F1 + 1/\" -e \"s/return F2/return MAGIC/\" tc.c >tmp &&\n     ++\tsed -e \"s/F1/F1 + 1/\" -e \"s/return F2/return FINDME/\" tc.c >tmp &&\n      +\tmv tmp tc.c &&\n      +\tgit commit -a -m \"change both funcs\" &&\n      +\techo \"tc.c diff=tc\" >.gitattributes &&\n     -+\n     -+\t# Without a textconv driver, -G is scoped to func1, so MAGIC (only\n     -+\t# in the func2 change) does not select the commit.\n     -+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n     ++\tgit log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual &&\n      +\ttest_must_be_empty actual &&\n     -+\n     -+\t# A textconv driver makes the range (original-file line numbers)\n     -+\t# meaningless against the driver output, so -G falls back to the\n     -+\t# whole file and MAGIC now selects the commit.\n      +\tgit config diff.tc.textconv cat &&\n     -+\tgit log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual &&\n     -+\ttest_grep \"change both funcs\" actual\n     ++\tgit log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual &&\n     ++\techo \"change both funcs\" >expect &&\n     ++\ttest_cmp expect actual\n      +'\n      +\n       test_done\n     @@ xdiff-interface.h: int xdi_diff_outf(mmfile_t *mf1, mmfile_t *mf2,\n      +struct range_set;\n      +/*\n      + * Like xdi_diff_outf(), but forwards only the lines within the given\n     -+ * (post-image) line ranges to line_fn, as \"git log -L\" scopes its output.\n     -+ * Returns line_fn's latched return value (so a consumer can signal a hit\n     -+ * with a non-zero return), or non-zero on xdiff failure.  Defined in\n     -+ * diff.c (it reuses the line-range filter there).\n     ++ * postimage line ranges to line_fn.\n      + */\n      +int diff_emit_line_ranges(mmfile_t *mf1, mmfile_t *mf2,\n      +\t\t\t  const struct range_set *ranges,\n\n-- \ngitgitgadget\n"},{"id":"551820","messageId":"020e07c0ea82c732bdb0702d2ec211503bd6f6e6.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 2/7] diff: simplify the line-range filter by classifying removals immediately","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:14Z","receivedAt":"2026-09-03T05:05:25Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nCurrently, the diff line-range filter buffers preimage removal lines\nuntil a postimage line arrives. That line's number confirms whether the\npreimage line falls in a relevant range. However, storing preimage\nlines in a separate buffer is unnecessary. Worse, the logic has a bug:\na preimage line outside the target range is included when it\nimmediately follows an in-range postimage line.\n\nPreimage lines will always precede their postimage counterpart both in\ncontent line number and emission order from xdiff's line callback\nfunction. So preimage lines can share the postimage buffer. The filter\nflushes them based on whether the postimage lines fall within the\ntarget range.\n\nRemove logic related to storing preimage lines in a separate \"removal\"\nbuffer and prepending them to the accumulating_hunk's line buffer.\nInstead, store those lines in the accumulating_hunk's line_buffer\nimmediately and flush everything as appropriate based on postimage\nline numbers that arrive. This resolves the bug by construction.\n\nAlso, calculate the old and new line counts for the diff hunk header\nwhen flushing rather than storing counters in line_range_filter to\nsimplify state management further.\n\nAdd a test to t/t4211-line-log.sh that verifies the preimage line\nemission bug is fixed.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c              | 121 +++++++++++++++++---------------------------\n t/t4211-line-log.sh |  31 ++++++++++++\n 2 files changed, 78 insertions(+), 74 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex 679a0e27d4..c94ddbebe5 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -621,19 +621,14 @@ struct line_range_filter {\n \tstruct {\n \t\tchar func_name[80];\n \t\tlong func_name_len;\n-\t\tlong old_begin, old_count;\n-\t\tlong new_begin, new_count;\n+\t\tlong old_begin;\n+\t\tlong new_begin;\n \t\tlong lno_in_preimage;\n \t\tlong lno_in_postimage;\n \t\tstruct strbuf lines;\n \t\tint active;\n-\t\tint has_changes;\n \t} accumulating_hunk;\n \n-\tstruct strbuf pending_rm;\n-\tint pending_rm_count;\n-\tlong pending_rm_pre_begin;\n-\n \tint ret;\n };\n \n@@ -2522,40 +2517,56 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n-static void discard_pending_rm(struct line_range_filter *filter)\n+static void begin_range_hunk(struct line_range_filter *filter)\n {\n-\tstrbuf_reset(&filter->pending_rm);\n-\tfilter->pending_rm_count = 0;\n+\tfilter->accumulating_hunk.active = 1;\n+\tfilter->accumulating_hunk.new_begin = filter->accumulating_hunk.lno_in_postimage;\n+\tfilter->accumulating_hunk.old_begin = filter->accumulating_hunk.lno_in_preimage;\n+\tstrbuf_reset(&filter->accumulating_hunk.lines);\n }\n \n static void flush_range_hunk(struct line_range_filter *filter)\n {\n \tstruct strbuf hdr = STRBUF_INIT;\n \tconst char *line_buf, *line_buf_end;\n+\tlong old_count = 0, new_count = 0;\n+\tint has_changes = 0;\n \n \tif (!filter->accumulating_hunk.active || filter->ret)\n \t\treturn;\n \n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n-\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->accumulating_hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n+\tline_buf = filter->accumulating_hunk.lines.buf;\n+\tline_buf_end = line_buf + filter->accumulating_hunk.lines.len;\n+\twhile (line_buf < line_buf_end) {\n+\t\tconst char *eol = memchr(line_buf, '\\n', line_buf_end - line_buf);\n+\t\tif (*line_buf == ' ') {\n+\t\t\told_count++;\n+\t\t\tnew_count++;\n+\t\t}\n+\t\telse if (*line_buf == '-') {\n+\t\t\told_count++;\n+\t\t\thas_changes = 1;\n+\t\t}\n+\t\telse if (*line_buf == '+') {\n+\t\t\tnew_count++;\n+\t\t\thas_changes = 1;\n+\t\t}\n+\t\tline_buf = eol ? eol + 1 : line_buf_end;\n \t}\n \n-\tif (!filter->accumulating_hunk.has_changes) {\n+\tif (!has_changes) {\n \t\tfilter->accumulating_hunk.active = 0;\n \t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n \t\treturn;\n \t}\n \n \tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count,\n-\t\t    filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count);\n+\t\t    filter->accumulating_hunk.old_begin, old_count,\n+\t\t    filter->accumulating_hunk.new_begin, new_count);\n \tif (filter->accumulating_hunk.func_name_len > 0) {\n \t\tstrbuf_addch(&hdr, ' ');\n \t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n-\t     filter->accumulating_hunk.func_name_len);\n+\t\t\t   filter->accumulating_hunk.func_name_len);\n \t}\n \tstrbuf_addch(&hdr, '\\n');\n \n@@ -2598,84 +2609,48 @@ static void line_range_hunk_fn(void *data,\n static int line_range_line_fn(void *priv, char *line, unsigned long len)\n {\n \tstruct line_range_filter *filter = priv;\n-\tconst struct range *cur;\n-\tlong idx_in_postimage, cur_pre;\n+\tlong idx_in_postimage;\n+\tint in_range;\n \n \tif (filter->ret)\n \t\treturn filter->ret;\n \n-\tif (line[0] == '-') {\n-\t\tif (!filter->pending_rm_count)\n-\t\t\tfilter->pending_rm_pre_begin =\n-\t\t\t\tfilter->accumulating_hunk.lno_in_preimage;\n-\t\tfilter->accumulating_hunk.lno_in_preimage++;\n-\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\tfilter->pending_rm_count++;\n-\t\treturn filter->ret;\n-\t}\n-\n \tif (line[0] == '\\\\') {\n-\t\tif (filter->pending_rm_count)\n-\t\t\tstrbuf_add(&filter->pending_rm, line, len);\n-\t\telse if (filter->accumulating_hunk.active)\n+\t\tif (filter->accumulating_hunk.active)\n \t\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n \t\treturn filter->ret;\n \t}\n \n-\tif (line[0] != '+' && line[0] != ' ')\n+\tif (line[0] != '+' && line[0] != ' ' && line[0] != '-')\n \t\tBUG(\"unexpected diff line type '%c'\", line[0]);\n \n \tidx_in_postimage = filter->accumulating_hunk.lno_in_postimage - 1;\n-\tcur_pre = filter->accumulating_hunk.lno_in_preimage;\n-\tfilter->accumulating_hunk.lno_in_postimage++;\n-\tif (line[0] == ' ')\n-\t\tfilter->accumulating_hunk.lno_in_preimage++;\n \n \twhile (filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n \t       idx_in_postimage >=\n \t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) {\n \t\tif (filter->accumulating_hunk.active)\n \t\t\tflush_range_hunk(filter);\n-\t\tdiscard_pending_rm(filter);\n \t\tfilter->range_set_idx++;\n \t}\n \n-\tif (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n-\t}\n-\n-\tcur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx];\n-\n-\tif (idx_in_postimage < cur->start) {\n-\t\tdiscard_pending_rm(filter);\n-\t\treturn filter->ret;\n-\t}\n+\tin_range = filter->range_set_idx < filter->range_sets_to_filter_by->nr &&\n+\t\t   idx_in_postimage >=\n+\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].start &&\n+\t\t   idx_in_postimage <\n+\t\tfilter->range_sets_to_filter_by->ranges[filter->range_set_idx].end;\n \n-\tif (!filter->accumulating_hunk.active) {\n-\t\tfilter->accumulating_hunk.active = 1;\n-\t\tfilter->accumulating_hunk.has_changes = 0;\n-\t\tfilter->accumulating_hunk.new_begin = idx_in_postimage + 1;\n-\t\tfilter->accumulating_hunk.old_begin = filter->pending_rm_count\n-\t\t\t? filter->pending_rm_pre_begin : cur_pre;\n-\t\tfilter->accumulating_hunk.old_count = 0;\n-\t\tfilter->accumulating_hunk.new_count = 0;\n-\t\tstrbuf_reset(&filter->accumulating_hunk.lines);\n-\t}\n+\tif (in_range) {\n+\t\tif (!filter->accumulating_hunk.active)\n+\t\t\tbegin_range_hunk(filter);\n \n-\tif (filter->pending_rm_count) {\n-\t\tstrbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm);\n-\t\tfilter->accumulating_hunk.old_count += filter->pending_rm_count;\n-\t\tfilter->accumulating_hunk.has_changes = 1;\n-\t\tdiscard_pending_rm(filter);\n+\t\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n \t}\n \n-\tstrbuf_add(&filter->accumulating_hunk.lines, line, len);\n-\tfilter->accumulating_hunk.new_count++;\n-\tif (line[0] == '+')\n-\t\tfilter->accumulating_hunk.has_changes = 1;\n-\telse\n-\t\tfilter->accumulating_hunk.old_count++;\n+\tif (line[0] == ' ' || line[0] == '+')\n+\t\tfilter->accumulating_hunk.lno_in_postimage++;\n+\tif (line[0] == ' ' || line[0] == '-')\n+\t\tfilter->accumulating_hunk.lno_in_preimage++;\n \n \treturn filter->ret;\n }\n@@ -4033,7 +4008,6 @@ static void builtin_diff(const char *name_a,\n \t\t\tlr_state.orig_cb_data = &ecbdata;\n \t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n \t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n-\t\t\tstrbuf_init(&lr_state.pending_rm, 0);\n \n \t\t\t/*\n \t\t\t * Inflate ctxlen so that all changes within\n@@ -4068,7 +4042,6 @@ static void builtin_diff(const char *name_a,\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n \t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n-\t\t\tstrbuf_release(&lr_state.pending_rm);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex d0a834ed8f..233dc232e3 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -738,6 +738,37 @@ test_expect_success '-L with -G filters to diff-text matches' '\n \ttest_grep \"F2 + 2\" actual\n '\n \n+test_expect_success 'setup for trailing deletion test' '\n+\tgit checkout --orphan trailing-del &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 1;\n+\t}\n+\t// trailing comment outside tracked range\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add file with trailing comment\" &&\n+\t# Remove the trailing comment AND modify tracked() so there\n+\t# is a modification to the line range we track and a\n+\t# modification to the following line, which we do not track.\n+\tcat >file.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return 2;\n+\t}\n+\tEOF\n+\tgit commit -a -m \"modify tracked and delete trailing comment\"\n+'\n+\n+test_expect_success '-L does not include deletions past end of tracked range' '\n+\tgit log -L:tracked:file.c --format= -1 -p >actual &&\n+\ttest_grep \"return 2\" actual &&\n+\ttest_grep ! \"trailing comment\" actual\n+'\n+\n test_expect_success '-L with --diff-filter=M excludes root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --diff-filter=M --format=%s --no-patch >actual &&\n-- \ngitgitgadget\n\n"},{"id":"551821","messageId":"2081d5d257d65b7b823fc46777e952a2f650e6dd.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 3/7] diff: emit -L hunk headers via xdiff's formatter","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:15Z","receivedAt":"2026-09-03T05:05:26Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nCurrently, diff's line-range filter implements its own method for\nemitting diff hunk headers. This mostly matches what xdiff itself\noutputs, but there is a discrepancy for postimage or preimage sides\nwith 0 line changes. For a side with no lines (count 0), the begin is\nthe line before the change. The header omits the line count of 1.\n\nRather than fix this case in the line-range implementation, expose the\nfunction xdiff uses to emit its headers. Reusing it keeps the header\nformat consistent with and without -L.\n\nUpdate test scenarios and fixtures to reflect the now consistent header\nformat.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c                                   | 21 ++++++++-------------\n t/t4211/sha1/expect.no-assertion-error   |  2 +-\n t/t4211/sha1/expect.vanishes-early       |  6 +++---\n t/t4211/sha256/expect.no-assertion-error |  2 +-\n t/t4211/sha256/expect.vanishes-early     |  6 +++---\n xdiff-interface.c                        | 19 +++++++++++++++++++\n xdiff-interface.h                        | 13 +++++++++++++\n 7 files changed, 48 insertions(+), 21 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex c94ddbebe5..cb1a85c624 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2560,15 +2560,10 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\treturn;\n \t}\n \n-\tstrbuf_addf(&hdr, \"@@ -%ld,%ld +%ld,%ld @@\",\n-\t\t    filter->accumulating_hunk.old_begin, old_count,\n-\t\t    filter->accumulating_hunk.new_begin, new_count);\n-\tif (filter->accumulating_hunk.func_name_len > 0) {\n-\t\tstrbuf_addch(&hdr, ' ');\n-\t\tstrbuf_add(&hdr, filter->accumulating_hunk.func_name,\n-\t\t\t   filter->accumulating_hunk.func_name_len);\n-\t}\n-\tstrbuf_addch(&hdr, '\\n');\n+\txdiff_emit_hunk_header(&hdr, filter->accumulating_hunk.old_begin, old_count,\n+\t\t\t       filter->accumulating_hunk.new_begin, new_count,\n+\t\t\t       filter->accumulating_hunk.func_name,\n+\t\t\tfilter->accumulating_hunk.func_name_len);\n \n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n@@ -2589,14 +2584,14 @@ static void flush_range_hunk(struct line_range_filter *filter)\n }\n \n static void line_range_hunk_fn(void *data,\n-\t\t\t       long old_begin, long old_nr UNUSED,\n-\t\t\t       long new_begin, long new_nr UNUSED,\n+\t\t\t       long old_begin, long old_nr,\n+\t\t\t       long new_begin, long new_nr,\n \t\t\t       const char *func, long funclen)\n {\n \tstruct line_range_filter *filter = data;\n \n-\tfilter->accumulating_hunk.lno_in_postimage = new_begin;\n-\tfilter->accumulating_hunk.lno_in_preimage = old_begin;\n+\tfilter->accumulating_hunk.lno_in_postimage = new_nr ? new_begin : new_begin + 1;\n+\tfilter->accumulating_hunk.lno_in_preimage = old_nr ? old_begin : old_begin + 1;\n \n \tif (funclen > 0) {\n \t\tif (funclen > (long)sizeof(filter->accumulating_hunk.func_name))\ndiff --git a/t/t4211/sha1/expect.no-assertion-error b/t/t4211/sha1/expect.no-assertion-error\nindex 54c568f273..95faf51a7b 100644\n--- a/t/t4211/sha1/expect.no-assertion-error\n+++ b/t/t4211/sha1/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index bf79c2f..27c829c 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha1/expect.vanishes-early b/t/t4211/sha1/expect.vanishes-early\nindex a413ad3659..e4b1a201d5 100644\n--- a/t/t4211/sha1/expect.vanishes-early\n+++ b/t/t4211/sha1/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index 0b9cae5..5de3ea4 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 100b61a6f2f720f812620a9d10afb3a960ccb73c\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index 5e709a1..0b9cae5 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..444e415\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/t/t4211/sha256/expect.no-assertion-error b/t/t4211/sha256/expect.no-assertion-error\nindex c25f2ce19c..815d27f7f1 100644\n--- a/t/t4211/sha256/expect.no-assertion-error\n+++ b/t/t4211/sha256/expect.no-assertion-error\n@@ -8,7 +8,7 @@ diff --git a/b.c b/b.c\n index 69cb69c..a0d566e 100644\n --- a/b.c\n +++ b/b.c\n-@@ -25,0 +18,9 @@\n+@@ -24,0 +18,9 @@\n +long f(long x)\n +{\n +\tint s = 0;\ndiff --git a/t/t4211/sha256/expect.vanishes-early b/t/t4211/sha256/expect.vanishes-early\nindex bc33b963dc..263fc9eaac 100644\n--- a/t/t4211/sha256/expect.vanishes-early\n+++ b/t/t4211/sha256/expect.vanishes-early\n@@ -8,7 +8,7 @@ diff --git a/a.c b/a.c\n index e4fa1d8..62c1fc2 100644\n --- a/a.c\n +++ b/a.c\n-@@ -23,0 +24,1 @@ int main ()\n+@@ -22,0 +24 @@ int main ()\n +/* incomplete lines are bad! */\n \n commit 29f32ac3141c48b22803e5c4127b719917b67d0f8ca8c5248bebfa2a19f7da10\n@@ -21,7 +21,7 @@ diff --git a/a.c b/a.c\n index d325124..e4fa1d8 100644\n --- a/a.c\n +++ b/a.c\n-@@ -22,1 +22,1 @@ int main ()\n+@@ -22 +22 @@ int main ()\n -}\n +}\n \\ No newline at end of file\n@@ -37,5 +37,5 @@ new file mode 100644\n index 0000000..9f550c3\n --- /dev/null\n +++ b/a.c\n-@@ -0,0 +20,1 @@\n+@@ -0,0 +20 @@\n +}\ndiff --git a/xdiff-interface.c b/xdiff-interface.c\nindex db6938689f..686db8bb8b 100644\n--- a/xdiff-interface.c\n+++ b/xdiff-interface.c\n@@ -91,6 +91,25 @@ static int xdiff_outf(void *priv_, mmbuffer_t *mb, int nbuf)\n \treturn 0;\n }\n \n+static int strbuf_out_line(void *priv, mmbuffer_t *mb, int nbuf)\n+{\n+\tstruct strbuf *out = priv;\n+\tint i;\n+\tfor (i = 0; i < nbuf; i++)\n+\t\tstrbuf_add(out, mb[i].ptr, mb[i].size);\n+\treturn 0;\n+}\n+\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_count,\n+\t\t\t    long new_begin, long new_count,\n+\t\t\t    const char *func, long funclen)\n+{\n+\txdemitcb_t ecb = { .priv = out, .out_line = strbuf_out_line };\n+\txdl_emit_hunk_hdr(old_begin, old_count, new_begin, new_count,\n+\t\t\t  func, funclen, &ecb);\n+}\n+\n /*\n  * Trim down common substring at the end of the buffers,\n  * but end on a complete line.\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex ce54e1c0e0..2428456629 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -76,4 +76,17 @@ int xdiff_compare_lines(const char *l1, long s1,\n  */\n unsigned long xdiff_hash_string(const char *s, size_t len, long flags);\n \n+struct strbuf;\n+\n+/*\n+ * Append a unified-diff hunk header to `out`, e.g.\n+ * \"@@ -<old> +<new> @@ func\\n\". The header comes from wrapping xdiff's\n+ * own hunk-header emitter, so it matches what a normal diff would\n+ * produce for the given line number begins and line counts.\n+ */\n+void xdiff_emit_hunk_header(struct strbuf *out,\n+\t\t\t    long old_begin, long old_nr,\n+\t\t\t    long new_begin, long new_nr,\n+\t\t\t    const char *func, long funclen);\n+\n #endif\n-- \ngitgitgadget\n\n"},{"id":"551822","messageId":"6b13c13ae72a24504aaa23be8d63571e6197aa1e.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 4/7] diff: extract a line-range diff helper for reuse","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:16Z","receivedAt":"2026-09-03T05:05:27Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nExtract logic for initializing the line-range filter and running a diff\nfor a specific line range. This logic is needed for any diff that\ntargets a line range independent of the current patch display path.\n\nThe subsequent commits use this logic to enable additional line range\ntargeted diff modes.\n\nNo logical behavior change.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n diff.c | 87 ++++++++++++++++++++++++++++++++--------------------------\n 1 file changed, 48 insertions(+), 39 deletions(-)\n\ndiff --git a/diff.c b/diff.c\nindex cb1a85c624..a7604a773a 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2517,6 +2517,18 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED\n \treturn 1;\n }\n \n+static void line_range_filter_init(struct line_range_filter *filter,\n+\t\t\t\t   const struct range_set *ranges,\n+\t\t\t\t   xdiff_emit_line_fn line_fn,\n+\t\t\t\t   void *cb_data)\n+{\n+\tmemset(filter, 0, sizeof(*filter));\n+\tfilter->orig_line_fn = line_fn;\n+\tfilter->orig_cb_data = cb_data;\n+\tfilter->range_sets_to_filter_by = ranges;\n+\tstrbuf_init(&filter->accumulating_hunk.lines, 0);\n+}\n+\n static void begin_range_hunk(struct line_range_filter *filter)\n {\n \tfilter->accumulating_hunk.active = 1;\n@@ -2650,6 +2662,37 @@ static int line_range_line_fn(void *priv, char *line, unsigned long len)\n \treturn filter->ret;\n }\n \n+\n+static int line_range_filter_diff(struct line_range_filter *filter,\n+\t\t\t\t  mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tconst struct range_set *ranges = filter->range_sets_to_filter_by;\n+\tlong max_span = 0;\n+\tunsigned int i;\n+\tint ret;\n+\n+\tfor (i = 0; i < ranges->nr; i++) {\n+\t\tlong span = ranges->ranges[i].end - ranges->ranges[i].start;\n+\t\tif (span > max_span)\n+\t\t\tmax_span = span;\n+\t}\n+\tif (max_span > xecfg->ctxlen)\n+\t\txecfg->ctxlen = max_span;\n+\n+\t/* the filter seeds its per-image position from hunk headers */\n+\txecfg->flags &= ~XDL_EMIT_NO_HUNK_HDR;\n+\n+\tret = xdi_diff_outf(mf1, mf2, line_range_hunk_fn,\n+\t\t\t    line_range_line_fn, filter, xpp, xecfg);\n+\tif (!ret) {\n+\t\tflush_range_hunk(filter);\n+\t\tret = filter->ret;\n+\t}\n+\tstrbuf_release(&filter->accumulating_hunk.lines);\n+\treturn ret;\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\n@@ -3994,49 +4037,15 @@ static void builtin_diff(const char *name_a,\n \t\t\txdi_diff_outf(&mf1, &mf2, NULL, quick_consume,\n \t\t\t\t      &ecbdata, &xpp, &xecfg);\n \t\t} else if (line_ranges) {\n-\t\t\tstruct line_range_filter lr_state;\n-\t\t\tunsigned int i;\n-\t\t\tlong max_span = 0;\n+\t\t\tstruct line_range_filter lr_filter;\n \n-\t\t\tmemset(&lr_state, 0, sizeof(lr_state));\n-\t\t\tlr_state.orig_line_fn = fn_out_consume;\n-\t\t\tlr_state.orig_cb_data = &ecbdata;\n-\t\t\tlr_state.range_sets_to_filter_by = line_ranges;\n-\t\t\tstrbuf_init(&lr_state.accumulating_hunk.lines, 0);\n-\n-\t\t\t/*\n-\t\t\t * Inflate ctxlen so that all changes within\n-\t\t\t * any single range are merged into one xdiff\n-\t\t\t * hunk and the inter-change context is emitted.\n-\t\t\t * The callback clips back to range boundaries.\n-\t\t\t *\n-\t\t\t * The optimal ctxlen depends on where changes\n-\t\t\t * fall within the range, which is only known\n-\t\t\t * after xdiff runs; the max range span is the\n-\t\t\t * upper bound that guarantees correctness in a\n-\t\t\t * single pass.\n-\t\t\t */\n-\t\t\tfor (i = 0; i < line_ranges->nr; i++) {\n-\t\t\t\tlong span = line_ranges->ranges[i].end -\n-\t\t\t\t\t    line_ranges->ranges[i].start;\n-\t\t\t\tif (span > max_span)\n-\t\t\t\t\tmax_span = span;\n-\t\t\t}\n-\t\t\tif (max_span > xecfg.ctxlen)\n-\t\t\t\txecfg.ctxlen = max_span;\n-\n-\t\t\tif (xdi_diff_outf(&mf1, &mf2,\n-\t\t\t\t\t  line_range_hunk_fn,\n-\t\t\t\t\t  line_range_line_fn,\n-\t\t\t\t\t  &lr_state, &xpp, &xecfg))\n-\t\t\t\tdie(\"unable to generate diff for %s\",\n-\t\t\t\t    one->path);\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       fn_out_consume, &ecbdata);\n \n-\t\t\tflush_range_hunk(&lr_state);\n-\t\t\tif (lr_state.ret)\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n \t\t\t\tdie(\"unable to generate diff for %s\",\n \t\t\t\t    one->path);\n-\t\t\tstrbuf_release(&lr_state.accumulating_hunk.lines);\n \t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume,\n \t\t\t\t\t &ecbdata, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diff for %s\", one->path);\n-- \ngitgitgadget\n\n"},{"id":"551824","messageId":"f12b61b9a408d23b8d8741571a9e9e030a69b6e0.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 5/7] diff: support stat formats with -L","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:17Z","receivedAt":"2026-09-03T05:05:29Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nReuse the line_range_filter in builtin_diffstat() so -L supports\nthe stat formats and add tests verifying the new behavior.\n\nUngate the newly enabled options and drop \"yet\" from the generic\n-L rejection message (\"does not yet support the requested diff\nformat\"). Some rejected formats do not fit -L at all, so \"yet\"\nwrongly implies they are all awaiting support.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  10 +-\n diff.c                                |  13 ++-\n revision.c                            |   6 +-\n t/t4211-line-log.sh                   | 130 ++++++++++++++++++++++----\n 4 files changed, 133 insertions(+), 26 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 72f639b5e7..b3e8b5c62c 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -9,10 +9,12 @@\n \t_<start>_ and _<end>_ (or _<funcname>_) must exist in the starting revision.\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n-\tNon-patch diff formats `--raw`, `--name-only`, `--name-status`,\n-\tand `--summary` are supported.  Diff stat formats\n-\t(`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not\n-\tcurrently implemented.\n+\tThe following non-patch diff formats are supported: `--raw`,\n+\t`--name-only`, `--name-status`, `--summary`, `--stat`, `--numstat`,\n+\tand `--shortstat`. The stat formats count only lines within the tracked\n+\trange. `--dirstat` is not supported with `-L`: it summarizes change as each\n+\tdirectory's share of the total churn, not as counts for the tracked lines.\n+\tUse `--numstat` for exact per-file counts within the range.\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\ndiff --git a/diff.c b/diff.c\nindex a7604a773a..4a30d7b631 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -4162,7 +4162,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \t\txecfg.ctxlen = o->context;\n \t\txecfg.interhunkctxlen = o->interhunkcontext;\n \t\txecfg.flags = XDL_EMIT_NO_HUNK_HDR;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, NULL,\n+\n+\t\tif (p->line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, p->line_ranges,\n+\t\t\t\t\t       diffstat_consume, diffstat);\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate diffstat for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, NULL,\n \t\t\t\t  diffstat_consume, diffstat, &xpp, &xecfg))\n \t\t\tdie(\"unable to generate diffstat for %s\", one->path);\n \ndiff --git a/revision.c b/revision.c\nindex 35afe52208..4639c0df8e 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3229,8 +3229,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t     (revs->diffopt.output_format &\n \t      ~(DIFF_FORMAT_PATCH | DIFF_FORMAT_NO_OUTPUT |\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n-\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY))))\n-\t\tdie(_(\"-L does not yet support the requested diff format\"));\n+\t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n+\t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n+\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\n \t\trevs->expand_tabs_in_log = revs->expand_tabs_in_log_default;\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex 233dc232e3..4e8f71c289 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -176,24 +176,9 @@ test_expect_success '--name-status shows status and path' '\n \ttest_grep ! \"^@@\" actual\n '\n \n-test_expect_success '--stat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --stat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--numstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --numstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--shortstat is not yet supported with -L' '\n-\ttest_must_fail git log -L1,24:b.c --shortstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n-'\n-\n-test_expect_success '--dirstat is not yet supported with -L' '\n+test_expect_success '--dirstat is not supported with -L' '\n \ttest_must_fail git log -L1,24:b.c --dirstat 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success 'setup for checking fancy rename following' '\n@@ -793,9 +778,9 @@ test_expect_success '-L with -S suppresses non-matching commits' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success '--full-diff is not yet supported with -L' '\n+test_expect_success '--full-diff is not supported with -L' '\n \ttest_must_fail git log -L1,24:b.c --full-diff 2>err &&\n-\ttest_grep \"does not yet support\" err\n+\ttest_grep \"does not support\" err\n '\n \n test_expect_success '-L --oneline has no extra blank line before diff' '\n@@ -806,6 +791,113 @@ test_expect_success '-L --oneline has no extra blank line before diff' '\n \ttest_grep \"^diff --git\" line2\n '\n \n+test_expect_success 'setup for -L stat tests' '\n+\tgit checkout --orphan stat-range &&\n+\tgit reset --hard &&\n+\tcat >file.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint tracked_fn()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add file.c &&\n+\ttest_tick &&\n+\tgit commit -m \"Add func1() and tracked_fn()\" &&\n+\n+\t# Modify both functions so whole-file stats (2 added, 2 deleted)\n+\t# differ from the tracked range of tracked_fn (1 and 1).\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" file.c >tmp &&\n+\tmv tmp file.c &&\n+\tgit commit -a -m \"Modify both functions\"\n+'\n+\n+test_expect_success '-L --numstat limits counts to the tracked range' '\n+\tgit log -L:tracked_fn:file.c --numstat --format=%s >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tModify both functions\n+\n+\t1\t1\tfile.c\n+\tAdd func1() and tracked_fn()\n+\n+\t4\t0\tfile.c\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '-L --stat and --shortstat limit counts to the tracked range' '\n+\tgit log -L:tracked_fn:file.c --stat --format=%s -1 >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tModify both functions\n+\n+\t file.c | 2 +-\n+\t 1 file changed, 1 insertion(+), 1 deletion(-)\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\tgit log -L:tracked_fn:file.c --shortstat --format=%s -1 >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tModify both functions\n+\n+\t 1 file changed, 1 insertion(+), 1 deletion(-)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--numstat across renames and multiple commits' '\n+\t# parallel-change carries the tracked function f across an a.c -> b.c\n+\t# rename and a merge of two parallel histories.\n+\tgit checkout parallel-change &&\n+\tgit log -M -L \":f:b.c\" --format= --numstat >actual &&\n+\tcat >expect <<-\\EOF &&\n+\t1\t1\tb.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t1\ta.c\n+\t1\t0\ta.c\n+\t13\t0\ta.c\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '-L multiple ranges with --numstat excludes untracked change' '\n+\tgit checkout --orphan multi-range &&\n+\tgit reset --hard &&\n+\tcat >m.c <<-\\EOF &&\n+\tint tracked_func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint tracked_func2()\n+\t{\n+\t    return F2;\n+\t}\n+\n+\tint func3()\n+\t{\n+\t    return F3;\n+\t}\n+\tEOF\n+\tgit add m.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add m.c\" &&\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/F2/F2 + 2/\" -e \"s/F3/F3 + 3/\" m.c >tmp &&\n+\tmv tmp m.c &&\n+\tgit commit -a -m \"Modify all three functions\" &&\n+\tgit log -L:tracked_func1:m.c -L:tracked_func2:m.c --numstat --format=%s -1 >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tModify all three functions\n+\n+\t2\t2\tm.c\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success '--summary shows new file on root commit' '\n \tgit checkout parent-oids &&\n \tgit log -L:func2:file.c --summary --format= >actual &&\n-- \ngitgitgadget\n\n"},{"id":"551825","messageId":"f1f5af8f77694ec4f775bcf33f17ed2cd00670e3.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 6/7] diff: support --check with -L line ranges","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:18Z","receivedAt":"2026-09-03T05:05:30Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nReuse the line_range_filter in builtin_checkdiff() so -L supports\nthe --check option.\n\nAdd orig_hunk_fn field similar to orig_line_fn that forwards\nxdiff_emit_hunk_fn calls when we flush filtered hunks. This is necessary\nbecause --check relies on receiving calls to its checkdiff_consume_hunk\nfunction for managing state.\n\nDocument and ungate the newly enabled option, and add tests verifying\nthe new behavior.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc | 11 ++--\n diff.c                                | 43 +++++++++++++-\n revision.c                            |  2 +-\n t/t4211-line-log.sh                   | 83 +++++++++++++++++++++++++++\n 4 files changed, 130 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex b3e8b5c62c..4a7ab97d75 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -10,11 +10,12 @@\n \tYou can specify this option more than once. Implies `--patch`.\n \tPatch output can be suppressed using `--no-patch`.\n \tThe following non-patch diff formats are supported: `--raw`,\n-\t`--name-only`, `--name-status`, `--summary`, `--stat`, `--numstat`,\n-\tand `--shortstat`. The stat formats count only lines within the tracked\n-\trange. `--dirstat` is not supported with `-L`: it summarizes change as each\n-\tdirectory's share of the total churn, not as counts for the tracked lines.\n-\tUse `--numstat` for exact per-file counts within the range.\n+\t`--name-only`, `--name-status`, `--summary`, `--check`, `--stat`,\n+\t`--numstat`, and `--shortstat`. The stat formats count only lines\n+\twithin the tracked range. `--dirstat` is not supported with `-L`: it\n+\treports how change is distributed across directories over whole files,\n+\twhich is not meaningful for line ranges within a file. Use `--numstat`\n+\tfor exact per-file counts within the range.\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\ndiff --git a/diff.c b/diff.c\nindex 4a30d7b631..49b6732c81 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -614,6 +614,7 @@ struct emit_callback {\n  */\n struct line_range_filter {\n \txdiff_emit_line_fn orig_line_fn;\n+\txdiff_emit_hunk_fn orig_hunk_fn;\n \tvoid *orig_cb_data;\n \tconst struct range_set *range_sets_to_filter_by;\n \tunsigned int range_set_idx;\n@@ -2577,6 +2578,13 @@ static void flush_range_hunk(struct line_range_filter *filter)\n \t\t\t       filter->accumulating_hunk.func_name,\n \t\t\tfilter->accumulating_hunk.func_name_len);\n \n+\tif (filter->orig_hunk_fn)\n+\t\tfilter->orig_hunk_fn(filter->orig_cb_data,\n+\t\t\t\tfilter->accumulating_hunk.old_begin, old_count,\n+\t\t\t\tfilter->accumulating_hunk.new_begin, new_count,\n+\t\t\t\tfilter->accumulating_hunk.func_name,\n+\t\t       filter->accumulating_hunk.func_name_len);\n+\n \tfilter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len);\n \tstrbuf_release(&hdr);\n \n@@ -4203,11 +4211,23 @@ static void builtin_diffstat(const char *name_a, const char *name_b,\n \tdiff_free_filespec_data(two);\n }\n \n+static int idx_in_ranges(const struct range_set *ranges, long idx)\n+{\n+\tunsigned int i;\n+\n+\tfor (i = 0; i < ranges->nr; i++)\n+\t\tif (idx >= ranges->ranges[i].start &&\n+\t\t    idx < ranges->ranges[i].end)\n+\t\t\treturn 1;\n+\treturn 0;\n+}\n+\n static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\t      const char *attr_path,\n \t\t\t      struct diff_filespec *one,\n \t\t\t      struct diff_filespec *two,\n-\t\t\t      struct diff_options *o)\n+\t\t\t      struct diff_options *o,\n+\t\t\t      const struct range_set *line_ranges)\n {\n \tmmfile_t mf1, mf2;\n \tstruct checkdiff_t data;\n@@ -4247,7 +4267,19 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\tmemset(&xecfg, 0, sizeof(xecfg));\n \t\txecfg.ctxlen = 1; /* at least one context line */\n \t\txpp.flags = 0;\n-\t\tif (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n+\n+\t\tif (line_ranges) {\n+\t\t\tstruct line_range_filter lr_filter;\n+\n+\t\t\tline_range_filter_init(&lr_filter, line_ranges,\n+\t\t\t\t\t       checkdiff_consume, &data);\n+\t\t\tlr_filter.orig_hunk_fn = checkdiff_consume_hunk;\n+\n+\t\t\tif (line_range_filter_diff(&lr_filter, &mf1, &mf2,\n+\t\t\t\t\t\t   &xpp, &xecfg))\n+\t\t\t\tdie(\"unable to generate checkdiff for %s\",\n+\t\t\t\t    one->path);\n+\t\t} else if (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk,\n \t\t\t\t  checkdiff_consume, &data,\n \t\t\t\t  &xpp, &xecfg))\n \t\t\tdie(\"unable to generate checkdiff for %s\", one->path);\n@@ -4260,6 +4292,10 @@ static void builtin_checkdiff(const char *name_a, const char *name_b,\n \t\t\tcheck_blank_at_eof(&mf1, &mf2, &ecbdata);\n \t\t\tblank_at_eof = ecbdata.blank_at_eof_in_postimage;\n \n+\t\t\tif (blank_at_eof && line_ranges &&\n+\t\t\t    !idx_in_ranges(line_ranges, blank_at_eof - 1))\n+\t\t\t\tblank_at_eof = 0;\n+\n \t\t\tif (blank_at_eof) {\n \t\t\t\tstatic char *err;\n \t\t\t\tif (!err)\n@@ -5055,7 +5091,8 @@ static void run_checkdiff(struct diff_filepair *p, struct diff_options *o)\n \tdiff_fill_oid_info(p->one, o->repo->index);\n \tdiff_fill_oid_info(p->two, o->repo->index);\n \n-\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o);\n+\tbuiltin_checkdiff(name, other, attr_path, p->one, p->two, o,\n+\t\t\t  p->line_ranges);\n }\n \n void repo_diff_setup(struct repository *r, struct diff_options *options)\ndiff --git a/revision.c b/revision.c\nindex 4639c0df8e..4cc0d032bc 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -3231,7 +3231,7 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s\n \t\tDIFF_FORMAT_RAW | DIFF_FORMAT_NAME |\n \t\tDIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |\n \t\tDIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |\n-\t\tDIFF_FORMAT_SHORTSTAT))))\n+\t\tDIFF_FORMAT_SHORTSTAT | DIFF_FORMAT_CHECKDIFF))))\n \t\tdie(_(\"-L does not support the requested diff format\"));\n \n \tif (revs->expand_tabs_in_log < 0)\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex 4e8f71c289..2a542aa643 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -924,4 +924,87 @@ test_expect_success 'get_commit_action() does not mutate a not-yet-walked commit\n \t)\n '\n \n+test_expect_success 'setup for --check test' '\n+\tgit checkout --orphan check-test &&\n+\tgit reset --hard &&\n+\tcat >check.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    return;\n+\t}\n+\n+\tvoid other()\n+\t{\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add check.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add check.c\" &&\n+\tsed \"s/return;/return; /\" check.c >check.c.tmp &&\n+\tmv check.c.tmp check.c &&\n+\tgit commit -a -m \"introduce trailing whitespace\"\n+'\n+\n+test_expect_success '--check is limited to tracked ranges and reports real file line numbers' '\n+\ttest_must_fail git log -L:tracked:check.c --check --format= >raw &&\n+\tgrep -E \":[0-9]+:\" raw >actual &&\n+\techo \"check.c:3: trailing whitespace.\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\ttest_must_fail git log -L:tracked:check.c -L:other:check.c \\\n+\t\t--check --format= >raw &&\n+\tgrep -E \":[0-9]+:\" raw >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tcheck.c:3: trailing whitespace.\n+\tcheck.c:8: trailing whitespace.\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--check reports each error at its real line across a gap in one range' '\n+\tgit checkout --orphan check-gap &&\n+\tgit reset --hard &&\n+\tcat >gap.c <<-\\EOF &&\n+\tvoid tracked()\n+\t{\n+\t    int a = 1;\n+\t    int b = 2;\n+\t    int c = 3;\n+\t    int d = 4;\n+\t    int e = 5;\n+\t    int g = 7;\n+\t    return;\n+\t}\n+\tEOF\n+\tgit add gap.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gap.c\" &&\n+\tsed -e \"s/int a = 1;/int a = 1; /\" -e \"s/int g = 7;/int g = 7; /\" gap.c >tmp &&\n+\tmv tmp gap.c &&\n+\tgit commit -a -m \"ws errors with a gap\" &&\n+\ttest_must_fail git log -L:tracked:gap.c --check --format= >raw &&\n+\tgrep -E \":[0-9]+:\" raw >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tgap.c:3: trailing whitespace.\n+\tgap.c:8: trailing whitespace.\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--check does not report blank-at-eof outside the range' '\n+\tgit checkout --orphan check-eof &&\n+\tgit reset --hard &&\n+\tprintf \"void tracked()\\n{\\n    return;\\n}\\n\\nint tail = 1;\\n\" >eof.c &&\n+\tgit add eof.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add eof.c\" &&\n+\tprintf \"void tracked()\\n{\\n    return; \\n}\\n\\nint tail = 1;\\n\\n\" >eof.c &&\n+\tgit commit -a -m \"ws in range, blank at eof out of range\" &&\n+\ttest_must_fail git log -L:tracked:eof.c --check --format= >raw &&\n+\tgrep -E \":[0-9]+:\" raw >actual &&\n+\techo \"eof.c:3: trailing whitespace.\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"551826","messageId":"cff5c124ecd9e28ea66921cebfa356ca38aa7c1e.1788411919.git.gitgitgadget@gmail.com","threadId":"65837","inReplyTo":"pull.2152.v3.git.1788411919.gitgitgadget@gmail.com","subject":"[PATCH v3 7/7] diffcore-pickaxe: limit -G to the -L tracked range","fromName":"Michael Montalbo via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-03T05:05:19Z","receivedAt":"2026-09-03T05:05:31Z","isPatch":true,"body":"From: Michael Montalbo <mmontalbo@gmail.com>\n\nTeach -G to only search the line ranges specified by -L.\nTeaching -S is left as future work, so it still matches the entire\nfile even if -L is specified.\n\nRather than being part of diff.c's builtin implementations, the\ndiffcore-pickaxe functionality interacts with xdiff-interface as a\nseparate component. Add a sibling to xdi_diff_outf(), called\ndiff_emit_line_ranges(), that limits emitted lines to the given line\nranges.\n\nUse diff_emit_line_ranges() when searching text if line ranges have\nbeen specified. If textconv is enabled, use normal diffing instead of\ndiff_emit_line_ranges() since line range tracking relies on the line\ncoordinates of the original, pre-textconv file.\n\nUpdate documentation and add tests accordingly.\n\nSigned-off-by: Michael Montalbo <mmontalbo@gmail.com>\n---\n Documentation/line-range-options.adoc |  4 +-\n diff.c                                | 11 ++++\n diffcore-pickaxe.c                    | 30 ++++++++--\n t/t4211-line-log.sh                   | 81 ++++++++++++++++++++++-----\n xdiff-interface.h                     | 10 ++++\n 5 files changed, 115 insertions(+), 21 deletions(-)\n\ndiff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc\nindex 4a7ab97d75..52e1262fd7 100644\n--- a/Documentation/line-range-options.adoc\n+++ b/Documentation/line-range-options.adoc\n@@ -19,6 +19,8 @@\n +\n Patch formatting options such as `--word-diff`, `--color-moved`,\n `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,\n-as are pickaxe options (`-S`, `-G`) and `--diff-filter`.\n+as are pickaxe options (`-S`, `-G`) and `--diff-filter`. `-G` is\n+limited to the tracked range. In contrast, `-S` is evaluated over the whole\n+file and may select a commit with a change outside the tracked range.\n +\n include::line-range-format.adoc[]\ndiff --git a/diff.c b/diff.c\nindex 49b6732c81..1a3571d229 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -2701,6 +2701,17 @@ static int line_range_filter_diff(struct line_range_filter *filter,\n \treturn ret;\n }\n \n+int diff_emit_line_ranges(mmfile_t *one, mmfile_t *two,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg)\n+{\n+\tstruct line_range_filter filter;\n+\n+\tline_range_filter_init(&filter, ranges, line_fn, cb_data);\n+\treturn line_range_filter_diff(&filter, one, two, xpp, xecfg);\n+}\n+\n static void pprint_rename(struct strbuf *name, const char *a, const char *b)\n {\n \tconst char *old_name = a;\ndiff --git a/diffcore-pickaxe.c b/diffcore-pickaxe.c\nindex b0915be86f..2425fe8101 100644\n--- a/diffcore-pickaxe.c\n+++ b/diffcore-pickaxe.c\n@@ -16,7 +16,8 @@\n \n typedef int (*pickaxe_fn)(mmfile_t *one, mmfile_t *two,\n \t\t\t  struct diff_options *o,\n-\t\t\t  regex_t *regexp, kwset_t kws);\n+\t\t\t  regex_t *regexp, kwset_t kws,\n+\t\t\t  const struct range_set *ranges);\n \n struct diffgrep_cb {\n \tregex_t *regexp;\n@@ -42,7 +43,8 @@ static int diffgrep_consume(void *priv, char *line, unsigned long len)\n \n static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t\t     struct diff_options *o,\n-\t\t     regex_t *regexp, kwset_t kws UNUSED)\n+\t\t     regex_t *regexp, kwset_t kws UNUSED,\n+\t\t     const struct range_set *ranges)\n {\n \tstruct diffgrep_cb ecbdata;\n \txpparam_t xpp;\n@@ -65,8 +67,12 @@ static int diff_grep(mmfile_t *one, mmfile_t *two,\n \t * An xdiff error might be our \"data->hit\" from above. See the\n \t * comment for xdiff_emit_line_fn in xdiff-interface.h\n \t */\n-\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n-\t\t\t    &ecbdata, &xpp, &xecfg);\n+\tif (ranges)\n+\t\tret = diff_emit_line_ranges(one, two, ranges, diffgrep_consume,\n+\t\t\t\t\t    &ecbdata, &xpp, &xecfg);\n+\telse\n+\t\tret = xdi_diff_outf(one, two, NULL, diffgrep_consume,\n+\t\t\t\t    &ecbdata, &xpp, &xecfg);\n \tif (ecbdata.hit)\n \t\treturn 1;\n \tif (ret)\n@@ -119,8 +125,13 @@ static unsigned int contains(mmfile_t *mf, regex_t *regexp, kwset_t kws,\n \n static int has_changes(mmfile_t *one, mmfile_t *two,\n \t\t       struct diff_options *o UNUSED,\n-\t\t       regex_t *regexp, kwset_t kws)\n+\t\t       regex_t *regexp, kwset_t kws,\n+\t\t       const struct range_set *ranges UNUSED)\n {\n+\t/*\n+\t * -S counts needle occurrences in each whole blob. Limiting this to\n+\t * an -L range is left as a follow-up; for now -S ignores the range.\n+\t */\n \tunsigned int c1 = one ? contains(one, regexp, kws, 0) : 0;\n \tunsigned int c2 = two ? contains(two, regexp, kws, c1 + 1) : 0;\n \treturn c1 != c2;\n@@ -132,6 +143,7 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tstruct userdiff_driver *textconv_one = NULL;\n \tstruct userdiff_driver *textconv_two = NULL;\n \tmmfile_t mf1, mf2;\n+\tconst struct range_set *ranges;\n \tint ret;\n \n \t/* ignore unmerged */\n@@ -169,7 +181,13 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o,\n \tmf1.size = fill_textconv(o->repo, textconv_one, p->one, &mf1.ptr);\n \tmf2.size = fill_textconv(o->repo, textconv_two, p->two, &mf2.ptr);\n \n-\tret = fn(&mf1, &mf2, o, regexp, kws);\n+\t/*\n+\t * -L limits the search to the tracked range, but the range is in\n+\t * pre-textconv line coordinates that do not map onto textconv\n+\t * output, so search the whole file when textconv is enabled.\n+\t */\n+\tranges = (textconv_one || textconv_two) ? NULL : p->line_ranges;\n+\tret = fn(&mf1, &mf2, o, regexp, kws, ranges);\n \n \tif (textconv_one)\n \t\tfree(mf1.ptr);\ndiff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh\nindex 2a542aa643..2354400d1c 100755\n--- a/t/t4211-line-log.sh\n+++ b/t/t4211-line-log.sh\n@@ -703,24 +703,18 @@ test_expect_success '-L suppresses deletions outside tracked range' '\n \ttest $(grep -c \"^diff --git\" actual) = 1\n '\n \n-test_expect_success '-L with -S filters to string-count changes' '\n+test_expect_success '-L with -S selects only the matching commit' '\n \tgit checkout parent-oids &&\n-\tgit log -L:func2:file.c -S \"F2 + 2\" --format= >actual &&\n-\t# -S searches the whole file, not just the tracked range;\n-\t# combined with the -L range walk, this selects commits that\n-\t# both touch func2 and change the count of \"F2 + 2\" in the file.\n-\ttest $(grep -c \"^diff --git\" actual) = 1 &&\n-\ttest_grep \"F2 + 2\" actual\n+\tgit log -L:func2:file.c -S \"F2 + 2\" --format=%s --no-patch >actual &&\n+\techo \"Modify func2() in file.c\" >expect &&\n+\ttest_cmp expect actual\n '\n \n-test_expect_success '-L with -G filters to diff-text matches' '\n+test_expect_success '-L with -G selects only the matching commit' '\n \tgit checkout parent-oids &&\n-\tgit log -L:func2:file.c -G \"F2 [+] 2\" --format= >actual &&\n-\t# -G greps the whole-file diff text, not just the tracked range;\n-\t# combined with -L, this selects commits that both touch func2\n-\t# and have \"F2 + 2\" in their diff.\n-\ttest $(grep -c \"^diff --git\" actual) = 1 &&\n-\ttest_grep \"F2 + 2\" actual\n+\tgit log -L:func2:file.c -G \"F2 [+] 2\" --format=%s --no-patch >actual &&\n+\techo \"Modify func2() in file.c\" >expect &&\n+\ttest_cmp expect actual\n '\n \n test_expect_success 'setup for trailing deletion test' '\n@@ -1007,4 +1001,63 @@ test_expect_success '--check does not report blank-at-eof outside the range' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '-L -G is limited to the tracked range' '\n+\tgit checkout --orphan grep-range &&\n+\tgit reset --hard &&\n+\tcat >gp.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return ALPHA;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return BETA;\n+\t}\n+\tEOF\n+\tgit add gp.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add gp.c\" &&\n+\tsed -e \"s/ALPHA/ALPHA2/\" -e \"s/BETA/BETA2/\" gp.c >tmp &&\n+\tmv tmp gp.c &&\n+\tgit commit -a -m \"touch both functions\" &&\n+\tgit log -L:func2:gp.c -G BETA --format=%s --no-patch >actual &&\n+\tcat >expect <<-\\EOF &&\n+\ttouch both functions\n+\tadd gp.c\n+\tEOF\n+\ttest_cmp expect actual &&\n+\tgit log -L:func2:gp.c -G ALPHA --format=%s --no-patch >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n+test_expect_success '-L -G searches the whole file under textconv' '\n+\tgit checkout --orphan grep-textconv &&\n+\tgit reset --hard &&\n+\tcat >tc.c <<-\\EOF &&\n+\tint func1()\n+\t{\n+\t    return F1;\n+\t}\n+\n+\tint func2()\n+\t{\n+\t    return F2;\n+\t}\n+\tEOF\n+\tgit add tc.c &&\n+\ttest_tick &&\n+\tgit commit -m \"add tc.c\" &&\n+\tsed -e \"s/F1/F1 + 1/\" -e \"s/return F2/return FINDME/\" tc.c >tmp &&\n+\tmv tmp tc.c &&\n+\tgit commit -a -m \"change both funcs\" &&\n+\techo \"tc.c diff=tc\" >.gitattributes &&\n+\tgit log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual &&\n+\ttest_must_be_empty actual &&\n+\tgit config diff.tc.textconv cat &&\n+\tgit log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual &&\n+\techo \"change both funcs\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\ndiff --git a/xdiff-interface.h b/xdiff-interface.h\nindex 2428456629..4151bc2097 100644\n--- a/xdiff-interface.h\n+++ b/xdiff-interface.h\n@@ -46,6 +46,16 @@ int xdi_diff_outf(mmfile_t *mf1, mmfile_t *mf2,\n \t\t  xdiff_emit_line_fn line_fn,\n \t\t  void *consume_callback_data,\n \t\t  xpparam_t const *xpp, xdemitconf_t const *xecfg);\n+\n+struct range_set;\n+/*\n+ * Like xdi_diff_outf(), but forwards only the lines within the given\n+ * postimage line ranges to line_fn.\n+ */\n+int diff_emit_line_ranges(mmfile_t *mf1, mmfile_t *mf2,\n+\t\t\t  const struct range_set *ranges,\n+\t\t\t  xdiff_emit_line_fn line_fn, void *cb_data,\n+\t\t\t  xpparam_t *xpp, xdemitconf_t *xecfg);\n int read_mmfile(mmfile_t *ptr, const char *filename);\n void read_mmblob(mmfile_t *ptr, struct object_database *odb,\n \t\t const struct object_id *oid);\n-- \ngitgitgadget\n"}]}