{"thread":{"id":"65570","subject":"[PATCH] pretty: add diff-stat log placeholders","startedAt":"2026-04-30T19:55:07Z","lastAt":"2026-05-04T21:00:18Z","messageCount":3,"participants":["Andrey Zarubin via GitGitGadget","Junio C Hamano","Andrey Zarubin"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"542537","messageId":"pull.2284.git.git.1777578903593.gitgitgadget@gmail.com","threadId":"65570","inReplyTo":null,"subject":"[PATCH] pretty: add diff-stat log placeholders","fromName":"Andrey Zarubin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-04-30T19:55:03Z","receivedAt":"2026-04-30T19:55:07Z","isPatch":true,"body":"From: Andrey Zarubin <zarandr@gmail.com>\n\nCurrently, users who want per-commit line/file change counts in\na custom log format must post-process `git log --shortstat`\noutput because the pretty formatter exposes no equivalent\nplaceholders.\n\nIntroduce `%(diff-stat:files)`, `%(diff-stat:insertions)`,\n`%(diff-stat:deletions)`, and `%(diff-stat:lines)`, computed\nfrom the same diffstat machinery as `--shortstat` and cached\nonce per commit during format expansion.\n\nShort aliases are provided as `%aF`, `%aA`, and `%aR`. The\nrequested `%aI` and `%aD` forms are unavailable because those\nnames already expand to author dates, so use additions/removals\nmnemonics instead.\n\nWhen log output is already walking a diff, the formatter reuses\nthe current diff queue. Otherwise it computes a private summary\nlazily, so formats without these placeholders still pay no diff\ncost.\n\nSigned-off-by: Andrey Zarubin <zarandr@gmail.com>\n---\n    pretty: add diff-stat log placeholders\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2284%2Fzarandr%2Fmaster-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2284/zarandr/master-v1\nPull-Request: https://github.com/git/git/pull/2284\n\n Documentation/pretty-formats.adoc |  12 +++\n builtin/log.c                     |   5 +\n diff.c                            |  32 ++++--\n diff.h                            |   8 ++\n log-tree.c                        |   2 +\n pretty.c                          | 166 ++++++++++++++++++++++++++++++\n pretty.h                          |   3 +\n t/t4205-log-pretty-formats.sh     | 162 +++++++++++++++++++++++++++++\n 8 files changed, 381 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/pretty-formats.adoc b/Documentation/pretty-formats.adoc\nindex 2ae0eb11a9..d1b574f3ad 100644\n--- a/Documentation/pretty-formats.adoc\n+++ b/Documentation/pretty-formats.adoc\n@@ -294,6 +294,18 @@ tags are added or removed at the same time.\n `exclude=<pattern>`;; Do not consider tags matching the given\n    `glob(7)` _<pattern>_, excluding the `refs/tags/` prefix.\n \n+++%(diff-stat:files)++:: show the number of files changed\n+++%(diff-stat:insertions)++:: show the number of inserted lines\n+++%(diff-stat:deletions)++:: show the number of deleted lines\n+++%(diff-stat:lines)++:: show the total number of inserted and deleted lines\n++\n+  These placeholders are computed like `--shortstat`. By default,\n+  merge commits expand to `0` unless a merge diff mode such as `-m`,\n+  `-c`, or `--cc` is in effect.\n++%aF+:: short alias for `%(diff-stat:files)`\n++%aA+:: short alias for `%(diff-stat:insertions)`\n++%aR+:: short alias for `%(diff-stat:deletions)`\n+\n +%S+:: ref name given on the command line by which the commit was reached\n        (like `git log --source`), only works with `git log`\n +%e+:: encoding\ndiff --git a/builtin/log.c b/builtin/log.c\nindex 8c0939dd42..017face2c0 100644\n--- a/builtin/log.c\n+++ b/builtin/log.c\n@@ -321,6 +321,11 @@ static void cmd_log_init_finish(int argc, const char **argv, const char *prefix,\n \tmemset(&w, 0, sizeof(w));\n \tuserformat_find_requirements(NULL, &w);\n \n+\tif (w.diffstat) {\n+\t\trev->diff = 1;\n+\t\trev->diffopt.output_format |= DIFF_FORMAT_NO_OUTPUT;\n+\t}\n+\n \tif (!rev->show_notes_given && (!rev->pretty_given || w.notes))\n \t\trev->show_notes = 1;\n \tif (rev->show_notes)\ndiff --git a/diff.c b/diff.c\nindex 397e38b41c..2f018e801a 100644\n--- a/diff.c\n+++ b/diff.c\n@@ -3195,12 +3195,14 @@ static void show_stats(struct diffstat_t *data, struct diff_options *options)\n \tstrbuf_release(&out);\n }\n \n-static void show_shortstats(struct diffstat_t *data, struct diff_options *options)\n+void summarize_diffstat(struct diffstat_t *data,\n+\t\t\tstruct diff_stat_summary *summary)\n {\n-\tint i, adds = 0, dels = 0, total_files = data->nr;\n+\tint i;\n \n-\tif (data->nr == 0)\n-\t\treturn;\n+\tsummary->files = data->nr;\n+\tsummary->insertions = 0;\n+\tsummary->deletions = 0;\n \n \tfor (i = 0; i < data->nr; i++) {\n \t\tint added = data->files[i]->added;\n@@ -3208,13 +3210,25 @@ static void show_shortstats(struct diffstat_t *data, struct diff_options *option\n \n \t\tif (data->files[i]->is_unmerged ||\n \t\t    (!data->files[i]->is_interesting && (added + deleted == 0))) {\n-\t\t\ttotal_files--;\n-\t\t} else if (!data->files[i]->is_binary) { /* don't count bytes */\n-\t\t\tadds += added;\n-\t\t\tdels += deleted;\n+\t\t\tsummary->files--;\n+\t\t} else if (!data->files[i]->is_binary) {\n+\t\t\tsummary->insertions += added;\n+\t\t\tsummary->deletions += deleted;\n \t\t}\n \t}\n-\tprint_stat_summary_inserts_deletes(options, total_files, adds, dels);\n+}\n+\n+static void show_shortstats(struct diffstat_t *data, struct diff_options *options)\n+{\n+\tstruct diff_stat_summary summary;\n+\n+\tif (data->nr == 0)\n+\t\treturn;\n+\n+\tsummarize_diffstat(data, &summary);\n+\tprint_stat_summary_inserts_deletes(options, summary.files,\n+\t\t\t\t\t   summary.insertions,\n+\t\t\t\t\t   summary.deletions);\n }\n \n static void show_numstat(struct diffstat_t *data, struct diff_options *options)\ndiff --git a/diff.h b/diff.h\nindex 7eb84aadf4..798c52138d 100644\n--- a/diff.h\n+++ b/diff.h\n@@ -449,6 +449,12 @@ struct diffstat_t {\n \t} **files;\n };\n \n+struct diff_stat_summary {\n+\tint files;\n+\tint insertions;\n+\tint deletions;\n+};\n+\n enum color_diff {\n \tDIFF_RESET = 0,\n \tDIFF_CONTEXT = 1,\n@@ -581,6 +587,8 @@ struct diff_filepair *diff_unmerge(struct diff_options *, const char *path);\n \n void compute_diffstat(struct diff_options *options, struct diffstat_t *diffstat,\n \t\t      struct diff_queue_struct *q);\n+void summarize_diffstat(struct diffstat_t *diffstat,\n+\t\t\tstruct diff_stat_summary *summary);\n void free_diffstat_info(struct diffstat_t *diffstat);\n \n #define DIFF_SETUP_REVERSE      \t1\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..aa6f6dd27d 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -881,6 +881,8 @@ void show_log(struct rev_info *opt)\n \tctx.expand_tabs_in_log = opt->expand_tabs_in_log;\n \tctx.output_encoding = get_log_output_encoding();\n \tctx.rev = opt;\n+\tctx.diff_parent = parent;\n+\tctx.diff_queue_present = diff_queued_diff.nr > 0;\n \tif (opt->from_ident.mail_begin && opt->from_ident.name_begin)\n \t\tctx.from_ident = &opt->from_ident;\n \tif (opt->graph)\ndiff --git a/pretty.c b/pretty.c\nindex 814803980b..a50ecd31ce 100644\n--- a/pretty.c\n+++ b/pretty.c\n@@ -10,6 +10,7 @@\n #include \"hex.h\"\n #include \"utf8.h\"\n #include \"diff.h\"\n+#include \"diffcore.h\"\n #include \"pager.h\"\n #include \"revision.h\"\n #include \"string-list.h\"\n@@ -893,6 +894,7 @@ struct format_commit_context {\n \tconst struct pretty_print_context *pretty_ctx;\n \tunsigned commit_header_parsed:1;\n \tunsigned commit_message_parsed:1;\n+\tunsigned diffstat_parsed:1;\n \tstruct signature_check signature_check;\n \tenum flush_type flush_type;\n \tenum trunc_type truncate;\n@@ -911,6 +913,7 @@ struct format_commit_context {\n \n \t/* The following ones are relative to the result struct strbuf. */\n \tsize_t wrap_start;\n+\tstruct diff_stat_summary diffstat;\n };\n \n static void parse_commit_header(struct format_commit_context *context)\n@@ -939,6 +942,145 @@ static void parse_commit_header(struct format_commit_context *context)\n \tcontext->commit_header_parsed = 1;\n }\n \n+enum diff_stat_placeholder {\n+\tDIFF_STAT_FILES,\n+\tDIFF_STAT_INSERTIONS,\n+\tDIFF_STAT_DELETIONS,\n+\tDIFF_STAT_LINES,\n+};\n+\n+static void parse_commit_diffstat(struct format_commit_context *c)\n+{\n+\tconst struct pretty_print_context *pretty_ctx = c->pretty_ctx;\n+\tconst struct rev_info *rev = pretty_ctx->rev;\n+\tstruct diff_options opts;\n+\tstruct diffstat_t diffstat;\n+\tconst struct commit *commit = c->commit;\n+\tconst struct commit *parent = pretty_ctx->diff_parent;\n+\tconst struct object_id *tree_oid;\n+\tint copied_pathspec = 0;\n+\tint use_current_queue = 0;\n+\tint use_rev_opts = rev && rev->diffopt.repo;\n+\n+\tif (c->diffstat_parsed)\n+\t\treturn;\n+\tc->diffstat_parsed = 1;\n+\tmemset(&c->diffstat, 0, sizeof(c->diffstat));\n+\n+\tif (pretty_ctx->diff_queue_present) {\n+\t\topts = rev->diffopt;\n+\t\tcompute_diffstat(&opts, &diffstat, &diff_queued_diff);\n+\t\tsummarize_diffstat(&diffstat, &c->diffstat);\n+\t\tfree_diffstat_info(&diffstat);\n+\t\treturn;\n+\t}\n+\n+\tparse_commit_or_die((struct commit *)commit);\n+\ttree_oid = get_commit_tree_oid(commit);\n+\n+\tif (use_rev_opts) {\n+\t\tmemcpy(&opts, &rev->diffopt, sizeof(opts));\n+\t\tcopy_pathspec(&opts.pathspec, &rev->diffopt.pathspec);\n+\t\tcopied_pathspec = 1;\n+\t} else {\n+\t\trepo_diff_setup(c->repository, &opts);\n+\t\tinit_diffstat_widths(&opts);\n+\t\topts.flags.recursive = 1;\n+\t\topts.flags.allow_textconv = 1;\n+\t}\n+\topts.output_format = DIFF_FORMAT_SHORTSTAT;\n+\tdiff_setup_done(&opts);\n+\n+\tif (!commit->parents) {\n+\t\tif (use_rev_opts && !rev->show_root_diff)\n+\t\t\tgoto out;\n+\t\tdiff_root_tree_oid(tree_oid, \"\", &opts);\n+\t\tuse_current_queue = 1;\n+\t\tgoto diffstat;\n+\t}\n+\n+\tif (!parent && commit->parents->next) {\n+\t\tif (!use_rev_opts)\n+\t\t\tgoto out;\n+\t\tif (rev->combine_merges ||\n+\t\t    (rev->separate_merges && rev->first_parent_merges))\n+\t\t\tparent = commit->parents->item;\n+\t\telse\n+\t\t\tgoto out;\n+\t} else if (!parent) {\n+\t\tparent = commit->parents->item;\n+\t}\n+\n+\tparse_commit_or_die((struct commit *)parent);\n+\tdiff_tree_oid(get_commit_tree_oid(parent), tree_oid, \"\", &opts);\n+\tuse_current_queue = 1;\n+\n+diffstat:\n+\tdiffcore_std(&opts);\n+\tcompute_diffstat(&opts, &diffstat, &diff_queued_diff);\n+\tsummarize_diffstat(&diffstat, &c->diffstat);\n+\tfree_diffstat_info(&diffstat);\n+out:\n+\tif (use_current_queue) {\n+\t\topts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\t\tdiff_flush(&opts);\n+\t}\n+\tif (copied_pathspec)\n+\t\tclear_pathspec(&opts.pathspec);\n+\telse\n+\t\tdiff_free(&opts);\n+}\n+\n+static void format_commit_diffstat(struct strbuf *sb,\n+\t\t\t\t   struct format_commit_context *c,\n+\t\t\t\t   enum diff_stat_placeholder which)\n+{\n+\tint value;\n+\n+\tparse_commit_diffstat(c);\n+\n+\tswitch (which) {\n+\tcase DIFF_STAT_FILES:\n+\t\tvalue = c->diffstat.files;\n+\t\tbreak;\n+\tcase DIFF_STAT_INSERTIONS:\n+\t\tvalue = c->diffstat.insertions;\n+\t\tbreak;\n+\tcase DIFF_STAT_DELETIONS:\n+\t\tvalue = c->diffstat.deletions;\n+\t\tbreak;\n+\tcase DIFF_STAT_LINES:\n+\t\tvalue = c->diffstat.insertions + c->diffstat.deletions;\n+\t\tbreak;\n+\tdefault:\n+\t\tBUG(\"unknown diff stat placeholder\");\n+\t}\n+\n+\tstrbuf_addf(sb, \"%d\", value);\n+}\n+\n+static size_t parse_diff_stat_placeholder(struct strbuf *sb,\n+\t\t\t\t\t  const char *placeholder,\n+\t\t\t\t\t  struct format_commit_context *c)\n+{\n+\tconst char *arg;\n+\tenum diff_stat_placeholder which;\n+\n+\tif (skip_prefix(placeholder, \"(diff-stat:files)\", &arg))\n+\t\twhich = DIFF_STAT_FILES;\n+\telse if (skip_prefix(placeholder, \"(diff-stat:insertions)\", &arg))\n+\t\twhich = DIFF_STAT_INSERTIONS;\n+\telse if (skip_prefix(placeholder, \"(diff-stat:deletions)\", &arg))\n+\t\twhich = DIFF_STAT_DELETIONS;\n+\telse if (skip_prefix(placeholder, \"(diff-stat:lines)\", &arg))\n+\t\twhich = DIFF_STAT_LINES;\n+\telse\n+\t\treturn 0;\n+\n+\tformat_commit_diffstat(sb, c, which);\n+\treturn arg - placeholder;\n+}\n+\n static int istitlechar(char c)\n {\n \treturn (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||\n@@ -1564,6 +1706,24 @@ static size_t format_commit_one(struct strbuf *sb, /* in UTF-8 */\n \t\treturn 7;\n \t}\n \n+\tif (placeholder[0] == 'a') {\n+\t\tswitch (placeholder[1]) {\n+\t\tcase 'F':\n+\t\t\tformat_commit_diffstat(sb, c, DIFF_STAT_FILES);\n+\t\t\treturn 2;\n+\t\tcase 'A':\n+\t\t\tformat_commit_diffstat(sb, c, DIFF_STAT_INSERTIONS);\n+\t\t\treturn 2;\n+\t\tcase 'R':\n+\t\t\tformat_commit_diffstat(sb, c, DIFF_STAT_DELETIONS);\n+\t\t\treturn 2;\n+\t\t}\n+\t}\n+\n+\tres = parse_diff_stat_placeholder(sb, placeholder, c);\n+\tif (res)\n+\t\treturn res;\n+\n \tswitch (placeholder[0]) {\n \tcase 'H':\t\t/* commit hash */\n \t\tstrbuf_addstr(sb, diff_get_color(c->auto_color, DIFF_COMMIT));\n@@ -1980,6 +2140,10 @@ void userformat_find_requirements(const char *fmt, struct userformat_want *w)\n \t\t\tfmt++;\n \n \t\tswitch (*fmt) {\n+\t\tcase 'a':\n+\t\t\tif (fmt[1] == 'F' || fmt[1] == 'A' || fmt[1] == 'R')\n+\t\t\t\tw->diffstat = 1;\n+\t\t\tbreak;\n \t\tcase 'N':\n \t\t\tw->notes = 1;\n \t\t\tbreak;\n@@ -1993,6 +2157,8 @@ void userformat_find_requirements(const char *fmt, struct userformat_want *w)\n \t\tcase '(':\n \t\t\tif (starts_with(fmt + 1, \"decorate\"))\n \t\t\t\tw->decorate = 1;\n+\t\t\telse if (starts_with(fmt + 1, \"diff-stat:\"))\n+\t\t\t\tw->diffstat = 1;\n \t\t\tbreak;\n \t\t}\n \t}\ndiff --git a/pretty.h b/pretty.h\nindex fac699033e..7f0491e512 100644\n--- a/pretty.h\n+++ b/pretty.h\n@@ -58,6 +58,8 @@ struct pretty_print_context {\n \t */\n \tstruct string_list in_body_headers;\n \tint graph_width;\n+\tconst struct commit *diff_parent;\n+\tunsigned diff_queue_present:1;\n };\n \n /* Check whether commit format is mail. */\n@@ -75,6 +77,7 @@ struct userformat_want {\n \tunsigned notes:1;\n \tunsigned source:1;\n \tunsigned decorate:1;\n+\tunsigned diffstat:1;\n };\n void userformat_find_requirements(const char *fmt, struct userformat_want *w);\n \ndiff --git a/t/t4205-log-pretty-formats.sh b/t/t4205-log-pretty-formats.sh\nindex 3865f6abc7..230950baed 100755\n--- a/t/t4205-log-pretty-formats.sh\n+++ b/t/t4205-log-pretty-formats.sh\n@@ -1227,4 +1227,166 @@ test_expect_failure 'wide and decomposed characters column counting' '\n \ttest_cmp expected actual\n '\n \n+diffstat_log_shortstat_values () {\n+\tgit -C diffstat log --shortstat --format=tformat:commit \"$@\" |\n+\tperl -ne '\n+\t\tchomp;\n+\t\tif ($_ eq \"commit\") {\n+\t\t\tif ($seen) {\n+\t\t\t\tprint \"$files $insertions $deletions \",\n+\t\t\t\t      $insertions + $deletions, \"\\n\";\n+\t\t\t}\n+\t\t\t$seen = 1;\n+\t\t\t($files, $insertions, $deletions) = (0, 0, 0);\n+\t\t} elsif (/^\\s*(\\d+) files? changed(?:, (\\d+) insertions?\\(\\+\\))?(?:, (\\d+) deletions?\\(-\\))?$/) {\n+\t\t\t$files = $1;\n+\t\t\t$insertions = defined($2) ? $2 : 0;\n+\t\t\t$deletions = defined($3) ? $3 : 0;\n+\t\t}\n+\t\tEND {\n+\t\t\tif ($seen) {\n+\t\t\t\tprint \"$files $insertions $deletions \",\n+\t\t\t\t      $insertions + $deletions, \"\\n\";\n+\t\t\t}\n+\t\t}\n+\t'\n+}\n+\n+test_diff_stat_placeholders () {\n+\tcommit=$1\n+\tshift &&\n+\tdiffstat_log_shortstat_values -1 \"$@\" \"$commit\" >expected &&\n+\tgit -C diffstat log -1 \\\n+\t\t--format=\"%(diff-stat:files) %(diff-stat:insertions) %(diff-stat:deletions) %(diff-stat:lines)\" \\\n+\t\t\"$@\" \\\n+\t\t\"$commit\" >actual &&\n+\tsed \"/^$/d\" <expected >expect-nonblank &&\n+\tsed \"/^$/d\" <actual >actual-nonblank &&\n+\ttest_cmp expect-nonblank actual-nonblank\n+}\n+\n+test_expect_success 'set up diffstat pretty-format history' '\n+\ttest_create_repo diffstat &&\n+\t(\n+\t\tcd diffstat &&\n+\t\techo root >file &&\n+\t\tgit add file &&\n+\t\ttest_tick &&\n+\t\tgit commit -m root &&\n+\t\troot=$(git rev-parse HEAD) &&\n+\t\tmain_branch=$(git symbolic-ref --quiet --short HEAD) &&\n+\n+\t\tprintf \"line two\\nline three\\n\" >>file &&\n+\t\tgit add file &&\n+\t\ttest_tick &&\n+\t\tgit commit -m text &&\n+\t\ttext=$(git rev-parse HEAD) &&\n+\n+\t\tprintf \"\\000\\001\\002\\003\" >bin &&\n+\t\tgit add bin &&\n+\t\ttest_tick &&\n+\t\tgit commit -m binary &&\n+\t\tbinary=$(git rev-parse HEAD) &&\n+\n+\t\techo doomed >doomed &&\n+\t\tgit add doomed &&\n+\t\ttest_tick &&\n+\t\tgit commit -m doomed &&\n+\n+\t\tgit rm doomed &&\n+\t\ttest_tick &&\n+\t\tgit commit -m delete-doomed &&\n+\t\tdelete_only=$(git rev-parse HEAD) &&\n+\n+\t\tgit branch topic &&\n+\t\tgit mv file renamed &&\n+\t\ttest_tick &&\n+\t\tgit commit -m rename &&\n+\t\trename=$(git rev-parse HEAD) &&\n+\n+\t\tgit checkout topic &&\n+\t\techo topic >topic &&\n+\t\tgit add topic &&\n+\t\ttest_tick &&\n+\t\tgit commit -m topic &&\n+\n+\t\tgit checkout \"$main_branch\" &&\n+\t\ttest_tick &&\n+\t\tgit merge --no-ff -m merge topic &&\n+\t\tmerge=$(git rev-parse HEAD) &&\n+\n+\t\tcat >../diffstat-oids <<-EOF\n+\t\troot=$root\n+\t\ttext=$text\n+\t\tbinary=$binary\n+\t\tdelete_only=$delete_only\n+\t\trename=$rename\n+\t\tmerge=$merge\n+\t\tEOF\n+\t)\n+'\n+\n+load_diffstat_oids () {\n+\t. ./diffstat-oids\n+}\n+\n+test_expect_success 'diff-stat placeholders match shortstat for root commit' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$root\"\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for normal commit' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$text\"\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for binary change' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$binary\"\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for delete-only commit' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$delete_only\"\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for rename commit' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$rename\" -M\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for merge commit' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$merge\"\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for -m merge output' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$merge\" -m\n+'\n+\n+test_expect_success 'diff-stat placeholders match shortstat for --cc merge output' '\n+\tload_diffstat_oids &&\n+\ttest_diff_stat_placeholders \"$merge\" --cc\n+'\n+\n+test_expect_success 'diff-stat aliases match shortstat' '\n+\tload_diffstat_oids &&\n+\tdiffstat_log_shortstat_values -1 -M \"$rename\" >expected &&\n+\tcut -d\" \" -f1-3 expected >expect-alias &&\n+\tgit -C diffstat log -1 -M --format=\"%aF %aA %aR\" \"$rename\" >actual &&\n+\ttest_cmp expect-alias actual\n+'\n+\n+test_expect_success 'multiple diff-stat placeholders reuse one summary' '\n+\tload_diffstat_oids &&\n+\tset -- $(diffstat_log_shortstat_values -1 \"$text\") &&\n+\tprintf \"%s %s %s %s %s %s %s\\n\" \\\n+\t\t\"$1\" \"$1\" \"$2\" \"$2\" \"$3\" \"$3\" \"$4\" >expected &&\n+\tgit -C diffstat log -1 \\\n+\t\t--format=\"%aF %(diff-stat:files) %aA %(diff-stat:insertions) %aR %(diff-stat:deletions) %(diff-stat:lines)\" \\\n+\t\t\"$text\" >actual &&\n+\ttest_cmp expected actual\n+'\n+\n test_done\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\n-- \ngitgitgadget\n"},{"id":"542643","messageId":"xmqqmryfpxpg.fsf@gitster.g","threadId":"65570","inReplyTo":"pull.2284.git.git.1777578903593.gitgitgadget@gmail.com","subject":"Re: [PATCH] pretty: add diff-stat log placeholders","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-04T05:09:47Z","receivedAt":"2026-05-04T05:09:49Z","isPatch":true,"body":"\"Andrey Zarubin via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Andrey Zarubin <zarandr@gmail.com>\n>\n> Currently, users who want per-commit line/file change counts in\n> a custom log format must post-process `git log --shortstat`\n> output because the pretty formatter exposes no equivalent\n> placeholders.\n>\n> Introduce `%(diff-stat:files)`, `%(diff-stat:insertions)`,\n> `%(diff-stat:deletions)`, and `%(diff-stat:lines)`, computed\n> from the same diffstat machinery as `--shortstat` and cached\n> once per commit during format expansion.\n>\n> Short aliases are provided as `%aF`, `%aA`, and `%aR`. The\n> requested `%aI` and `%aD` forms are unavailable because those\n> names already expand to author dates, so use additions/removals\n> mnemonics instead.\n>\n> When log output is already walking a diff, the formatter reuses\n> the current diff queue. Otherwise it computes a private summary\n> lazily, so formats without these placeholders still pay no diff\n> cost.\n>\n> Signed-off-by: Andrey Zarubin <zarandr@gmail.com>\n> ---\n>     pretty: add diff-stat log placeholders\n\nPersonally I find this a bit on the other side of the line between\nsensible and insanity.  Will we next be adding a new placeholder to\nshow the summary (i.e. list of created, deleted, and renamed paths)\nand another placeholder to show the entire patch text?\n\n\n"},{"id":"542730","messageId":"CAHGMpHAeSQ7xfm5AcfPNAEkuFwhfbwbU9dNgX1vk5qhC++1eFA@mail.gmail.com","threadId":"65570","inReplyTo":"xmqqmryfpxpg.fsf@gitster.g","subject":"Re: [PATCH] pretty: add diff-stat log placeholders","fromName":"Andrey Zarubin","fromEmail":"zarandr@gmail.com","sentAt":"2026-05-04T21:00:06Z","receivedAt":"2026-05-04T21:00:18Z","isPatch":true,"body":"On Mon, May 4, 2026 at 8:09 AM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> \"Andrey Zarubin via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>\n> > From: Andrey Zarubin <zarandr@gmail.com>\n> >\n> > Currently, users who want per-commit line/file change counts in\n> > a custom log format must post-process `git log --shortstat`\n> > output because the pretty formatter exposes no equivalent\n> > placeholders.\n> >\n> > Introduce `%(diff-stat:files)`, `%(diff-stat:insertions)`,\n> > `%(diff-stat:deletions)`, and `%(diff-stat:lines)`, computed\n> > from the same diffstat machinery as `--shortstat` and cached\n> > once per commit during format expansion.\n> >\n> > Short aliases are provided as `%aF`, `%aA`, and `%aR`. The\n> > requested `%aI` and `%aD` forms are unavailable because those\n> > names already expand to author dates, so use additions/removals\n> > mnemonics instead.\n> >\n> > When log output is already walking a diff, the formatter reuses\n> > the current diff queue. Otherwise it computes a private summary\n> > lazily, so formats without these placeholders still pay no diff\n> > cost.\n> >\n> > Signed-off-by: Andrey Zarubin <zarandr@gmail.com>\n> > ---\n> >     pretty: add diff-stat log placeholders\n>\n> Personally I find this a bit on the other side of the line between\n> sensible and insanity.  Will we next be adding a new placeholder to\n> show the summary (i.e. list of created, deleted, and renamed paths)\n> and another placeholder to show the entire patch text?\n\nI see the concern, and I agree that placeholders for `--summary` or\nfull patch text would cross that line.\n\nThe distinction I had in mind is that these are bounded scalar values,\nnot diff output. They are the same three counters already produced by\n`--shortstat`, and the main use case is one-line structured log output\nwhere today callers have to run `git log --shortstat` and parse/correlate\nthe human-oriented output after the fact.\n\nPath summaries and patch text are qualitatively different: they are\nmulti-line, formatting-heavy, affected by quoting/color/output choices,\nand would effectively embed diff output inside the pretty formatter. I\nwould not want this change to imply support for that direction.\n\nIf the short aliases make this feel too much like expanding the kitchen\nsink, I can drop them and keep only the explicit\n`%(diff-stat:<field>)` forms. I think the long forms make the intended\nscope clearer: numeric shortstat counters only.\n"}]}