line-log: support diff stat formats with -L

Reuse the line_range_filter in builtin_diffstat() so the stat formats count only the lines within the tracked range. When a filepair carries line_ranges, the filter wraps diffstat_consume() as its output callback, forwarding only the lines inside the range for counting. flush_range_hunk() replays buffered content through diffstat_consume(), which ignores synthetic @@ headers since it only counts '+' and '-' lines. Expand the output format allowlist in setup_revisions() to accept --stat, --numstat, and --shortstat with -L. Leave --dirstat out of the allowlist so it is rejected like any other unsupported format. Its default mode counts each file's whole-file byte damage via diffcore_count_changes(), outside the line-based pipeline that the -L filter scopes, so bare --dirstat cannot honor the tracked range. The --dirstat=lines mode could: it aggregates the same per-file line counts as --numstat, which -L already scopes. But accepting only that sub-mode while bare --dirstat keeps erroring is a confusing split, so the whole format is deferred to a follow-up; --numstat already reports the exact per-file counts within the tracked range. Also drop "yet" from the generic -L rejection message ("does not yet support the requested diff format"). Some rejected formats do not fit a line range at all, so "yet" wrongly implied they are all just awaiting support. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Michael Montalbo committed Jun 27, 2026 at 17:28 UTC 660aae7323bca80e4441479ccd3b83e60dc16477
4 files changed +155 -26
Documentation/line-range-options.adoc
+8 -4
@@ -9,10 +9,14 @@
9 _<start>_ and _<end>_ (or _<funcname>_) must exist in the starting revision.
10 You can specify this option more than once. Implies `--patch`.
11 Patch output can be suppressed using `--no-patch`.
12 - Non-patch diff formats `--raw`, `--name-only`, `--name-status`,
13 - and `--summary` are supported. Diff stat formats
14 - (`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not
15 - currently implemented.
12 + The following non-patch diff formats are supported: `--raw`,
13 + `--name-only`, `--name-status`, `--summary`,
14 + `--stat`, `--numstat`, and `--shortstat`.
15 + The stat formats count only lines within the tracked range.
16 + `--dirstat` is not supported
17 + with `-L`: it summarizes change as each directory's share of
18 + the total churn, not as counts for the tracked lines. Use
19 + `--numstat` for exact per-file counts within the range.
20 +
21 Patch formatting options such as `--word-diff`, `--color-moved`,
22 `--no-prefix`, and whitespace options (`-w`, `-b`) are supported,
diff.c
+12 -1
@@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b,
4289 xecfg.ctxlen = o->context;
4290 xecfg.interhunkctxlen = o->interhunkcontext;
4291 xecfg.flags = XDL_EMIT_NO_HUNK_HDR;
4292 - if (xdi_diff_outf(&mf1, &mf2, NULL,
4292 +
4293 + if (p->line_ranges) {
4294 + struct line_range_filter lr_filter;
4295 +
4296 + line_range_filter_init(&lr_filter, p->line_ranges,
4297 + diffstat_consume, diffstat);
4298 +
4299 + if (line_range_filter_diff(&lr_filter, &mf1, &mf2,
4300 + &xpp, &xecfg))
4301 + die("unable to generate diffstat for %s",
4302 + one->path);
4303 + } else if (xdi_diff_outf(&mf1, &mf2, NULL,
4304 diffstat_consume, diffstat, &xpp, &xecfg))
4305 die("unable to generate diffstat for %s", one->path);
4306
revision.c
+4 -2
@@ -3193,8 +3193,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s
3193 (revs->diffopt.output_format &
3194 ~(DIFF_FORMAT_PATCH | DIFF_FORMAT_NO_OUTPUT |
3195 DIFF_FORMAT_RAW | DIFF_FORMAT_NAME |
3196 - DIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY))))
3197 - die(_("-L does not yet support the requested diff format"));
3196 + DIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY |
3197 + DIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT |
3198 + DIFF_FORMAT_SHORTSTAT))))
3199 + die(_("-L does not support the requested diff format"));
3200
3201 if (revs->expand_tabs_in_log < 0)
3202 revs->expand_tabs_in_log = revs->expand_tabs_in_log_default;
t/t4211-line-log.sh
+131 -19
@@ -176,24 +176,15 @@ test_expect_success '--name-status shows status and path' '
176 test_grep ! "^@@" actual
177 '
178
179 -test_expect_success '--stat is not yet supported with -L' '
180 - test_must_fail git log -L1,24:b.c --stat 2>err &&
181 - test_grep "does not yet support" err
182 -'
183 -
184 -test_expect_success '--numstat is not yet supported with -L' '
185 - test_must_fail git log -L1,24:b.c --numstat 2>err &&
186 - test_grep "does not yet support" err
187 -'
188 -
189 -test_expect_success '--shortstat is not yet supported with -L' '
190 - test_must_fail git log -L1,24:b.c --shortstat 2>err &&
191 - test_grep "does not yet support" err
192 -'
193 -
194 -test_expect_success '--dirstat is not yet supported with -L' '
179 +test_expect_success '--dirstat is not supported with -L' '
180 + # --dirstat is not supported with -L: its default mode measures
181 + # whole-file change, not the tracked lines, and the
182 + # --dirstat=lines variant is deferred too, so both forms are
183 + # rejected like any other unsupported format.
184 test_must_fail git log -L1,24:b.c --dirstat 2>err &&
196 - test_grep "does not yet support" err
185 + test_grep "does not support" err &&
186 + test_must_fail git log -L1,24:b.c --dirstat=lines 2>err &&
187 + test_grep "does not support" err
188 '
189
190 test_expect_success 'setup for checking fancy rename following' '
@@ -887,9 +878,9 @@ test_expect_success '-L with -S suppresses non-matching commits' '
878 test_cmp expect actual
879 '
880
890 -test_expect_success '--full-diff is not yet supported with -L' '
881 +test_expect_success '--full-diff is not supported with -L' '
882 test_must_fail git log -L1,24:b.c --full-diff 2>err &&
892 - test_grep "does not yet support" err
883 + test_grep "does not support" err
884 '
885
886 test_expect_success '-L --oneline has no extra blank line before diff' '
@@ -900,6 +891,127 @@ test_expect_success '-L --oneline has no extra blank line before diff' '
891 test_grep "^diff --git" line2
892 '
893
894 +test_expect_success 'setup for stat range-scoping tests' '
895 + git checkout --orphan stat-scoping &&
896 + git reset --hard &&
897 + cat >file.c <<-\EOF &&
898 + int func1()
899 + {
900 + return F1;
901 + }
902 +
903 + int func2()
904 + {
905 + return F2;
906 + }
907 + EOF
908 + git add file.c &&
909 + test_tick &&
910 + git commit -m "Add func1() and func2()" &&
911 +
912 + # Modify both functions in a single commit so that
913 + # whole-file stats differ from the counts for the tracked range.
914 + sed -e "s/F1/F1 + 1/" -e "s/F2/F2 + 2/" file.c >tmp &&
915 + mv tmp file.c &&
916 + git commit -a -m "Modify both functions"
917 +'
918 +
919 +test_expect_success '--numstat counts only lines in tracked range' '
920 + # "Modify both functions" changes one line in func1 and one in
921 + # func2. Whole-file numstat would show 2 added, 2 deleted.
922 + # numstat for func2 within the tracked range should show only 1 and 1.
923 + git log -L:func2:file.c --numstat --format=%s -1 >actual &&
924 + test_grep "Modify both functions" actual &&
925 + test_grep "^1 1 file.c$" actual &&
926 + test_grep ! "^diff --git" actual
927 +'
928 +
929 +test_expect_success '--numstat counts only additions for root commit' '
930 + # Root commit creates both func1 (4 lines) and func2 (4 lines).
931 + # Whole-file numstat would show 9 lines added. numstat for func2
932 + # within the tracked range should show only 4.
933 + git log -L:func2:file.c --numstat --format=%s >actual &&
934 + test_grep "Add func1() and func2()" actual &&
935 + test_grep "^4 0 file.c$" actual &&
936 + test_grep ! "^diff --git" actual
937 +'
938 +
939 +test_expect_success '--stat counts only lines in tracked range' '
940 + git log -L:func2:file.c --stat --format=%s -1 >actual &&
941 + test_grep "Modify both functions" actual &&
942 + test_grep "file.c |" actual &&
943 + test_grep "1 insertion" actual &&
944 + test_grep "1 deletion" actual &&
945 + test_grep ! "^diff --git" actual
946 +'
947 +
948 +test_expect_success '--shortstat counts only lines in tracked range' '
949 + # --shortstat prints only the summary line: no per-file "file.c |"
950 + # line. Counts cover only the tracked range, as for --numstat above.
951 + git log -L:func2:file.c --shortstat --format=%s -1 >actual &&
952 + test_grep "Modify both functions" actual &&
953 + test_grep "1 insertion" actual &&
954 + test_grep "1 deletion" actual &&
955 + test_grep ! "file.c |" actual &&
956 + test_grep ! "^diff --git" actual
957 +'
958 +
959 +test_expect_success '--numstat across renames and multiple commits' '
960 + # parallel-change carries the tracked function f across an a.c -> b.c
961 + # rename and a merge of two parallel histories. With -M, --numstat
962 + # follows the rename and reports added/removed counts for f within
963 + # the tracked range (not whole-file) per commit; the file column flips from
964 + # b.c to a.c at the rename as the walk goes back in time. Commits
965 + # that do not change the range of f emit no row (the merge and the
966 + # pure file-move produce nothing), so there are fewer rows than
967 + # commits.
968 + git checkout parallel-change &&
969 + git log -M -L ":f:b.c" --format= --numstat >actual &&
970 + cat >expect <<-\EOF &&
971 + 1 1 b.c
972 + 1 1 a.c
973 + 1 1 a.c
974 + 1 1 a.c
975 + 1 0 a.c
976 + 13 0 a.c
977 + EOF
978 + test_cmp expect actual
979 +'
980 +
981 +test_expect_success '-L multiple ranges with --numstat excludes untracked change' '
982 + git checkout --orphan multi-range &&
983 + git reset --hard &&
984 + cat >m.c <<-\EOF &&
985 + int func1()
986 + {
987 + return F1;
988 + }
989 +
990 + int func2()
991 + {
992 + return F2;
993 + }
994 +
995 + int func3()
996 + {
997 + return F3;
998 + }
999 + EOF
1000 + git add m.c &&
1001 + test_tick &&
1002 + git commit -m "add m.c" &&
1003 + # Change all three functions but track only func1 and func2.
1004 + # Whole-file numstat would be 3 3; a 2 2 result proves the
1005 + # untracked func3 change is excluded and the two ranges just sum.
1006 + sed -e "s/F1/F1 + 1/" -e "s/F2/F2 + 2/" -e "s/F3/F3 + 3/" m.c >tmp &&
1007 + mv tmp m.c &&
1008 + git commit -a -m "Modify all three functions" &&
1009 + git log -L:func1:m.c -L:func2:m.c --numstat --format=%s -1 >actual &&
1010 + test_grep "Modify all three functions" actual &&
1011 + test_grep "^2 2 m.c$" actual &&
1012 + test_grep ! "^3 3 m.c$" actual
1013 +'
1014 +
1015 test_expect_success '--summary shows new file on root commit' '
1016 git checkout parent-oids &&
1017 git log -L:func2:file.c --summary --format= >actual &&