From: "Andrey Zarubin via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Andrey Zarubin <zarandr@gmail.com>, Andrey Zarubin <zarandr@gmail.com>
Subject: [PATCH] pretty: add diff-stat log placeholders
Date: Thu, 30 Apr 2026 19:55:03 +0000 [thread overview]
Message-ID: <pull.2284.git.git.1777578903593.gitgitgadget@gmail.com> (raw)
From: Andrey Zarubin <zarandr@gmail.com>
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 <zarandr@gmail.com>
---
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
next reply other threads:[~2026-04-30 19:55 UTC|newest]
Thread overview: 3+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-30 19:55 Andrey Zarubin via GitGitGadget [this message]
2026-05-04 5:09 ` [PATCH] pretty: add diff-stat log placeholders Junio C Hamano
2026-05-04 21:00 ` Andrey Zarubin
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2284.git.git.1777578903593.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=zarandr@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.