Subject: [PATCH] pretty: add diff-stat log placeholders

From: "Andrey Zarubin via GitGitGadget" <[email-masked]>
Date: Thu, 30 Apr 2026 19:55:03 +0000

From: Andrey Zarubin <[email-masked]>

Currently, users who want per-commit line/file change counts in
a custom log format must post-process `git log --shortstat`
output because the pretty formatter exposes no equivalent
placeholders.

Introduce `%(diff-stat:files)`, `%(diff-stat:insertions)`,
`%(diff-stat:deletions)`, and `%(diff-stat:lines)`, computed
from the same diffstat machinery as `--shortstat` and cached
once per commit during format expansion.

Short aliases are provided as `%aF`, `%aA`, and `%aR`. The
requested `%aI` and `%aD` forms are unavailable because those
names already expand to author dates, so use additions/removals
mnemonics instead.

When log output is already walking a diff, the formatter reuses
the current diff queue. Otherwise it computes a private summary
lazily, so formats without these placeholders still pay no diff
cost.

Signed-off-by: Andrey Zarubin <[email-masked]>
---
    pretty: add diff-stat log placeholders

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2284%2Fzarandr%2Fmaster-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2284/zarandr/master-v1
Pull-Request: https://github.com/git/git/pull/2284

 Documentation/pretty-formats.adoc |  12 +++
 builtin/log.c                     |   5 +
 diff.c                            |  32 ++++--
 diff.h                            |   8 ++
 log-tree.c                        |   2 +
 pretty.c                          | 166 ++++++++++++++++++++++++++++++
 pretty.h                          |   3 +
 t/t4205-log-pretty-formats.sh     | 162 +++++++++++++++++++++++++++++
 8 files changed, 381 insertions(+), 9 deletions(-)

diff --git a/Documentation/pretty-formats.adoc b/Documentation/pretty-formats.adoc
index 2ae0eb11a9..d1b574f3ad 100644
--- a/Documentation/pretty-formats.adoc
+++ b/Documentation/pretty-formats.adoc
@@ -294,6 +294,18 @@ tags are added or removed at the same time.
 `exclude=<pattern>`;; Do not consider tags matching the given
    `glob(7)` _<pattern>_, excluding the `refs/tags/` prefix.
 
+++%(diff-stat:files)++:: show the number of files changed
+++%(diff-stat:insertions)++:: show the number of inserted lines
+++%(diff-stat:deletions)++:: show the number of deleted lines
+++%(diff-stat:lines)++:: show the total number of inserted and deleted lines
++
+  These placeholders are computed like `--shortstat`. By default,
+  merge commits expand to `0` unless a merge diff mode such as `-m`,
+  `-c`, or `--cc` is in effect.
++%aF+:: short alias for `%(diff-stat:files)`
++%aA+:: short alias for `%(diff-stat:insertions)`
++%aR+:: short alias for `%(diff-stat:deletions)`
+
 +%S+:: ref name given on the command line by which the commit was reached
        (like `git log --source`), only works with `git log`
 +%e+:: encoding
diff --git a/builtin/log.c b/builtin/log.c
index 8c0939dd42..017face2c0 100644
--- a/builtin/log.c
+++ b/builtin/log.c
@@ -321,6 +321,11 @@ static void cmd_log_init_finish(int argc, const char **argv, const char *prefix,
 	memset(&w, 0, sizeof(w));
 	userformat_find_requirements(NULL, &w);
 
+	if (w.diffstat) {
+		rev->diff = 1;
+		rev->diffopt.output_format |= DIFF_FORMAT_NO_OUTPUT;
+	}
+
 	if (!rev->show_notes_given && (!rev->pretty_given || w.notes))
 		rev->show_notes = 1;
 	if (rev->show_notes)
diff --git a/diff.c b/diff.c
index 397e38b41c..2f018e801a 100644
--- a/diff.c
+++ b/diff.c
@@ -3195,12 +3195,14 @@ static void show_stats(struct diffstat_t *data, struct diff_options *options)
 	strbuf_release(&out);
 }
 
-static void show_shortstats(struct diffstat_t *data, struct diff_options *options)
+void summarize_diffstat(struct diffstat_t *data,
+			struct diff_stat_summary *summary)
 {
-	int i, adds = 0, dels = 0, total_files = data->nr;
+	int i;
 
-	if (data->nr == 0)
-		return;
+	summary->files = data->nr;
+	summary->insertions = 0;
+	summary->deletions = 0;
 
 	for (i = 0; i < data->nr; i++) {
 		int added = data->files[i]->added;
@@ -3208,13 +3210,25 @@ static void show_shortstats(struct diffstat_t *data, struct diff_options *option
 
 		if (data->files[i]->is_unmerged ||
 		    (!data->files[i]->is_interesting && (added + deleted == 0))) {
-			total_files--;
-		} else if (!data->files[i]->is_binary) { /* don't count bytes */
-			adds += added;
-			dels += deleted;
+			summary->files--;
+		} else if (!data->files[i]->is_binary) {
+			summary->insertions += added;
+			summary->deletions += deleted;
 		}
 	}
-	print_stat_summary_inserts_deletes(options, total_files, adds, dels);
+}
+
+static void show_shortstats(struct diffstat_t *data, struct diff_options *options)
+{
+	struct diff_stat_summary summary;
+
+	if (data->nr == 0)
+		return;
+
+	summarize_diffstat(data, &summary);
+	print_stat_summary_inserts_deletes(options, summary.files,
+					   summary.insertions,
+					   summary.deletions);
 }
 
 static void show_numstat(struct diffstat_t *data, struct diff_options *options)
diff --git a/diff.h b/diff.h
index 7eb84aadf4..798c52138d 100644
--- a/diff.h
+++ b/diff.h
@@ -449,6 +449,12 @@ struct diffstat_t {
 	} **files;
 };
 
+struct diff_stat_summary {
+	int files;
+	int insertions;
+	int deletions;
+};
+
 enum color_diff {
 	DIFF_RESET = 0,
 	DIFF_CONTEXT = 1,
@@ -581,6 +587,8 @@ struct diff_filepair *diff_unmerge(struct diff_options *, const char *path);
 
 void compute_diffstat(struct diff_options *options, struct diffstat_t *diffstat,
 		      struct diff_queue_struct *q);
+void summarize_diffstat(struct diffstat_t *diffstat,
+			struct diff_stat_summary *summary);
 void free_diffstat_info(struct diffstat_t *diffstat);
 
 #define DIFF_SETUP_REVERSE      	1
diff --git a/log-tree.c b/log-tree.c
index 7e048701d0..aa6f6dd27d 100644
--- a/log-tree.c
+++ b/log-tree.c
@@ -881,6 +881,8 @@ void show_log(struct rev_info *opt)
 	ctx.expand_tabs_in_log = opt->expand_tabs_in_log;
 	ctx.output_encoding = get_log_output_encoding();
 	ctx.rev = opt;
+	ctx.diff_parent = parent;
+	ctx.diff_queue_present = diff_queued_diff.nr > 0;
 	if (opt->from_ident.mail_begin && opt->from_ident.name_begin)
 		ctx.from_ident = &opt->from_ident;
 	if (opt->graph)
diff --git a/pretty.c b/pretty.c
index 814803980b..a50ecd31ce 100644
--- a/pretty.c
+++ b/pretty.c
@@ -10,6 +10,7 @@
 #include "hex.h"
 #include "utf8.h"
 #include "diff.h"
+#include "diffcore.h"
 #include "pager.h"
 #include "revision.h"
 #include "string-list.h"
@@ -893,6 +894,7 @@ struct format_commit_context {
 	const struct pretty_print_context *pretty_ctx;
 	unsigned commit_header_parsed:1;
 	unsigned commit_message_parsed:1;
+	unsigned diffstat_parsed:1;
 	struct signature_check signature_check;
 	enum flush_type flush_type;
 	enum trunc_type truncate;
@@ -911,6 +913,7 @@ struct format_commit_context {
 
 	/* The following ones are relative to the result struct strbuf. */
 	size_t wrap_start;
+	struct diff_stat_summary diffstat;
 };
 
 static void parse_commit_header(struct format_commit_context *context)
@@ -939,6 +942,145 @@ static void parse_commit_header(struct format_commit_context *context)
 	context->commit_header_parsed = 1;
 }
 
+enum diff_stat_placeholder {
+	DIFF_STAT_FILES,
+	DIFF_STAT_INSERTIONS,
+	DIFF_STAT_DELETIONS,
+	DIFF_STAT_LINES,
+};
+
+static void parse_commit_diffstat(struct format_commit_context *c)
+{
+	const struct pretty_print_context *pretty_ctx = c->pretty_ctx;
+	const struct rev_info *rev = pretty_ctx->rev;
+	struct diff_options opts;
+	struct diffstat_t diffstat;
+	const struct commit *commit = c->commit;
+	const struct commit *parent = pretty_ctx->diff_parent;
+	const struct object_id *tree_oid;
+	int copied_pathspec = 0;
+	int use_current_queue = 0;
+	int use_rev_opts = rev && rev->diffopt.repo;
+
+	if (c->diffstat_parsed)
+		return;
+	c->diffstat_parsed = 1;
+	memset(&c->diffstat, 0, sizeof(c->diffstat));
+
+	if (pretty_ctx->diff_queue_present) {
+		opts = rev->diffopt;
+		compute_diffstat(&opts, &diffstat, &diff_queued_diff);
+		summarize_diffstat(&diffstat, &c->diffstat);
+		free_diffstat_info(&diffstat);
+		return;
+	}
+
+	parse_commit_or_die((struct commit *)commit);
+	tree_oid = get_commit_tree_oid(commit);
+
+	if (use_rev_opts) {
+		memcpy(&opts, &rev->diffopt, sizeof(opts));
+		copy_pathspec(&opts.pathspec, &rev->diffopt.pathspec);
+		copied_pathspec = 1;
+	} else {
+		repo_diff_setup(c->repository, &opts);
+		init_diffstat_widths(&opts);
+		opts.flags.recursive = 1;
+		opts.flags.allow_textconv = 1;
+	}
+	opts.output_format = DIFF_FORMAT_SHORTSTAT;
+	diff_setup_done(&opts);
+
+	if (!commit->parents) {
+		if (use_rev_opts && !rev->show_root_diff)
+			goto out;
+		diff_root_tree_oid(tree_oid, "", &opts);
+		use_current_queue = 1;
+		goto diffstat;
+	}
+
+	if (!parent && commit->parents->next) {
+		if (!use_rev_opts)
+			goto out;
+		if (rev->combine_merges ||
+		    (rev->separate_merges && rev->first_parent_merges))
+			parent = commit->parents->item;
+		else
+			goto out;
+	} else if (!parent) {
+		parent = commit->parents->item;
+	}
+
+	parse_commit_or_die((struct commit *)parent);
+	diff_tree_oid(get_commit_tree_oid(parent), tree_oid, "", &opts);
+	use_current_queue = 1;
+
+diffstat:
+	diffcore_std(&opts);
+	compute_diffstat(&opts, &diffstat, &diff_queued_diff);
+	summarize_diffstat(&diffstat, &c->diffstat);
+	free_diffstat_info(&diffstat);
+out:
+	if (use_current_queue) {
+		opts.output_format = DIFF_FORMAT_NO_OUTPUT;
+		diff_flush(&opts);
+	}
+	if (copied_pathspec)
+		clear_pathspec(&opts.pathspec);
+	else
+		diff_free(&opts);
+}
+
+static void format_commit_diffstat(struct strbuf *sb,
+				   struct format_commit_context *c,
+				   enum diff_stat_placeholder which)
+{
+	int value;
+
+	parse_commit_diffstat(c);
+
+	switch (which) {
+	case DIFF_STAT_FILES:
+		value = c->diffstat.files;
+		break;
+	case DIFF_STAT_INSERTIONS:
+		value = c->diffstat.insertions;
+		break;
+	case DIFF_STAT_DELETIONS:
+		value = c->diffstat.deletions;
+		break;
+	case DIFF_STAT_LINES:
+		value = c->diffstat.insertions + c->diffstat.deletions;
+		break;
+	default:
+		BUG("unknown diff stat placeholder");
+	}
+
+	strbuf_addf(sb, "%d", value);
+}
+
+static size_t parse_diff_stat_placeholder(struct strbuf *sb,
+					  const char *placeholder,
+					  struct format_commit_context *c)
+{
+	const char *arg;
+	enum diff_stat_placeholder which;
+
+	if (skip_prefix(placeholder, "(diff-stat:files)", &arg))
+		which = DIFF_STAT_FILES;
+	else if (skip_prefix(placeholder, "(diff-stat:insertions)", &arg))
+		which = DIFF_STAT_INSERTIONS;
+	else if (skip_prefix(placeholder, "(diff-stat:deletions)", &arg))
+		which = DIFF_STAT_DELETIONS;
+	else if (skip_prefix(placeholder, "(diff-stat:lines)", &arg))
+		which = DIFF_STAT_LINES;
+	else
+		return 0;
+
+	format_commit_diffstat(sb, c, which);
+	return arg - placeholder;
+}
+
 static int istitlechar(char c)
 {
 	return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
@@ -1564,6 +1706,24 @@ static size_t format_commit_one(struct strbuf *sb, /* in UTF-8 */
 		return 7;
 	}
 
+	if (placeholder[0] == 'a') {
+		switch (placeholder[1]) {
+		case 'F':
+			format_commit_diffstat(sb, c, DIFF_STAT_FILES);
+			return 2;
+		case 'A':
+			format_commit_diffstat(sb, c, DIFF_STAT_INSERTIONS);
+			return 2;
+		case 'R':
+			format_commit_diffstat(sb, c, DIFF_STAT_DELETIONS);
+			return 2;
+		}
+	}
+
+	res = parse_diff_stat_placeholder(sb, placeholder, c);
+	if (res)
+		return res;
+
 	switch (placeholder[0]) {
 	case 'H':		/* commit hash */
 		strbuf_addstr(sb, diff_get_color(c->auto_color, DIFF_COMMIT));
@@ -1980,6 +2140,10 @@ void userformat_find_requirements(const char *fmt, struct userformat_want *w)
 			fmt++;
 
 		switch (*fmt) {
+		case 'a':
+			if (fmt[1] == 'F' || fmt[1] == 'A' || fmt[1] == 'R')
+				w->diffstat = 1;
+			break;
 		case 'N':
 			w->notes = 1;
 			break;
@@ -1993,6 +2157,8 @@ void userformat_find_requirements(const char *fmt, struct userformat_want *w)
 		case '(':
 			if (starts_with(fmt + 1, "decorate"))
 				w->decorate = 1;
+			else if (starts_with(fmt + 1, "diff-stat:"))
+				w->diffstat = 1;
 			break;
 		}
 	}
diff --git a/pretty.h b/pretty.h
index fac699033e..7f0491e512 100644
--- a/pretty.h
+++ b/pretty.h
@@ -58,6 +58,8 @@ struct pretty_print_context {
 	 */
 	struct string_list in_body_headers;
 	int graph_width;
+	const struct commit *diff_parent;
+	unsigned diff_queue_present:1;
 };
 
 /* Check whether commit format is mail. */
@@ -75,6 +77,7 @@ struct userformat_want {
 	unsigned notes:1;
 	unsigned source:1;
 	unsigned decorate:1;
+	unsigned diffstat:1;
 };
 void userformat_find_requirements(const char *fmt, struct userformat_want *w);
 
diff --git a/t/t4205-log-pretty-formats.sh b/t/t4205-log-pretty-formats.sh
index 3865f6abc7..230950baed 100755
--- a/t/t4205-log-pretty-formats.sh
+++ b/t/t4205-log-pretty-formats.sh
@@ -1227,4 +1227,166 @@ test_expect_failure 'wide and decomposed characters column counting' '
 	test_cmp expected actual
 '
 
+diffstat_log_shortstat_values () {
+	git -C diffstat log --shortstat --format=tformat:commit "$@" |
+	perl -ne '
+		chomp;
+		if ($_ eq "commit") {
+			if ($seen) {
+				print "$files $insertions $deletions ",
+				      $insertions + $deletions, "\n";
+			}
+			$seen = 1;
+			($files, $insertions, $deletions) = (0, 0, 0);
+		} elsif (/^\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?$/) {
+			$files = $1;
+			$insertions = defined($2) ? $2 : 0;
+			$deletions = defined($3) ? $3 : 0;
+		}
+		END {
+			if ($seen) {
+				print "$files $insertions $deletions ",
+				      $insertions + $deletions, "\n";
+			}
+		}
+	'
+}
+
+test_diff_stat_placeholders () {
+	commit=$1
+	shift &&
+	diffstat_log_shortstat_values -1 "$@" "$commit" >expected &&
+	git -C diffstat log -1 \
+		--format="%(diff-stat:files) %(diff-stat:insertions) %(diff-stat:deletions) %(diff-stat:lines)" \
+		"$@" \
+		"$commit" >actual &&
+	sed "/^$/d" <expected >expect-nonblank &&
+	sed "/^$/d" <actual >actual-nonblank &&
+	test_cmp expect-nonblank actual-nonblank
+}
+
+test_expect_success 'set up diffstat pretty-format history' '
+	test_create_repo diffstat &&
+	(
+		cd diffstat &&
+		echo root >file &&
+		git add file &&
+		test_tick &&
+		git commit -m root &&
+		root=$(git rev-parse HEAD) &&
+		main_branch=$(git symbolic-ref --quiet --short HEAD) &&
+
+		printf "line two\nline three\n" >>file &&
+		git add file &&
+		test_tick &&
+		git commit -m text &&
+		text=$(git rev-parse HEAD) &&
+
+		printf "\000\001\002\003" >bin &&
+		git add bin &&
+		test_tick &&
+		git commit -m binary &&
+		binary=$(git rev-parse HEAD) &&
+
+		echo doomed >doomed &&
+		git add doomed &&
+		test_tick &&
+		git commit -m doomed &&
+
+		git rm doomed &&
+		test_tick &&
+		git commit -m delete-doomed &&
+		delete_only=$(git rev-parse HEAD) &&
+
+		git branch topic &&
+		git mv file renamed &&
+		test_tick &&
+		git commit -m rename &&
+		rename=$(git rev-parse HEAD) &&
+
+		git checkout topic &&
+		echo topic >topic &&
+		git add topic &&
+		test_tick &&
+		git commit -m topic &&
+
+		git checkout "$main_branch" &&
+		test_tick &&
+		git merge --no-ff -m merge topic &&
+		merge=$(git rev-parse HEAD) &&
+
+		cat >../diffstat-oids <<-EOF
+		root=$root
+		text=$text
+		binary=$binary
+		delete_only=$delete_only
+		rename=$rename
+		merge=$merge
+		EOF
+	)
+'
+
+load_diffstat_oids () {
+	. ./diffstat-oids
+}
+
+test_expect_success 'diff-stat placeholders match shortstat for root commit' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$root"
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for normal commit' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$text"
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for binary change' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$binary"
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for delete-only commit' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$delete_only"
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for rename commit' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$rename" -M
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for merge commit' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$merge"
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for -m merge output' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$merge" -m
+'
+
+test_expect_success 'diff-stat placeholders match shortstat for --cc merge output' '
+	load_diffstat_oids &&
+	test_diff_stat_placeholders "$merge" --cc
+'
+
+test_expect_success 'diff-stat aliases match shortstat' '
+	load_diffstat_oids &&
+	diffstat_log_shortstat_values -1 -M "$rename" >expected &&
+	cut -d" " -f1-3 expected >expect-alias &&
+	git -C diffstat log -1 -M --format="%aF %aA %aR" "$rename" >actual &&
+	test_cmp expect-alias actual
+'
+
+test_expect_success 'multiple diff-stat placeholders reuse one summary' '
+	load_diffstat_oids &&
+	set -- $(diffstat_log_shortstat_values -1 "$text") &&
+	printf "%s %s %s %s %s %s %s\n" \
+		"$1" "$1" "$2" "$2" "$3" "$3" "$4" >expected &&
+	git -C diffstat log -1 \
+		--format="%aF %(diff-stat:files) %aA %(diff-stat:insertions) %aR %(diff-stat:deletions) %(diff-stat:lines)" \
+		"$text" >actual &&
+	test_cmp expected actual
+'
+
 test_done

base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
-- 
gitgitgadget