{"thread":{"id":"63332","subject":"[PATCH RFC 0/5] Introduce git-blame-tree(1) command","startedAt":"2025-04-22T17:46:48Z","lastAt":"2025-08-28T22:44:44Z","messageCount":135,"participants":["Toon Claes","Marc Branchaud","Junio C Hamano","Kristoffer Haugsbakk","D. Ben Knoble","Patrick Steinhardt","Jeff King","Justin Tobler","Taylor Blau","Christian Couder","Jean-Noël AVILA"],"isPatch":true,"patchVersion":1,"patchTotal":5},"messages":[{"id":"516517","messageId":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":null,"subject":"[PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:23Z","receivedAt":"2025-04-22T17:46:48Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This is another attempt to upstream the git-blame-tree(1) subcommand.\nAfter the previous attempt[1] the people of GitHub shared their version\nof the subcommand, and this version integrates those changes.\n\nWhat is different from the series shared by GitHub:\n\n* Patches for --max-depth are excluded. I think it's a separate topic to\n  discuss and I'm not sure it needs to be part of blame-tree anyway. The\n  main patch was submitted in the previous attempt[2] and if people\n  consider it valuable, I'm happy to discuss that in a separate patch\n  series.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[3] implements a\n  caching layer. This feature reads/writes cached blame-tree results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* The blame-tree command isn't recursive by default. If you want recurse\n  into subtrees, you need to pass `-r`.\n\n* Fixed all memory leaks, and removed the use of\n  USE_THE_REPOSITORY_VARIABLE.\n\nI've attempted to reuse commit messages as good as possible, but feel\nfree to correct me where you think I didn't give proper credit or messed\nup. Although I have no idea what to do with the Signed-off-by trailers.\n\nI didn't modify the benchmark results in the commit messages, simply\nbecause I didn't get comparable results. In my benchmarks the difference\nbetween two implementations was negligible, and even in some scenarios\nthe performance was worse in the \"improved\" implementation. As far as I\ncan tell, I didn't break anything in my refactoring, because the version\nin these patches acts similar to Taylor's branch. To be honest, I cannot\nexplain why...?\n\nWith this version I'd like to gather feedback as much as possible for a\nnext version. I realize this feature is far from done, so that's why I'm\nsubmitting it as an RFC.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-0-4173133f3786@iotcl.com/\n[2]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[3]: git@github.com:ttaylorr/git.git\n\n--\nToon\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nJeff King (1):\n      t/perf: add blame-tree perf script\n\nTaylor Blau (2):\n      blame-tree: use Bloom filters when available\n      blame-tree: implement faster algorithm\n\nToon Claes (2):\n      blame-tree: introduce new subcommand to blame files\n      blame-tree.c: initialize revision machinery without walk\n\n .gitignore                 |   1 +\n Makefile                   |   2 +\n blame-tree.c               | 496 +++++++++++++++++++++++++++++++++++++++++++++\n blame-tree.h               |  30 +++\n builtin.h                  |   1 +\n builtin/blame-tree.c       |  43 ++++\n git.c                      |   1 +\n meson.build                |   2 +\n t/helper/test-tool.h       |   1 +\n t/meson.build              |   1 +\n t/perf/p8020-blame-tree.sh |  21 ++\n t/t8020-blame-tree.sh      | 148 ++++++++++++++\n 12 files changed, 747 insertions(+)\n---\n\n\n\n---\n\nbase-commit: 4bbb303af69990ccd05fe3a2eb58a1ce036f8220\nchange-id: 20250410-toon-new-blame-tree-bcdbb78c1c0f\n\nThanks\n--\nToon\n\n"},{"id":"516518","messageId":"20250422-toon-new-blame-tree-v1-1-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC 1/5] blame-tree: introduce new subcommand to blame files","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:24Z","receivedAt":"2025-04-22T17:46:53Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand git-blame-tree(1).\nThis command shows the most recent modification to paths in a tree. It\ndoes so by expanding the tree at a given commit, taking note of the\ncurrent state of each path, and then walking backwards through history\nlooking for commits where each path changed into its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: \"Ævar Arnfjörð Bjarmason\" <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore            |   1 +\n Makefile              |   2 +\n blame-tree.c          | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++\n blame-tree.h          |  27 +++++++\n builtin.h             |   1 +\n builtin/blame-tree.c  |  43 ++++++++++\n git.c                 |   1 +\n meson.build           |   2 +\n t/helper/test-tool.h  |   1 +\n t/meson.build         |   1 +\n t/t8020-blame-tree.sh | 147 ++++++++++++++++++++++++++++++++++\n 11 files changed, 439 insertions(+)\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..ba23d5b098 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -22,6 +22,7 @@\n /git-backfill\n /git-bisect\n /git-blame\n+/git-blame-tree\n /git-branch\n /git-bugreport\n /git-bundle\ndiff --git a/Makefile b/Makefile\nindex 13f9062a05..aaf22af0b8 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -972,6 +972,7 @@ LIB_OBJS += archive.o\n LIB_OBJS += attr.o\n LIB_OBJS += base85.o\n LIB_OBJS += bisect.o\n+LIB_OBJS += blame-tree.o\n LIB_OBJS += blame.o\n LIB_OBJS += blob.o\n LIB_OBJS += bloom.o\n@@ -1216,6 +1217,7 @@ BUILTIN_OBJS += builtin/archive.o\n BUILTIN_OBJS += builtin/backfill.o\n BUILTIN_OBJS += builtin/bisect.o\n BUILTIN_OBJS += builtin/blame.o\n+BUILTIN_OBJS += builtin/blame-tree.o\n BUILTIN_OBJS += builtin/branch.o\n BUILTIN_OBJS += builtin/bugreport.o\n BUILTIN_OBJS += builtin/bundle.o\ndiff --git a/blame-tree.c b/blame-tree.c\nnew file mode 100644\nindex 0000000000..ce57db2cfc\n--- /dev/null\n+++ b/blame-tree.c\n@@ -0,0 +1,213 @@\n+#include \"git-compat-util.h\"\n+#include \"blame-tree.h\"\n+#include \"commit.h\"\n+#include \"diffcore.h\"\n+#include \"diff.h\"\n+#include \"object.h\"\n+#include \"revision.h\"\n+#include \"repository.h\"\n+#include \"log-tree.h\"\n+\n+struct blame_tree_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tstruct commit *commit;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static void add_from_diff(struct diff_queue_struct *q,\n+\t\t\t  struct diff_options *opt UNUSED,\n+\t\t\t  void *data)\n+{\n+\tstruct blame_tree *bt = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct blame_tree_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&bt->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int add_from_revs(struct blame_tree *bt)\n+{\n+\tsize_t count = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &bt->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &bt->rev.diffopt.pathspec);\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_from_diff;\n+\tdiffopt.format_callback_data = bt;\n+\n+\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = bt->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (count++)\n+\t\t\treturn error(_(\"can only blame one tree at a time\"));\n+\n+\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tclear_pathspec(&diffopt.pathspec);\n+\n+\treturn 0;\n+}\n+\n+static int blame_tree_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t    const struct hashmap_entry *he1,\n+\t\t\t\t    const struct hashmap_entry *he2,\n+\t\t\t\t    const void *path)\n+{\n+\tconst struct blame_tree_entry *e1 =\n+\t\tcontainer_of(he1, const struct blame_tree_entry, hashent);\n+\tconst struct blame_tree_entry *e2 =\n+\t\tcontainer_of(he2, const struct blame_tree_entry, hashent);\n+\treturn strcmp(e1->path, path ? path : e2->path);\n+}\n+\n+void blame_tree_init(struct blame_tree *bt,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv)\n+{\n+\tmemset(bt, 0, sizeof(*bt));\n+\thashmap_init(&bt->paths, blame_tree_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &bt->rev, prefix);\n+\tbt->rev.def = \"HEAD\";\n+\tbt->rev.combine_merges = 1;\n+\tbt->rev.show_root_diff = 1;\n+\tbt->rev.boundary = 1;\n+\tbt->rev.no_commit_id = 1;\n+\tbt->rev.diff = 1;\n+\tif (setup_revisions(argc, argv, &bt->rev, NULL) > 1)\n+\t\tdie(_(\"unknown blame-tree argument: %s\"), argv[1]);\n+\n+\tif (add_from_revs(bt) < 0)\n+\t\tdie(_(\"unable to setup blame-tree\"));\n+}\n+\n+void blame_tree_release(struct blame_tree *bt)\n+{\n+\thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n+\trelease_revisions(&bt->rev);\n+}\n+\n+struct blame_tree_callback_data {\n+\tstruct commit *commit;\n+\tstruct hashmap *paths;\n+\n+\tblame_tree_callback callback;\n+\tvoid *callback_data;\n+};\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct blame_tree_callback_data *data)\n+{\n+\tstruct blame_tree_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n+\t\t\t\t\t  struct blame_tree_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/* Have we already blamed a commit? */\n+\tif (ent->commit)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tent->commit = data->commit;\n+\tif (data->callback)\n+\t\tdata->callback(path, data->commit, data->callback_data);\n+\n+\thashmap_remove(data->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void blame_diff(struct diff_queue_struct *q,\n+\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct blame_tree_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final path/sha1 state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be blamed, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and blame\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n+{\n+\tstruct blame_tree_callback_data data;\n+\n+\tdata.paths = &bt->paths;\n+\tdata.callback = cb;\n+\tdata.callback_data = cbdata;\n+\n+\tbt->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tbt->rev.diffopt.format_callback = blame_diff;\n+\tbt->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&bt->rev);\n+\n+\twhile (hashmap_get_size(&bt->paths)) {\n+\t\tdata.commit = get_revision(&bt->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t       &data.commit->object.oid,\n+\t\t\t\t       \"\", &bt->rev.diffopt);\n+\t\t\tdiff_flush(&bt->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&bt->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\ndiff --git a/blame-tree.h b/blame-tree.h\nnew file mode 100644\nindex 0000000000..abb467cf1b\n--- /dev/null\n+++ b/blame-tree.h\n@@ -0,0 +1,27 @@\n+#ifndef BLAME_TREE_H\n+#define BLAME_TREE_H\n+\n+#include \"commit.h\"\n+#include \"revision.h\"\n+#include \"hashmap.h\"\n+\n+struct blame_tree {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+};\n+\n+void blame_tree_init(struct blame_tree *bt,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv);\n+\n+void blame_tree_release(struct blame_tree *);\n+\n+typedef void (*blame_tree_callback)(const char *path,\n+\t\t\t\t    const struct commit *commit,\n+\t\t\t\t    void *data);\n+int blame_tree_run(struct blame_tree *,\n+\t\t   blame_tree_callback cb,\n+\t\t   void *data);\n+\n+#endif /* BLAME_TREE_H */\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..c7b06130b6 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -123,6 +123,7 @@ int cmd_archive(int argc, const char **argv, const char *prefix, struct reposito\n int cmd_backfill(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_bisect(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_blame(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_blame_tree(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_branch(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_bugreport(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_bundle(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/blame-tree.c b/builtin/blame-tree.c\nnew file mode 100644\nindex 0000000000..aaa3e9daa1\n--- /dev/null\n+++ b/builtin/blame-tree.c\n@@ -0,0 +1,43 @@\n+#include \"git-compat-util.h\"\n+#include \"blame-tree.h\"\n+#include \"hex.h\"\n+#include \"quote.h\"\n+#include \"config.h\"\n+#include \"object-name.h\"\n+#include \"parse-options.h\"\n+#include \"builtin.h\"\n+\n+static void show_entry(const char *path, const struct commit *commit, void *d)\n+{\n+\tstruct blame_tree *bt = d;\n+\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (bt->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+int cmd_blame_tree(int argc,\n+\t\t   const char **argv,\n+\t\t   const char *prefix,\n+\t\t   struct repository *repo)\n+{\n+\tint ret = 0;\n+\tstruct blame_tree bt;\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tblame_tree_init(&bt, repo, prefix, argc, argv);\n+\tif (blame_tree_run(&bt, show_entry, &bt) < 0)\n+\t\tdie(_(\"error running blame-tree traversal\"));\n+\n+\tblame_tree_release(&bt);\n+\n+\treturn ret;\n+}\ndiff --git a/git.c b/git.c\nindex 77c4359522..9f8b99b2d1 100644\n--- a/git.c\n+++ b/git.c\n@@ -509,6 +509,7 @@ static struct cmd_struct commands[] = {\n \t{ \"backfill\", cmd_backfill, RUN_SETUP },\n \t{ \"bisect\", cmd_bisect, RUN_SETUP },\n \t{ \"blame\", cmd_blame, RUN_SETUP },\n+\t{ \"blame-tree\", cmd_blame_tree, RUN_SETUP },\n \t{ \"branch\", cmd_branch, RUN_SETUP | DELAY_PAGER_CONFIG },\n \t{ \"bugreport\", cmd_bugreport, RUN_SETUP_GENTLY },\n \t{ \"bundle\", cmd_bundle, RUN_SETUP_GENTLY },\ndiff --git a/meson.build b/meson.build\nindex c47cb79af0..214ccf5a72 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -274,6 +274,7 @@ libgit_sources = [\n   'attr.c',\n   'base85.c',\n   'bisect.c',\n+  'blame-tree.c',\n   'blame.c',\n   'blob.c',\n   'bloom.c',\n@@ -546,6 +547,7 @@ builtin_sources = [\n   'builtin/archive.c',\n   'builtin/backfill.c',\n   'builtin/bisect.c',\n+  'builtin/blame-tree.c',\n   'builtin/blame.c',\n   'builtin/branch.c',\n   'builtin/bugreport.c',\ndiff --git a/t/helper/test-tool.h b/t/helper/test-tool.h\nindex 6d62a5b53d..41cc3730dc 100644\n--- a/t/helper/test-tool.h\n+++ b/t/helper/test-tool.h\n@@ -5,6 +5,7 @@\n \n int cmd__advise_if_enabled(int argc, const char **argv);\n int cmd__bitmap(int argc, const char **argv);\n+int cmd__blame_tree(int argc, const char **argv);\n int cmd__bloom(int argc, const char **argv);\n int cmd__bundle_uri(int argc, const char **argv);\n int cmd__cache_tree(int argc, const char **argv);\ndiff --git a/t/meson.build b/t/meson.build\nindex bfb744e886..65402e97da 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -960,6 +960,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-blame-tree.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-blame-tree.sh b/t/t8020-blame-tree.sh\nnew file mode 100755\nindex 0000000000..c11876c210\n--- /dev/null\n+++ b/t/t8020-blame-tree.sh\n@@ -0,0 +1,147 @@\n+#!/bin/sh\n+\n+test_description='blame-tree tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot blame two trees' '\n+\ttest_must_fail git blame-tree HEAD HEAD~1\n+'\n+\n+check_blame() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} blame-tree \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n+\tsort tmp.3 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'blame recursive' '\n+\tcheck_blame --recursive <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'blame non-recursive' '\n+\tcheck_blame --no-recursive <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'blame subdir' '\n+\tcheck_blame a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'blame subdir recursive' '\n+\tcheck_blame --recursive a <<-\\EOF\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'blame from non-HEAD commit' '\n+\tcheck_blame --no-recursive HEAD^ <<-\\EOF\n+\t1 file\n+\t2 a\n+\tEOF\n+'\n+\n+test_expect_success 'blame from subdir defaults to root' '\n+\tcheck_blame -C a --no-recursive <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'blame from subdir uses relative pathspecs' '\n+\tcheck_blame -C a --recursive b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_failure 'limit blame traversal by count' '\n+\tcheck_blame --no-recursive -1 <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'limit blame traversal by commit' '\n+\tcheck_blame --no-recursive HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only blame files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_blame <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_blame <<-\\EOF\n+\tm1 m1.t\n+\tm2 m2.t\n+\tEOF\n+'\n+\n+test_expect_success 'blame merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_blame conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+test_expect_success 'blame-tree complains about unknown arguments' '\n+\ttest_must_fail git blame-tree --foo 2>err &&\n+\tgrep \"unknown blame-tree argument: --foo\" err\n+'\n+\n+test_done\n\n-- \n2.49.0.rc2\n\n"},{"id":"516519","messageId":"20250422-toon-new-blame-tree-v1-2-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC 2/5] t/perf: add blame-tree perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:25Z","receivedAt":"2025-04-22T17:46:56Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"From: Jeff King <peff@peff.net>\n\nThis just runs some simple blame-tree's. We already test correctness in\nthe regular suite, so this is just about finding performance regressions\nfrom one version to another.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/perf/p8020-blame-tree.sh | 21 +++++++++++++++++++++\n t/t8020-blame-tree.sh      | 19 ++++++++++---------\n 2 files changed, 31 insertions(+), 9 deletions(-)\n\ndiff --git a/t/perf/p8020-blame-tree.sh b/t/perf/p8020-blame-tree.sh\nnew file mode 100755\nindex 0000000000..6c4c2a369e\n--- /dev/null\n+++ b/t/perf/p8020-blame-tree.sh\n@@ -0,0 +1,21 @@\n+#!/bin/sh\n+\n+test_description='blame-tree perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level blame-tree' '\n+\tgit blame-tree HEAD\n+'\n+\n+test_perf 'top-level recursive blame-tree' '\n+\tgit blame-tree -r HEAD\n+'\n+\n+test_perf 'subdir blame-tree' '\n+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n+\tgit blame-tree -r HEAD -- \"$path\"\n+'\n+\n+test_done\ndiff --git a/t/t8020-blame-tree.sh b/t/t8020-blame-tree.sh\nindex c11876c210..6a1db7efaa 100755\n--- a/t/t8020-blame-tree.sh\n+++ b/t/t8020-blame-tree.sh\n@@ -43,7 +43,7 @@ check_blame() {\n }\n \n test_expect_success 'blame recursive' '\n-\tcheck_blame --recursive <<-\\EOF\n+\tcheck_blame -r <<-\\EOF\n \t1 file\n \t2 a/file\n \t3 a/b/file\n@@ -51,7 +51,7 @@ test_expect_success 'blame recursive' '\n '\n \n test_expect_success 'blame non-recursive' '\n-\tcheck_blame --no-recursive <<-\\EOF\n+\tcheck_blame <<-\\EOF\n \t1 file\n \t3 a\n \tEOF\n@@ -64,40 +64,41 @@ test_expect_success 'blame subdir' '\n '\n \n test_expect_success 'blame subdir recursive' '\n-\tcheck_blame --recursive a <<-\\EOF\n+\tcheck_blame -r a <<-\\EOF\n \t2 a/file\n \t3 a/b/file\n \tEOF\n '\n \n test_expect_success 'blame from non-HEAD commit' '\n-\tcheck_blame --no-recursive HEAD^ <<-\\EOF\n+\tcheck_blame HEAD^ <<-\\EOF\n \t1 file\n \t2 a\n \tEOF\n '\n \n test_expect_success 'blame from subdir defaults to root' '\n-\tcheck_blame -C a --no-recursive <<-\\EOF\n+\tcheck_blame -C a <<-\\EOF\n \t1 file\n \t3 a\n \tEOF\n '\n \n test_expect_success 'blame from subdir uses relative pathspecs' '\n-\tcheck_blame -C a --recursive b <<-\\EOF\n+\tcheck_blame -C a -r b <<-\\EOF\n \t3 a/b/file\n \tEOF\n '\n \n-test_expect_failure 'limit blame traversal by count' '\n-\tcheck_blame --no-recursive -1 <<-\\EOF\n+test_expect_success 'limit blame traversal by count' '\n+\tcheck_blame <<-\\EOF\n \t3 a\n+\t^2 file\n \tEOF\n '\n \n test_expect_success 'limit blame traversal by commit' '\n-\tcheck_blame --no-recursive HEAD~2..HEAD <<-\\EOF\n+\tcheck_blame HEAD~2..HEAD <<-\\EOF\n \t3 a\n \t^1 file\n \tEOF\n\n-- \n2.49.0.rc2\n\n"},{"id":"516520","messageId":"20250422-toon-new-blame-tree-v1-3-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC 3/5] blame-tree: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:26Z","receivedAt":"2025-04-22T17:46:59Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"From: Taylor Blau <me@ttaylorr.com>\n\nOur 'git blame-tree' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nThis results in a substantial performance speed-up in common cases of\n'git blame-tree'. In the kernel, here is the before and after (all times\ncomputed with best-of-five):\n\nWith commit-graphs (but no Bloom filters):\n\n    real\t0m5.133s\n    user\t0m4.942s\n    sys\t0m0.180s\n\n...and with Bloom filters:\n\n    real\t0m0.936s\n    user\t0m0.842s\n    sys\t0m0.092s\n\nThese times are with my development-version of Git, so it's compiled\nwithout optimizations. Compiling instead with `-O3`, the results look\neven better:\n\n    real\t0m0.754s\n    user\t0m0.661s\n    sys\t0m0.092s\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n blame-tree.c | 44 ++++++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 44 insertions(+)\n\ndiff --git a/blame-tree.c b/blame-tree.c\nindex ce57db2cfc..47354557a7 100644\n--- a/blame-tree.c\n+++ b/blame-tree.c\n@@ -7,11 +7,15 @@\n #include \"revision.h\"\n #include \"repository.h\"\n #include \"log-tree.h\"\n+#include \"dir.h\"\n+#include \"commit-graph.h\"\n+#include \"bloom.h\"\n \n struct blame_tree_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n \tstruct commit *commit;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -28,6 +32,9 @@ static void add_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (bt->rev.bloom_filter_settings)\n+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\t\t       bt->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&bt->paths, &ent->hashent);\n \t}\n@@ -92,12 +99,21 @@ void blame_tree_init(struct blame_tree *bt,\n \tif (setup_revisions(argc, argv, &bt->rev, NULL) > 1)\n \t\tdie(_(\"unknown blame-tree argument: %s\"), argv[1]);\n \n+\t(void)generation_numbers_enabled(bt->rev.repo);\n+\tbt->rev.bloom_filter_settings = get_bloom_filter_settings(bt->rev.repo);\n+\n \tif (add_from_revs(bt) < 0)\n \t\tdie(_(\"unable to setup blame-tree\"));\n }\n \n void blame_tree_release(struct blame_tree *bt)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct blame_tree_entry *ent;\n+\n+\thashmap_for_each_entry(&bt->paths, &iter, ent, hashent) {\n+\t\tclear_bloom_key(&ent->key);\n+\t}\n \thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n \trelease_revisions(&bt->rev);\n }\n@@ -137,6 +153,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n+\tclear_bloom_key(&ent->key);\n \tfree(ent);\n }\n \n@@ -180,6 +197,30 @@ static void blame_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct blame_tree_entry *e;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!bt->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(bt->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n+\t\tif (bloom_filter_contains(filter, &e->key,\n+\t\t\t\t\t  bt->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n {\n \tstruct blame_tree_callback_data data;\n@@ -199,6 +240,9 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \t\tif (!data.commit)\n \t\t\tbreak;\n \n+\t\tif (!maybe_changed_path(bt, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n \t\t\t\t       &data.commit->object.oid,\n\n-- \n2.49.0.rc2\n\n"},{"id":"516521","messageId":"20250422-toon-new-blame-tree-v1-4-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC 4/5] blame-tree: implement faster algorithm","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:27Z","receivedAt":"2025-04-22T17:47:03Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"From: Taylor Blau <me@ttaylorr.com>\n\nThe current implementation of 'git blame-tree' works by doing a revision\nwalk, and inspecting the diff at each level of that walk to annotate the\nyet-unblamed entries to a path. In other words, if the diff at some\nlevel touches a path which has not yet been associated with a commit,\nthen that commit becomes associated with the path.\n\nWhile a perfectly reasonable implementation, it can perform poorly in\neither one of two scenarios:\n\n  1. There are many entries of interest, in which case there is simply\n     more work to do.\n\n  2. Or, there are (even a few) entries which have not been updated in a\n     long time, and so we must walk through a lot of history in order to\n     find a commit that touches that path.\n\nThis patch rewrites the blame-tree implementation that addresses (2).\nThe idea behind the algorithm is to propagate a set of 'active' paths (a\npath is 'active' if it does not yet belong to a commit) up to parents\nand do a truncated revision walk.\n\nThe walk is truncated because it does not produce a revision for every\nchange in the original pathspec, but rather only for active paths.\n\nMore specifically, consider a priority queue of commits sorted by\ngeneration number. First, enqueue the set of boundary commits with all\npaths in the original spec marked as interesting.\n\nThen, while the queue is not empty, do the following:\n\n  1. Pop an element, say, 'c', off of the queue, making sure that 'c'\n     isn't reachable by anything in the '--not' set.\n\n  2. For each parent 'p' (with index 'parent_i') of 'c', do the\n     following:\n\n     a. Compute the diff between 'c' and 'p'.\n     b. Pass any active paths that are TREESAME from 'c' to 'p'.\n     c. If 'p' has any active paths, push it onto the queue.\n\n  3. Associate any remaining paths with 'c', and mark them as inactive.\n\nThis ends up being equivalent to doing something like 'git log -1 --\n$path' for each path simultaneously. But, it allows us to go much faster\nthan the original implementation by limiting the number of diffs we\ncompute, since we can avoid parts of history that would have been\nconsidered by the revision walk in the original implementation, but are\nknown to be uninteresting to us because we have already marked all paths\nin that area to be inactive.\n\nOne other trick we can do on top is to avoid computing many first-parent\ndiffs when all paths active in 'c' are DEFINITELY_NOT in c's Bloom\nfilter. Since the commit-graph only stores first-parent diffs in the\nBloom filters, we can only apply this trick to first-parent diffs.\n\nNow, some performance numbers. On github/git, our numbers look like the\nfollowing (all wall-clock times best-of-five, and with '--max-depth=0'\non the root):\n\n                 github\t\tttaylorr/blame-tree-fast\n   with filters: 0.754s\t\t0.271s (2.78x faster, 6.18x overall)\nwithout filters: 1.676s\t\t1.056s (1.58x faster)\n\nand on torvalds/linux:\n\n                 github\t\tttaylorr/blame-tree-fast\n   with filters: 0.608\t\t0.062 (9.81x faster, ~52x overall)\nwithout filters: 3.251\t\t0.676 (4.81x faster)\n\nIn short, the existing implementation is comparably fast *with* filters\nas the new implementation is *without* filters. So, most repositories\nshould get a dramatic speed-up by just deploying this (even without\ncomputing Bloom filters), and all repositories should get faster still\nwhen computing Bloom filters.\n\nCo-authored-by: Derrick Stolee <dstolee@microsoft.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n blame-tree.c | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----\n blame-tree.h |   3 +\n 2 files changed, 256 insertions(+), 17 deletions(-)\n\ndiff --git a/blame-tree.c b/blame-tree.c\nindex 47354557a7..2cb7a5045c 100644\n--- a/blame-tree.c\n+++ b/blame-tree.c\n@@ -3,18 +3,20 @@\n #include \"commit.h\"\n #include \"diffcore.h\"\n #include \"diff.h\"\n-#include \"object.h\"\n #include \"revision.h\"\n #include \"repository.h\"\n #include \"log-tree.h\"\n #include \"dir.h\"\n #include \"commit-graph.h\"\n #include \"bloom.h\"\n+#include \"prio-queue.h\"\n+#include \"commit-slab.h\"\n \n struct blame_tree_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n \tstruct commit *commit;\n+\tint diff_idx;\n \tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n@@ -86,6 +88,9 @@ void blame_tree_init(struct blame_tree *bt,\n \t\t     const char *prefix,\n \t\t     int argc, const char **argv)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct blame_tree_entry *e;\n+\n \tmemset(bt, 0, sizeof(*bt));\n \thashmap_init(&bt->paths, blame_tree_entry_hashcmp, NULL, 0);\n \n@@ -104,6 +109,13 @@ void blame_tree_init(struct blame_tree *bt,\n \n \tif (add_from_revs(bt) < 0)\n \t\tdie(_(\"unable to setup blame-tree\"));\n+\n+\tbt->all_paths = xcalloc(hashmap_get_size(&bt->paths), sizeof(const char *));\n+\tbt->all_paths_nr = 0;\n+\thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n+\t\te->diff_idx = bt->all_paths_nr++;\n+\t\tbt->all_paths[e->diff_idx] = e->path;\n+\t}\n }\n \n void blame_tree_release(struct blame_tree *bt)\n@@ -116,6 +128,20 @@ void blame_tree_release(struct blame_tree *bt)\n \t}\n \thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n \trelease_revisions(&bt->rev);\n+\tfree(bt->all_paths);\n+}\n+\n+struct commit_active_paths {\n+\tchar *active;\n+\tint nr;\n+};\n+\n+define_commit_slab(active_paths, struct commit_active_paths);\n+static struct active_paths active_paths;\n+\n+static void free_one_active_path(struct commit_active_paths *active)\n+{\n+\tfree(active->active);\n }\n \n struct blame_tree_callback_data {\n@@ -130,6 +156,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\t      struct blame_tree_callback_data *data)\n {\n \tstruct blame_tree_entry *ent;\n+\tstruct commit_active_paths *active;\n \n \t/* Is it even a path that we are interested in? */\n \tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n@@ -141,11 +168,17 @@ static void mark_path(const char *path, const struct object_id *oid,\n \tif (ent->commit)\n \t\treturn;\n \n+\t/* Are we inactive on the current commit? */\n+\tactive = active_paths_at(&active_paths, data->commit);\n+\tif (active && active->active &&\n+\t    !active->active[ent->diff_idx])\n+\t\treturn;\n+\n \t/*\n \t * Is it arriving at a version of interest, or is it from a side branch\n \t * which did not contribute to the final state?\n \t */\n-\tif (!oideq(oid, &ent->oid))\n+\tif (oid && !oideq(oid, &ent->oid))\n \t\treturn;\n \n \tent->commit = data->commit;\n@@ -197,7 +230,32 @@ static void blame_diff(struct diff_queue_struct *q,\n \t}\n }\n \n-static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n+static char *scratch;\n+\n+static void pass_to_parent(struct commit_active_paths *c,\n+\t\t\t   struct commit_active_paths *p,\n+\t\t\t   int i)\n+{\n+\tc->active[i] = 0;\n+\tc->nr--;\n+\tp->active[i] = 1;\n+\tp->nr++;\n+}\n+\n+#define PARENT1 (1u<<16) /* used instead of SEEN */\n+#define PARENT2 (1u<<17) /* used instead of BOTTOM, BOUNDARY */\n+\n+static int diff2idx(struct blame_tree *bt, char *path)\n+{\n+\tstruct blame_tree_entry *ent;\n+\tent = hashmap_get_entry_from_hash(&bt->paths, strhash(path), path,\n+\t\t\t\t\t  struct blame_tree_entry, hashent);\n+\treturn ent ? ent->diff_idx : -1;\n+}\n+\n+static int maybe_changed_path(struct blame_tree *bt,\n+\t\t\t      struct commit *origin,\n+\t\t\t      struct commit_active_paths *active)\n {\n \tstruct bloom_filter *filter;\n \tstruct blame_tree_entry *e;\n@@ -214,6 +272,8 @@ static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n \t\treturn 1;\n \n \thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n+\t\tif (active && !active->active[e->diff_idx])\n+\t\t\tcontinue;\n \t\tif (bloom_filter_contains(filter, &e->key,\n \t\t\t\t\t  bt->rev.bloom_filter_settings))\n \t\t\treturn 1;\n@@ -221,8 +281,88 @@ static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n \treturn 0;\n }\n \n+static int process_parent(struct blame_tree *bt,\n+\t\t\t   struct prio_queue *queue,\n+\t\t\t   struct commit *c, struct commit_active_paths *active_c,\n+\t\t\t   struct commit *parent, int parent_i)\n+{\n+\tint i, ret = 0; // TODO type & for loop var\n+\tstruct commit_active_paths *active_p;\n+\n+\trepo_parse_commit(bt->rev.repo, parent);\n+\n+\tactive_p = active_paths_at(&active_paths, parent);\n+\tif (!active_p->active) {\n+\t\tactive_p->active = xcalloc(sizeof(char), bt->all_paths_nr);\n+\t\tactive_p->nr = 0;\n+\t}\n+\n+\t/*\n+\t * Before calling 'diff_tree_oid()' on our first parent, see if Bloom\n+\t * filters will tell us the diff is conclusively uninteresting.\n+\t */\n+\tif (parent_i || maybe_changed_path(bt, c, active_c)) {\n+\t\tdiff_tree_oid(&parent->object.oid,\n+\t\t\t      &c->object.oid, \"\", &bt->rev.diffopt);\n+\t\tdiffcore_std(&bt->rev.diffopt);\n+\t}\n+\n+\tif (!diff_queued_diff.nr) {\n+\t\t/*\n+\t\t * No diff entries means we are TREESAME on the base path, and\n+\t\t * so all active paths get passed onto this parent.\n+\t\t */\n+\t\tfor (i = 0; i < bt->all_paths_nr; i++) {\n+\t\t\tif (active_c->active[i])\n+\t\t\t\tpass_to_parent(active_c, active_p, i);\n+\t\t}\n+\n+\t\tif (!(parent->object.flags & PARENT1)) {\n+\t\t\tparent->object.flags |= PARENT1;\n+\t\t\tprio_queue_put(queue, parent);\n+\t\t}\n+\t\tret = 1;\n+\t\tgoto cleanup;\n+\t}\n+\n+\t/*\n+\t * Otherwise, test each path for TREESAME-ness against the parent, and\n+\t * pass those along.\n+\t *\n+\t * First, set each position in 'scratch' to be zero for TREESAME paths,\n+\t * and one otherwise. Then, pass active and TREESAME paths to the\n+\t * parent.\n+\t */\n+\tfor (i = 0; i < diff_queued_diff.nr; i++) {\n+\t\tstruct diff_filepair *fp = diff_queued_diff.queue[i];\n+\t\tint k = diff2idx(bt, fp->two->path);\n+\t\tif (0 <= k && active_c->active[k])\n+\t\t\tscratch[k] = 1;\n+\t\tdiff_free_filepair(fp);\n+\t}\n+\tdiff_queued_diff.nr = 0;\n+\tfor (i = 0; i < bt->all_paths_nr; i++) {\n+\t\tif (active_c->active[i] && !scratch[i])\n+\t\t\tpass_to_parent(active_c, active_p, i);\n+\t}\n+\n+\tif (active_p->nr && !(parent->object.flags & PARENT1)) {\n+\t\tparent->object.flags |= PARENT1;\n+\t\tprio_queue_put(queue, parent);\n+\t}\n+\n+cleanup:\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tmemset(scratch, 0, bt->all_paths_nr);\n+\n+\treturn ret;\n+}\n+\n int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n {\n+\tint max_count, queue_popped = 0;\n+\tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n+\tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n \tstruct blame_tree_callback_data data;\n \n \tdata.paths = &bt->paths;\n@@ -233,25 +373,121 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \tbt->rev.diffopt.format_callback = blame_diff;\n \tbt->rev.diffopt.format_callback_data = &data;\n \n-\tprepare_revision_walk(&bt->rev);\n+\tmax_count = bt->rev.max_count;\n \n-\twhile (hashmap_get_size(&bt->paths)) {\n-\t\tdata.commit = get_revision(&bt->rev);\n-\t\tif (!data.commit)\n-\t\t\tbreak;\n+\tinit_active_paths(&active_paths);\n+\tscratch = xcalloc(bt->all_paths_nr, sizeof(char));\n \n-\t\tif (!maybe_changed_path(bt, data.commit))\n-\t\t\tcontinue;\n+\t/*\n+\t * bt->rev.pending holds the set of boundary commits for our walk.\n+\t *\n+\t * Loop through each such commit, and place it in the appropriate queue.\n+\t */\n+\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n+\t\tstruct commit *c = lookup_commit(bt->rev.repo,\n+\t\t\t\t\t\t &bt->rev.pending.objects[i].item->oid);\n+\t\trepo_parse_commit(bt->rev.repo, c);\n \n-\t\tif (data.commit->object.flags & BOUNDARY) {\n-\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n-\t\t\t\t       &data.commit->object.oid,\n-\t\t\t\t       \"\", &bt->rev.diffopt);\n-\t\t\tdiff_flush(&bt->rev.diffopt);\n-\t\t} else {\n-\t\t\tlog_tree_commit(&bt->rev, data.commit);\n+\t\tif (c->object.flags & BOTTOM) {\n+\t\t\tprio_queue_put(&not_queue, c);\n+\t\t\tc->object.flags |= PARENT2;\n+\t\t} else if (!(c->object.flags & PARENT1)) {\n+\t\t\t/*\n+\t\t\t * If the commit is a starting point (and hasn't been\n+\t\t\t * seen yet), then initialize the set of interesting\n+\t\t\t * paths, too.\n+\t\t\t */\n+\t\t\tstruct commit_active_paths *active;\n+\n+\t\t\tprio_queue_put(&queue, c);\n+\t\t\tc->object.flags |= PARENT1;\n+\n+\t\t\tactive = active_paths_at(&active_paths, c);\n+\t\t\tactive->active = xcalloc(sizeof(char), bt->all_paths_nr);\n+\t\t\tmemset(active->active, 1, bt->all_paths_nr);\n+\t\t\tactive->nr = bt->all_paths_nr;\n \t\t}\n \t}\n \n+\t/*\n+\t * Now that we have processed the pending commits, allow the revision\n+\t * machinery to flush them by calling prepare_revision_walk().\n+\t */\n+\tprepare_revision_walk(&bt->rev);\n+\n+\twhile (queue.nr) {\n+\t\tint parent_i;\n+\t\tstruct commit_list *p;\n+\t\tstruct commit *c = prio_queue_get(&queue);\n+\t\tstruct commit_active_paths *active_c = active_paths_at(&active_paths, c);\n+\n+\t\tif ((0 <= max_count && max_count < ++queue_popped) ||\n+\t\t    (c->object.flags & PARENT2)) {\n+\t\t\t/*\n+\t\t\t * Either a boundary commit, or we have already seen too\n+\t\t\t * many others. Either way, stop here.\n+\t\t\t */\n+\t\t\tc->object.flags |= PARENT2 | BOUNDARY;\n+\t\t\tdata.commit = c;\n+\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &c->object.oid,\n+\t\t\t\t      \"\", &bt->rev.diffopt);\n+\t\t\tdiff_flush(&bt->rev.diffopt);\n+\t\t\tgoto cleanup;\n+\t\t}\n+\n+\t\t/*\n+\t\t * Otherwise, keep going, but make sure that 'c' isn't reachable\n+\t\t * from anything in the '--not' queue.\n+\t\t */\n+\t\trepo_parse_commit(bt->rev.repo, c);\n+\n+\t\twhile (not_queue.nr) {\n+\t\t\tstruct commit_list *np;\n+\t\t\tstruct commit *n = prio_queue_get(&not_queue);\n+\n+\t\t\trepo_parse_commit(bt->rev.repo, n);\n+\n+\t\t\tfor (np = n->parents; np; np = np->next) {\n+\t\t\t\tif (!(np->item->object.flags & PARENT2)) {\n+\t\t\t\t\tprio_queue_put(&not_queue, np->item);\n+\t\t\t\t\tnp->item->object.flags |= PARENT2;\n+\t\t\t\t}\n+\t\t\t}\n+\n+\t\t\tif (commit_graph_generation(n) < commit_graph_generation(c))\n+\t\t\t\tbreak;\n+\t\t}\n+\n+\t\t/*\n+\t\t * Look at each remaining interesting path, and pass it onto\n+\t\t * parents in order if TREESAME.\n+\t\t */\n+\t\tfor (p = c->parents, parent_i = 0; p; p = p->next, parent_i++) {\n+\t\t\tif (process_parent(bt, &queue,\n+\t\t\t\t\t   c, active_c,\n+\t\t\t\t\t   p->item, parent_i) > 0 )\n+\t\t\t\tbreak;\n+\t\t}\n+\n+\t\tif (active_c->nr)  {\n+\t\t\t/* Any paths that remain active were changed by 'c'. */\n+\t\t\tdata.commit = c;\n+\t\t\tfor (int i = 0; i < bt->all_paths_nr; i++) {\n+\t\t\t\tif (active_c->active[i])\n+\t\t\t\t\tmark_path(bt->all_paths[i], NULL, &data);\n+\t\t\t}\n+\t\t}\n+\n+cleanup:\n+\t\tFREE_AND_NULL(active_c->active);\n+\t\tactive_c->nr = 0;\n+\t}\n+\n+\tclear_prio_queue(&not_queue);\n+\tclear_prio_queue(&queue);\n+\tdeep_clear_active_paths(&active_paths, free_one_active_path);\n+\tfree(scratch);\n+\n \treturn 0;\n }\ndiff --git a/blame-tree.h b/blame-tree.h\nindex abb467cf1b..0e6a6929f6 100644\n--- a/blame-tree.h\n+++ b/blame-tree.h\n@@ -8,6 +8,9 @@\n struct blame_tree {\n \tstruct hashmap paths;\n \tstruct rev_info rev;\n+\n+\tconst char **all_paths;\n+\tint all_paths_nr;\n };\n \n void blame_tree_init(struct blame_tree *bt,\n\n-- \n2.49.0.rc2\n\n"},{"id":"516522","messageId":"20250422-toon-new-blame-tree-v1-5-fdb51b8a394a@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC 5/5] blame-tree.c: initialize revision machinery without walk","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-04-22T17:46:28Z","receivedAt":"2025-04-22T17:47:10Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"In a previous commit we inserted a call to 'prepare_revision_walk()'\nbefore we started our traversal. This was done when we leveraged the\nrevision machinery more (at the time, we were leaning on\n'log_tree_commit()' which only worked after calling\n'prepare_revision_walk()').\n\nBut, we have since dropped 'log_tree_commit()', so we don't need most of\nthe initialization work of 'prepare_revision_walk()'. Now we ask it to\ndo very little work during initialization by setting the '->no_walk'\nflag to '1', which leaves its internal state alone enough that we can\nstill function as normal.\n\nUnfortunately, this means that we now no longer complain about\nnon-commit inputs, since the revision machinery check this for us (it\njust silently ignores them).\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n blame-tree.c | 25 ++++++++++++++-----------\n 1 file changed, 14 insertions(+), 11 deletions(-)\n\ndiff --git a/blame-tree.c b/blame-tree.c\nindex 2cb7a5045c..e244797b7e 100644\n--- a/blame-tree.c\n+++ b/blame-tree.c\n@@ -271,6 +271,13 @@ static int maybe_changed_path(struct blame_tree *bt,\n \tif (!filter)\n \t\treturn 1;\n \n+\tfor (int i = 0; i < bt->rev.bloom_keys_nr; i++) {\n+\t\tif (!(bloom_filter_contains(filter,\n+\t\t\t\t\t    &bt->rev.bloom_keys[i],\n+\t\t\t\t\t    bt->rev.bloom_filter_settings)))\n+\t\t\treturn 0;\n+\t}\n+\n \thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n \t\tif (active && !active->active[e->diff_idx])\n \t\t\tcontinue;\n@@ -364,6 +371,7 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n \tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n \tstruct blame_tree_callback_data data;\n+\tstruct commit_list *list;\n \n \tdata.paths = &bt->paths;\n \tdata.callback = cb;\n@@ -372,6 +380,9 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \tbt->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n \tbt->rev.diffopt.format_callback = blame_diff;\n \tbt->rev.diffopt.format_callback_data = &data;\n+\tbt->rev.no_walk = 1;\n+\n+\tprepare_revision_walk(&bt->rev);\n \n \tmax_count = bt->rev.max_count;\n \n@@ -379,14 +390,12 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \tscratch = xcalloc(bt->all_paths_nr, sizeof(char));\n \n \t/*\n-\t * bt->rev.pending holds the set of boundary commits for our walk.\n+\t * bt->rev.commits holds the set of boundary commits for our walk.\n \t *\n \t * Loop through each such commit, and place it in the appropriate queue.\n \t */\n-\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n-\t\tstruct commit *c = lookup_commit(bt->rev.repo,\n-\t\t\t\t\t\t &bt->rev.pending.objects[i].item->oid);\n-\t\trepo_parse_commit(bt->rev.repo, c);\n+\tfor (list = bt->rev.commits; list; list = list->next) {\n+\t\tstruct commit *c = list->item;\n \n \t\tif (c->object.flags & BOTTOM) {\n \t\t\tprio_queue_put(&not_queue, c);\n@@ -409,12 +418,6 @@ int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n \t\t}\n \t}\n \n-\t/*\n-\t * Now that we have processed the pending commits, allow the revision\n-\t * machinery to flush them by calling prepare_revision_walk().\n-\t */\n-\tprepare_revision_walk(&bt->rev);\n-\n \twhile (queue.nr) {\n \t\tint parent_i;\n \t\tstruct commit_list *p;\n\n-- \n2.49.0.rc2\n\n"},{"id":"516605","messageId":"69a10929-0e44-4ce9-af91-048b8404c752@xiplink.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-04-23T13:26:59Z","receivedAt":"2025-04-23T13:27:08Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\nOn 2025-04-22 13:46, Toon Claes wrote:\n> This is another attempt to upstream the git-blame-tree(1) subcommand.\n> After the previous attempt[1] the people of GitHub shared their version\n> of the subcommand, and this version integrates those changes.\n\nThis functionality is awesome -- thanks for pushing this forwards.\n\nI feel the need to get some bike-shedding off my chest, though: \n\"blame-tree\" would be a terrible name for this command.  I think that if \nGit ends up with two blame-like commands it will merely solidify Git's \nreputation for obscurity.\n\nIf this is really a form of blaming, then just make it an extension of \n\"git blame\", like maybe \"git blame --latest\".\n\nOtherwise, please come up with a new command name.  \"git latest\"?  \"git \n\"latest-revs\"?  As long as it doesn't use the word \"blame\"...\n\nFYI, here's Peff's original explanation[1] of how he came up with the name:\n\n > I wasn't sure at first what to call it or what the calling conventions\n > should be. The initial thought was to make it part of \"ls-tree\". But\n > that feels wrong, as ls-tree otherwise never cares about traversal.\n > The combination of traversal and diff made me think of blame, and\n > indeed, I think this is really just about blaming a whole tree at the\n > file-level, rather than at the content-level. Thus I called it blame-\n > tree, and I used the same calling conventions as blame:\n > \"git blame-tree <path> <rev opts>\".\n\nTo me that reads like an argument for folding this into \"git blame\".\n\n\t\tM.\n\n[1] \nhttps://lore.kernel.org/git/20110302164031.GA18233@sigill.intra.peff.net/\n\n"},{"id":"516679","messageId":"xmqqfrhxv8fn.fsf@gitster.g","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-1-fdb51b8a394a@iotcl.com","subject":"Re: [PATCH RFC 1/5] blame-tree: introduce new subcommand to blame files","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-04-24T16:19:24Z","receivedAt":"2025-04-24T16:19:28Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Similar to git-blame(1), introduce a new subcommand git-blame-tree(1).\n> This command shows the most recent modification to paths in a tree. It\n> does so by expanding the tree at a given commit, taking note of the\n> current state of each path, and then walking backwards through history\n> looking for commits where each path changed into its final commit ID.\n\nWhat is missing in the series is an end-user facing documentation,\nit seems?  Don't take this as a complaint; an RFC is expected to be\nincomplete and one of the reasons asking for comment responses is\nto fill the gaps.\n\nHow is the \"most recent modification\" defined in a history with\nforks and merges?  For example, in this topology:\n\n A---B---B---B---B---B\n          \\     /\n           C---D\n\nwhere each letter denotes the contents in the path we are interested\nin (and as usual, time flows from left to right), is it the child of\ncommit A that made the last modification from A to B?  Or was it the\nmerge commit that compared B and D and decided that the path should\nhave B?  Something else?  Does it change the story if the sides of\nthe merge were swapped, i.e. if the branch that kept B all the way\nwere not the mainline but the side branch that got merged?\n\n A---B---B---C---D---B---B\n          \\         /\n           B-------B\n\nThe same question applies if the path we are interested in is a\ntree, not a leaf file.\n\nI do not seem to see such a case that involves \"ours\" merge in the\ntests, either.\n"},{"id":"517486","messageId":"87h61wimxj.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqfrhxv8fn.fsf@gitster.g","subject":"Re: [PATCH RFC 1/5] blame-tree: introduce new subcommand to blame files","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-07T13:13:12Z","receivedAt":"2025-05-07T13:13:26Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> What is missing in the series is an end-user facing documentation,\n> it seems?  Don't take this as a complaint; an RFC is expected to be\n> incomplete and one of the reasons asking for comment responses is\n> to fill the gaps.\n\nYes, I'm aware that was missing, but I didn't want to spend too much\ntime on this if the general idea would be discarded anyway.\n\n> How is the \"most recent modification\" defined in a history with\n> forks and merges?  For example, in this topology:\n>\n>  A---B---B---B---B---B\n>           \\     /\n>            C---D\n>\n> where each letter denotes the contents in the path we are interested\n> in (and as usual, time flows from left to right), is it the child of\n> commit A that made the last modification from A to B?  Or was it the\n> merge commit that compared B and D and decided that the path should\n> have B?  Something else?  Does it change the story if the sides of\n> the merge were swapped, i.e. if the branch that kept B all the way\n> were not the mainline but the side branch that got merged?\n>\n>  A---B---B---C---D---B---B\n>           \\         /\n>            B-------B\n>\n> The same question applies if the path we are interested in is a\n> tree, not a leaf file.\n>\n> I do not seem to see such a case that involves \"ours\" merge in the\n> tests, either.\n\nThose are good scenarios to think about. I'll try to include them in\ntest cases for the next version. I think it's easier to argue about it\nthen.\n\n--\nToon\n"},{"id":"517493","messageId":"87ecx0ijqt.fsf@iotcl.com","threadId":"63332","inReplyTo":"69a10929-0e44-4ce9-af91-048b8404c752@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-07T14:22:02Z","receivedAt":"2025-05-07T14:22:19Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Marc Branchaud <marcnarc@xiplink.com> writes:\n\n> I feel the need to get some bike-shedding off my chest, though:\n\nAlways welcome!\n\n> \"blame-tree\" would be a terrible name for this command.\n\nDo you feel this way because \"blame\" as a negative conotation?\n\n> I think that if Git ends up with two blame-like commands it will\n> merely solidify Git's reputation for obscurity.\n\nI think \"blaming\" is a well-concept in Git, and many people (familiar\nwith Git) would understand in instant what `blame-tree` would do.\n\n> If this is really a form of blaming, then just make it an extension of \n> \"git blame\", like maybe \"git blame --latest\".\n\nI'm afraid that won't work very well, because the code is very much\ndifferent. If naming is the only motivation to shoehorn this in, then I\nthink it's better to rethink the name?\n\n> Otherwise, please come up with a new command name.  \"git latest\"?  \"git \n> \"latest-revs\"?  As long as it doesn't use the word \"blame\"...\n\nI've been thinking about this a lot more, but I failed to come up with a\nbetter name.\n\n> FYI, here's Peff's original explanation[1] of how he came up with the name:\n>\n>  > I wasn't sure at first what to call it or what the calling conventions\n>  > should be. The initial thought was to make it part of \"ls-tree\". But\n>  > that feels wrong, as ls-tree otherwise never cares about traversal.\n>  > The combination of traversal and diff made me think of blame, and\n>  > indeed, I think this is really just about blaming a whole tree at the\n>  > file-level, rather than at the content-level. Thus I called it blame-\n>  > tree, and I used the same calling conventions as blame:\n>  > \"git blame-tree <path> <rev opts>\".\n>\n> To me that reads like an argument for folding this into \"git blame\".\n\nForgive me, but I think folding into git-blame(1) will also solidify\nGit's reputation of obscurity.\n\nI think `blame-tree` is a fine name for this feature, but in the end I\ndon't care too much about the exact name. If we end up naming it `git\nlast-for-each`, `git annonate-files`, `git log-everyone`, or `git\nwhen-modified` ... It's all good for me.\n\n> [1] \n> https://lore.kernel.org/git/20110302164031.GA18233@sigill.intra.peff.net/\n\n\n--\nToon\n"},{"id":"517516","messageId":"323dc2c8-41bb-433a-a1c9-662609e359ea@xiplink.com","threadId":"63332","inReplyTo":"87ecx0ijqt.fsf@iotcl.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-07T20:23:37Z","receivedAt":"2025-05-07T20:23:43Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\nOn 2025-05-07 10:22, Toon Claes wrote:\n> Marc Branchaud <marcnarc@xiplink.com> writes:\n> \n>> I feel the need to get some bike-shedding off my chest, though:\n> \n> Always welcome!\n> \n>> \"blame-tree\" would be a terrible name for this command.\n> \n> Do you feel this way because \"blame\" as a negative conotation?\n\nGood question, but no, not at all.\n\nMy concern is about having two commands to do blaming (or \"crediting\" or \nwhatever anyone wants to call it), instead of just one.\n\n>> I think that if Git ends up with two blame-like commands it will\n>> merely solidify Git's reputation for obscurity.\n> \n> I think \"blaming\" is a well-concept in Git, and many people (familiar\n> with Git) would understand in instant what `blame-tree` would do.\n\nI agree that blaming is a well-(known) concept.  I also agree that most \nusers would understand what blame-tree would do, *once they find it*.\n\nBut I think that's beside the point I'm trying to make.  Git is \nnotorious for making users learn countless commands, and having two \nslightly-different commands for blaming is just going to make that worse.\n\nI mean, from a usability point of view, it makes much more sense if \"git \nblame\" simply understood how to handle blaming a directory differently \nfrom blaming a file/blob:\n\nWant to see which commit last touched each line of a file?  Just run\n\tgit blame path/to/file\n\nWant to see which commits last touched each file under a tree?  Just run\n\tgit blame path/to/directory\n\nGit should be smart enough to figure out what to do from just whether or \nnot the last argument is a file or directory.\n\n>> If this is really a form of blaming, then just make it an extension of\n>> \"git blame\", like maybe \"git blame --latest\".\n> \n> I'm afraid that won't work very well, because the code is very much\n> different. If naming is the only motivation to shoehorn this in, then I\n> think it's better to rethink the name?\n\nIt's not just \"naming\" but rather trying to help Git be intuitively \nuseful to users.\n\nAlso, I think sacrificing usability because it makes the coding hard is \nunfortunate.\n\nI personally think it's fine for blame.c to contain two different \ninternal swathes of code that do different things.  The ~500 lines or so \nto implement blame-tree don't feel like a major burden to me, especially \ncompared to the ~3000 lines already in blame.c...\n\nBut if combining the two features into a single C file is too much to \nbear, perhaps refactor the existing blame.c code?  Something like:\n\n  - blame-file.c (the existing \"git blame\" implementation)\n  - blame-tree.c (the new functionality)\n  - blame.c (exposes both blame-file and blame-tree under \"git blame\")\n\n>> Otherwise, please come up with a new command name.  \"git latest\"?  \"git\n>> \"latest-revs\"?  As long as it doesn't use the word \"blame\"...\n> \n> I've been thinking about this a lot more, but I failed to come up with a\n> better name.\n> \n>> FYI, here's Peff's original explanation[1] of how he came up with the name:\n>>\n>>   > I wasn't sure at first what to call it or what the calling conventions\n>>   > should be. The initial thought was to make it part of \"ls-tree\". But\n>>   > that feels wrong, as ls-tree otherwise never cares about traversal.\n>>   > The combination of traversal and diff made me think of blame, and\n>>   > indeed, I think this is really just about blaming a whole tree at the\n>>   > file-level, rather than at the content-level. Thus I called it blame-\n>>   > tree, and I used the same calling conventions as blame:\n>>   > \"git blame-tree <path> <rev opts>\".\n>>\n>> To me that reads like an argument for folding this into \"git blame\".\n> \n> Forgive me, but I think folding into git-blame(1) will also solidify\n> Git's reputation of obscurity.\n\nPlease elaborate.\n\n> I think `blame-tree` is a fine name for this feature, but in the end I\n> don't care too much about the exact name. If we end up naming it `git\n> last-for-each`, `git annonate-files`, `git log-everyone`, or `git\n> when-modified` ... It's all good for me.\n\nBut then, why not just expand \"git blame\"?\n\nI feel that the\n\tgit blame path/to/directory\nuse case I mentioned above is a compelling argument to fold the feature \ninto standard \"git blame\", aside from any reputation-for-obscurity \ndiscussion.\n\n\t\tM.\n\n"},{"id":"517518","messageId":"xmqqjz6sb15u.fsf@gitster.g","threadId":"63332","inReplyTo":"323dc2c8-41bb-433a-a1c9-662609e359ea@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-05-07T20:45:17Z","receivedAt":"2025-05-07T20:45:20Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Marc Branchaud <marcnarc@xiplink.com> writes:\n\n> My concern is about having two commands to do blaming (or \"crediting\"\n> or whatever anyone wants to call it), instead of just one.\n\nExisting \"git blame\" (or \"git annotate\") is about tracing the origin\nof individual lines, so perhaps we can say \"git blame\" has two\nmodes, blame lines or blame files, and run the code for this new\nmode with \"git blame --mode=file\" (and add \"git blame --mode=line\"\nthat is on by default that runs the original \"git blame\" code\npaths)?\n\n> I mean, from a usability point of view, it makes much more sense if\n> \"git blame\" simply understood how to handle blaming a directory\n> differently from blaming a file/blob:\n\nI think this needs rephrasing: blaming a whole file (or a whole\ntree) differently from blaming individual lines.\n\nAs lines can move across files, and we do find such moves while\ntracing the origin of each line, \"blaming a file\" is not quite the\nright way to think about it.  \"blaming lines in a file\", perhaps.\n\n> Git should be smart enough to figure out what to do from just whether\n> or not the last argument is a file or directory.\n\nAh, that is interesting.  We do not have to introduce \"--mode=line/file\"\noption.  Just see if the given pathspec names a tree object in the starting\ncommit and trigger the blame-tree logic, otherwise we just line the\n\"blame lines in a file\" mode.  So dispatching between the two modes\nis almost trivial.  I like that.\n\nAfter command line option parsing, however, there may need some\nsanity checking logic like \"You said you want to blame the t/\ndirectory and its contents, but at the same time you have -L1,10\nto say you only want to blame the first 10 lines, which is an option\nthat does not make sense in blame-tree mode, so I abort\".  As long\nas that is cleanly done, I think it is a good direction forward.\n\n"},{"id":"517519","messageId":"9a0c98d1-4a77-4f2e-9aa9-bbab11b3e44d@app.fastmail.com","threadId":"63332","inReplyTo":"323dc2c8-41bb-433a-a1c9-662609e359ea@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2025-05-07T20:49:01Z","receivedAt":"2025-05-07T20:49:23Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Wed, May 7, 2025, at 22:23, Marc Branchaud wrote:\n> On 2025-05-07 10:22, Toon Claes wrote:\n>> Marc Branchaud <marcnarc@xiplink.com> writes:\n>>\n>>> I feel the need to get some bike-shedding off my chest, though:\n>>\n>> Always welcome!\n>>\n>>> \"blame-tree\" would be a terrible name for this command.\n>>\n>> Do you feel this way because \"blame\" as a negative conotation?\n>\n> Good question, but no, not at all.\n>\n> My concern is about having two commands to do blaming (or \"crediting\" or\n> whatever anyone wants to call it), instead of just one.\n>\n>>> I think that if Git ends up with two blame-like commands it will\n>>> merely solidify Git's reputation for obscurity.\n>>\n>> I think \"blaming\" is a well-concept in Git, and many people (familiar\n>> with Git) would understand in instant what `blame-tree` would do.\n>\n> I agree that blaming is a well-(known) concept.  I also agree that most\n> users would understand what blame-tree would do, *once they find it*.\n>\n> But I think that's beside the point I'm trying to make.  Git is\n> notorious for making users learn countless commands, and having two\n> slightly-different commands for blaming is just going to make that worse.\n\nUse a Git user I don’t see the problem.  `git --list-cmds=builtins`\nlists 144 commands.  Six of them are `-tree` commands.\n\nIt’s not been my understanding that people stumble upon niche commands\nthat easily.  Most questions I’ve seen about git-commit-tree(1) (one of\nthe `-tree` commands that seems to come up from time to time) seem to\ncome from a point of idle curiosity.  That’s questions that bring it up\n(i.e. potential user confusion).\n\n(The first impression I got of `-tree` commands was that they were less\nuser-friendly commands for hardcore users.)\n\nThat’s just my perspective.  Do you have a case in mind where such a new\ncommand could lead to user confusion?\n"},{"id":"517544","messageId":"CALnO6CBzKbo86RC0DprT+ZxWfbsea8Kb7ZZurSZeMMA5HOthSw@mail.gmail.com","threadId":"63332","inReplyTo":"323dc2c8-41bb-433a-a1c9-662609e359ea@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2025-05-08T13:18:48Z","receivedAt":"2025-05-08T13:19:06Z","isPatch":true,"sender":{"key":"ben.knoble@gmail.com","avatar":"https://avatars.githubusercontent.com/u/22802209?v=4"},"body":"On Wed, May 7, 2025 at 4:23 PM Marc Branchaud <marcnarc@xiplink.com> wrote:\n>\n>\n> On 2025-05-07 10:22, Toon Claes wrote:\n> > Marc Branchaud <marcnarc@xiplink.com> writes:\n[cut]\n> I agree that blaming is a well-(known) concept.  I also agree that most\n> users would understand what blame-tree would do, *once they find it*.\n>\n> But I think that's beside the point I'm trying to make.  Git is\n> notorious for making users learn countless commands, and having two\n> slightly-different commands for blaming is just going to make that worse.\n>\n> I mean, from a usability point of view, it makes much more sense if \"git\n> blame\" simply understood how to handle blaming a directory differently\n> from blaming a file/blob:\n>\n> Want to see which commit last touched each line of a file?  Just run\n>         git blame path/to/file\n>\n> Want to see which commits last touched each file under a tree?  Just run\n>         git blame path/to/directory\n>\n> Git should be smart enough to figure out what to do from just whether or\n> not the last argument is a file or directory.\n\nI quite like this idea, too: today, \"git blame t\" in git.git is a\nfatal error, for example (no such path 't' in HEAD). (#leftoverbits:\nit's also not translated?)\nTurning an error into a new use case seems like an excellent expansion\nof capabilities.\n\n>\n> >> If this is really a form of blaming, then just make it an extension of\n> >> \"git blame\", like maybe \"git blame --latest\".\n> >\n> > I'm afraid that won't work very well, because the code is very much\n> > different. If naming is the only motivation to shoehorn this in, then I\n> > think it's better to rethink the name?\n>\n> It's not just \"naming\" but rather trying to help Git be intuitively\n> useful to users.\n>\n> Also, I think sacrificing usability because it makes the coding hard is\n> unfortunate.\n>\n> I personally think it's fine for blame.c to contain two different\n> internal swathes of code that do different things.  The ~500 lines or so\n> to implement blame-tree don't feel like a major burden to me, especially\n> compared to the ~3000 lines already in blame.c...\n>\n> But if combining the two features into a single C file is too much to\n> bear, perhaps refactor the existing blame.c code?  Something like:\n>\n>   - blame-file.c (the existing \"git blame\" implementation)\n>   - blame-tree.c (the new functionality)\n>   - blame.c (exposes both blame-file and blame-tree under \"git blame\")\n\nA third alternative is to allow \"git blame-tree\" as here, but as\nplumbing. Then we have \"git blame <dir>\" use it—today, that might mean\ndirectly invoking \"git blame-tree\"; in the future, that might look\nmore like the relationship between \"diff\" and \"diff-tree\" (assuming\nthere is one)?\n\n-- \nD. Ben Knoble\n"},{"id":"517545","messageId":"CALnO6CBNiQ503+YTXfq0pwY-6OPRZvzAtE0BPJsw7wb=8O3YfQ@mail.gmail.com","threadId":"63332","inReplyTo":"9a0c98d1-4a77-4f2e-9aa9-bbab11b3e44d@app.fastmail.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2025-05-08T13:20:35Z","receivedAt":"2025-05-08T13:20:54Z","isPatch":true,"sender":{"key":"ben.knoble@gmail.com","avatar":"https://avatars.githubusercontent.com/u/22802209?v=4"},"body":"On Wed, May 7, 2025 at 4:49 PM Kristoffer Haugsbakk\n<kristofferhaugsbakk@fastmail.com> wrote:\n> Use a Git user I don’t see the problem.  `git --list-cmds=builtins`\n> lists 144 commands.  Six of them are `-tree` commands.\n>\n> It’s not been my understanding that people stumble upon niche commands\n> that easily.\n\nSeconded. My experience is that the distribution of Git users is\nskewed left towards \"add/commit/push\" with a long tail of curiosity on\nthe right… improving discoverability is a worthwhile goal, I think.\n\n-- \nD. Ben Knoble\n"},{"id":"517546","messageId":"7a6afafc-6dee-4a09-8779-83748a4d3fc4@xiplink.com","threadId":"63332","inReplyTo":"xmqqjz6sb15u.fsf@gitster.g","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-08T13:26:01Z","receivedAt":"2025-05-08T13:26:06Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\nOn 2025-05-07 16:45, Junio C Hamano wrote:\n> Marc Branchaud <marcnarc@xiplink.com> writes:\n>> \n>> I mean, from a usability point of view, it makes much more sense if\n>> \"git blame\" simply understood how to handle blaming a directory\n>> differently from blaming a file/blob:\n> \n> I think this needs rephrasing: blaming a whole file (or a whole\n> tree) differently from blaming individual lines.\n> \n> As lines can move across files, and we do find such moves while\n> tracing the origin of each line, \"blaming a file\" is not quite the\n> right way to think about it.  \"blaming lines in a file\", perhaps.\n\nI see what you mean.  \"Blaming lines in a file\" works for me.\n\nThis distinction brings up a wrinkle in my proposed DWIMery: should\n\tgit blame path/to/file\nshow the annotated blamed lines of the file, or simply display the last \ncommit that changed the file?\n\nWhile a \"whole-file blame\" is really just\n\tgit log -1 path/to/file\nI can appreciate the convenience of being able to do that with \"git \nblame\".  I suggest adding an option for this specific case, like maybe \n\"--latest\" (I don't feel strongly about the option's name).\n\nWant to see the annotated blamed lines of a file?\n\tgit blame path/to/file\n\nWant to see the last commit to touch a file?\n\tgit blame --latest path/to/file\nor\n\tgit log -1 path/to/file\n\nWant to see the last commits to touch each file under a directory?\n\tgit blame path/to/directory\n(--latest is implied because the target is a directory.)\n\n\nIt also occurs to me that\n\tgit blame path/to/directory\nmight need a way to toggle recursion.  I suggest recursion be off by \ndefault.\n\n\t\tM.\n\n"},{"id":"517547","messageId":"b162707d-0da3-4ea7-94da-5546119012c4@xiplink.com","threadId":"63332","inReplyTo":"9a0c98d1-4a77-4f2e-9aa9-bbab11b3e44d@app.fastmail.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-08T13:26:05Z","receivedAt":"2025-05-08T13:26:09Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\nOn 2025-05-07 16:49, Kristoffer Haugsbakk wrote:\n> On Wed, May 7, 2025, at 22:23, Marc Branchaud wrote:\n>> I agree that blaming is a well-(known) concept.  I also agree that most\n>> users would understand what blame-tree would do, *once they find it*.\n>>\n>> But I think that's beside the point I'm trying to make.  Git is\n>> notorious for making users learn countless commands, and having two\n>> slightly-different commands for blaming is just going to make that worse.\n> \n> Use a Git user I don’t see the problem.  `git --list-cmds=builtins`\n> lists 144 commands.  Six of them are `-tree` commands.\n\nNone of the -tree commands are porcelain meant for regular use, and only \nmerge-tree is \"ancillary\".  The rest are all plumbing.  These are hardly \nthe commands normal users will use.  I've been using and scripting Git \nfor a great many years, and I think I've maybe used read-tree a handful \nof times.\n\n(I see that --list-cmds is experimental and only documented deep within \n\"git help git\".  You seem to be a very advanced Git user!)\n\n> It’s not been my understanding that people stumble upon niche commands\n> that easily.\n\nYes, I agree.  That seems to support the point I've been trying to make...\n\n> Most questions I’ve seen about git-commit-tree(1) (one of\n> the `-tree` commands that seems to come up from time to time) seem to\n> come from a point of idle curiosity.  That’s questions that bring it up\n> (i.e. potential user confusion).\n> \n> (The first impression I got of `-tree` commands was that they were less\n> user-friendly commands for hardcore users.)\n\nOf course they're less user-friendly: They're not porcelain.\n\n> That’s just my perspective.  Do you have a case in mind where such a new\n> command could lead to user confusion?\n\nOnly decades of experience writing and using software.  Bloating Git's \ncommand set should only be done after serious consideration of alternatives.\n\nIf I were not subscribed to this list, and Git went ahead with \n\"blame-tree\", I would most likely never learn about it.  Since I do know \nabout \"blame\", if the feature were part of that command then I have a \ngood chance of discovering it the next time I read blame's documentation.\n\n\t\tM.\n\n"},{"id":"517555","messageId":"xmqqldr789gr.fsf@gitster.g","threadId":"63332","inReplyTo":"7a6afafc-6dee-4a09-8779-83748a4d3fc4@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-05-08T14:26:28Z","receivedAt":"2025-05-08T14:26:31Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Marc Branchaud <marcnarc@xiplink.com> writes:\n\n> This distinction brings up a wrinkle in my proposed DWIMery: should\n> \tgit blame path/to/file\n> show the annotated blamed lines of the file, or simply display the\n> last commit that changed the file?\n\nI thought you switch to blame-at-the-file-level only when you are\ngiven a directory (or a tree)?  \"git blame path/to/file\" has ALWAYS\ndone \"blame these lines that appear in this file\", and cannot change.\n\nOf course you can say \"git blame path/to/ | grep file\"; as you said\nyourself,\n\n> \tgit log -1 path/to/file\n\nis so obvious, we do not need to introduce yet another way to get to\nthe same information, I think.\n\n> It also occurs to me that\n> \tgit blame path/to/directory\n> might need a way to toggle recursion.  I suggest recursion be off by\n> default.\n\nI do not have strong opinion on this part; I've somehow assumed\nwhile reading your message that you wanted it to always recurse\n(like `git ls-files` does) and I thought it made sense, but not\nrecursing and just showing a single level (like `git ls-tree` does)\nwith an option to make it recurse is certainly a possibility.\n\nThanks.\n"},{"id":"517561","messageId":"9109a8ab-d4b2-4ada-8969-bc72edac5ecd@xiplink.com","threadId":"63332","inReplyTo":"xmqqldr789gr.fsf@gitster.g","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-08T15:12:51Z","receivedAt":"2025-05-08T15:12:57Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\nOn 2025-05-08 10:26, Junio C Hamano wrote:\n> Marc Branchaud <marcnarc@xiplink.com> writes:\n> \n>> This distinction brings up a wrinkle in my proposed DWIMery: should\n>> \tgit blame path/to/file\n>> show the annotated blamed lines of the file, or simply display the\n>> last commit that changed the file?\n> \n> I thought you switch to blame-at-the-file-level only when you are\n> given a directory (or a tree)?  \"git blame path/to/file\" has ALWAYS\n> done \"blame these lines that appear in this file\", and cannot change.\n> \n> Of course you can say \"git blame path/to/ | grep file\"; as you said\n> yourself,\n> \n>> \tgit log -1 path/to/file\n> \n> is so obvious, we do not need to introduce yet another way to get to\n> the same information, I think.\n\nFine by me.  I personally don't think of \"git blame\" when I want to see \na file's commit history.\n\n>> It also occurs to me that\n>> \tgit blame path/to/directory\n>> might need a way to toggle recursion.  I suggest recursion be off by\n>> default.\n> \n> I do not have strong opinion on this part; I've somehow assumed\n> while reading your message that you wanted it to always recurse\n> (like `git ls-files` does) and I thought it made sense, but not\n> recursing and just showing a single level (like `git ls-tree` does)\n> with an option to make it recurse is certainly a possibility.\n\nI also don't feel strongly either way.  It just seemed that defaulting \nto recursion could end up creating a lot of processing (and output), and \nthat making the user explicitly ask for it seems friendly.\n\nBut whatever the default is, it does seem useful to have an option to \ncontrol recursion.\n\n\t\tM.\n\n"},{"id":"518043","messageId":"874ixnjltf.fsf@iotcl.com","threadId":"63332","inReplyTo":"9109a8ab-d4b2-4ada-8969-bc72edac5ecd@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-14T14:42:20Z","receivedAt":"2025-05-14T14:42:37Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Marc Branchaud <marcnarc@xiplink.com> writes:\n\n> On 2025-05-08 10:26, Junio C Hamano wrote:\n>> Marc Branchaud <marcnarc@xiplink.com> writes:\n>> \n>>> This distinction brings up a wrinkle in my proposed DWIMery: should\n>>> \tgit blame path/to/file\n>>> show the annotated blamed lines of the file, or simply display the\n>>> last commit that changed the file?\n>> \n>> I thought you switch to blame-at-the-file-level only when you are\n>> given a directory (or a tree)?  \"git blame path/to/file\" has ALWAYS\n>> done \"blame these lines that appear in this file\", and cannot change.\n\nI don't know about that. What if you want to blame multiple files:\n\n  $ git blame-tree refs.c refs.h\n\nor (letting your shell do the globbing):\n\n  $ $ git blame-tree *.h\n\nI see these use-cases are very convenient. At GitLab we need to have\nsome kind pagination on files in a tree, if we can pass individual\nfilenames, we could use that for pagination.\n\n>> Of course you can say \"git blame path/to/ | grep file\"; as you said\n>> yourself,\n\nThis isn't very efficient. If a file in that tree was only touched in\nthe \"initial commit\" you have to wait for the blame process to walk the\nhistory all the way down to that commit, while you're not actually\ninterested in that file.\n\n>> \n>>> \tgit log -1 path/to/file\n>> \n>> is so obvious, we do not need to introduce yet another way to get to\n>> the same information, I think.\n\nWell, if you can pass multiple files (which git-blame-tree(1) currently\ncan) it's way more efficient to walk the history once, and see along the\nhistory which file was touched when. For us at GitLab that's the whole\nidea of upstreaming this feature.\n\n> Fine by me.  I personally don't think of \"git blame\" when I want to see \n> a file's commit history.\n\nPersonally I don't like the idea of the DWIM approach. I rather keep\nfollowing the UNIX philosophy and having each command do one thing well.\nI think it weird to change behavior based on context.\n\nYou said earlier in this thread:\n\n> This distinction brings up a wrinkle in my proposed DWIMery: should\n>         git blame path/to/file\n> show the annotated blamed lines of the file, or simply display the last \n> commit that changed the file?\n\nFor me this gives good motivation to not mix behavior of file-level and\nline-level blames into a single command. If behavior in ambiguous, we\nshould avoid it.\n\n> I can appreciate the convenience of being able to do that with \"git \n> blame\".  I suggest adding an option for this specific case, like maybe \n> \"--latest\" (I don't feel strongly about the option's name).\n\nWhat makes `git blame --latest` better than `git blame-tree`?\n\n> I agree that blaming is a well-(known) concept.  I also agree that most \n> users would understand what blame-tree would do, *once they find it*.\n\nI'm also not convinced why a option argument to an existing command\nwould be easier to discover than a new command. I think it's more an\nissue of us advertising features, than commands being discoverable on\nit's own.\n\n> Also, I think sacrificing usability because it makes the coding hard is \n> unfortunate.\n\nAgreed, that was not a good motivation from my side to make.\n\nI wrote:\n> > Forgive me, but I think folding into git-blame(1) will also solidify\n> > Git's reputation of obscurity.\n> \n> Please elaborate.\n\nAs I mentioned above, I think having behavior of git-blame(1) depend on\nthe type of the argument (is it a dir or a file) is rather obscure. The\nformat of the output returned will be drastically different in both\ncases, and having to machine-parse this might be tricky.\n\nCheers,\n-- Toon\n"},{"id":"518067","messageId":"xmqqy0uz7zzh.fsf@gitster.g","threadId":"63332","inReplyTo":"874ixnjltf.fsf@iotcl.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-05-14T19:29:22Z","receivedAt":"2025-05-14T19:29:26Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n>>> I thought you switch to blame-at-the-file-level only when you are\n>>> given a directory (or a tree)?  \"git blame path/to/file\" has ALWAYS\n>>> done \"blame these lines that appear in this file\", and cannot change.\n>\n> I don't know about that. What if you want to blame multiple files:\n>\n>   $ git blame-tree refs.c refs.h\n\nI do not mind \"multiple files mean blame-tree mode\" as a yet another\nheuristics to tell which mode we are talking about, as \"blame these\nlines\" mode would take just one pathname to a blob and never a tree.\n\nBut the topic, IIRC, was about how \"git blame\" (with 'blame-tree'\nfeature rolled into it) can tell which mode the request by the user\nis about.  So you should have said \"git blame refs.c refs.h\" above.\n\n> or (letting your shell do the globbing):\n>\n>   $ $ git blame-tree *.h\n\nThis one (with command name corrected) is questionable, as there\ncould be a case where there is a single .h file, in which case, the\ncommand line would become \"git blame that-single-header-file.h\".\n\nAgain, I do not mind \"even though I may have only a single blob\nspecified on the command line, I want the blame-tree mode\" command\nline option.  So to recap\n\n  $ git blame path-to-dir\t ;# blame-tree mode for paths in the directory\n  $ git blame path1 path2        ;# blame-tree mode\n  $ git blame path               ;# traditional blame-these-lines mode\n  $ git blame --tree path        ;# blame-tree mode\n  $ git blame --tree path1 path2 ;# blame-tree mode\n\nwould work fine.\n\nHaving said that, I personally do not think of what \"blame-tree\"\ndoes as \"blame\" at all, and there should be a better name for that\noperation that does not use \"blame\" or \"annotate\".  So a separate\ncommand that does not even hint it has any relationship with \"blame\"\n(because it doesn't; in my mental model, it does not do any \"blame\"\nat all---it just does \"git log -1 path\" for many paths in parallel)\nwould be even more preferrable.\n\nThanks.\n"},{"id":"518074","messageId":"0f491261-bbe1-474d-a240-0ddbf22cc754@xiplink.com","threadId":"63332","inReplyTo":"xmqqy0uz7zzh.fsf@gitster.g","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-14T21:15:30Z","receivedAt":"2025-05-14T21:15:37Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"On 2025-05-14 15:29, Junio C Hamano wrote:\n> Toon Claes <toon@iotcl.com> writes:\n> \n>>>> I thought you switch to blame-at-the-file-level only when you are\n>>>> given a directory (or a tree)?  \"git blame path/to/file\" has ALWAYS\n>>>> done \"blame these lines that appear in this file\", and cannot change.\n>>\n>> I don't know about that. What if you want to blame multiple files:\n>>\n>>    $ git blame-tree refs.c refs.h\n> \n> I do not mind \"multiple files mean blame-tree mode\" as a yet another\n> heuristics to tell which mode we are talking about, as \"blame these\n> lines\" mode would take just one pathname to a blob and never a tree.\n> \n> But the topic, IIRC, was about how \"git blame\" (with 'blame-tree'\n> feature rolled into it) can tell which mode the request by the user\n> is about.  So you should have said \"git blame refs.c refs.h\" above.\n> \n>> or (letting your shell do the globbing):\n>>\n>>    $ $ git blame-tree *.h\n> \n> This one (with command name corrected) is questionable, as there\n> could be a case where there is a single .h file, in which case, the\n> command line would become \"git blame that-single-header-file.h\".\n> \n> Again, I do not mind \"even though I may have only a single blob\n> specified on the command line, I want the blame-tree mode\" command\n> line option.  So to recap\n> \n>    $ git blame path-to-dir\t ;# blame-tree mode for paths in the directory\n>    $ git blame path1 path2        ;# blame-tree mode\n>    $ git blame path               ;# traditional blame-these-lines mode\n>    $ git blame --tree path        ;# blame-tree mode\n>    $ git blame --tree path1 path2 ;# blame-tree mode\n> \n> would work fine.\n\nI'd be happy with all of that.\n\n> Having said that, I personally do not think of what \"blame-tree\"\n> does as \"blame\" at all, and there should be a better name for that\n> operation that does not use \"blame\" or \"annotate\".  So a separate\n> command that does not even hint it has any relationship with \"blame\"\n> (because it doesn't; in my mental model, it does not do any \"blame\"\n> at all---it just does \"git log -1 path\" for many paths in parallel)\n> would be even more preferrable.\n\nI'd also be happy if instead this came in as a new command without \n\"blame\" in its name.\n\nHow about [[consults thesaurus ...]] \"git ascribe-tree\"?\n\nOr maybe fold it into ls-tree, e.g. \"git ls-tree --ascribe\"?\n\n\t\tM.\n\n"},{"id":"518075","messageId":"6b034b50-4661-4887-8a6e-86bc42c3a935@xiplink.com","threadId":"63332","inReplyTo":"874ixnjltf.fsf@iotcl.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-14T21:15:33Z","receivedAt":"2025-05-14T21:15:40Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\n(I agree with Junio's reply to your message, so here I'm just going to \naddress the things that Junio didn't.)\n\nI'll preface all this by restating my original point: If you really want \nto implement this feature as a new command, please don't use \"blame\" in \nthat new command's name.\n\nOn 2025-05-14 10:42, Toon Claes wrote:\n> \n> Personally I don't like the idea of the DWIM approach. I rather keep\n> following the UNIX philosophy and having each command do one thing well.\n> I think it weird to change behavior based on context.\n> \n> You said earlier in this thread:\n> \n>> This distinction brings up a wrinkle in my proposed DWIMery: should\n>>          git blame path/to/file\n>> show the annotated blamed lines of the file, or simply display the last\n>> commit that changed the file?\n> \n> For me this gives good motivation to not mix behavior of file-level and\n> line-level blames into a single command. If behavior in ambiguous, we\n> should avoid it.\n\nThe behavior is not ambiguous at all, it's simply context-dependent.\n\nLike with \"git add\":  We don't have \"git add-tree\" to add a directory of \nfiles.  Adding is adding, and so we make the \"add\" command handle all \ntypes of adding.\n\nSimilarly, we don't need \"blame-tree\" to annotate a directory.  Just \nbecause annotating a single file has different output from annotating a \ntree of files doesn't mean that we need two different verbs to annotate \neither kind of object.\n\n>> I can appreciate the convenience of being able to do that with \"git\n>> blame\".  I suggest adding an option for this specific case, like maybe\n>> \"--latest\" (I don't feel strongly about the option's name).\n> \n> What makes `git blame --latest` better than `git blame-tree`?\n\nIf a user wants to blame/annotate something -- a tree or a file -- it's \nmuch easier for them to just use one command to blame whatever they \nwant.  No need to discover a different command and read a whole new man \npage to figure it out.\n\nAnd all the people who already know about \"git blame\" get new and useful \nbehavior from their familiar command.  They are much more likely to \ndiscover that when it's built into \"git blame\" than if the new feature \nis hiding under a different command.\n\nAlso, people who tab-complete commands will appreciate that\n\tgit bl<tab>\ncontinues to complete to \"git blame \" instead of \"git blame\".  (You \ncould argue that this is one way people might discover blame-tree, but I \nthink messing with completionists' muscle-memory is going to annoy them \nmore than help them.)\n\n>> I agree that blaming is a well-(known) concept.  I also agree that most\n>> users would understand what blame-tree would do, *once they find it*.\n> \n> I'm also not convinced why a option argument to an existing command\n> would be easier to discover than a new command. I think it's more an\n> issue of us advertising features, than commands being discoverable on\n> it's own.\n\nExtending an existing command is an incremental way of making things \nbetter for all the people who are already using that command.  They are \nmore likely to discover the new behavior, either by spotting it when \nthey're checking the man page or, in this case, by accidentally passing \na directory to \"git blame\".\n\nHiding this in a new command makes it much less likely to be discovered \nby current Git users.\n\nYes, it is an advertising issue.  I don't consider Git to be a gold \nstandard for feature discoverability.  So I don't think that simply \nsaying it's more of an advertising problem gets us anywhere, because so \nfar Git has failed miserably at advertising its commands.\n\n>> Also, I think sacrificing usability because it makes the coding hard is\n>> unfortunate.\n> \n> Agreed, that was not a good motivation from my side to make.\n> \n> I wrote:\n>>> Forgive me, but I think folding into git-blame(1) will also solidify\n>>> Git's reputation of obscurity.\n>>\n>> Please elaborate.\n> \n> As I mentioned above, I think having behavior of git-blame(1) depend on\n> the type of the argument (is it a dir or a file) is rather obscure.\n\nI don't buy that.  Many Unix commands give different outputs when run \nagainst a file vs. a directory (try diff, for example).  Even simple \nthings like \"ls\" will show a single line of output for a file but \nmultiple lines for a directory.  You can argue that one line vs. many \nisn't a drastic difference, but it *is* a difference.  And there's a \nreason why it's \"ls -R\" instead of \"ls-tree\":  Listing is listing, so \n\"ls\" fulfills all your listing needs.\n\n> The format of the output returned will be drastically different in both\n> cases, and having to machine-parse this might be tricky.\n\nMachine-parsing output is a strawman.\n\nFirst of all, even though \"blame\" is considered an ancillary command and \nnot officially listed as porcelain, it's also not plumbing and so it has \nno obligation to make machines' lives easier.\n\nSecond, why do you think a script needs to parse both output formats? \nEven if there are reasons to write such a script, how does having two \ncommands for the different formats help?  Either way such a script's \nauthor needs to deal with both formats.  Furthermore, if I was \nmaintaining a script that already understands how to parse single-file \nannotation:\n\tgit blame path/to/file | my-script\nI would be quite happy for it to die horribly if someone ran it on the \noutput of a tree annotation.\n\nAs you say, in that pipe example teaching my-script how to tell what \nkind of output it's receiving could be tricky.  But I doubt that many \nexisting scripts that parse blame output are implemented as \npipe-readers.  Rather, I think (yes, without any evidence) that most \nscript authors run the blame command directly as part of their script \nand so they'll know what kind of output the command they're running will \ngenerate (since they'll know what kind of arguments they're passing to \nthe commmand).\n\nFolks who really need to write a pipe-reader can just teach their script \nan argument identifying the kind of output to expect.  Much easier, and \nmore robust, than making the code figure it out.  Pipe-reading scripts \nwill need figure out something like this regardless of how we resolve \nthis discussion.\n\n\t\tM.\n\n"},{"id":"518137","messageId":"aCXsKHiP4uIzCMrt@pks.im","threadId":"63332","inReplyTo":"0f491261-bbe1-474d-a240-0ddbf22cc754@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-15T13:29:12Z","receivedAt":"2025-05-15T13:29:18Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Wed, May 14, 2025 at 05:15:30PM -0400, Marc Branchaud wrote:\n> On 2025-05-14 15:29, Junio C Hamano wrote:\n> > Having said that, I personally do not think of what \"blame-tree\"\n> > does as \"blame\" at all, and there should be a better name for that\n> > operation that does not use \"blame\" or \"annotate\".  So a separate\n> > command that does not even hint it has any relationship with \"blame\"\n> > (because it doesn't; in my mental model, it does not do any \"blame\"\n> > at all---it just does \"git log -1 path\" for many paths in parallel)\n> > would be even more preferrable.\n\nCurious. Isn't it exactly the same what git-blame(1) does though? Taken\nthe textual representation of a tree object, we figure out when each of\nthe lines has last been changed. That to me sounds like exactly the same\nthing as git-blame(1), but just for trees instead of for blobs.\n\nSure, git-blame-tree(1) goes further than that. But conceptually it is\nexactly the above thing, isn't it?\n\n> I'd also be happy if instead this came in as a new command without \"blame\"\n> in its name.\n> \n> How about [[consults thesaurus ...]] \"git ascribe-tree\"?\n> \n> Or maybe fold it into ls-tree, e.g. \"git ls-tree --ascribe\"?\n\nI think anything that needs a thesaurus to come up with probably isn't a\ngood name for non-native speakers. I personally had to look up what this\nword means.\n\nPatrick\n"},{"id":"518150","messageId":"xmqqa57d3k1d.fsf@gitster.g","threadId":"63332","inReplyTo":"aCXsKHiP4uIzCMrt@pks.im","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-05-15T16:39:42Z","receivedAt":"2025-05-15T16:39:45Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> Curious. Isn't it exactly the same what git-blame(1) does though? Taken\n> the textual representation of a tree object, we figure out when each of\n> the lines has last been changed. That to me sounds like exactly the same\n> thing as git-blame(1), but just for trees instead of for blobs.\n\nThat's mechanical worldview from the viewpoint of those who know the\ninternal representation and workings of Git, I would have to say.\n\nAs an end-user, I view \"where does the body of this function came\nfrom\" and \"when did I touch this file the last time\" quite different\nand unrelated kind of queries.\n"},{"id":"518156","messageId":"f01e1e88-c161-43ba-9845-4fc7c9f85d0e@xiplink.com","threadId":"63332","inReplyTo":"aCXsKHiP4uIzCMrt@pks.im","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-15T17:30:47Z","receivedAt":"2025-05-15T17:30:52Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\n\nOn 2025-05-15 09:29, Patrick Steinhardt wrote:\n> On Wed, May 14, 2025 at 05:15:30PM -0400, Marc Branchaud wrote:\n>> On 2025-05-14 15:29, Junio C Hamano wrote:\n>>> Having said that, I personally do not think of what \"blame-tree\"\n>>> does as \"blame\" at all, and there should be a better name for that\n>>> operation that does not use \"blame\" or \"annotate\".  So a separate\n>>> command that does not even hint it has any relationship with \"blame\"\n>>> (because it doesn't; in my mental model, it does not do any \"blame\"\n>>> at all---it just does \"git log -1 path\" for many paths in parallel)\n>>> would be even more preferrable.\n> \n> Curious. Isn't it exactly the same what git-blame(1) does though? Taken\n> the textual representation of a tree object, we figure out when each of\n> the lines has last been changed. That to me sounds like exactly the same\n> thing as git-blame(1), but just for trees instead of for blobs.\n> \n> Sure, git-blame-tree(1) goes further than that. But conceptually it is\n> exactly the above thing, isn't it?\n\nI think the operation can be perceived in different ways.  My only point \nis that if we do conclude that it is a form of blaming then we fold it \ninto \"git blame\" instead of a new command.\n\n>> I'd also be happy if instead this came in as a new command without \"blame\"\n>> in its name.\n>>\n>> How about [[consults thesaurus ...]] \"git ascribe-tree\"?\n>>\n>> Or maybe fold it into ls-tree, e.g. \"git ls-tree --ascribe\"?\n> \n> I think anything that needs a thesaurus to come up with probably isn't a\n> good name for non-native speakers. I personally had to look up what this\n> word means.\n\nYeah, that was a bit tongue-in-cheek, sorry.\n\n(Honestly, \"ascribe\" would be really bad, precisely because it is a \nsynonym of \"blame\"...)\n\n\t\tM.\n\n"},{"id":"518157","messageId":"a97db895-5121-427a-b64f-df225309f429@xiplink.com","threadId":"63332","inReplyTo":"xmqqa57d3k1d.fsf@gitster.g","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Marc Branchaud","fromEmail":"marcnarc@xiplink.com","sentAt":"2025-05-15T17:39:59Z","receivedAt":"2025-05-15T17:40:03Z","isPatch":true,"sender":{"key":"marcnarc@xiplink.com","avatar":"https://avatars.githubusercontent.com/u/14980203?v=4"},"body":"\n\nOn 2025-05-15 12:39, Junio C Hamano wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n>> Curious. Isn't it exactly the same what git-blame(1) does though? Taken\n>> the textual representation of a tree object, we figure out when each of\n>> the lines has last been changed. That to me sounds like exactly the same\n>> thing as git-blame(1), but just for trees instead of for blobs.\n> \n> That's mechanical worldview from the viewpoint of those who know the\n> internal representation and workings of Git, I would have to say.\n\nI interpreted Patrick's statement in the exact opposite way!  I thought \nhe was speaking as a normal Git user, who is just considering the \nsemantics of the outputs: both are fundamentally just lines of a \nSomething (a file or a directory listing), each prefixed by a commit ID.\n\n> As an end-user, I view \"where does the body of this function came\n> from\" and \"when did I touch this file the last time\" quite different\n> and unrelated kind of queries.\n\nI can see them either way, depending on how I squint.  I have no \nobjection if people want to think of this new operation as \nsomething-that-is-not-a-blame.  But then don't call it blame-tree!\n\nHow about last-touch?\n\n\t\tM.\n\n"},{"id":"518166","messageId":"20250515193046.GA3320240@coredump.intra.peff.net","threadId":"63332","inReplyTo":"a97db895-5121-427a-b64f-df225309f429@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2025-05-15T19:30:46Z","receivedAt":"2025-05-15T19:30:48Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Thu, May 15, 2025 at 01:39:59PM -0400, Marc Branchaud wrote:\n\n> > As an end-user, I view \"where does the body of this function came\n> > from\" and \"when did I touch this file the last time\" quite different\n> > and unrelated kind of queries.\n> \n> I can see them either way, depending on how I squint.  I have no objection\n> if people want to think of this new operation as\n> something-that-is-not-a-blame.  But then don't call it blame-tree!\n> \n> How about last-touch?\n\nThe name \"blame-tree\" is probably my fault, as that's what I called it\nin 2012 when I originally wrote it. I don't have access to the adjacent\nrepos anymore, but I _think_ it was replacing a script that was in fact\ncalled \"git-last-modified\" or something like that. So it all comes\naround. ;)\n\nThe debate has mostly been over \"blame\" here. But I think \"tree\" is also\ninaccurate. Theoretically it can be about any set of paths in the repo,\nnot just the entries of a single tree. So:\n\n  git last-modified Makefile Documentation/Makefile t/Makefile\n\nwould be a perfectly valid thing to ask about (and of course a\npathspec like '**Makefile' would be a simpler way to do so). The word\n\"tree\" was there because the original use case at GitHub was getting\nthose values for all of the entries in a particular tree.\n\nBut conceptually it is just about expanding a pathspec into a set of\npaths, and then traversing and reporting the last time each path was\nmodified. It _almost_ fits into the \"git-log\" family, which is all about\ntraversing and pathspecs. The output is a bit different, but I almost\nwonder if it would work as an option to continuously limit the pathspec.\nSomething like:\n\n  $ git log --format=%H --last-modified --raw '**Makefile'\n  89d557b950c7a0581c12452e8f9576c45546246b\n  :100644 100644 13f9062a05 c4d21ccd3d M  Makefile\n  [ skip a bunch of commits that touched only Makefile, nothing else ]\n  a7fa5b2f0ccb567a5a6afedece113f207902fa6f\n  :100644 100644 6485d40f62 b109d25e9c M  Documentation/Makefile\n  [ skip more; now this one is interesting, because one commit touches a\n    bunch of files! It also touches Documentation/Makefile, but we'd\n    have already narrowed our pathspec to forget about it by this point ]\n  5309c1e9fb399c390ed36ef476e91f76f6746fa9\n  :100644 100644 3e67552cc5 97ce9c92fb M  contrib/credential/libsecret/Makefile\n  :100644 100644 238f5f8c36 0948297e20 M  contrib/credential/osxkeychain/Makefile\n  :100644 100644 6e992c0866 5b795fc9fe M  contrib/credential/wincred/Makefile\n  :100644 100644 f2be7cc924 33c2ccc9f7 M  contrib/diff-highlight/Makefile\n  :100644 100644 5ff5275496 2a98541477 M  contrib/diff-highlight/t/Makefile\n  :100644 100644 4e603512a3 497ac434d6 M  contrib/mw-to-git/Makefile\n  :100644 100644 f422203fa0 6c9f377caa M  contrib/mw-to-git/t/Makefile\n  :100644 100644 52b84ba3d4 691737e76b M  contrib/persistent-https/Makefile\n  :100644 100644 093399c788 2a85f5ee84 M  contrib/subtree/t/Makefile\n  :100644 100644 667c39ed56 6c5a12bc32 M  git-gui/Makefile\n  :100644 100644 749aa2e7ec e656b0d2b0 M  git-gui/po/glossary/Makefile\n  :100644 100644 6911c2915a 4ff4ed0616 M  t/interop/Makefile\n  :100644 100644 e4808aebed 9b3090c4ed M  t/perf/Makefile\n  :100644 100644 bd1e9e30c1 722755338d M  templates/Makefile\n  [ ... end immediately without traversing further here, since all\n    paths have been reported ... ]\n\nI dunno. I just made that up. The output is obviously quite different\nthan blame-tree produces, but it would be easy-ish to collect it in the\nsame way. And it's much more flexible, because you could use --format\nand diff options to report as much or as little about each commit as\nyou'd want.\n\nIt is a bit different from regular log, though, in that we'd expand the\npathspec at the very start, rather than applying it continuously as we\ntraverse (otherwise we could never end early, since we'd never know if\nthere was a \"foo/Makefile\" deep in history).\n\nSo you could argue that \"git last-modified\" could also just take\nformat and diff output options. ;)\n\n-Peff\n"},{"id":"518196","messageId":"aCa_Y2th6xaYeSs8@pks.im","threadId":"63332","inReplyTo":"f01e1e88-c161-43ba-9845-4fc7c9f85d0e@xiplink.com","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-16T04:30:27Z","receivedAt":"2025-05-16T04:30:34Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Thu, May 15, 2025 at 01:30:47PM -0400, Marc Branchaud wrote:\n> On 2025-05-15 09:29, Patrick Steinhardt wrote:\n> > On Wed, May 14, 2025 at 05:15:30PM -0400, Marc Branchaud wrote:\n> > > in its name.\n> > > \n> > > How about [[consults thesaurus ...]] \"git ascribe-tree\"?\n> > > \n> > > Or maybe fold it into ls-tree, e.g. \"git ls-tree --ascribe\"?\n> > \n> > I think anything that needs a thesaurus to come up with probably isn't a\n> > good name for non-native speakers. I personally had to look up what this\n> > word means.\n> \n> Yeah, that was a bit tongue-in-cheek, sorry.\n> \n> (Honestly, \"ascribe\" would be really bad, precisely because it is a synonym\n> of \"blame\"...)\n\nThere's no need to be sorry, even if it hadn't been tongue-in-cheek :)\n\nPatrick\n"},{"id":"518197","messageId":"aCbBKj7O9LjO3SMK@pks.im","threadId":"63332","inReplyTo":"20250515193046.GA3320240@coredump.intra.peff.net","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-16T04:38:02Z","receivedAt":"2025-05-16T04:38:07Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Thu, May 15, 2025 at 03:30:46PM -0400, Jeff King wrote:\n> On Thu, May 15, 2025 at 01:39:59PM -0400, Marc Branchaud wrote:\n> \n> > > As an end-user, I view \"where does the body of this function came\n> > > from\" and \"when did I touch this file the last time\" quite different\n> > > and unrelated kind of queries.\n> > \n> > I can see them either way, depending on how I squint.  I have no objection\n> > if people want to think of this new operation as\n> > something-that-is-not-a-blame.  But then don't call it blame-tree!\n> > \n> > How about last-touch?\n> \n> The name \"blame-tree\" is probably my fault, as that's what I called it\n> in 2012 when I originally wrote it. I don't have access to the adjacent\n> repos anymore, but I _think_ it was replacing a script that was in fact\n> called \"git-last-modified\" or something like that. So it all comes\n> around. ;)\n> \n> The debate has mostly been over \"blame\" here. But I think \"tree\" is also\n> inaccurate. Theoretically it can be about any set of paths in the repo,\n> not just the entries of a single tree. So:\n> \n>   git last-modified Makefile Documentation/Makefile t/Makefile\n> \n> would be a perfectly valid thing to ask about (and of course a\n> pathspec like '**Makefile' would be a simpler way to do so). The word\n> \"tree\" was there because the original use case at GitHub was getting\n> those values for all of the entries in a particular tree.\n\nI like \"git last-modified\". It's name is very telling and it does just\nwhat it says.\n\n> But conceptually it is just about expanding a pathspec into a set of\n> paths, and then traversing and reporting the last time each path was\n> modified. It _almost_ fits into the \"git-log\" family, which is all about\n> traversing and pathspecs. The output is a bit different, but I almost\n> wonder if it would work as an option to continuously limit the pathspec.\n> Something like:\n> \n>   $ git log --format=%H --last-modified --raw '**Makefile'\n>   89d557b950c7a0581c12452e8f9576c45546246b\n>   :100644 100644 13f9062a05 c4d21ccd3d M  Makefile\n>   [ skip a bunch of commits that touched only Makefile, nothing else ]\n>   a7fa5b2f0ccb567a5a6afedece113f207902fa6f\n>   :100644 100644 6485d40f62 b109d25e9c M  Documentation/Makefile\n>   [ skip more; now this one is interesting, because one commit touches a\n>     bunch of files! It also touches Documentation/Makefile, but we'd\n>     have already narrowed our pathspec to forget about it by this point ]\n>   5309c1e9fb399c390ed36ef476e91f76f6746fa9\n>   :100644 100644 3e67552cc5 97ce9c92fb M  contrib/credential/libsecret/Makefile\n>   :100644 100644 238f5f8c36 0948297e20 M  contrib/credential/osxkeychain/Makefile\n>   :100644 100644 6e992c0866 5b795fc9fe M  contrib/credential/wincred/Makefile\n>   :100644 100644 f2be7cc924 33c2ccc9f7 M  contrib/diff-highlight/Makefile\n>   :100644 100644 5ff5275496 2a98541477 M  contrib/diff-highlight/t/Makefile\n>   :100644 100644 4e603512a3 497ac434d6 M  contrib/mw-to-git/Makefile\n>   :100644 100644 f422203fa0 6c9f377caa M  contrib/mw-to-git/t/Makefile\n>   :100644 100644 52b84ba3d4 691737e76b M  contrib/persistent-https/Makefile\n>   :100644 100644 093399c788 2a85f5ee84 M  contrib/subtree/t/Makefile\n>   :100644 100644 667c39ed56 6c5a12bc32 M  git-gui/Makefile\n>   :100644 100644 749aa2e7ec e656b0d2b0 M  git-gui/po/glossary/Makefile\n>   :100644 100644 6911c2915a 4ff4ed0616 M  t/interop/Makefile\n>   :100644 100644 e4808aebed 9b3090c4ed M  t/perf/Makefile\n>   :100644 100644 bd1e9e30c1 722755338d M  templates/Makefile\n>   [ ... end immediately without traversing further here, since all\n>     paths have been reported ... ]\n> \n> I dunno. I just made that up. The output is obviously quite different\n> than blame-tree produces, but it would be easy-ish to collect it in the\n> same way. And it's much more flexible, because you could use --format\n> and diff options to report as much or as little about each commit as\n> you'd want.\n> \n> It is a bit different from regular log, though, in that we'd expand the\n> pathspec at the very start, rather than applying it continuously as we\n> traverse (otherwise we could never end early, since we'd never know if\n> there was a \"foo/Makefile\" deep in history).\n\nThat's the biggest downside from my point of view: it works quite\ndifferently, so we can expect that many of the options that git-log(1)\naccepts wouldn't make sense at all. From my point of view we already\nhave too many commands where we have different \"modes\" hidden behind\noptions. They are hard to discover, and in theory you have to manually\nmark all incompatible options as such, which is bound to grow stale.\n\n> So you could argue that \"git last-modified\" could also just take\n> format and diff output options. ;)\n\nBut this one I agree with -- if we had git-last-modified(1), then it\nwould eventually make sense to have at least `--format`. I don't have a\nuse case for diff output options, but if any come up it could probably\nbe added at a later point, as well.\n\nPatrick\n"},{"id":"518469","messageId":"87tt5fis4z.fsf@iotcl.com","threadId":"63332","inReplyTo":"aCbBKj7O9LjO3SMK@pks.im","subject":"Re: [PATCH RFC 0/5] Introduce git-blame-tree(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-20T08:49:16Z","receivedAt":"2025-05-20T08:49:37Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n>> On Thu, May 15, 2025 at 01:39:59PM -0400, Marc Branchaud wrote:\n\n> On Thu, May 15, 2025 at 03:30:46PM -0400, Jeff King wrote:\n>>\n>> The debate has mostly been over \"blame\" here. \n\nOkay, I'm happy to move away from \"blame\".\n\n>> But I think \"tree\" is also inaccurate. Theoretically it can be about\n>> any set of paths in the repo, not just the entries of a single tree.\n\nTotally.\n\n>> So:\n>> \n>>   git last-modified Makefile Documentation/Makefile t/Makefile\n>> \n>> would be a perfectly valid thing to ask about (and of course a\n>> pathspec like '**Makefile' would be a simpler way to do so). The word\n>> \"tree\" was there because the original use case at GitHub was getting\n>> those values for all of the entries in a particular tree.\n>\n> I like \"git last-modified\". It's name is very telling and it does just\n> what it says.\n\nI like `git last-modified` too, but I'm only wondering if it makes sense\nif you pass it a revision range:\n\n    git last-modified HEAD~2..HEAD\n\nIt kind of still does, but it's a little more questionable.\n\n>> But conceptually it is just about expanding a pathspec into a set of\n>> paths, and then traversing and reporting the last time each path was\n>> modified. It _almost_ fits into the \"git-log\" family, which is all about\n>> traversing and pathspecs. The output is a bit different, but I almost\n>> wonder if it would work as an option to continuously limit the pathspec.\n>> Something like:\n>> \n>>   $ git log --format=%H --last-modified --raw '**Makefile'\n>>   89d557b950c7a0581c12452e8f9576c45546246b\n>>   :100644 100644 13f9062a05 c4d21ccd3d M  Makefile\n>>   [ skip a bunch of commits that touched only Makefile, nothing else ]\n>>   a7fa5b2f0ccb567a5a6afedece113f207902fa6f\n>>   :100644 100644 6485d40f62 b109d25e9c M  Documentation/Makefile\n>>   [ skip more; now this one is interesting, because one commit touches a\n>>     bunch of files! It also touches Documentation/Makefile, but we'd\n>>     have already narrowed our pathspec to forget about it by this point ]\n>>   5309c1e9fb399c390ed36ef476e91f76f6746fa9\n>>   :100644 100644 3e67552cc5 97ce9c92fb M  contrib/credential/libsecret/Makefile\n>>   :100644 100644 238f5f8c36 0948297e20 M  contrib/credential/osxkeychain/Makefile\n>>   :100644 100644 6e992c0866 5b795fc9fe M  contrib/credential/wincred/Makefile\n>>   :100644 100644 f2be7cc924 33c2ccc9f7 M  contrib/diff-highlight/Makefile\n>>   :100644 100644 5ff5275496 2a98541477 M  contrib/diff-highlight/t/Makefile\n>>   :100644 100644 4e603512a3 497ac434d6 M  contrib/mw-to-git/Makefile\n>>   :100644 100644 f422203fa0 6c9f377caa M  contrib/mw-to-git/t/Makefile\n>>   :100644 100644 52b84ba3d4 691737e76b M  contrib/persistent-https/Makefile\n>>   :100644 100644 093399c788 2a85f5ee84 M  contrib/subtree/t/Makefile\n>>   :100644 100644 667c39ed56 6c5a12bc32 M  git-gui/Makefile\n>>   :100644 100644 749aa2e7ec e656b0d2b0 M  git-gui/po/glossary/Makefile\n>>   :100644 100644 6911c2915a 4ff4ed0616 M  t/interop/Makefile\n>>   :100644 100644 e4808aebed 9b3090c4ed M  t/perf/Makefile\n>>   :100644 100644 bd1e9e30c1 722755338d M  templates/Makefile\n>>   [ ... end immediately without traversing further here, since all\n>>     paths have been reported ... ]\n\nI like this idea. I think it makes sense to \"commit ABBC touched X, Y, and\nZ; and commit BBCD touched xx, and yy; and ...\". It makes the output a\nlot less verbose.\n\n>> I dunno. I just made that up. The output is obviously quite different\n>> than blame-tree produces\n\nI think it depends on who you consider the primary user would be? Or\nsaid differently, whether we mark this new command as plumbing or\nporcelain? I would consider it a plumbing command, and that's the main\nreason why I'm trying to upstream it: to use it in our tooling at\n$DAYJOB. The output you present above is even more obscure than my\nproposed git-blame-tree version, making it even more plumbing-like.\n\n>> It is a bit different from regular log, though, in that we'd expand the\n>> pathspec at the very start, rather than applying it continuously as we\n>> traverse (otherwise we could never end early, since we'd never know if\n>> there was a \"foo/Makefile\" deep in history).\n>\n> That's the biggest downside from my point of view: it works quite\n> differently, so we can expect that many of the options that git-log(1)\n> accepts wouldn't make sense at all.\n\nAgreed.\n\n>> So you could argue that \"git last-modified\" could also just take\n>> format and diff output options. ;)\n>\n> But this one I agree with -- if we had git-last-modified(1), then it\n> would eventually make sense to have at least `--format`. I don't have a\n> use case for diff output options, but if any come up it could probably\n> be added at a later point, as well.\n\nI was planning to add `--format` to git-blame-tree(1) in the future, or\ndo you think it should be part of the initial version?\n\n\n-- \nCheers,\nToon\n"},{"id":"518751","messageId":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:47Z","receivedAt":"2025-05-23T09:34:07Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This is another attempt to upstream the ~~git-blame-tree(1)~~\ngit-last-modified(1) subcommand. After my previous attempt[1] the\npeople of GitHub shared their version of the subcommand, and this\nversion integrates those changes.\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[4] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I think it's a separate topic to\n  discuss and I'm not sure it needs to be part of series anyway. The\n  main patch was submitted in the previous attempt[2] and if people\n  consider it valuable, I'm happy to discuss that in a separate patch\n  series.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[3] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* Fixed all memory leaks, and removed the use of\n  USE_THE_REPOSITORY_VARIABLE.\n\nI've attempted to reuse commit messages as good as possible, but feel\nfree to correct me where you think I didn't give proper credit or messed\nup. Although I have no idea what to do with the Signed-off-by trailers.\n\nI didn't modify the benchmark results in the commit messages, simply\nbecause I didn't get comparable results. In my benchmarks the difference\nbetween two implementations was negligible, and even in some scenarios\nthe performance was worse in the \"improved\" implementation. As far as I\ncan tell, I didn't break anything in my refactoring, because the version\nin these patches acts similar to Taylor's branch. To be honest, I cannot\nexplain why...?\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-0-4173133f3786@iotcl.com/\n[2]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[3]: git@github.com:ttaylorr/git.git\n[4]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n--\nCheers,\nToon\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\n---\nJeff King (1):\n      t/perf: add last-modified perf script\n\nToon Claes (4):\n      last-modified: new subcommand to show when files were last modified\n      last-modified: use Bloom filters when available\n      last-modified: implement faster algorithm\n      last-modified: initialize revision machinery without walk\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 ++++\n Documentation/meson.build            |   1 +\n Makefile                             |   2 +\n builtin.h                            |   1 +\n builtin/last-modified.c              |  43 +++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n last-modified.c                      | 496 +++++++++++++++++++++++++++++++++++\n last-modified.h                      |  30 +++\n meson.build                          |   2 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  21 ++\n t/t8020-last-modified.sh             | 194 ++++++++++++++\n 14 files changed, 844 insertions(+)\n---\n\nRange-diff versus v1:\n\n1:  1b6cb2603e ! 1:  586f60da1f blame-tree: introduce new subcommand to blame files\n    @@ Metadata\n     Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n    -    blame-tree: introduce new subcommand to blame files\n    +    last-modified: new subcommand to show when files were last modified\n     \n    -    Similar to git-blame(1), introduce a new subcommand git-blame-tree(1).\n    -    This command shows the most recent modification to paths in a tree. It\n    -    does so by expanding the tree at a given commit, taking note of the\n    -    current state of each path, and then walking backwards through history\n    -    looking for commits where each path changed into its final commit ID.\n    +    Similar to git-blame(1), introduce a new subcommand\n    +    git-last-modified(1). This command shows the most recent modification to\n    +    paths in a tree. It does so by expanding the tree at a given commit,\n    +    taking note of the current state of each path, and then walking\n    +    backwards through history looking for commits where each path changed\n    +    into its final commit ID.\n     \n         Based-on-patch-by: Jeff King <peff@peff.net>\n         Improved-by: \"Ævar Arnfjörð Bjarmason\" <avarab@gmail.com>\n    @@ Commit message\n     \n      ## .gitignore ##\n     @@\n    - /git-backfill\n    - /git-bisect\n    - /git-blame\n    -+/git-blame-tree\n    - /git-branch\n    - /git-bugreport\n    - /git-bundle\n    + /git-init-db\n    + /git-interpret-trailers\n    + /git-instaweb\n    ++/git-last-modified\n    + /git-log\n    + /git-ls-files\n    + /git-ls-remote\n    +\n    + ## Documentation/git-last-modified.adoc (new) ##\n    +@@\n    ++git-last-modified(1)\n    ++====================\n    ++\n    ++NAME\n    ++----\n    ++git-last-modified - EXPERIMENTAL: Show when files were last modified\n    ++\n    ++\n    ++SYNOPSIS\n    ++--------\n    ++[synopsis]\n    ++git last-modified [-r] [<revision-range>] [[--] <path>...]\n    ++\n    ++DESCRIPTION\n    ++-----------\n    ++\n    ++Shows which commit last modified each of the relevant files and subdirectories.\n    ++\n    ++THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n    ++\n    ++OPTIONS\n    ++-------\n    ++\n    ++-r::\n    ++\tRecurse into subtrees.\n    ++\n    ++-t::\n    ++\tShow tree entry itself as well as subtrees.  Implies `-r`.\n    ++\n    ++<revision-range>::\n    ++\tOnly traverse commits in the specified revision range. When no\n    ++\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n    ++\thistory leading to the current commit). For a complete list of ways to\n    ++\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n    ++\tlinkgit:gitrevisions[7].\n    ++\n    ++[--] <path>...::\n    ++\tFor each _<path>_ given, the commit which last modified it is returned.\n    ++\tWithout an optional path parameter, all files and subdirectories\n    ++\tof the current working directory are included in the\n    ++\n    ++SEE ALSO\n    ++--------\n    ++linkgit:git-blame[1],\n    ++linkgit:git-log[1].\n    ++\n    ++GIT\n    ++---\n    ++Part of the linkgit:git[1] suite\n    +\n    + ## Documentation/meson.build ##\n    +@@ Documentation/meson.build: manpages = {\n    +   'git-init.adoc' : 1,\n    +   'git-instaweb.adoc' : 1,\n    +   'git-interpret-trailers.adoc' : 1,\n    ++  'git-last-modified.adoc' : 1,\n    +   'git-log.adoc' : 1,\n    +   'git-ls-files.adoc' : 1,\n    +   'git-ls-remote.adoc' : 1,\n     \n      ## Makefile ##\n    -@@ Makefile: LIB_OBJS += archive.o\n    - LIB_OBJS += attr.o\n    - LIB_OBJS += base85.o\n    - LIB_OBJS += bisect.o\n    -+LIB_OBJS += blame-tree.o\n    - LIB_OBJS += blame.o\n    - LIB_OBJS += blob.o\n    - LIB_OBJS += bloom.o\n    -@@ Makefile: BUILTIN_OBJS += builtin/archive.o\n    - BUILTIN_OBJS += builtin/backfill.o\n    - BUILTIN_OBJS += builtin/bisect.o\n    - BUILTIN_OBJS += builtin/blame.o\n    -+BUILTIN_OBJS += builtin/blame-tree.o\n    - BUILTIN_OBJS += builtin/branch.o\n    - BUILTIN_OBJS += builtin/bugreport.o\n    - BUILTIN_OBJS += builtin/bundle.o\n    +@@ Makefile: LIB_OBJS += hook.o\n    + LIB_OBJS += ident.o\n    + LIB_OBJS += json-writer.o\n    + LIB_OBJS += kwset.o\n    ++LIB_OBJS += last-modified.o\n    + LIB_OBJS += levenshtein.o\n    + LIB_OBJS += line-log.o\n    + LIB_OBJS += line-range.o\n    +@@ Makefile: BUILTIN_OBJS += builtin/hook.o\n    + BUILTIN_OBJS += builtin/index-pack.o\n    + BUILTIN_OBJS += builtin/init-db.o\n    + BUILTIN_OBJS += builtin/interpret-trailers.o\n    ++BUILTIN_OBJS += builtin/last-modified.o\n    + BUILTIN_OBJS += builtin/log.o\n    + BUILTIN_OBJS += builtin/ls-files.o\n    + BUILTIN_OBJS += builtin/ls-remote.o\n     \n    - ## blame-tree.c (new) ##\n    + ## builtin.h ##\n    +@@ builtin.h: int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n    + int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n    + int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n    + int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n    ++int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n    + int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n    + int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n    + int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\n    +\n    + ## builtin/last-modified.c (new) ##\n     @@\n     +#include \"git-compat-util.h\"\n    -+#include \"blame-tree.h\"\n    ++#include \"last-modified.h\"\n    ++#include \"hex.h\"\n    ++#include \"quote.h\"\n    ++#include \"config.h\"\n    ++#include \"object-name.h\"\n    ++#include \"parse-options.h\"\n    ++#include \"builtin.h\"\n    ++\n    ++static void show_entry(const char *path, const struct commit *commit, void *d)\n    ++{\n    ++\tstruct last_modified *lm = d;\n    ++\n    ++\tif (commit->object.flags & BOUNDARY)\n    ++\t\tputchar('^');\n    ++\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    ++\n    ++\tif (lm->rev.diffopt.line_termination)\n    ++\t\twrite_name_quoted(path, stdout, '\\n');\n    ++\telse\n    ++\t\tprintf(\"%s%c\", path, '\\0');\n    ++\n    ++\tfflush(stdout);\n    ++}\n    ++\n    ++int cmd_last_modified(int argc,\n    ++\t\t   const char **argv,\n    ++\t\t   const char *prefix,\n    ++\t\t   struct repository *repo)\n    ++{\n    ++\tint ret = 0;\n    ++\tstruct last_modified lm;\n    ++\n    ++\trepo_config(repo, git_default_config, NULL);\n    ++\n    ++\tlast_modified_init(&lm, repo, prefix, argc, argv);\n    ++\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n    ++\t\tdie(_(\"error running last-modified traversal\"));\n    ++\n    ++\tlast_modified_release(&lm);\n    ++\n    ++\treturn ret;\n    ++}\n    +\n    + ## command-list.txt ##\n    +@@ command-list.txt: git-index-pack                          plumbingmanipulators\n    + git-init                                mainporcelain           init\n    + git-instaweb                            ancillaryinterrogators          complete\n    + git-interpret-trailers                  purehelpers\n    ++git-last-modified                       plumbinginterrogators\n    + git-log                                 mainporcelain           info\n    + git-ls-files                            plumbinginterrogators\n    + git-ls-remote                           plumbinginterrogators\n    +\n    + ## git.c ##\n    +@@ git.c: static struct cmd_struct commands[] = {\n    + \t{ \"init\", cmd_init_db },\n    + \t{ \"init-db\", cmd_init_db },\n    + \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n    ++\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n    + \t{ \"log\", cmd_log, RUN_SETUP },\n    + \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n    + \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\n    +\n    + ## last-modified.c (new) ##\n    +@@\n    ++#include \"git-compat-util.h\"\n    ++#include \"last-modified.h\"\n     +#include \"commit.h\"\n     +#include \"diffcore.h\"\n     +#include \"diff.h\"\n    @@ blame-tree.c (new)\n     +#include \"repository.h\"\n     +#include \"log-tree.h\"\n     +\n    -+struct blame_tree_entry {\n    ++struct last_modified_entry {\n     +\tstruct hashmap_entry hashent;\n     +\tstruct object_id oid;\n     +\tstruct commit *commit;\n    @@ blame-tree.c (new)\n     +\t\t\t  struct diff_options *opt UNUSED,\n     +\t\t\t  void *data)\n     +{\n    -+\tstruct blame_tree *bt = data;\n    ++\tstruct last_modified *lm = data;\n     +\n     +\tfor (int i = 0; i < q->nr; i++) {\n     +\t\tstruct diff_filepair *p = q->queue[i];\n    -+\t\tstruct blame_tree_entry *ent;\n    ++\t\tstruct last_modified_entry *ent;\n     +\t\tconst char *path = p->two->path;\n     +\n     +\t\tFLEX_ALLOC_STR(ent, path, path);\n     +\t\toidcpy(&ent->oid, &p->two->oid);\n     +\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n    -+\t\thashmap_add(&bt->paths, &ent->hashent);\n    ++\t\thashmap_add(&lm->paths, &ent->hashent);\n     +\t}\n     +}\n     +\n    -+static int add_from_revs(struct blame_tree *bt)\n    ++static int add_from_revs(struct last_modified *lm)\n     +{\n     +\tsize_t count = 0;\n     +\tstruct diff_options diffopt;\n     +\n    -+\tmemcpy(&diffopt, &bt->rev.diffopt, sizeof(diffopt));\n    -+\tcopy_pathspec(&diffopt.pathspec, &bt->rev.diffopt.pathspec);\n    ++\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n    ++\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n     +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n     +\tdiffopt.format_callback = add_from_diff;\n    -+\tdiffopt.format_callback_data = bt;\n    ++\tdiffopt.format_callback_data = lm;\n     +\n    -+\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n    -+\t\tstruct object_array_entry *obj = bt->rev.pending.objects + i;\n    ++\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n    ++\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n     +\n     +\t\tif (obj->item->flags & UNINTERESTING)\n     +\t\t\tcontinue;\n     +\n     +\t\tif (count++)\n    -+\t\t\treturn error(_(\"can only blame one tree at a time\"));\n    ++\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n     +\n    -+\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n    ++\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     +\t\t\t      &obj->item->oid, \"\", &diffopt);\n     +\t\tdiff_flush(&diffopt);\n     +\t}\n    @@ blame-tree.c (new)\n     +\treturn 0;\n     +}\n     +\n    -+static int blame_tree_entry_hashcmp(const void *unused UNUSED,\n    -+\t\t\t\t    const struct hashmap_entry *he1,\n    -+\t\t\t\t    const struct hashmap_entry *he2,\n    ++static int last_modified_entry_hashcmp(const void *unused UNUSED,\n    ++\t\t\t\t    const struct hashmap_entry *hent1,\n    ++\t\t\t\t    const struct hashmap_entry *hent2,\n     +\t\t\t\t    const void *path)\n     +{\n    -+\tconst struct blame_tree_entry *e1 =\n    -+\t\tcontainer_of(he1, const struct blame_tree_entry, hashent);\n    -+\tconst struct blame_tree_entry *e2 =\n    -+\t\tcontainer_of(he2, const struct blame_tree_entry, hashent);\n    -+\treturn strcmp(e1->path, path ? path : e2->path);\n    ++\tconst struct last_modified_entry *ent1 =\n    ++\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n    ++\tconst struct last_modified_entry *ent2 =\n    ++\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n    ++\treturn strcmp(ent1->path, path ? path : ent2->path);\n     +}\n     +\n    -+void blame_tree_init(struct blame_tree *bt,\n    ++void last_modified_init(struct last_modified *lm,\n     +\t\t     struct repository *r,\n     +\t\t     const char *prefix,\n     +\t\t     int argc, const char **argv)\n     +{\n    -+\tmemset(bt, 0, sizeof(*bt));\n    -+\thashmap_init(&bt->paths, blame_tree_entry_hashcmp, NULL, 0);\n    ++\tmemset(lm, 0, sizeof(*lm));\n    ++\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n     +\n    -+\trepo_init_revisions(r, &bt->rev, prefix);\n    -+\tbt->rev.def = \"HEAD\";\n    -+\tbt->rev.combine_merges = 1;\n    -+\tbt->rev.show_root_diff = 1;\n    -+\tbt->rev.boundary = 1;\n    -+\tbt->rev.no_commit_id = 1;\n    -+\tbt->rev.diff = 1;\n    -+\tif (setup_revisions(argc, argv, &bt->rev, NULL) > 1)\n    -+\t\tdie(_(\"unknown blame-tree argument: %s\"), argv[1]);\n    ++\trepo_init_revisions(r, &lm->rev, prefix);\n    ++\tlm->rev.def = \"HEAD\";\n    ++\tlm->rev.combine_merges = 1;\n    ++\tlm->rev.show_root_diff = 1;\n    ++\tlm->rev.boundary = 1;\n    ++\tlm->rev.no_commit_id = 1;\n    ++\tlm->rev.diff = 1;\n    ++\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    ++\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n     +\n    -+\tif (add_from_revs(bt) < 0)\n    -+\t\tdie(_(\"unable to setup blame-tree\"));\n    ++\tif (add_from_revs(lm) < 0)\n    ++\t\tdie(_(\"unable to setup last-modified\"));\n     +}\n     +\n    -+void blame_tree_release(struct blame_tree *bt)\n    ++void last_modified_release(struct last_modified *lm)\n     +{\n    -+\thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n    -+\trelease_revisions(&bt->rev);\n    ++\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n    ++\trelease_revisions(&lm->rev);\n     +}\n     +\n    -+struct blame_tree_callback_data {\n    ++struct last_modified_callback_data {\n     +\tstruct commit *commit;\n     +\tstruct hashmap *paths;\n     +\n    -+\tblame_tree_callback callback;\n    ++\tlast_modified_callback callback;\n     +\tvoid *callback_data;\n     +};\n     +\n     +static void mark_path(const char *path, const struct object_id *oid,\n    -+\t\t      struct blame_tree_callback_data *data)\n    ++\t\t      struct last_modified_callback_data *data)\n     +{\n    -+\tstruct blame_tree_entry *ent;\n    ++\tstruct last_modified_entry *ent;\n     +\n     +\t/* Is it even a path that we are interested in? */\n     +\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n    -+\t\t\t\t\t  struct blame_tree_entry, hashent);\n    ++\t\t\t\t\t  struct last_modified_entry, hashent);\n     +\tif (!ent)\n     +\t\treturn;\n     +\n    -+\t/* Have we already blamed a commit? */\n    ++\t/* Have we already found a commit? */\n     +\tif (ent->commit)\n     +\t\treturn;\n     +\n    @@ blame-tree.c (new)\n     +\tfree(ent);\n     +}\n     +\n    -+static void blame_diff(struct diff_queue_struct *q,\n    ++static void last_modified_diff(struct diff_queue_struct *q,\n     +\t\t       struct diff_options *opt UNUSED, void *cbdata)\n     +{\n    -+\tstruct blame_tree_callback_data *data = cbdata;\n    ++\tstruct last_modified_callback_data *data = cbdata;\n     +\n     +\tfor (int i = 0; i < q->nr; i++) {\n     +\t\tstruct diff_filepair *p = q->queue[i];\n    @@ blame-tree.c (new)\n     +\t\t\t * a final path/sha1 state. Note that this covers some\n     +\t\t\t * potentially controversial areas, including:\n     +\t\t\t *\n    -+\t\t\t *  1. A rename or copy will be blamed, as it is the\n    ++\t\t\t *  1. A rename or copy will be found, as it is the\n     +\t\t\t *     first time the content has arrived at the given\n     +\t\t\t *     path.\n     +\t\t\t *\n     +\t\t\t *  2. Even a non-content modification like a mode or\n     +\t\t\t *     type change will trigger it.\n     +\t\t\t *\n    -+\t\t\t * We take the inclusive approach for now, and blame\n    ++\t\t\t * We take the inclusive approach for now, and find\n     +\t\t\t * anything which impacts the path. Options to tweak\n     +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n     +\t\t\t * renames) can come later.\n    @@ blame-tree.c (new)\n     +\t}\n     +}\n     +\n    -+int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    ++int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n     +{\n    -+\tstruct blame_tree_callback_data data;\n    ++\tstruct last_modified_callback_data data;\n     +\n    -+\tdata.paths = &bt->paths;\n    ++\tdata.paths = &lm->paths;\n     +\tdata.callback = cb;\n     +\tdata.callback_data = cbdata;\n     +\n    -+\tbt->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n    -+\tbt->rev.diffopt.format_callback = blame_diff;\n    -+\tbt->rev.diffopt.format_callback_data = &data;\n    ++\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n    ++\tlm->rev.diffopt.format_callback = last_modified_diff;\n    ++\tlm->rev.diffopt.format_callback_data = &data;\n     +\n    -+\tprepare_revision_walk(&bt->rev);\n    ++\tprepare_revision_walk(&lm->rev);\n     +\n    -+\twhile (hashmap_get_size(&bt->paths)) {\n    -+\t\tdata.commit = get_revision(&bt->rev);\n    ++\twhile (hashmap_get_size(&lm->paths)) {\n    ++\t\tdata.commit = get_revision(&lm->rev);\n     +\t\tif (!data.commit)\n     +\t\t\tbreak;\n     +\n     +\t\tif (data.commit->object.flags & BOUNDARY) {\n    -+\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n    ++\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     +\t\t\t\t       &data.commit->object.oid,\n    -+\t\t\t\t       \"\", &bt->rev.diffopt);\n    -+\t\t\tdiff_flush(&bt->rev.diffopt);\n    ++\t\t\t\t       \"\", &lm->rev.diffopt);\n    ++\t\t\tdiff_flush(&lm->rev.diffopt);\n     +\t\t} else {\n    -+\t\t\tlog_tree_commit(&bt->rev, data.commit);\n    ++\t\t\tlog_tree_commit(&lm->rev, data.commit);\n     +\t\t}\n     +\t}\n     +\n     +\treturn 0;\n     +}\n     \n    - ## blame-tree.h (new) ##\n    + ## last-modified.h (new) ##\n     @@\n    -+#ifndef BLAME_TREE_H\n    -+#define BLAME_TREE_H\n    ++#ifndef LAST_MODIFIED_H\n    ++#define LAST_MODIFIED_H\n     +\n     +#include \"commit.h\"\n     +#include \"revision.h\"\n     +#include \"hashmap.h\"\n     +\n    -+struct blame_tree {\n    ++struct last_modified {\n     +\tstruct hashmap paths;\n     +\tstruct rev_info rev;\n     +};\n     +\n    -+void blame_tree_init(struct blame_tree *bt,\n    ++void last_modified_init(struct last_modified *lm,\n     +\t\t     struct repository *r,\n     +\t\t     const char *prefix,\n     +\t\t     int argc, const char **argv);\n     +\n    -+void blame_tree_release(struct blame_tree *);\n    ++void last_modified_release(struct last_modified *);\n     +\n    -+typedef void (*blame_tree_callback)(const char *path,\n    ++typedef void (*last_modified_callback)(const char *path,\n     +\t\t\t\t    const struct commit *commit,\n     +\t\t\t\t    void *data);\n    -+int blame_tree_run(struct blame_tree *,\n    -+\t\t   blame_tree_callback cb,\n    -+\t\t   void *data);\n    ++int last_modified_run(struct last_modified *lm,\n    ++\t\t   last_modified_callback cb,\n    ++\t\t   void *cbdata);\n     +\n    -+#endif /* BLAME_TREE_H */\n    -\n    - ## builtin.h ##\n    -@@ builtin.h: int cmd_archive(int argc, const char **argv, const char *prefix, struct reposito\n    - int cmd_backfill(int argc, const char **argv, const char *prefix, struct repository *repo);\n    - int cmd_bisect(int argc, const char **argv, const char *prefix, struct repository *repo);\n    - int cmd_blame(int argc, const char **argv, const char *prefix, struct repository *repo);\n    -+int cmd_blame_tree(int argc, const char **argv, const char *prefix, struct repository *repo);\n    - int cmd_branch(int argc, const char **argv, const char *prefix, struct repository *repo);\n    - int cmd_bugreport(int argc, const char **argv, const char *prefix, struct repository *repo);\n    - int cmd_bundle(int argc, const char **argv, const char *prefix, struct repository *repo);\n    -\n    - ## builtin/blame-tree.c (new) ##\n    -@@\n    -+#include \"git-compat-util.h\"\n    -+#include \"blame-tree.h\"\n    -+#include \"hex.h\"\n    -+#include \"quote.h\"\n    -+#include \"config.h\"\n    -+#include \"object-name.h\"\n    -+#include \"parse-options.h\"\n    -+#include \"builtin.h\"\n    -+\n    -+static void show_entry(const char *path, const struct commit *commit, void *d)\n    -+{\n    -+\tstruct blame_tree *bt = d;\n    -+\n    -+\tif (commit->object.flags & BOUNDARY)\n    -+\t\tputchar('^');\n    -+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    -+\n    -+\tif (bt->rev.diffopt.line_termination)\n    -+\t\twrite_name_quoted(path, stdout, '\\n');\n    -+\telse\n    -+\t\tprintf(\"%s%c\", path, '\\0');\n    -+\n    -+\tfflush(stdout);\n    -+}\n    -+\n    -+int cmd_blame_tree(int argc,\n    -+\t\t   const char **argv,\n    -+\t\t   const char *prefix,\n    -+\t\t   struct repository *repo)\n    -+{\n    -+\tint ret = 0;\n    -+\tstruct blame_tree bt;\n    -+\n    -+\trepo_config(repo, git_default_config, NULL);\n    -+\n    -+\tblame_tree_init(&bt, repo, prefix, argc, argv);\n    -+\tif (blame_tree_run(&bt, show_entry, &bt) < 0)\n    -+\t\tdie(_(\"error running blame-tree traversal\"));\n    -+\n    -+\tblame_tree_release(&bt);\n    -+\n    -+\treturn ret;\n    -+}\n    -\n    - ## git.c ##\n    -@@ git.c: static struct cmd_struct commands[] = {\n    - \t{ \"backfill\", cmd_backfill, RUN_SETUP },\n    - \t{ \"bisect\", cmd_bisect, RUN_SETUP },\n    - \t{ \"blame\", cmd_blame, RUN_SETUP },\n    -+\t{ \"blame-tree\", cmd_blame_tree, RUN_SETUP },\n    - \t{ \"branch\", cmd_branch, RUN_SETUP | DELAY_PAGER_CONFIG },\n    - \t{ \"bugreport\", cmd_bugreport, RUN_SETUP_GENTLY },\n    - \t{ \"bundle\", cmd_bundle, RUN_SETUP_GENTLY },\n    ++#endif /* LAST_MODIFIED_H */\n     \n      ## meson.build ##\n     @@ meson.build: libgit_sources = [\n    -   'attr.c',\n    -   'base85.c',\n    -   'bisect.c',\n    -+  'blame-tree.c',\n    -   'blame.c',\n    -   'blob.c',\n    -   'bloom.c',\n    +   'ident.c',\n    +   'json-writer.c',\n    +   'kwset.c',\n    ++  'last-modified.c',\n    +   'levenshtein.c',\n    +   'line-log.c',\n    +   'line-range.c',\n     @@ meson.build: builtin_sources = [\n    -   'builtin/archive.c',\n    -   'builtin/backfill.c',\n    -   'builtin/bisect.c',\n    -+  'builtin/blame-tree.c',\n    -   'builtin/blame.c',\n    -   'builtin/branch.c',\n    -   'builtin/bugreport.c',\n    -\n    - ## t/helper/test-tool.h ##\n    -@@\n    - \n    - int cmd__advise_if_enabled(int argc, const char **argv);\n    - int cmd__bitmap(int argc, const char **argv);\n    -+int cmd__blame_tree(int argc, const char **argv);\n    - int cmd__bloom(int argc, const char **argv);\n    - int cmd__bundle_uri(int argc, const char **argv);\n    - int cmd__cache_tree(int argc, const char **argv);\n    +   'builtin/index-pack.c',\n    +   'builtin/init-db.c',\n    +   'builtin/interpret-trailers.c',\n    ++  'builtin/last-modified.c',\n    +   'builtin/log.c',\n    +   'builtin/ls-files.c',\n    +   'builtin/ls-remote.c',\n     \n      ## t/meson.build ##\n     @@ t/meson.build: integration_tests = [\n        't8012-blame-colors.sh',\n        't8013-blame-ignore-revs.sh',\n        't8014-blame-ignore-fuzzy.sh',\n    -+  't8020-blame-tree.sh',\n    ++  't8020-last-modified.sh',\n        't9001-send-email.sh',\n        't9002-column.sh',\n        't9003-help-autocorrect.sh',\n     \n    - ## t/t8020-blame-tree.sh (new) ##\n    + ## t/t8020-last-modified.sh (new) ##\n     @@\n     +#!/bin/sh\n     +\n    -+test_description='blame-tree tests'\n    ++test_description='last-modified tests'\n     +\n     +. ./test-lib.sh\n     +\n    @@ t/t8020-blame-tree.sh (new)\n     +\ttest_commit 3 a/b/file\n     +'\n     +\n    -+test_expect_success 'cannot blame two trees' '\n    -+\ttest_must_fail git blame-tree HEAD HEAD~1\n    ++test_expect_success 'cannot run last-modified on two trees' '\n    ++\ttest_must_fail git last-modified HEAD HEAD~1\n     +'\n     +\n    -+check_blame() {\n    ++check_last_modified() {\n     +\tlocal indir= &&\n     +\twhile test $# != 0\n     +\tdo\n    @@ t/t8020-blame-tree.sh (new)\n     +\n     +\tcat >expect &&\n     +\ttest_when_finished \"rm -f tmp.*\" &&\n    -+\tgit ${indir:+-C \"$indir\"} blame-tree \"$@\" >tmp.1 &&\n    ++\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n     +\tgit name-rev --annotate-stdin --name-only --tags \\\n     +\t\t<tmp.1 >tmp.2 &&\n     +\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n    @@ t/t8020-blame-tree.sh (new)\n     +\ttest_cmp expect actual\n     +}\n     +\n    -+test_expect_success 'blame recursive' '\n    -+\tcheck_blame --recursive <<-\\EOF\n    ++test_expect_success 'last-modified non-recursive' '\n    ++\tcheck_last_modified <<-\\EOF\n    ++\t1 file\n    ++\t3 a\n    ++\tEOF\n    ++'\n    ++\n    ++test_expect_success 'last-modified recursive' '\n    ++\tcheck_last_modified -r <<-\\EOF\n     +\t1 file\n     +\t2 a/file\n     +\t3 a/b/file\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame non-recursive' '\n    -+\tcheck_blame --no-recursive <<-\\EOF\n    -+\t1 file\n    ++test_expect_success 'last-modified subdir' '\n    ++\tcheck_last_modified a <<-\\EOF\n     +\t3 a\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame subdir' '\n    -+\tcheck_blame a <<-\\EOF\n    -+\t3 a\n    -+\tEOF\n    -+'\n    -+\n    -+test_expect_success 'blame subdir recursive' '\n    -+\tcheck_blame --recursive a <<-\\EOF\n    ++test_expect_success 'last-modified subdir recursive' '\n    ++\tcheck_last_modified -r a <<-\\EOF\n     +\t2 a/file\n     +\t3 a/b/file\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame from non-HEAD commit' '\n    -+\tcheck_blame --no-recursive HEAD^ <<-\\EOF\n    ++test_expect_success 'last-modified from non-HEAD commit' '\n    ++\tcheck_last_modified HEAD^ <<-\\EOF\n     +\t1 file\n     +\t2 a\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame from subdir defaults to root' '\n    -+\tcheck_blame -C a --no-recursive <<-\\EOF\n    ++test_expect_success 'last-modified from subdir defaults to root' '\n    ++\tcheck_last_modified -C a <<-\\EOF\n     +\t1 file\n     +\t3 a\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame from subdir uses relative pathspecs' '\n    -+\tcheck_blame -C a --recursive b <<-\\EOF\n    ++test_expect_success 'last-modified from subdir uses relative pathspecs' '\n    ++\tcheck_last_modified -C a -r b <<-\\EOF\n     +\t3 a/b/file\n     +\tEOF\n     +'\n     +\n    -+test_expect_failure 'limit blame traversal by count' '\n    -+\tcheck_blame --no-recursive -1 <<-\\EOF\n    ++test_expect_success 'limit last-modified traversal by count' '\n    ++\tcheck_last_modified -1 <<-\\EOF\n     +\t3 a\n    ++\t^2 file\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'limit blame traversal by commit' '\n    -+\tcheck_blame --no-recursive HEAD~2..HEAD <<-\\EOF\n    ++test_expect_success 'limit last-modified traversal by commit' '\n    ++\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n     +\t3 a\n     +\t^1 file\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'only blame files in the current tree' '\n    ++test_expect_success 'only last-modified files in the current tree' '\n     +\tgit rm -rf a &&\n     +\tgit commit -m \"remove a\" &&\n    -+\tcheck_blame <<-\\EOF\n    ++\tcheck_last_modified <<-\\EOF\n     +\t1 file\n     +\tEOF\n     +'\n    @@ t/t8020-blame-tree.sh (new)\n     +\tgit rm -rf . &&\n     +\ttest_commit m2 &&\n     +\tgit merge m1 &&\n    -+\tcheck_blame <<-\\EOF\n    ++\tcheck_last_modified <<-\\EOF\n     +\tm1 m1.t\n     +\tm2 m2.t\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame merge for resolved conflicts' '\n    ++test_expect_success 'last-modified merge for resolved conflicts' '\n     +\tgit checkout HEAD^0 &&\n     +\tgit rm -rf . &&\n     +\ttest_commit c1 conflict &&\n    @@ t/t8020-blame-tree.sh (new)\n     +\ttest_commit c2 conflict &&\n     +\ttest_must_fail git merge c1 &&\n     +\ttest_commit resolved conflict &&\n    -+\tcheck_blame conflict <<-\\EOF\n    ++\tcheck_last_modified conflict <<-\\EOF\n     +\tresolved conflict\n     +\tEOF\n     +'\n     +\n    -+test_expect_success 'blame-tree complains about unknown arguments' '\n    -+\ttest_must_fail git blame-tree --foo 2>err &&\n    -+\tgrep \"unknown blame-tree argument: --foo\" err\n    ++\n    ++# Consider `file` with this content through history:\n    ++#\n    ++# A---B---B-------B---B\n    ++#          \\     /\n    ++#           C---D\n    ++test_expect_success 'last-modified merge ignores content from branch' '\n    ++\tgit checkout HEAD^0 &&\n    ++\tgit rm -rf . &&\n    ++\ttest_commit a1 file A &&\n    ++\ttest_commit a2 file B &&\n    ++\ttest_commit a3 file C &&\n    ++\ttest_commit a4 file D &&\n    ++\tgit checkout a2 &&\n    ++\tgit merge --no-commit --no-ff a4 &&\n    ++\tgit checkout a2 -- file &&\n    ++\tgit merge --continue &&\n    ++\tcheck_last_modified <<-\\EOF\n    ++\ta2 file\n    ++\tEOF\n    ++'\n    ++\n    ++# Consider `file` with this content through history:\n    ++#\n    ++#  A---B---B---C---D---B---B\n    ++#           \\         /\n    ++#            B-------B\n    ++test_expect_success 'last-modified merge undoes changes' '\n    ++\tgit checkout HEAD^0 &&\n    ++\tgit rm -rf . &&\n    ++\ttest_commit b1 file A &&\n    ++\ttest_commit b2 file B &&\n    ++\ttest_commit b3 file C &&\n    ++\ttest_commit b4 file D &&\n    ++\tgit checkout b2 &&\n    ++\ttest_commit b5 file2 2 &&\n    ++\tgit checkout b4 &&\n    ++\tgit merge --no-commit --no-ff b5 &&\n    ++\tgit checkout b2 -- file &&\n    ++\tgit merge --continue &&\n    ++\tcheck_last_modified <<-\\EOF\n    ++\tb2 file\n    ++\tb5 file2\n    ++\tEOF\n    ++'\n    ++\n    ++test_expect_success 'last-modified complains about unknown arguments' '\n    ++\ttest_must_fail git last-modified --foo 2>err &&\n    ++\tgrep \"unknown last-modified argument: --foo\" err\n     +'\n     +\n     +test_done\n2:  595a8836fb ! 2:  54383e3f5c t/perf: add blame-tree perf script\n    @@ Metadata\n     Author: Jeff King <peff@peff.net>\n     \n      ## Commit message ##\n    -    t/perf: add blame-tree perf script\n    +    t/perf: add last-modified perf script\n     \n    -    This just runs some simple blame-tree's. We already test correctness in\n    -    the regular suite, so this is just about finding performance regressions\n    -    from one version to another.\n    +    This just runs some simple last-modified commands. We already test\n    +    correctness in the regular suite, so this is just about finding\n    +    performance regressions from one version to another.\n     \n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n    - ## t/perf/p8020-blame-tree.sh (new) ##\n    + ## t/meson.build ##\n    +@@ t/meson.build: benchmarks = [\n    +   'perf/p7820-grep-engines.sh',\n    +   'perf/p7821-grep-engines-fixed.sh',\n    +   'perf/p7822-grep-perl-character.sh',\n    ++  'perf/p8020-last-modified.sh',\n    +   'perf/p9210-scalar.sh',\n    +   'perf/p9300-fast-import-export.sh',\n    + ]\n    +\n    + ## t/perf/p8020-last-modified.sh (new) ##\n     @@\n     +#!/bin/sh\n     +\n    -+test_description='blame-tree perf tests'\n    ++test_description='last-modified perf tests'\n     +. ./perf-lib.sh\n     +\n     +test_perf_default_repo\n     +\n    -+test_perf 'top-level blame-tree' '\n    -+\tgit blame-tree HEAD\n    ++test_perf 'top-level last-modified' '\n    ++\tgit last-modified HEAD\n     +'\n     +\n    -+test_perf 'top-level recursive blame-tree' '\n    -+\tgit blame-tree -r HEAD\n    ++test_perf 'top-level recursive last-modified' '\n    ++\tgit last-modified -r HEAD\n     +'\n     +\n    -+test_perf 'subdir blame-tree' '\n    ++test_perf 'subdir last-modified' '\n     +\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n    -+\tgit blame-tree -r HEAD -- \"$path\"\n    ++\tgit last-modified -r HEAD -- \"$path\"\n     +'\n     +\n     +test_done\n    -\n    - ## t/t8020-blame-tree.sh ##\n    -@@ t/t8020-blame-tree.sh: check_blame() {\n    - }\n    - \n    - test_expect_success 'blame recursive' '\n    --\tcheck_blame --recursive <<-\\EOF\n    -+\tcheck_blame -r <<-\\EOF\n    - \t1 file\n    - \t2 a/file\n    - \t3 a/b/file\n    -@@ t/t8020-blame-tree.sh: test_expect_success 'blame recursive' '\n    - '\n    - \n    - test_expect_success 'blame non-recursive' '\n    --\tcheck_blame --no-recursive <<-\\EOF\n    -+\tcheck_blame <<-\\EOF\n    - \t1 file\n    - \t3 a\n    - \tEOF\n    -@@ t/t8020-blame-tree.sh: test_expect_success 'blame subdir' '\n    - '\n    - \n    - test_expect_success 'blame subdir recursive' '\n    --\tcheck_blame --recursive a <<-\\EOF\n    -+\tcheck_blame -r a <<-\\EOF\n    - \t2 a/file\n    - \t3 a/b/file\n    - \tEOF\n    - '\n    - \n    - test_expect_success 'blame from non-HEAD commit' '\n    --\tcheck_blame --no-recursive HEAD^ <<-\\EOF\n    -+\tcheck_blame HEAD^ <<-\\EOF\n    - \t1 file\n    - \t2 a\n    - \tEOF\n    - '\n    - \n    - test_expect_success 'blame from subdir defaults to root' '\n    --\tcheck_blame -C a --no-recursive <<-\\EOF\n    -+\tcheck_blame -C a <<-\\EOF\n    - \t1 file\n    - \t3 a\n    - \tEOF\n    - '\n    - \n    - test_expect_success 'blame from subdir uses relative pathspecs' '\n    --\tcheck_blame -C a --recursive b <<-\\EOF\n    -+\tcheck_blame -C a -r b <<-\\EOF\n    - \t3 a/b/file\n    - \tEOF\n    - '\n    - \n    --test_expect_failure 'limit blame traversal by count' '\n    --\tcheck_blame --no-recursive -1 <<-\\EOF\n    -+test_expect_success 'limit blame traversal by count' '\n    -+\tcheck_blame <<-\\EOF\n    - \t3 a\n    -+\t^2 file\n    - \tEOF\n    - '\n    - \n    - test_expect_success 'limit blame traversal by commit' '\n    --\tcheck_blame --no-recursive HEAD~2..HEAD <<-\\EOF\n    -+\tcheck_blame HEAD~2..HEAD <<-\\EOF\n    - \t3 a\n    - \t^1 file\n    - \tEOF\n3:  4d01e68e9b ! 3:  f67b406980 blame-tree: use Bloom filters when available\n    @@\n      ## Metadata ##\n    -Author: Taylor Blau <me@ttaylorr.com>\n    +Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n    -    blame-tree: use Bloom filters when available\n    +    last-modified: use Bloom filters when available\n     \n    -    Our 'git blame-tree' performs a revision walk, and computes a diff at\n    +    Our 'git last-modified' performs a revision walk, and computes a diff at\n         each point in the walk to figure out whether a given revision changed\n         any of the paths it considers interesting.\n     \n    @@ Commit message\n         can avoid computing it in this case.\n     \n         This results in a substantial performance speed-up in common cases of\n    -    'git blame-tree'. In the kernel, here is the before and after (all times\n    -    computed with best-of-five):\n    +    'git last-modified'. In the kernel, here is the before and after (all\n    +    times computed with best-of-five):\n     \n         With commit-graphs (but no Bloom filters):\n     \n    @@ Commit message\n     \n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n    - ## blame-tree.c ##\n    + ## last-modified.c ##\n     @@\n      #include \"revision.h\"\n      #include \"repository.h\"\n    @@ blame-tree.c\n     +#include \"commit-graph.h\"\n     +#include \"bloom.h\"\n      \n    - struct blame_tree_entry {\n    + struct last_modified_entry {\n      \tstruct hashmap_entry hashent;\n      \tstruct object_id oid;\n      \tstruct commit *commit;\n    @@ blame-tree.c\n      \tconst char path[FLEX_ARRAY];\n      };\n      \n    -@@ blame-tree.c: static void add_from_diff(struct diff_queue_struct *q,\n    +@@ last-modified.c: static void add_from_diff(struct diff_queue_struct *q,\n      \n      \t\tFLEX_ALLOC_STR(ent, path, path);\n      \t\toidcpy(&ent->oid, &p->two->oid);\n    -+\t\tif (bt->rev.bloom_filter_settings)\n    ++\t\tif (lm->rev.bloom_filter_settings)\n     +\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n    -+\t\t\t\t       bt->rev.bloom_filter_settings);\n    ++\t\t\t\t       lm->rev.bloom_filter_settings);\n      \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n    - \t\thashmap_add(&bt->paths, &ent->hashent);\n    + \t\thashmap_add(&lm->paths, &ent->hashent);\n      \t}\n    -@@ blame-tree.c: void blame_tree_init(struct blame_tree *bt,\n    - \tif (setup_revisions(argc, argv, &bt->rev, NULL) > 1)\n    - \t\tdie(_(\"unknown blame-tree argument: %s\"), argv[1]);\n    +@@ last-modified.c: void last_modified_init(struct last_modified *lm,\n    + \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    + \t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n      \n    -+\t(void)generation_numbers_enabled(bt->rev.repo);\n    -+\tbt->rev.bloom_filter_settings = get_bloom_filter_settings(bt->rev.repo);\n    ++\t(void)generation_numbers_enabled(lm->rev.repo);\n    ++\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n     +\n    - \tif (add_from_revs(bt) < 0)\n    - \t\tdie(_(\"unable to setup blame-tree\"));\n    + \tif (add_from_revs(lm) < 0)\n    + \t\tdie(_(\"unable to setup last-modified\"));\n      }\n      \n    - void blame_tree_release(struct blame_tree *bt)\n    + void last_modified_release(struct last_modified *lm)\n      {\n     +\tstruct hashmap_iter iter;\n    -+\tstruct blame_tree_entry *ent;\n    ++\tstruct last_modified_entry *ent;\n     +\n    -+\thashmap_for_each_entry(&bt->paths, &iter, ent, hashent) {\n    ++\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n     +\t\tclear_bloom_key(&ent->key);\n     +\t}\n    - \thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n    - \trelease_revisions(&bt->rev);\n    + \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n    + \trelease_revisions(&lm->rev);\n      }\n    -@@ blame-tree.c: static void mark_path(const char *path, const struct object_id *oid,\n    +@@ last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n      \t\tdata->callback(path, data->commit, data->callback_data);\n      \n      \thashmap_remove(data->paths, &ent->hashent, path);\n    @@ blame-tree.c: static void mark_path(const char *path, const struct object_id *oi\n      \tfree(ent);\n      }\n      \n    -@@ blame-tree.c: static void blame_diff(struct diff_queue_struct *q,\n    +@@ last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n      \t}\n      }\n      \n    -+static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n    ++static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n     +{\n     +\tstruct bloom_filter *filter;\n    -+\tstruct blame_tree_entry *e;\n    ++\tstruct last_modified_entry *ent;\n     +\tstruct hashmap_iter iter;\n     +\n    -+\tif (!bt->rev.bloom_filter_settings)\n    ++\tif (!lm->rev.bloom_filter_settings)\n     +\t\treturn 1;\n     +\n     +\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n     +\t\treturn 1;\n     +\n    -+\tfilter = get_bloom_filter(bt->rev.repo, origin);\n    ++\tfilter = get_bloom_filter(lm->rev.repo, origin);\n     +\tif (!filter)\n     +\t\treturn 1;\n     +\n    -+\thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n    -+\t\tif (bloom_filter_contains(filter, &e->key,\n    -+\t\t\t\t\t  bt->rev.bloom_filter_settings))\n    ++\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n    ++\t\tif (bloom_filter_contains(filter, &ent->key,\n    ++\t\t\t\t\t  lm->rev.bloom_filter_settings))\n     +\t\t\treturn 1;\n     +\t}\n     +\treturn 0;\n     +}\n     +\n    - int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    + int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n      {\n    - \tstruct blame_tree_callback_data data;\n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    + \tstruct last_modified_callback_data data;\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n      \t\tif (!data.commit)\n      \t\t\tbreak;\n      \n    -+\t\tif (!maybe_changed_path(bt, data.commit))\n    ++\t\tif (!maybe_changed_path(lm, data.commit))\n     +\t\t\tcontinue;\n     +\n      \t\tif (data.commit->object.flags & BOUNDARY) {\n    - \t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n    + \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n      \t\t\t\t       &data.commit->object.oid,\n4:  1a20acce8f ! 4:  3eac929e36 blame-tree: implement faster algorithm\n    @@\n      ## Metadata ##\n    -Author: Taylor Blau <me@ttaylorr.com>\n    +Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n    -    blame-tree: implement faster algorithm\n    +    last-modified: implement faster algorithm\n     \n    -    The current implementation of 'git blame-tree' works by doing a revision\n    -    walk, and inspecting the diff at each level of that walk to annotate the\n    -    yet-unblamed entries to a path. In other words, if the diff at some\n    -    level touches a path which has not yet been associated with a commit,\n    -    then that commit becomes associated with the path.\n    +    The current implementation of 'git last-modified' works by doing a\n    +    revision walk, and inspecting the diff at each level of that walk to\n    +    annotate the to-be-found entries to a path. In other words, if the diff\n    +    at some level touches a path which has not yet been associated with a\n    +    commit, then that commit becomes associated with the path.\n     \n         While a perfectly reasonable implementation, it can perform poorly in\n         either one of two scenarios:\n    @@ Commit message\n              long time, and so we must walk through a lot of history in order to\n              find a commit that touches that path.\n     \n    -    This patch rewrites the blame-tree implementation that addresses (2).\n    +    This patch rewrites the last-modified implementation that addresses (2).\n         The idea behind the algorithm is to propagate a set of 'active' paths (a\n         path is 'active' if it does not yet belong to a commit) up to parents\n         and do a truncated revision walk.\n    @@ Commit message\n         Co-authored-by: Derrick Stolee <dstolee@microsoft.com>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n    - ## blame-tree.c ##\n    + ## last-modified.c ##\n     @@\n      #include \"commit.h\"\n      #include \"diffcore.h\"\n    @@ blame-tree.c\n     +#include \"prio-queue.h\"\n     +#include \"commit-slab.h\"\n      \n    - struct blame_tree_entry {\n    + struct last_modified_entry {\n      \tstruct hashmap_entry hashent;\n      \tstruct object_id oid;\n      \tstruct commit *commit;\n    @@ blame-tree.c\n      \tstruct bloom_key key;\n      \tconst char path[FLEX_ARRAY];\n      };\n    -@@ blame-tree.c: void blame_tree_init(struct blame_tree *bt,\n    +@@ last-modified.c: void last_modified_init(struct last_modified *lm,\n      \t\t     const char *prefix,\n      \t\t     int argc, const char **argv)\n      {\n     +\tstruct hashmap_iter iter;\n    -+\tstruct blame_tree_entry *e;\n    ++\tstruct last_modified_entry *ent;\n     +\n    - \tmemset(bt, 0, sizeof(*bt));\n    - \thashmap_init(&bt->paths, blame_tree_entry_hashcmp, NULL, 0);\n    + \tmemset(lm, 0, sizeof(*lm));\n    + \thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n      \n    -@@ blame-tree.c: void blame_tree_init(struct blame_tree *bt,\n    +@@ last-modified.c: void last_modified_init(struct last_modified *lm,\n      \n    - \tif (add_from_revs(bt) < 0)\n    - \t\tdie(_(\"unable to setup blame-tree\"));\n    + \tif (add_from_revs(lm) < 0)\n    + \t\tdie(_(\"unable to setup last-modified\"));\n     +\n    -+\tbt->all_paths = xcalloc(hashmap_get_size(&bt->paths), sizeof(const char *));\n    -+\tbt->all_paths_nr = 0;\n    -+\thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n    -+\t\te->diff_idx = bt->all_paths_nr++;\n    -+\t\tbt->all_paths[e->diff_idx] = e->path;\n    ++\tlm->all_paths = xcalloc(hashmap_get_size(&lm->paths), sizeof(const char *));\n    ++\tlm->all_paths_nr = 0;\n    ++\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n    ++\t\tent->diff_idx = lm->all_paths_nr++;\n    ++\t\tlm->all_paths[ent->diff_idx] = ent->path;\n     +\t}\n      }\n      \n    - void blame_tree_release(struct blame_tree *bt)\n    -@@ blame-tree.c: void blame_tree_release(struct blame_tree *bt)\n    + void last_modified_release(struct last_modified *lm)\n    +@@ last-modified.c: void last_modified_release(struct last_modified *lm)\n      \t}\n    - \thashmap_clear_and_free(&bt->paths, struct blame_tree_entry, hashent);\n    - \trelease_revisions(&bt->rev);\n    -+\tfree(bt->all_paths);\n    + \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n    + \trelease_revisions(&lm->rev);\n    ++\tfree(lm->all_paths);\n     +}\n     +\n     +struct commit_active_paths {\n    @@ blame-tree.c: void blame_tree_release(struct blame_tree *bt)\n     +\tfree(active->active);\n      }\n      \n    - struct blame_tree_callback_data {\n    -@@ blame-tree.c: static void mark_path(const char *path, const struct object_id *oid,\n    - \t\t      struct blame_tree_callback_data *data)\n    + struct last_modified_callback_data {\n    +@@ last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n    + \t\t      struct last_modified_callback_data *data)\n      {\n    - \tstruct blame_tree_entry *ent;\n    + \tstruct last_modified_entry *ent;\n     +\tstruct commit_active_paths *active;\n      \n      \t/* Is it even a path that we are interested in? */\n      \tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n    -@@ blame-tree.c: static void mark_path(const char *path, const struct object_id *oid,\n    +@@ last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n      \tif (ent->commit)\n      \t\treturn;\n      \n    @@ blame-tree.c: static void mark_path(const char *path, const struct object_id *oi\n      \t\treturn;\n      \n      \tent->commit = data->commit;\n    -@@ blame-tree.c: static void blame_diff(struct diff_queue_struct *q,\n    +@@ last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n      \t}\n      }\n      \n    --static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n    +-static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n     +static char *scratch;\n     +\n     +static void pass_to_parent(struct commit_active_paths *c,\n    @@ blame-tree.c: static void blame_diff(struct diff_queue_struct *q,\n     +#define PARENT1 (1u<<16) /* used instead of SEEN */\n     +#define PARENT2 (1u<<17) /* used instead of BOTTOM, BOUNDARY */\n     +\n    -+static int diff2idx(struct blame_tree *bt, char *path)\n    ++static int diff2idx(struct last_modified *lm, char *path)\n     +{\n    -+\tstruct blame_tree_entry *ent;\n    -+\tent = hashmap_get_entry_from_hash(&bt->paths, strhash(path), path,\n    -+\t\t\t\t\t  struct blame_tree_entry, hashent);\n    ++\tstruct last_modified_entry *ent;\n    ++\tent = hashmap_get_entry_from_hash(&lm->paths, strhash(path), path,\n    ++\t\t\t\t\t  struct last_modified_entry, hashent);\n     +\treturn ent ? ent->diff_idx : -1;\n     +}\n     +\n    -+static int maybe_changed_path(struct blame_tree *bt,\n    ++static int maybe_changed_path(struct last_modified *lm,\n     +\t\t\t      struct commit *origin,\n     +\t\t\t      struct commit_active_paths *active)\n      {\n      \tstruct bloom_filter *filter;\n    - \tstruct blame_tree_entry *e;\n    -@@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n    + \tstruct last_modified_entry *ent;\n    +@@ last-modified.c: static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n      \t\treturn 1;\n      \n    - \thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n    -+\t\tif (active && !active->active[e->diff_idx])\n    + \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n    ++\t\tif (active && !active->active[ent->diff_idx])\n     +\t\t\tcontinue;\n    - \t\tif (bloom_filter_contains(filter, &e->key,\n    - \t\t\t\t\t  bt->rev.bloom_filter_settings))\n    + \t\tif (bloom_filter_contains(filter, &ent->key,\n    + \t\t\t\t\t  lm->rev.bloom_filter_settings))\n      \t\t\treturn 1;\n    -@@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit *origin)\n    +@@ last-modified.c: static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n      \treturn 0;\n      }\n      \n    -+static int process_parent(struct blame_tree *bt,\n    -+\t\t\t   struct prio_queue *queue,\n    -+\t\t\t   struct commit *c, struct commit_active_paths *active_c,\n    -+\t\t\t   struct commit *parent, int parent_i)\n    ++static int process_parent(struct last_modified *lm, struct prio_queue *queue,\n    ++\t\t\t  struct commit *c,\n    ++\t\t\t  struct commit_active_paths *active_c,\n    ++\t\t\t  struct commit *parent, int parent_i)\n     +{\n     +\tint i, ret = 0; // TODO type & for loop var\n     +\tstruct commit_active_paths *active_p;\n     +\n    -+\trepo_parse_commit(bt->rev.repo, parent);\n    ++\trepo_parse_commit(lm->rev.repo, parent);\n     +\n     +\tactive_p = active_paths_at(&active_paths, parent);\n     +\tif (!active_p->active) {\n    -+\t\tactive_p->active = xcalloc(sizeof(char), bt->all_paths_nr);\n    ++\t\tactive_p->active = xcalloc(sizeof(char), lm->all_paths_nr);\n     +\t\tactive_p->nr = 0;\n     +\t}\n     +\n    @@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit\n     +\t * Before calling 'diff_tree_oid()' on our first parent, see if Bloom\n     +\t * filters will tell us the diff is conclusively uninteresting.\n     +\t */\n    -+\tif (parent_i || maybe_changed_path(bt, c, active_c)) {\n    ++\tif (parent_i || maybe_changed_path(lm, c, active_c)) {\n     +\t\tdiff_tree_oid(&parent->object.oid,\n    -+\t\t\t      &c->object.oid, \"\", &bt->rev.diffopt);\n    -+\t\tdiffcore_std(&bt->rev.diffopt);\n    ++\t\t\t      &c->object.oid, \"\", &lm->rev.diffopt);\n    ++\t\tdiffcore_std(&lm->rev.diffopt);\n     +\t}\n     +\n     +\tif (!diff_queued_diff.nr) {\n    @@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit\n     +\t\t * No diff entries means we are TREESAME on the base path, and\n     +\t\t * so all active paths get passed onto this parent.\n     +\t\t */\n    -+\t\tfor (i = 0; i < bt->all_paths_nr; i++) {\n    ++\t\tfor (i = 0; i < lm->all_paths_nr; i++) {\n     +\t\t\tif (active_c->active[i])\n     +\t\t\t\tpass_to_parent(active_c, active_p, i);\n     +\t\t}\n    @@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit\n     +\t */\n     +\tfor (i = 0; i < diff_queued_diff.nr; i++) {\n     +\t\tstruct diff_filepair *fp = diff_queued_diff.queue[i];\n    -+\t\tint k = diff2idx(bt, fp->two->path);\n    ++\t\tint k = diff2idx(lm, fp->two->path);\n     +\t\tif (0 <= k && active_c->active[k])\n     +\t\t\tscratch[k] = 1;\n     +\t\tdiff_free_filepair(fp);\n     +\t}\n     +\tdiff_queued_diff.nr = 0;\n    -+\tfor (i = 0; i < bt->all_paths_nr; i++) {\n    ++\tfor (i = 0; i < lm->all_paths_nr; i++) {\n     +\t\tif (active_c->active[i] && !scratch[i])\n     +\t\t\tpass_to_parent(active_c, active_p, i);\n     +\t}\n    @@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt, struct commit\n     +\n     +cleanup:\n     +\tdiff_queue_clear(&diff_queued_diff);\n    -+\tmemset(scratch, 0, bt->all_paths_nr);\n    ++\tmemset(scratch, 0, lm->all_paths_nr);\n     +\n     +\treturn ret;\n     +}\n     +\n    - int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    + int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n      {\n     +\tint max_count, queue_popped = 0;\n     +\tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n     +\tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n    - \tstruct blame_tree_callback_data data;\n    + \tstruct last_modified_callback_data data;\n      \n    - \tdata.paths = &bt->paths;\n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    - \tbt->rev.diffopt.format_callback = blame_diff;\n    - \tbt->rev.diffopt.format_callback_data = &data;\n    + \tdata.paths = &lm->paths;\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n    + \tlm->rev.diffopt.format_callback = last_modified_diff;\n    + \tlm->rev.diffopt.format_callback_data = &data;\n      \n    --\tprepare_revision_walk(&bt->rev);\n    -+\tmax_count = bt->rev.max_count;\n    +-\tprepare_revision_walk(&lm->rev);\n    ++\tmax_count = lm->rev.max_count;\n      \n    --\twhile (hashmap_get_size(&bt->paths)) {\n    --\t\tdata.commit = get_revision(&bt->rev);\n    +-\twhile (hashmap_get_size(&lm->paths)) {\n    +-\t\tdata.commit = get_revision(&lm->rev);\n     -\t\tif (!data.commit)\n     -\t\t\tbreak;\n     +\tinit_active_paths(&active_paths);\n    -+\tscratch = xcalloc(bt->all_paths_nr, sizeof(char));\n    ++\tscratch = xcalloc(lm->all_paths_nr, sizeof(char));\n      \n    --\t\tif (!maybe_changed_path(bt, data.commit))\n    +-\t\tif (!maybe_changed_path(lm, data.commit))\n     -\t\t\tcontinue;\n     +\t/*\n    -+\t * bt->rev.pending holds the set of boundary commits for our walk.\n    ++\t * lm->rev.pending holds the set of boundary commits for our walk.\n     +\t *\n     +\t * Loop through each such commit, and place it in the appropriate queue.\n     +\t */\n    -+\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n    -+\t\tstruct commit *c = lookup_commit(bt->rev.repo,\n    -+\t\t\t\t\t\t &bt->rev.pending.objects[i].item->oid);\n    -+\t\trepo_parse_commit(bt->rev.repo, c);\n    ++\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n    ++\t\tstruct commit *c = lookup_commit(lm->rev.repo,\n    ++\t\t\t\t\t\t &lm->rev.pending.objects[i].item->oid);\n    ++\t\trepo_parse_commit(lm->rev.repo, c);\n      \n     -\t\tif (data.commit->object.flags & BOUNDARY) {\n    --\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n    +-\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     -\t\t\t\t       &data.commit->object.oid,\n    --\t\t\t\t       \"\", &bt->rev.diffopt);\n    --\t\t\tdiff_flush(&bt->rev.diffopt);\n    +-\t\t\t\t       \"\", &lm->rev.diffopt);\n    +-\t\t\tdiff_flush(&lm->rev.diffopt);\n     -\t\t} else {\n    --\t\t\tlog_tree_commit(&bt->rev, data.commit);\n    +-\t\t\tlog_tree_commit(&lm->rev, data.commit);\n     +\t\tif (c->object.flags & BOTTOM) {\n     +\t\t\tprio_queue_put(&not_queue, c);\n     +\t\t\tc->object.flags |= PARENT2;\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t\t\tc->object.flags |= PARENT1;\n     +\n     +\t\t\tactive = active_paths_at(&active_paths, c);\n    -+\t\t\tactive->active = xcalloc(sizeof(char), bt->all_paths_nr);\n    -+\t\t\tmemset(active->active, 1, bt->all_paths_nr);\n    -+\t\t\tactive->nr = bt->all_paths_nr;\n    ++\t\t\tactive->active = xcalloc(sizeof(char), lm->all_paths_nr);\n    ++\t\t\tmemset(active->active, 1, lm->all_paths_nr);\n    ++\t\t\tactive->nr = lm->all_paths_nr;\n      \t\t}\n      \t}\n      \n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t * Now that we have processed the pending commits, allow the revision\n     +\t * machinery to flush them by calling prepare_revision_walk().\n     +\t */\n    -+\tprepare_revision_walk(&bt->rev);\n    ++\tprepare_revision_walk(&lm->rev);\n     +\n     +\twhile (queue.nr) {\n     +\t\tint parent_i;\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t\t\t */\n     +\t\t\tc->object.flags |= PARENT2 | BOUNDARY;\n     +\t\t\tdata.commit = c;\n    -+\t\t\tdiff_tree_oid(bt->rev.repo->hash_algo->empty_tree,\n    ++\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     +\t\t\t\t      &c->object.oid,\n    -+\t\t\t\t      \"\", &bt->rev.diffopt);\n    -+\t\t\tdiff_flush(&bt->rev.diffopt);\n    ++\t\t\t\t      \"\", &lm->rev.diffopt);\n    ++\t\t\tdiff_flush(&lm->rev.diffopt);\n     +\t\t\tgoto cleanup;\n     +\t\t}\n     +\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t\t * Otherwise, keep going, but make sure that 'c' isn't reachable\n     +\t\t * from anything in the '--not' queue.\n     +\t\t */\n    -+\t\trepo_parse_commit(bt->rev.repo, c);\n    ++\t\trepo_parse_commit(lm->rev.repo, c);\n     +\n     +\t\twhile (not_queue.nr) {\n     +\t\t\tstruct commit_list *np;\n     +\t\t\tstruct commit *n = prio_queue_get(&not_queue);\n     +\n    -+\t\t\trepo_parse_commit(bt->rev.repo, n);\n    ++\t\t\trepo_parse_commit(lm->rev.repo, n);\n     +\n     +\t\t\tfor (np = n->parents; np; np = np->next) {\n     +\t\t\t\tif (!(np->item->object.flags & PARENT2)) {\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t\t * parents in order if TREESAME.\n     +\t\t */\n     +\t\tfor (p = c->parents, parent_i = 0; p; p = p->next, parent_i++) {\n    -+\t\t\tif (process_parent(bt, &queue,\n    ++\t\t\tif (process_parent(lm, &queue,\n     +\t\t\t\t\t   c, active_c,\n     +\t\t\t\t\t   p->item, parent_i) > 0 )\n     +\t\t\t\tbreak;\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     +\t\tif (active_c->nr)  {\n     +\t\t\t/* Any paths that remain active were changed by 'c'. */\n     +\t\t\tdata.commit = c;\n    -+\t\t\tfor (int i = 0; i < bt->all_paths_nr; i++) {\n    ++\t\t\tfor (int i = 0; i < lm->all_paths_nr; i++) {\n     +\t\t\t\tif (active_c->active[i])\n    -+\t\t\t\t\tmark_path(bt->all_paths[i], NULL, &data);\n    ++\t\t\t\t\tmark_path(lm->all_paths[i], NULL, &data);\n     +\t\t\t}\n     +\t\t}\n     +\n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n      \treturn 0;\n      }\n     \n    - ## blame-tree.h ##\n    + ## last-modified.h ##\n     @@\n    - struct blame_tree {\n    + struct last_modified {\n      \tstruct hashmap paths;\n      \tstruct rev_info rev;\n     +\n    @@ blame-tree.h\n     +\tint all_paths_nr;\n      };\n      \n    - void blame_tree_init(struct blame_tree *bt,\n    + void last_modified_init(struct last_modified *lm,\n5:  10b306953f ! 5:  6808799f8b blame-tree.c: initialize revision machinery without walk\n    @@ Metadata\n     Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n    -    blame-tree.c: initialize revision machinery without walk\n    +    last-modified: initialize revision machinery without walk\n     \n         In a previous commit we inserted a call to 'prepare_revision_walk()'\n         before we started our traversal. This was done when we leveraged the\n    @@ Commit message\n         Based-on-patch-by: Taylor Blau <me@ttaylorr.com>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n    - ## blame-tree.c ##\n    -@@ blame-tree.c: static int maybe_changed_path(struct blame_tree *bt,\n    + ## last-modified.c ##\n    +@@ last-modified.c: static int maybe_changed_path(struct last_modified *lm,\n      \tif (!filter)\n      \t\treturn 1;\n      \n    -+\tfor (int i = 0; i < bt->rev.bloom_keys_nr; i++) {\n    ++\tfor (int i = 0; i < lm->rev.bloom_keys_nr; i++) {\n     +\t\tif (!(bloom_filter_contains(filter,\n    -+\t\t\t\t\t    &bt->rev.bloom_keys[i],\n    -+\t\t\t\t\t    bt->rev.bloom_filter_settings)))\n    ++\t\t\t\t\t    &lm->rev.bloom_keys[i],\n    ++\t\t\t\t\t    lm->rev.bloom_filter_settings)))\n     +\t\t\treturn 0;\n     +\t}\n     +\n    - \thashmap_for_each_entry(&bt->paths, &iter, e, hashent) {\n    - \t\tif (active && !active->active[e->diff_idx])\n    + \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n    + \t\tif (active && !active->active[ent->diff_idx])\n      \t\t\tcontinue;\n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n      \tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n      \tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n    - \tstruct blame_tree_callback_data data;\n    + \tstruct last_modified_callback_data data;\n     +\tstruct commit_list *list;\n      \n    - \tdata.paths = &bt->paths;\n    + \tdata.paths = &lm->paths;\n      \tdata.callback = cb;\n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    - \tbt->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n    - \tbt->rev.diffopt.format_callback = blame_diff;\n    - \tbt->rev.diffopt.format_callback_data = &data;\n    -+\tbt->rev.no_walk = 1;\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n    + \tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n    + \tlm->rev.diffopt.format_callback = last_modified_diff;\n    + \tlm->rev.diffopt.format_callback_data = &data;\n    ++\tlm->rev.no_walk = 1;\n     +\n    -+\tprepare_revision_walk(&bt->rev);\n    ++\tprepare_revision_walk(&lm->rev);\n      \n    - \tmax_count = bt->rev.max_count;\n    + \tmax_count = lm->rev.max_count;\n      \n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    - \tscratch = xcalloc(bt->all_paths_nr, sizeof(char));\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n    + \tscratch = xcalloc(lm->all_paths_nr, sizeof(char));\n      \n      \t/*\n    --\t * bt->rev.pending holds the set of boundary commits for our walk.\n    -+\t * bt->rev.commits holds the set of boundary commits for our walk.\n    +-\t * lm->rev.pending holds the set of boundary commits for our walk.\n    ++\t * lm->rev.commits holds the set of boundary commits for our walk.\n      \t *\n      \t * Loop through each such commit, and place it in the appropriate queue.\n      \t */\n    --\tfor (size_t i = 0; i < bt->rev.pending.nr; i++) {\n    --\t\tstruct commit *c = lookup_commit(bt->rev.repo,\n    --\t\t\t\t\t\t &bt->rev.pending.objects[i].item->oid);\n    --\t\trepo_parse_commit(bt->rev.repo, c);\n    -+\tfor (list = bt->rev.commits; list; list = list->next) {\n    +-\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n    +-\t\tstruct commit *c = lookup_commit(lm->rev.repo,\n    +-\t\t\t\t\t\t &lm->rev.pending.objects[i].item->oid);\n    +-\t\trepo_parse_commit(lm->rev.repo, c);\n    ++\tfor (list = lm->rev.commits; list; list = list->next) {\n     +\t\tstruct commit *c = list->item;\n      \n      \t\tif (c->object.flags & BOTTOM) {\n      \t\t\tprio_queue_put(&not_queue, c);\n    -@@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb, void *cbdata)\n    +@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n      \t\t}\n      \t}\n      \n    @@ blame-tree.c: int blame_tree_run(struct blame_tree *bt, blame_tree_callback cb,\n     -\t * Now that we have processed the pending commits, allow the revision\n     -\t * machinery to flush them by calling prepare_revision_walk().\n     -\t */\n    --\tprepare_revision_walk(&bt->rev);\n    +-\tprepare_revision_walk(&lm->rev);\n     -\n      \twhile (queue.nr) {\n      \t\tint parent_i;\n\n\n---\n\nbase-commit: 8613c2bb6cd16ef530dc5dd74d3b818a1ccbf1c0\nchange-id: 20250410-toon-new-blame-tree-bcdbb78c1c0f\n\nThanks\n--\nToon\n\n"},{"id":"518752","messageId":"20250523-toon-new-blame-tree-v2-1-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"[PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:48Z","receivedAt":"2025-05-23T09:34:10Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: \"Ævar Arnfjörð Bjarmason\" <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 ++++++++\n Documentation/meson.build            |   1 +\n Makefile                             |   2 +\n builtin.h                            |   1 +\n builtin/last-modified.c              |  43 +++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n last-modified.c                      | 213 +++++++++++++++++++++++++++++++++++\n last-modified.h                      |  27 +++++\n meson.build                          |   2 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 194 +++++++++++++++++++++++++++++++\n 13 files changed, 536 insertions(+)\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..1af38f402e\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,49 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [-r] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+\tRecurse into subtrees.\n+\n+-t::\n+\tShow tree entry itself as well as subtrees.  Implies `-r`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tof the current working directory are included in the\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 1433acfd31..fa93cec5c3 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex ecd590a643..40bc24c704 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1051,6 +1051,7 @@ LIB_OBJS += hook.o\n LIB_OBJS += ident.o\n LIB_OBJS += json-writer.o\n LIB_OBJS += kwset.o\n+LIB_OBJS += last-modified.o\n LIB_OBJS += levenshtein.o\n LIB_OBJS += line-log.o\n LIB_OBJS += line-range.o\n@@ -1266,6 +1267,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..0d4733f666\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,43 @@\n+#include \"git-compat-util.h\"\n+#include \"last-modified.h\"\n+#include \"hex.h\"\n+#include \"quote.h\"\n+#include \"config.h\"\n+#include \"object-name.h\"\n+#include \"parse-options.h\"\n+#include \"builtin.h\"\n+\n+static void show_entry(const char *path, const struct commit *commit, void *d)\n+{\n+\tstruct last_modified *lm = d;\n+\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+int cmd_last_modified(int argc,\n+\t\t   const char **argv,\n+\t\t   const char *prefix,\n+\t\t   struct repository *repo)\n+{\n+\tint ret = 0;\n+\tstruct last_modified lm;\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tlast_modified_init(&lm, repo, prefix, argc, argv);\n+\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n+\t\tdie(_(\"error running last-modified traversal\"));\n+\n+\tlast_modified_release(&lm);\n+\n+\treturn ret;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 77c4359522..65afc0d0e7 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/last-modified.c b/last-modified.c\nnew file mode 100644\nindex 0000000000..9283f8fcae\n--- /dev/null\n+++ b/last-modified.c\n@@ -0,0 +1,213 @@\n+#include \"git-compat-util.h\"\n+#include \"last-modified.h\"\n+#include \"commit.h\"\n+#include \"diffcore.h\"\n+#include \"diff.h\"\n+#include \"object.h\"\n+#include \"revision.h\"\n+#include \"repository.h\"\n+#include \"log-tree.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tstruct commit *commit;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static void add_from_diff(struct diff_queue_struct *q,\n+\t\t\t  struct diff_options *opt UNUSED,\n+\t\t\t  void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int add_from_revs(struct last_modified *lm)\n+{\n+\tsize_t count = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (count++)\n+\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tclear_pathspec(&diffopt.pathspec);\n+\n+\treturn 0;\n+}\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t    const struct hashmap_entry *hent1,\n+\t\t\t\t    const struct hashmap_entry *hent2,\n+\t\t\t\t    const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+void last_modified_init(struct last_modified *lm,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv)\n+{\n+\tmemset(lm, 0, sizeof(*lm));\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n+\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\n+\tif (add_from_revs(lm) < 0)\n+\t\tdie(_(\"unable to setup last-modified\"));\n+}\n+\n+void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+struct last_modified_callback_data {\n+\tstruct commit *commit;\n+\tstruct hashmap *paths;\n+\n+\tlast_modified_callback callback;\n+\tvoid *callback_data;\n+};\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/* Have we already found a commit? */\n+\tif (ent->commit)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tent->commit = data->commit;\n+\tif (data->callback)\n+\t\tdata->callback(path, data->commit, data->callback_data);\n+\n+\thashmap_remove(data->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final path/sha1 state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n+{\n+\tstruct last_modified_callback_data data;\n+\n+\tdata.paths = &lm->paths;\n+\tdata.callback = cb;\n+\tdata.callback_data = cbdata;\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t       &data.commit->object.oid,\n+\t\t\t\t       \"\", &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\ndiff --git a/last-modified.h b/last-modified.h\nnew file mode 100644\nindex 0000000000..42a819d979\n--- /dev/null\n+++ b/last-modified.h\n@@ -0,0 +1,27 @@\n+#ifndef LAST_MODIFIED_H\n+#define LAST_MODIFIED_H\n+\n+#include \"commit.h\"\n+#include \"revision.h\"\n+#include \"hashmap.h\"\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+};\n+\n+void last_modified_init(struct last_modified *lm,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv);\n+\n+void last_modified_release(struct last_modified *);\n+\n+typedef void (*last_modified_callback)(const char *path,\n+\t\t\t\t    const struct commit *commit,\n+\t\t\t\t    void *data);\n+int last_modified_run(struct last_modified *lm,\n+\t\t   last_modified_callback cb,\n+\t\t   void *cbdata);\n+\n+#endif /* LAST_MODIFIED_H */\ndiff --git a/meson.build b/meson.build\nindex a1476e5b32..bdd9ed2c4c 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -365,6 +365,7 @@ libgit_sources = [\n   'ident.c',\n   'json-writer.c',\n   'kwset.c',\n+  'last-modified.c',\n   'levenshtein.c',\n   'line-log.c',\n   'line-range.c',\n@@ -609,6 +610,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex fcfc1c2c2b..be5a711375 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -962,6 +962,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..0c4a19c029\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,194 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n+\tsort tmp.3 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t1 file\n+\t2 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm1 m1.t\n+\tm2 m2.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb2 file\n+\tb5 file2\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n\n-- \n2.49.0\n\n"},{"id":"518753","messageId":"20250523-toon-new-blame-tree-v2-2-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"[PATCH RFC v2 2/5] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:49Z","receivedAt":"2025-05-23T09:34:15Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"From: Jeff King <peff@peff.net>\n\nThis just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 21 +++++++++++++++++++++\n 2 files changed, 22 insertions(+)\n\ndiff --git a/t/meson.build b/t/meson.build\nindex be5a711375..4ac28c04fe 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1155,6 +1155,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..a02ec907d4\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,21 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n\n-- \n2.49.0\n\n"},{"id":"518754","messageId":"20250523-toon-new-blame-tree-v2-3-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"[PATCH RFC v2 3/5] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:50Z","receivedAt":"2025-05-23T09:34:17Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nThis results in a substantial performance speed-up in common cases of\n'git last-modified'. In the kernel, here is the before and after (all\ntimes computed with best-of-five):\n\nWith commit-graphs (but no Bloom filters):\n\n    real\t0m5.133s\n    user\t0m4.942s\n    sys\t0m0.180s\n\n...and with Bloom filters:\n\n    real\t0m0.936s\n    user\t0m0.842s\n    sys\t0m0.092s\n\nThese times are with my development-version of Git, so it's compiled\nwithout optimizations. Compiling instead with `-O3`, the results look\neven better:\n\n    real\t0m0.754s\n    user\t0m0.661s\n    sys\t0m0.092s\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n last-modified.c | 44 ++++++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 44 insertions(+)\n\ndiff --git a/last-modified.c b/last-modified.c\nindex 9283f8fcae..f628434929 100644\n--- a/last-modified.c\n+++ b/last-modified.c\n@@ -7,11 +7,15 @@\n #include \"revision.h\"\n #include \"repository.h\"\n #include \"log-tree.h\"\n+#include \"dir.h\"\n+#include \"commit-graph.h\"\n+#include \"bloom.h\"\n \n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n \tstruct commit *commit;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -28,6 +32,9 @@ static void add_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -92,12 +99,21 @@ void last_modified_init(struct last_modified *lm,\n \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n \t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n \n+\t(void)generation_numbers_enabled(lm->rev.repo);\n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (add_from_revs(lm) < 0)\n \t\tdie(_(\"unable to setup last-modified\"));\n }\n \n void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tclear_bloom_key(&ent->key);\n+\t}\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -137,6 +153,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n+\tclear_bloom_key(&ent->key);\n \tfree(ent);\n }\n \n@@ -180,6 +197,30 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n {\n \tstruct last_modified_callback_data data;\n@@ -199,6 +240,9 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \t\tif (!data.commit)\n \t\t\tbreak;\n \n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n \t\t\t\t       &data.commit->object.oid,\n\n-- \n2.49.0\n\n"},{"id":"518755","messageId":"20250523-toon-new-blame-tree-v2-4-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"[PATCH RFC v2 4/5] last-modified: implement faster algorithm","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:51Z","receivedAt":"2025-05-23T09:34:22Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"The current implementation of 'git last-modified' works by doing a\nrevision walk, and inspecting the diff at each level of that walk to\nannotate the to-be-found entries to a path. In other words, if the diff\nat some level touches a path which has not yet been associated with a\ncommit, then that commit becomes associated with the path.\n\nWhile a perfectly reasonable implementation, it can perform poorly in\neither one of two scenarios:\n\n  1. There are many entries of interest, in which case there is simply\n     more work to do.\n\n  2. Or, there are (even a few) entries which have not been updated in a\n     long time, and so we must walk through a lot of history in order to\n     find a commit that touches that path.\n\nThis patch rewrites the last-modified implementation that addresses (2).\nThe idea behind the algorithm is to propagate a set of 'active' paths (a\npath is 'active' if it does not yet belong to a commit) up to parents\nand do a truncated revision walk.\n\nThe walk is truncated because it does not produce a revision for every\nchange in the original pathspec, but rather only for active paths.\n\nMore specifically, consider a priority queue of commits sorted by\ngeneration number. First, enqueue the set of boundary commits with all\npaths in the original spec marked as interesting.\n\nThen, while the queue is not empty, do the following:\n\n  1. Pop an element, say, 'c', off of the queue, making sure that 'c'\n     isn't reachable by anything in the '--not' set.\n\n  2. For each parent 'p' (with index 'parent_i') of 'c', do the\n     following:\n\n     a. Compute the diff between 'c' and 'p'.\n     b. Pass any active paths that are TREESAME from 'c' to 'p'.\n     c. If 'p' has any active paths, push it onto the queue.\n\n  3. Associate any remaining paths with 'c', and mark them as inactive.\n\nThis ends up being equivalent to doing something like 'git log -1 --\n$path' for each path simultaneously. But, it allows us to go much faster\nthan the original implementation by limiting the number of diffs we\ncompute, since we can avoid parts of history that would have been\nconsidered by the revision walk in the original implementation, but are\nknown to be uninteresting to us because we have already marked all paths\nin that area to be inactive.\n\nOne other trick we can do on top is to avoid computing many first-parent\ndiffs when all paths active in 'c' are DEFINITELY_NOT in c's Bloom\nfilter. Since the commit-graph only stores first-parent diffs in the\nBloom filters, we can only apply this trick to first-parent diffs.\n\nNow, some performance numbers. On github/git, our numbers look like the\nfollowing (all wall-clock times best-of-five, and with '--max-depth=0'\non the root):\n\n                 github\t\tttaylorr/blame-tree-fast\n   with filters: 0.754s\t\t0.271s (2.78x faster, 6.18x overall)\nwithout filters: 1.676s\t\t1.056s (1.58x faster)\n\nand on torvalds/linux:\n\n                 github\t\tttaylorr/blame-tree-fast\n   with filters: 0.608\t\t0.062 (9.81x faster, ~52x overall)\nwithout filters: 3.251\t\t0.676 (4.81x faster)\n\nIn short, the existing implementation is comparably fast *with* filters\nas the new implementation is *without* filters. So, most repositories\nshould get a dramatic speed-up by just deploying this (even without\ncomputing Bloom filters), and all repositories should get faster still\nwhen computing Bloom filters.\n\nCo-authored-by: Derrick Stolee <dstolee@microsoft.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n last-modified.c | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++----\n last-modified.h |   3 +\n 2 files changed, 256 insertions(+), 17 deletions(-)\n\ndiff --git a/last-modified.c b/last-modified.c\nindex f628434929..0a0818cdf1 100644\n--- a/last-modified.c\n+++ b/last-modified.c\n@@ -3,18 +3,20 @@\n #include \"commit.h\"\n #include \"diffcore.h\"\n #include \"diff.h\"\n-#include \"object.h\"\n #include \"revision.h\"\n #include \"repository.h\"\n #include \"log-tree.h\"\n #include \"dir.h\"\n #include \"commit-graph.h\"\n #include \"bloom.h\"\n+#include \"prio-queue.h\"\n+#include \"commit-slab.h\"\n \n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n \tstruct commit *commit;\n+\tint diff_idx;\n \tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n@@ -86,6 +88,9 @@ void last_modified_init(struct last_modified *lm,\n \t\t     const char *prefix,\n \t\t     int argc, const char **argv)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n \tmemset(lm, 0, sizeof(*lm));\n \thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n \n@@ -104,6 +109,13 @@ void last_modified_init(struct last_modified *lm,\n \n \tif (add_from_revs(lm) < 0)\n \t\tdie(_(\"unable to setup last-modified\"));\n+\n+\tlm->all_paths = xcalloc(hashmap_get_size(&lm->paths), sizeof(const char *));\n+\tlm->all_paths_nr = 0;\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tent->diff_idx = lm->all_paths_nr++;\n+\t\tlm->all_paths[ent->diff_idx] = ent->path;\n+\t}\n }\n \n void last_modified_release(struct last_modified *lm)\n@@ -116,6 +128,20 @@ void last_modified_release(struct last_modified *lm)\n \t}\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n+\tfree(lm->all_paths);\n+}\n+\n+struct commit_active_paths {\n+\tchar *active;\n+\tint nr;\n+};\n+\n+define_commit_slab(active_paths, struct commit_active_paths);\n+static struct active_paths active_paths;\n+\n+static void free_one_active_path(struct commit_active_paths *active)\n+{\n+\tfree(active->active);\n }\n \n struct last_modified_callback_data {\n@@ -130,6 +156,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\t      struct last_modified_callback_data *data)\n {\n \tstruct last_modified_entry *ent;\n+\tstruct commit_active_paths *active;\n \n \t/* Is it even a path that we are interested in? */\n \tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n@@ -141,11 +168,17 @@ static void mark_path(const char *path, const struct object_id *oid,\n \tif (ent->commit)\n \t\treturn;\n \n+\t/* Are we inactive on the current commit? */\n+\tactive = active_paths_at(&active_paths, data->commit);\n+\tif (active && active->active &&\n+\t    !active->active[ent->diff_idx])\n+\t\treturn;\n+\n \t/*\n \t * Is it arriving at a version of interest, or is it from a side branch\n \t * which did not contribute to the final state?\n \t */\n-\tif (!oideq(oid, &ent->oid))\n+\tif (oid && !oideq(oid, &ent->oid))\n \t\treturn;\n \n \tent->commit = data->commit;\n@@ -197,7 +230,32 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n-static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+static char *scratch;\n+\n+static void pass_to_parent(struct commit_active_paths *c,\n+\t\t\t   struct commit_active_paths *p,\n+\t\t\t   int i)\n+{\n+\tc->active[i] = 0;\n+\tc->nr--;\n+\tp->active[i] = 1;\n+\tp->nr++;\n+}\n+\n+#define PARENT1 (1u<<16) /* used instead of SEEN */\n+#define PARENT2 (1u<<17) /* used instead of BOTTOM, BOUNDARY */\n+\n+static int diff2idx(struct last_modified *lm, char *path)\n+{\n+\tstruct last_modified_entry *ent;\n+\tent = hashmap_get_entry_from_hash(&lm->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\treturn ent ? ent->diff_idx : -1;\n+}\n+\n+static int maybe_changed_path(struct last_modified *lm,\n+\t\t\t      struct commit *origin,\n+\t\t\t      struct commit_active_paths *active)\n {\n \tstruct bloom_filter *filter;\n \tstruct last_modified_entry *ent;\n@@ -214,6 +272,8 @@ static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n \t\treturn 1;\n \n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (active && !active->active[ent->diff_idx])\n+\t\t\tcontinue;\n \t\tif (bloom_filter_contains(filter, &ent->key,\n \t\t\t\t\t  lm->rev.bloom_filter_settings))\n \t\t\treturn 1;\n@@ -221,8 +281,88 @@ static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n \treturn 0;\n }\n \n+static int process_parent(struct last_modified *lm, struct prio_queue *queue,\n+\t\t\t  struct commit *c,\n+\t\t\t  struct commit_active_paths *active_c,\n+\t\t\t  struct commit *parent, int parent_i)\n+{\n+\tint i, ret = 0; // TODO type & for loop var\n+\tstruct commit_active_paths *active_p;\n+\n+\trepo_parse_commit(lm->rev.repo, parent);\n+\n+\tactive_p = active_paths_at(&active_paths, parent);\n+\tif (!active_p->active) {\n+\t\tactive_p->active = xcalloc(sizeof(char), lm->all_paths_nr);\n+\t\tactive_p->nr = 0;\n+\t}\n+\n+\t/*\n+\t * Before calling 'diff_tree_oid()' on our first parent, see if Bloom\n+\t * filters will tell us the diff is conclusively uninteresting.\n+\t */\n+\tif (parent_i || maybe_changed_path(lm, c, active_c)) {\n+\t\tdiff_tree_oid(&parent->object.oid,\n+\t\t\t      &c->object.oid, \"\", &lm->rev.diffopt);\n+\t\tdiffcore_std(&lm->rev.diffopt);\n+\t}\n+\n+\tif (!diff_queued_diff.nr) {\n+\t\t/*\n+\t\t * No diff entries means we are TREESAME on the base path, and\n+\t\t * so all active paths get passed onto this parent.\n+\t\t */\n+\t\tfor (i = 0; i < lm->all_paths_nr; i++) {\n+\t\t\tif (active_c->active[i])\n+\t\t\t\tpass_to_parent(active_c, active_p, i);\n+\t\t}\n+\n+\t\tif (!(parent->object.flags & PARENT1)) {\n+\t\t\tparent->object.flags |= PARENT1;\n+\t\t\tprio_queue_put(queue, parent);\n+\t\t}\n+\t\tret = 1;\n+\t\tgoto cleanup;\n+\t}\n+\n+\t/*\n+\t * Otherwise, test each path for TREESAME-ness against the parent, and\n+\t * pass those along.\n+\t *\n+\t * First, set each position in 'scratch' to be zero for TREESAME paths,\n+\t * and one otherwise. Then, pass active and TREESAME paths to the\n+\t * parent.\n+\t */\n+\tfor (i = 0; i < diff_queued_diff.nr; i++) {\n+\t\tstruct diff_filepair *fp = diff_queued_diff.queue[i];\n+\t\tint k = diff2idx(lm, fp->two->path);\n+\t\tif (0 <= k && active_c->active[k])\n+\t\t\tscratch[k] = 1;\n+\t\tdiff_free_filepair(fp);\n+\t}\n+\tdiff_queued_diff.nr = 0;\n+\tfor (i = 0; i < lm->all_paths_nr; i++) {\n+\t\tif (active_c->active[i] && !scratch[i])\n+\t\t\tpass_to_parent(active_c, active_p, i);\n+\t}\n+\n+\tif (active_p->nr && !(parent->object.flags & PARENT1)) {\n+\t\tparent->object.flags |= PARENT1;\n+\t\tprio_queue_put(queue, parent);\n+\t}\n+\n+cleanup:\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tmemset(scratch, 0, lm->all_paths_nr);\n+\n+\treturn ret;\n+}\n+\n int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n {\n+\tint max_count, queue_popped = 0;\n+\tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n+\tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n \tstruct last_modified_callback_data data;\n \n \tdata.paths = &lm->paths;\n@@ -233,25 +373,121 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \tlm->rev.diffopt.format_callback = last_modified_diff;\n \tlm->rev.diffopt.format_callback_data = &data;\n \n-\tprepare_revision_walk(&lm->rev);\n+\tmax_count = lm->rev.max_count;\n \n-\twhile (hashmap_get_size(&lm->paths)) {\n-\t\tdata.commit = get_revision(&lm->rev);\n-\t\tif (!data.commit)\n-\t\t\tbreak;\n+\tinit_active_paths(&active_paths);\n+\tscratch = xcalloc(lm->all_paths_nr, sizeof(char));\n \n-\t\tif (!maybe_changed_path(lm, data.commit))\n-\t\t\tcontinue;\n+\t/*\n+\t * lm->rev.pending holds the set of boundary commits for our walk.\n+\t *\n+\t * Loop through each such commit, and place it in the appropriate queue.\n+\t */\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct commit *c = lookup_commit(lm->rev.repo,\n+\t\t\t\t\t\t &lm->rev.pending.objects[i].item->oid);\n+\t\trepo_parse_commit(lm->rev.repo, c);\n \n-\t\tif (data.commit->object.flags & BOUNDARY) {\n-\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n-\t\t\t\t       &data.commit->object.oid,\n-\t\t\t\t       \"\", &lm->rev.diffopt);\n-\t\t\tdiff_flush(&lm->rev.diffopt);\n-\t\t} else {\n-\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\tif (c->object.flags & BOTTOM) {\n+\t\t\tprio_queue_put(&not_queue, c);\n+\t\t\tc->object.flags |= PARENT2;\n+\t\t} else if (!(c->object.flags & PARENT1)) {\n+\t\t\t/*\n+\t\t\t * If the commit is a starting point (and hasn't been\n+\t\t\t * seen yet), then initialize the set of interesting\n+\t\t\t * paths, too.\n+\t\t\t */\n+\t\t\tstruct commit_active_paths *active;\n+\n+\t\t\tprio_queue_put(&queue, c);\n+\t\t\tc->object.flags |= PARENT1;\n+\n+\t\t\tactive = active_paths_at(&active_paths, c);\n+\t\t\tactive->active = xcalloc(sizeof(char), lm->all_paths_nr);\n+\t\t\tmemset(active->active, 1, lm->all_paths_nr);\n+\t\t\tactive->nr = lm->all_paths_nr;\n \t\t}\n \t}\n \n+\t/*\n+\t * Now that we have processed the pending commits, allow the revision\n+\t * machinery to flush them by calling prepare_revision_walk().\n+\t */\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (queue.nr) {\n+\t\tint parent_i;\n+\t\tstruct commit_list *p;\n+\t\tstruct commit *c = prio_queue_get(&queue);\n+\t\tstruct commit_active_paths *active_c = active_paths_at(&active_paths, c);\n+\n+\t\tif ((0 <= max_count && max_count < ++queue_popped) ||\n+\t\t    (c->object.flags & PARENT2)) {\n+\t\t\t/*\n+\t\t\t * Either a boundary commit, or we have already seen too\n+\t\t\t * many others. Either way, stop here.\n+\t\t\t */\n+\t\t\tc->object.flags |= PARENT2 | BOUNDARY;\n+\t\t\tdata.commit = c;\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &c->object.oid,\n+\t\t\t\t      \"\", &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t\tgoto cleanup;\n+\t\t}\n+\n+\t\t/*\n+\t\t * Otherwise, keep going, but make sure that 'c' isn't reachable\n+\t\t * from anything in the '--not' queue.\n+\t\t */\n+\t\trepo_parse_commit(lm->rev.repo, c);\n+\n+\t\twhile (not_queue.nr) {\n+\t\t\tstruct commit_list *np;\n+\t\t\tstruct commit *n = prio_queue_get(&not_queue);\n+\n+\t\t\trepo_parse_commit(lm->rev.repo, n);\n+\n+\t\t\tfor (np = n->parents; np; np = np->next) {\n+\t\t\t\tif (!(np->item->object.flags & PARENT2)) {\n+\t\t\t\t\tprio_queue_put(&not_queue, np->item);\n+\t\t\t\t\tnp->item->object.flags |= PARENT2;\n+\t\t\t\t}\n+\t\t\t}\n+\n+\t\t\tif (commit_graph_generation(n) < commit_graph_generation(c))\n+\t\t\t\tbreak;\n+\t\t}\n+\n+\t\t/*\n+\t\t * Look at each remaining interesting path, and pass it onto\n+\t\t * parents in order if TREESAME.\n+\t\t */\n+\t\tfor (p = c->parents, parent_i = 0; p; p = p->next, parent_i++) {\n+\t\t\tif (process_parent(lm, &queue,\n+\t\t\t\t\t   c, active_c,\n+\t\t\t\t\t   p->item, parent_i) > 0 )\n+\t\t\t\tbreak;\n+\t\t}\n+\n+\t\tif (active_c->nr)  {\n+\t\t\t/* Any paths that remain active were changed by 'c'. */\n+\t\t\tdata.commit = c;\n+\t\t\tfor (int i = 0; i < lm->all_paths_nr; i++) {\n+\t\t\t\tif (active_c->active[i])\n+\t\t\t\t\tmark_path(lm->all_paths[i], NULL, &data);\n+\t\t\t}\n+\t\t}\n+\n+cleanup:\n+\t\tFREE_AND_NULL(active_c->active);\n+\t\tactive_c->nr = 0;\n+\t}\n+\n+\tclear_prio_queue(&not_queue);\n+\tclear_prio_queue(&queue);\n+\tdeep_clear_active_paths(&active_paths, free_one_active_path);\n+\tfree(scratch);\n+\n \treturn 0;\n }\ndiff --git a/last-modified.h b/last-modified.h\nindex 42a819d979..8b2d896aaa 100644\n--- a/last-modified.h\n+++ b/last-modified.h\n@@ -8,6 +8,9 @@\n struct last_modified {\n \tstruct hashmap paths;\n \tstruct rev_info rev;\n+\n+\tconst char **all_paths;\n+\tint all_paths_nr;\n };\n \n void last_modified_init(struct last_modified *lm,\n\n-- \n2.49.0\n\n"},{"id":"518756","messageId":"20250523-toon-new-blame-tree-v2-5-101e4ca4c1c9@iotcl.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"[PATCH RFC v2 5/5] last-modified: initialize revision machinery without walk","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-05-23T09:33:52Z","receivedAt":"2025-05-23T09:34:24Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"In a previous commit we inserted a call to 'prepare_revision_walk()'\nbefore we started our traversal. This was done when we leveraged the\nrevision machinery more (at the time, we were leaning on\n'log_tree_commit()' which only worked after calling\n'prepare_revision_walk()').\n\nBut, we have since dropped 'log_tree_commit()', so we don't need most of\nthe initialization work of 'prepare_revision_walk()'. Now we ask it to\ndo very little work during initialization by setting the '->no_walk'\nflag to '1', which leaves its internal state alone enough that we can\nstill function as normal.\n\nUnfortunately, this means that we now no longer complain about\nnon-commit inputs, since the revision machinery check this for us (it\njust silently ignores them).\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n last-modified.c | 25 ++++++++++++++-----------\n 1 file changed, 14 insertions(+), 11 deletions(-)\n\ndiff --git a/last-modified.c b/last-modified.c\nindex 0a0818cdf1..b1458db0bc 100644\n--- a/last-modified.c\n+++ b/last-modified.c\n@@ -271,6 +271,13 @@ static int maybe_changed_path(struct last_modified *lm,\n \tif (!filter)\n \t\treturn 1;\n \n+\tfor (int i = 0; i < lm->rev.bloom_keys_nr; i++) {\n+\t\tif (!(bloom_filter_contains(filter,\n+\t\t\t\t\t    &lm->rev.bloom_keys[i],\n+\t\t\t\t\t    lm->rev.bloom_filter_settings)))\n+\t\t\treturn 0;\n+\t}\n+\n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n \t\tif (active && !active->active[ent->diff_idx])\n \t\t\tcontinue;\n@@ -364,6 +371,7 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \tstruct prio_queue queue = { compare_commits_by_gen_then_commit_date };\n \tstruct prio_queue not_queue = { compare_commits_by_gen_then_commit_date };\n \tstruct last_modified_callback_data data;\n+\tstruct commit_list *list;\n \n \tdata.paths = &lm->paths;\n \tdata.callback = cb;\n@@ -372,6 +380,9 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n \tlm->rev.diffopt.format_callback = last_modified_diff;\n \tlm->rev.diffopt.format_callback_data = &data;\n+\tlm->rev.no_walk = 1;\n+\n+\tprepare_revision_walk(&lm->rev);\n \n \tmax_count = lm->rev.max_count;\n \n@@ -379,14 +390,12 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \tscratch = xcalloc(lm->all_paths_nr, sizeof(char));\n \n \t/*\n-\t * lm->rev.pending holds the set of boundary commits for our walk.\n+\t * lm->rev.commits holds the set of boundary commits for our walk.\n \t *\n \t * Loop through each such commit, and place it in the appropriate queue.\n \t */\n-\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n-\t\tstruct commit *c = lookup_commit(lm->rev.repo,\n-\t\t\t\t\t\t &lm->rev.pending.objects[i].item->oid);\n-\t\trepo_parse_commit(lm->rev.repo, c);\n+\tfor (list = lm->rev.commits; list; list = list->next) {\n+\t\tstruct commit *c = list->item;\n \n \t\tif (c->object.flags & BOTTOM) {\n \t\t\tprio_queue_put(&not_queue, c);\n@@ -409,12 +418,6 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \t\t}\n \t}\n \n-\t/*\n-\t * Now that we have processed the pending commits, allow the revision\n-\t * machinery to flush them by calling prepare_revision_walk().\n-\t */\n-\tprepare_revision_walk(&lm->rev);\n-\n \twhile (queue.nr) {\n \t\tint parent_i;\n \t\tstruct commit_list *p;\n\n-- \n2.49.0\n\n"},{"id":"518880","messageId":"zz2xv667flcb46gp4scqu4wmrxviiecqz2ru35glalbsaluc3p@kmymzn5lxapk","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-1-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Justin Tobler","fromEmail":"jltobler@gmail.com","sentAt":"2025-05-25T20:07:42Z","receivedAt":"2025-05-25T20:12:27Z","isPatch":true,"sender":{"key":"jltobler@gmail.com","avatar":"https://avatars.githubusercontent.com/u/53454972?v=4"},"body":"On 25/05/23 11:33AM, Toon Claes wrote:\n> Similar to git-blame(1), introduce a new subcommand\n> git-last-modified(1). This command shows the most recent modification to\n> paths in a tree. It does so by expanding the tree at a given commit,\n> taking note of the current state of each path, and then walking\n> backwards through history looking for commits where each path changed\n> into its final commit ID.\n\nJust a thought, but it might be nice to include in a commit message why\nthis operation is useful.\n\n> Based-on-patch-by: Jeff King <peff@peff.net>\n> Improved-by: \"Ævar Arnfjörð Bjarmason\" <avarab@gmail.com>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  .gitignore                           |   1 +\n>  Documentation/git-last-modified.adoc |  49 ++++++++\n>  Documentation/meson.build            |   1 +\n>  Makefile                             |   2 +\n>  builtin.h                            |   1 +\n>  builtin/last-modified.c              |  43 +++++++\n>  command-list.txt                     |   1 +\n>  git.c                                |   1 +\n>  last-modified.c                      | 213 +++++++++++++++++++++++++++++++++++\n>  last-modified.h                      |  27 +++++\n>  meson.build                          |   2 +\n>  t/meson.build                        |   1 +\n>  t/t8020-last-modified.sh             | 194 +++++++++++++++++++++++++++++++\n>  13 files changed, 536 insertions(+)\n> \n> diff --git a/.gitignore b/.gitignore\n> index 04c444404e..a36ee94443 100644\n> --- a/.gitignore\n> +++ b/.gitignore\n> @@ -87,6 +87,7 @@\n>  /git-init-db\n>  /git-interpret-trailers\n>  /git-instaweb\n> +/git-last-modified\n>  /git-log\n>  /git-ls-files\n>  /git-ls-remote\n> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n> new file mode 100644\n> index 0000000000..1af38f402e\n> --- /dev/null\n> +++ b/Documentation/git-last-modified.adoc\n> @@ -0,0 +1,49 @@\n> +git-last-modified(1)\n> +====================\n> +\n> +NAME\n> +----\n> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n> +\n> +\n> +SYNOPSIS\n> +--------\n> +[synopsis]\n> +git last-modified [-r] [<revision-range>] [[--] <path>...]\n> +\n> +DESCRIPTION\n> +-----------\n> +\n> +Shows which commit last modified each of the relevant files and subdirectories.\n> +\n> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n> +\n> +OPTIONS\n> +-------\n> +\n> +-r::\n> +\tRecurse into subtrees.\n> +\n> +-t::\n> +\tShow tree entry itself as well as subtrees.  Implies `-r`.\n\nThis left me wondering about the default behavior regarding displaying\ntrees when neither `-t` and `-r` are specified. If we omit showing when\na tree was last mostified?\n\n> +\n> +<revision-range>::\n> +\tOnly traverse commits in the specified revision range. When no\n> +\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n> +\thistory leading to the current commit). For a complete list of ways to\n> +\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n> +\tlinkgit:gitrevisions[7].\n> +\n> +[--] <path>...::\n> +\tFor each _<path>_ given, the commit which last modified it is returned.\n> +\tWithout an optional path parameter, all files and subdirectories\n> +\tof the current working directory are included in the\n\nare include in the? I assume you meant to say the search/operation.\n\n> +\n> +SEE ALSO\n> +--------\n> +linkgit:git-blame[1],\n> +linkgit:git-log[1].\n> +\n> +GIT\n> +---\n> +Part of the linkgit:git[1] suite\n> diff --git a/Documentation/meson.build b/Documentation/meson.build\n> index 1433acfd31..fa93cec5c3 100644\n> --- a/Documentation/meson.build\n> +++ b/Documentation/meson.build\n> @@ -74,6 +74,7 @@ manpages = {\n>    'git-init.adoc' : 1,\n>    'git-instaweb.adoc' : 1,\n>    'git-interpret-trailers.adoc' : 1,\n> +  'git-last-modified.adoc' : 1,\n>    'git-log.adoc' : 1,\n>    'git-ls-files.adoc' : 1,\n>    'git-ls-remote.adoc' : 1,\n> diff --git a/Makefile b/Makefile\n> index ecd590a643..40bc24c704 100644\n> --- a/Makefile\n> +++ b/Makefile\n> @@ -1051,6 +1051,7 @@ LIB_OBJS += hook.o\n>  LIB_OBJS += ident.o\n>  LIB_OBJS += json-writer.o\n>  LIB_OBJS += kwset.o\n> +LIB_OBJS += last-modified.o\n>  LIB_OBJS += levenshtein.o\n>  LIB_OBJS += line-log.o\n>  LIB_OBJS += line-range.o\n> @@ -1266,6 +1267,7 @@ BUILTIN_OBJS += builtin/hook.o\n>  BUILTIN_OBJS += builtin/index-pack.o\n>  BUILTIN_OBJS += builtin/init-db.o\n>  BUILTIN_OBJS += builtin/interpret-trailers.o\n> +BUILTIN_OBJS += builtin/last-modified.o\n>  BUILTIN_OBJS += builtin/log.o\n>  BUILTIN_OBJS += builtin/ls-files.o\n>  BUILTIN_OBJS += builtin/ls-remote.o\n> diff --git a/builtin.h b/builtin.h\n> index bff13e3069..6ed6759ec4 100644\n> --- a/builtin.h\n> +++ b/builtin.h\n> @@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n>  int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n>  int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n>  int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n> +int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n>  int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n>  int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n>  int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> new file mode 100644\n> index 0000000000..0d4733f666\n> --- /dev/null\n> +++ b/builtin/last-modified.c\n> @@ -0,0 +1,43 @@\n> +#include \"git-compat-util.h\"\n> +#include \"last-modified.h\"\n> +#include \"hex.h\"\n> +#include \"quote.h\"\n> +#include \"config.h\"\n> +#include \"object-name.h\"\n> +#include \"parse-options.h\"\n> +#include \"builtin.h\"\n\nFor builtins, \"builtin.h\" should be included at the top and\n\"git-compat-util.h\" should be omitted.\n\n> +\n> +static void show_entry(const char *path, const struct commit *commit, void *d)\n> +{\n> +\tstruct last_modified *lm = d;\n> +\n> +\tif (commit->object.flags & BOUNDARY)\n> +\t\tputchar('^');\n> +\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n> +\n> +\tif (lm->rev.diffopt.line_termination)\n> +\t\twrite_name_quoted(path, stdout, '\\n');\n> +\telse\n> +\t\tprintf(\"%s%c\", path, '\\0');\n> +\n> +\tfflush(stdout);\n> +}\n> +\n> +int cmd_last_modified(int argc,\n> +\t\t   const char **argv,\n> +\t\t   const char *prefix,\n> +\t\t   struct repository *repo)\n> +{\n> +\tint ret = 0;\n> +\tstruct last_modified lm;\n> +\n> +\trepo_config(repo, git_default_config, NULL);\n> +\n> +\tlast_modified_init(&lm, repo, prefix, argc, argv);\n> +\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n> +\t\tdie(_(\"error running last-modified traversal\"));\n> +\n> +\tlast_modified_release(&lm);\n> +\n> +\treturn ret;\n> +}\n> diff --git a/command-list.txt b/command-list.txt\n> index b7ade3ab9f..b715777b24 100644\n> --- a/command-list.txt\n> +++ b/command-list.txt\n> @@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n>  git-init                                mainporcelain           init\n>  git-instaweb                            ancillaryinterrogators          complete\n>  git-interpret-trailers                  purehelpers\n> +git-last-modified                       plumbinginterrogators\n>  git-log                                 mainporcelain           info\n>  git-ls-files                            plumbinginterrogators\n>  git-ls-remote                           plumbinginterrogators\n> diff --git a/git.c b/git.c\n> index 77c4359522..65afc0d0e7 100644\n> --- a/git.c\n> +++ b/git.c\n> @@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n>  \t{ \"init\", cmd_init_db },\n>  \t{ \"init-db\", cmd_init_db },\n>  \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n> +\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n>  \t{ \"log\", cmd_log, RUN_SETUP },\n>  \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n>  \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\n> diff --git a/last-modified.c b/last-modified.c\n> new file mode 100644\n> index 0000000000..9283f8fcae\n> --- /dev/null\n> +++ b/last-modified.c\n> @@ -0,0 +1,213 @@\n> +#include \"git-compat-util.h\"\n> +#include \"last-modified.h\"\n> +#include \"commit.h\"\n> +#include \"diffcore.h\"\n> +#include \"diff.h\"\n> +#include \"object.h\"\n> +#include \"revision.h\"\n> +#include \"repository.h\"\n> +#include \"log-tree.h\"\n> +\n> +struct last_modified_entry {\n> +\tstruct hashmap_entry hashent;\n> +\tstruct object_id oid;\n> +\tstruct commit *commit;\n> +\tconst char path[FLEX_ARRAY];\n> +};\n> +\n> +static void add_from_diff(struct diff_queue_struct *q,\n> +\t\t\t  struct diff_options *opt UNUSED,\n> +\t\t\t  void *data)\n> +{\n> +\tstruct last_modified *lm = data;\n> +\n> +\tfor (int i = 0; i < q->nr; i++) {\n> +\t\tstruct diff_filepair *p = q->queue[i];\n> +\t\tstruct last_modified_entry *ent;\n> +\t\tconst char *path = p->two->path;\n> +\n> +\t\tFLEX_ALLOC_STR(ent, path, path);\n> +\t\toidcpy(&ent->oid, &p->two->oid);\n> +\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n> +\t\thashmap_add(&lm->paths, &ent->hashent);\n> +\t}\n> +}\n> +\n> +static int add_from_revs(struct last_modified *lm)\n> +{\n> +\tsize_t count = 0;\n> +\tstruct diff_options diffopt;\n> +\n> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tdiffopt.format_callback = add_from_diff;\n> +\tdiffopt.format_callback_data = lm;\n> +\n> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n> +\n> +\t\tif (obj->item->flags & UNINTERESTING)\n> +\t\t\tcontinue;\n> +\n> +\t\tif (count++)\n> +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n> +\n> +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n> +\t\t\t      &obj->item->oid, \"\", &diffopt);\n> +\t\tdiff_flush(&diffopt);\n> +\t}\n> +\tclear_pathspec(&diffopt.pathspec);\n> +\n> +\treturn 0;\n> +}\n> +\n> +static int last_modified_entry_hashcmp(const void *unused UNUSED,\n> +\t\t\t\t    const struct hashmap_entry *hent1,\n> +\t\t\t\t    const struct hashmap_entry *hent2,\n> +\t\t\t\t    const void *path)\n> +{\n> +\tconst struct last_modified_entry *ent1 =\n> +\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n> +\tconst struct last_modified_entry *ent2 =\n> +\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n> +\treturn strcmp(ent1->path, path ? path : ent2->path);\n> +}\n> +\n> +void last_modified_init(struct last_modified *lm,\n> +\t\t     struct repository *r,\n> +\t\t     const char *prefix,\n> +\t\t     int argc, const char **argv)\n> +{\n> +\tmemset(lm, 0, sizeof(*lm));\n> +\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n> +\n> +\trepo_init_revisions(r, &lm->rev, prefix);\n> +\tlm->rev.def = \"HEAD\";\n> +\tlm->rev.combine_merges = 1;\n> +\tlm->rev.show_root_diff = 1;\n> +\tlm->rev.boundary = 1;\n> +\tlm->rev.no_commit_id = 1;\n> +\tlm->rev.diff = 1;\n> +\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n> +\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n> +\n> +\tif (add_from_revs(lm) < 0)\n> +\t\tdie(_(\"unable to setup last-modified\"));\n> +}\n> +\n> +void last_modified_release(struct last_modified *lm)\n> +{\n> +\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n> +\trelease_revisions(&lm->rev);\n> +}\n> +\n> +struct last_modified_callback_data {\n> +\tstruct commit *commit;\n> +\tstruct hashmap *paths;\n> +\n> +\tlast_modified_callback callback;\n> +\tvoid *callback_data;\n> +};\n> +\n> +static void mark_path(const char *path, const struct object_id *oid,\n> +\t\t      struct last_modified_callback_data *data)\n> +{\n> +\tstruct last_modified_entry *ent;\n> +\n> +\t/* Is it even a path that we are interested in? */\n> +\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n> +\t\t\t\t\t  struct last_modified_entry, hashent);\n> +\tif (!ent)\n> +\t\treturn;\n> +\n> +\t/* Have we already found a commit? */\n> +\tif (ent->commit)\n> +\t\treturn;\n> +\n> +\t/*\n> +\t * Is it arriving at a version of interest, or is it from a side branch\n> +\t * which did not contribute to the final state?\n> +\t */\n> +\tif (!oideq(oid, &ent->oid))\n> +\t\treturn;\n> +\n> +\tent->commit = data->commit;\n> +\tif (data->callback)\n> +\t\tdata->callback(path, data->commit, data->callback_data);\n> +\n> +\thashmap_remove(data->paths, &ent->hashent, path);\n> +\tfree(ent);\n> +}\n> +\n> +static void last_modified_diff(struct diff_queue_struct *q,\n> +\t\t       struct diff_options *opt UNUSED, void *cbdata)\n> +{\n> +\tstruct last_modified_callback_data *data = cbdata;\n> +\n> +\tfor (int i = 0; i < q->nr; i++) {\n> +\t\tstruct diff_filepair *p = q->queue[i];\n> +\t\tswitch (p->status) {\n> +\t\tcase DIFF_STATUS_DELETED:\n> +\t\t\t/*\n> +\t\t\t * There's no point in feeding a deletion, as it could\n> +\t\t\t * not have resulted in our current state, which\n> +\t\t\t * actually has the file.\n> +\t\t\t */\n> +\t\t\tbreak;\n> +\n> +\t\tdefault:\n> +\t\t\t/*\n> +\t\t\t * Otherwise, we care only that we somehow arrived at\n> +\t\t\t * a final path/sha1 state. Note that this covers some\n> +\t\t\t * potentially controversial areas, including:\n> +\t\t\t *\n> +\t\t\t *  1. A rename or copy will be found, as it is the\n> +\t\t\t *     first time the content has arrived at the given\n> +\t\t\t *     path.\n> +\t\t\t *\n> +\t\t\t *  2. Even a non-content modification like a mode or\n> +\t\t\t *     type change will trigger it.\n> +\t\t\t *\n> +\t\t\t * We take the inclusive approach for now, and find\n> +\t\t\t * anything which impacts the path. Options to tweak\n> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n> +\t\t\t * renames) can come later.\n> +\t\t\t */\n> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n> +\t\t\tbreak;\n> +\t\t}\n> +\t}\n> +}\n> +\n> +int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n> +{\n> +\tstruct last_modified_callback_data data;\n> +\n> +\tdata.paths = &lm->paths;\n> +\tdata.callback = cb;\n> +\tdata.callback_data = cbdata;\n> +\n> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n> +\tlm->rev.diffopt.format_callback_data = &data;\n> +\n> +\tprepare_revision_walk(&lm->rev);\n> +\n> +\twhile (hashmap_get_size(&lm->paths)) {\n> +\t\tdata.commit = get_revision(&lm->rev);\n> +\t\tif (!data.commit)\n> +\t\t\tbreak;\n> +\n> +\t\tif (data.commit->object.flags & BOUNDARY) {\n> +\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n> +\t\t\t\t       &data.commit->object.oid,\n> +\t\t\t\t       \"\", &lm->rev.diffopt);\n> +\t\t\tdiff_flush(&lm->rev.diffopt);\n> +\t\t} else {\n> +\t\t\tlog_tree_commit(&lm->rev, data.commit);\n> +\t\t}\n> +\t}\n> +\n> +\treturn 0;\n> +}\n> diff --git a/last-modified.h b/last-modified.h\n> new file mode 100644\n> index 0000000000..42a819d979\n> --- /dev/null\n> +++ b/last-modified.h\n\nAny reason this code doesn't just live with the builtin? Is there intent\nfor it to be used elsewhere?\n\n> @@ -0,0 +1,27 @@\n> +#ifndef LAST_MODIFIED_H\n> +#define LAST_MODIFIED_H\n> +\n> +#include \"commit.h\"\n> +#include \"revision.h\"\n> +#include \"hashmap.h\"\n> +\n> +struct last_modified {\n> +\tstruct hashmap paths;\n> +\tstruct rev_info rev;\n> +};\n\nIt might be nice to leave some comments to document the types and\nfunctions here.\n\n> +\n> +void last_modified_init(struct last_modified *lm,\n> +\t\t     struct repository *r,\n> +\t\t     const char *prefix,\n> +\t\t     int argc, const char **argv);\n\nBeing that `last_modified_init()` handles argument parsing for the\nbuiltin, I somewhat question that value of having it outside the\nbuiltin.\n\n> +\n> +void last_modified_release(struct last_modified *);\n> +\n> +typedef void (*last_modified_callback)(const char *path,\n> +\t\t\t\t    const struct commit *commit,\n> +\t\t\t\t    void *data);\n> +int last_modified_run(struct last_modified *lm,\n> +\t\t   last_modified_callback cb,\n> +\t\t   void *cbdata);\n> +\n> +#endif /* LAST_MODIFIED_H */\n> diff --git a/meson.build b/meson.build\n> index a1476e5b32..bdd9ed2c4c 100644\n> --- a/meson.build\n> +++ b/meson.build\n> @@ -365,6 +365,7 @@ libgit_sources = [\n>    'ident.c',\n>    'json-writer.c',\n>    'kwset.c',\n> +  'last-modified.c',\n>    'levenshtein.c',\n>    'line-log.c',\n>    'line-range.c',\n> @@ -609,6 +610,7 @@ builtin_sources = [\n>    'builtin/index-pack.c',\n>    'builtin/init-db.c',\n>    'builtin/interpret-trailers.c',\n> +  'builtin/last-modified.c',\n>    'builtin/log.c',\n>    'builtin/ls-files.c',\n>    'builtin/ls-remote.c',\n> diff --git a/t/meson.build b/t/meson.build\n> index fcfc1c2c2b..be5a711375 100644\n> --- a/t/meson.build\n> +++ b/t/meson.build\n> @@ -962,6 +962,7 @@ integration_tests = [\n>    't8012-blame-colors.sh',\n>    't8013-blame-ignore-revs.sh',\n>    't8014-blame-ignore-fuzzy.sh',\n> +  't8020-last-modified.sh',\n>    't9001-send-email.sh',\n>    't9002-column.sh',\n>    't9003-help-autocorrect.sh',\n> diff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\n> new file mode 100755\n> index 0000000000..0c4a19c029\n> --- /dev/null\n> +++ b/t/t8020-last-modified.sh\n> @@ -0,0 +1,194 @@\n> +#!/bin/sh\n> +\n> +test_description='last-modified tests'\n> +\n> +. ./test-lib.sh\n> +\n> +test_expect_success 'setup' '\n> +\ttest_commit 1 file &&\n> +\tmkdir a &&\n> +\ttest_commit 2 a/file &&\n> +\tmkdir a/b &&\n> +\ttest_commit 3 a/b/file\n> +'\n> +\n> +test_expect_success 'cannot run last-modified on two trees' '\n> +\ttest_must_fail git last-modified HEAD HEAD~1\n> +'\n> +\n> +check_last_modified() {\n> +\tlocal indir= &&\n> +\twhile test $# != 0\n> +\tdo\n> +\t\tcase \"$1\" in\n> +\t\t-C)\n> +\t\t\tindir=\"$2\"\n> +\t\t\tshift\n> +\t\t\t;;\n> +\t\t*)\n> +\t\t\tbreak\n> +\t\t\t;;\n> +\t\tesac &&\n> +\t\tshift\n> +\tdone &&\n> +\n> +\tcat >expect &&\n> +\ttest_when_finished \"rm -f tmp.*\" &&\n> +\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n> +\tgit name-rev --annotate-stdin --name-only --tags \\\n> +\t\t<tmp.1 >tmp.2 &&\n> +\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n> +\tsort tmp.3 >actual &&\n> +\ttest_cmp expect actual\n> +}\n> +\n> +test_expect_success 'last-modified non-recursive' '\n> +\tcheck_last_modified <<-\\EOF\n> +\t1 file\n> +\t3 a\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified recursive' '\n> +\tcheck_last_modified -r <<-\\EOF\n> +\t1 file\n> +\t2 a/file\n> +\t3 a/b/file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified subdir' '\n> +\tcheck_last_modified a <<-\\EOF\n> +\t3 a\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified subdir recursive' '\n> +\tcheck_last_modified -r a <<-\\EOF\n> +\t2 a/file\n> +\t3 a/b/file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified from non-HEAD commit' '\n> +\tcheck_last_modified HEAD^ <<-\\EOF\n> +\t1 file\n> +\t2 a\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified from subdir defaults to root' '\n> +\tcheck_last_modified -C a <<-\\EOF\n> +\t1 file\n> +\t3 a\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified from subdir uses relative pathspecs' '\n> +\tcheck_last_modified -C a -r b <<-\\EOF\n> +\t3 a/b/file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'limit last-modified traversal by count' '\n> +\tcheck_last_modified -1 <<-\\EOF\n> +\t3 a\n> +\t^2 file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'limit last-modified traversal by commit' '\n> +\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n> +\t3 a\n> +\t^1 file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'only last-modified files in the current tree' '\n> +\tgit rm -rf a &&\n> +\tgit commit -m \"remove a\" &&\n> +\tcheck_last_modified <<-\\EOF\n> +\t1 file\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'cross merge boundaries in blaming' '\n> +\tgit checkout HEAD^0 &&\n> +\tgit rm -rf . &&\n> +\ttest_commit m1 &&\n> +\tgit checkout HEAD^ &&\n> +\tgit rm -rf . &&\n> +\ttest_commit m2 &&\n> +\tgit merge m1 &&\n> +\tcheck_last_modified <<-\\EOF\n> +\tm1 m1.t\n> +\tm2 m2.t\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified merge for resolved conflicts' '\n> +\tgit checkout HEAD^0 &&\n> +\tgit rm -rf . &&\n> +\ttest_commit c1 conflict &&\n> +\tgit checkout HEAD^ &&\n> +\tgit rm -rf . &&\n> +\ttest_commit c2 conflict &&\n> +\ttest_must_fail git merge c1 &&\n> +\ttest_commit resolved conflict &&\n> +\tcheck_last_modified conflict <<-\\EOF\n> +\tresolved conflict\n> +\tEOF\n> +'\n> +\n> +\n> +# Consider `file` with this content through history:\n> +#\n> +# A---B---B-------B---B\n> +#          \\     /\n> +#           C---D\n> +test_expect_success 'last-modified merge ignores content from branch' '\n> +\tgit checkout HEAD^0 &&\n> +\tgit rm -rf . &&\n> +\ttest_commit a1 file A &&\n> +\ttest_commit a2 file B &&\n> +\ttest_commit a3 file C &&\n> +\ttest_commit a4 file D &&\n> +\tgit checkout a2 &&\n> +\tgit merge --no-commit --no-ff a4 &&\n> +\tgit checkout a2 -- file &&\n> +\tgit merge --continue &&\n> +\tcheck_last_modified <<-\\EOF\n> +\ta2 file\n> +\tEOF\n> +'\n> +\n> +# Consider `file` with this content through history:\n> +#\n> +#  A---B---B---C---D---B---B\n> +#           \\         /\n> +#            B-------B\n> +test_expect_success 'last-modified merge undoes changes' '\n> +\tgit checkout HEAD^0 &&\n> +\tgit rm -rf . &&\n> +\ttest_commit b1 file A &&\n> +\ttest_commit b2 file B &&\n> +\ttest_commit b3 file C &&\n> +\ttest_commit b4 file D &&\n> +\tgit checkout b2 &&\n> +\ttest_commit b5 file2 2 &&\n> +\tgit checkout b4 &&\n> +\tgit merge --no-commit --no-ff b5 &&\n> +\tgit checkout b2 -- file &&\n> +\tgit merge --continue &&\n> +\tcheck_last_modified <<-\\EOF\n> +\tb2 file\n> +\tb5 file2\n> +\tEOF\n> +'\n> +\n> +test_expect_success 'last-modified complains about unknown arguments' '\n> +\ttest_must_fail git last-modified --foo 2>err &&\n> +\tgrep \"unknown last-modified argument: --foo\" err\n> +'\n> +\n> +test_done\n> \n> -- \n> 2.49.0\n> \n> \n"},{"id":"518966","messageId":"aDWWdGVW90LpxZhH@pks.im","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-4-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 4/5] last-modified: implement faster algorithm","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-27T10:39:48Z","receivedAt":"2025-05-27T10:39:57Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, May 23, 2025 at 11:33:51AM +0200, Toon Claes wrote:\n> The current implementation of 'git last-modified' works by doing a\n> revision walk, and inspecting the diff at each level of that walk to\n> annotate the to-be-found entries to a path. In other words, if the diff\n> at some level touches a path which has not yet been associated with a\n> commit, then that commit becomes associated with the path.\n\nIt's a bit funny that we first introduce an algorithm, only to change it\nin a subsequent step again. I don't mind it much though: this variant\nhere is quite a bit more complicated, and it's nice to first gain an\nunderstanding of how the simple algorithm works before going to the\nharder one.\n\nIt's also nice that the tests prove that this is indeed leads to the\nsame result.\n\n[snip]\n> More specifically, consider a priority queue of commits sorted by\n> generation number. First, enqueue the set of boundary commits with all\n> paths in the original spec marked as interesting.\n> \n> Then, while the queue is not empty, do the following:\n> \n>   1. Pop an element, say, 'c', off of the queue, making sure that 'c'\n>      isn't reachable by anything in the '--not' set.\n> \n>   2. For each parent 'p' (with index 'parent_i') of 'c', do the\n>      following:\n> \n>      a. Compute the diff between 'c' and 'p'.\n>      b. Pass any active paths that are TREESAME from 'c' to 'p'.\n>      c. If 'p' has any active paths, push it onto the queue.\n\nWhat if an active path is changed on both sides of a merge commit? Do we\npass it to the first parent?\n\n[snip]\n> Now, some performance numbers. On github/git, our numbers look like the\n> following (all wall-clock times best-of-five, and with '--max-depth=0'\n> on the root):\n\nThis option does not exist in this version of git-last-modified(1).\n\n>                  github\t\tttaylorr/blame-tree-fast\n>    with filters: 0.754s\t\t0.271s (2.78x faster, 6.18x overall)\n> without filters: 1.676s\t\t1.056s (1.58x faster)\n> \n> and on torvalds/linux:\n> \n>                  github\t\tttaylorr/blame-tree-fast\n>    with filters: 0.608\t\t0.062 (9.81x faster, ~52x overall)\n> without filters: 3.251\t\t0.676 (4.81x faster)\n> \n> In short, the existing implementation is comparably fast *with* filters\n> as the new implementation is *without* filters. So, most repositories\n> should get a dramatic speed-up by just deploying this (even without\n> computing Bloom filters), and all repositories should get faster still\n> when computing Bloom filters.\n\nIt would be nice to introduce \"filters\" as \"Bloom filters\". I was\ninitially wondering what filters you talk about until you then mention\nit in the last sentence.\n\n> diff --git a/last-modified.c b/last-modified.c\n> index f628434929..0a0818cdf1 100644\n> --- a/last-modified.c\n> +++ b/last-modified.c\n> @@ -3,18 +3,20 @@\n>  #include \"commit.h\"\n>  #include \"diffcore.h\"\n>  #include \"diff.h\"\n> -#include \"object.h\"\n>  #include \"revision.h\"\n>  #include \"repository.h\"\n>  #include \"log-tree.h\"\n>  #include \"dir.h\"\n>  #include \"commit-graph.h\"\n>  #include \"bloom.h\"\n> +#include \"prio-queue.h\"\n> +#include \"commit-slab.h\"\n\nIt would be nice if we could keep these lexicographically sorted right\nfrom the first commit.\n\n> @@ -116,6 +128,20 @@ void last_modified_release(struct last_modified *lm)\n>  \t}\n>  \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n>  \trelease_revisions(&lm->rev);\n> +\tfree(lm->all_paths);\n> +}\n> +\n> +struct commit_active_paths {\n> +\tchar *active;\n> +\tint nr;\n\nShould this be a `size_t` as it is counting something?\n\n> +};\n\nHm, a bit weird, as I don't kno what `nr` is supposed to stand for.\nIntuitively I would have expected that `active` is an array of strings,\nand that `nr` tracks how many there are. But that's not the case.\n\nLet's read on.\n\n> +define_commit_slab(active_paths, struct commit_active_paths);\n> +static struct active_paths active_paths;\n> +\n> +static void free_one_active_path(struct commit_active_paths *active)\n\ns/free_one_active_path/commit_active_paths_release/\n\n> @@ -197,7 +230,32 @@ static void last_modified_diff(struct diff_queue_struct *q,\n>  \t}\n>  }\n>  \n> -static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n> +static char *scratch;\n\nHaving a global variable like this in a library is not great. Can we\ninstead pass around a context or something like this internally?\n\n> +\n> +static void pass_to_parent(struct commit_active_paths *c,\n> +\t\t\t   struct commit_active_paths *p,\n> +\t\t\t   int i)\n> +{\n> +\tc->active[i] = 0;\n> +\tc->nr--;\n> +\tp->active[i] = 1;\n> +\tp->nr++;\n\nOkay, so `active` is a bitfield. It's a bit weird that we use a full\nbyte for each bit though. It might not matter much in practice, but it\nfeels quite wasteful to me. Doubly so because we allocate this bitfield\nfor every commit we visit.\n\nCan we maybe instead use `struct bitmap` for this?\n\n> +}\n> +\n> +#define PARENT1 (1u<<16) /* used instead of SEEN */\n> +#define PARENT2 (1u<<17) /* used instead of BOTTOM, BOUNDARY */\n\nThese are the same definitions as in \"commit-reach.c\". Might be worth it\nto deduplicate those.\n\n> @@ -221,8 +281,88 @@ static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n>  \treturn 0;\n>  }\n>  \n> +static int process_parent(struct last_modified *lm, struct prio_queue *queue,\n> +\t\t\t  struct commit *c,\n> +\t\t\t  struct commit_active_paths *active_c,\n> +\t\t\t  struct commit *parent, int parent_i)\n> +{\n> +\tint i, ret = 0; // TODO type & for loop var\n\nThis looks like a left-over comment that should be addressed.\n\nPatrick\n"},{"id":"518967","messageId":"aDWWe6qCQXorPESd@pks.im","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-1-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-27T10:39:55Z","receivedAt":"2025-05-27T10:39:59Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, May 23, 2025 at 11:33:48AM +0200, Toon Claes wrote:\n> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n> new file mode 100644\n> index 0000000000..1af38f402e\n> --- /dev/null\n> +++ b/Documentation/git-last-modified.adoc\n> @@ -0,0 +1,49 @@\n> +git-last-modified(1)\n> +====================\n> +\n> +NAME\n> +----\n> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n\nNit: we don't have the EXPERIMENTAL label here for git-switch(1) or\ngit-restore(1).\n\n> +\n> +\n> +SYNOPSIS\n> +--------\n> +[synopsis]\n> +git last-modified [-r] [<revision-range>] [[--] <path>...]\n> +\n> +DESCRIPTION\n> +-----------\n> +\n> +Shows which commit last modified each of the relevant files and subdirectories.\n> +\n> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n> +\n> +OPTIONS\n> +-------\n> +\n> +-r::\n> +\tRecurse into subtrees.\n> +\n> +-t::\n> +\tShow tree entry itself as well as subtrees.  Implies `-r`.\n\nThese flags aren't yet supported in this version, are they?\n\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> new file mode 100644\n> index 0000000000..0d4733f666\n> --- /dev/null\n> +++ b/builtin/last-modified.c\n> @@ -0,0 +1,43 @@\n> +#include \"git-compat-util.h\"\n> +#include \"last-modified.h\"\n> +#include \"hex.h\"\n> +#include \"quote.h\"\n> +#include \"config.h\"\n> +#include \"object-name.h\"\n> +#include \"parse-options.h\"\n> +#include \"builtin.h\"\n> +\n> +static void show_entry(const char *path, const struct commit *commit, void *d)\n> +{\n> +\tstruct last_modified *lm = d;\n> +\n> +\tif (commit->object.flags & BOUNDARY)\n> +\t\tputchar('^');\n> +\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n> +\n> +\tif (lm->rev.diffopt.line_termination)\n> +\t\twrite_name_quoted(path, stdout, '\\n');\n> +\telse\n> +\t\tprintf(\"%s%c\", path, '\\0');\n> +\n> +\tfflush(stdout);\n> +}\n> +\n> +int cmd_last_modified(int argc,\n> +\t\t   const char **argv,\n> +\t\t   const char *prefix,\n> +\t\t   struct repository *repo)\n> +{\n> +\tint ret = 0;\n\n`ret` is basically unused here, we only use it to return 0.\n\n> diff --git a/last-modified.c b/last-modified.c\n> new file mode 100644\n> index 0000000000..9283f8fcae\n> --- /dev/null\n> +++ b/last-modified.c\n> @@ -0,0 +1,213 @@\n> +#include \"git-compat-util.h\"\n> +#include \"last-modified.h\"\n> +#include \"commit.h\"\n> +#include \"diffcore.h\"\n> +#include \"diff.h\"\n> +#include \"object.h\"\n> +#include \"revision.h\"\n> +#include \"repository.h\"\n> +#include \"log-tree.h\"\n> +\n> +struct last_modified_entry {\n> +\tstruct hashmap_entry hashent;\n> +\tstruct object_id oid;\n> +\tstruct commit *commit;\n> +\tconst char path[FLEX_ARRAY];\n> +};\n> +\n> +static void add_from_diff(struct diff_queue_struct *q,\n> +\t\t\t  struct diff_options *opt UNUSED,\n> +\t\t\t  void *data)\n> +{\n> +\tstruct last_modified *lm = data;\n> +\n> +\tfor (int i = 0; i < q->nr; i++) {\n> +\t\tstruct diff_filepair *p = q->queue[i];\n> +\t\tstruct last_modified_entry *ent;\n> +\t\tconst char *path = p->two->path;\n> +\n> +\t\tFLEX_ALLOC_STR(ent, path, path);\n> +\t\toidcpy(&ent->oid, &p->two->oid);\n> +\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n> +\t\thashmap_add(&lm->paths, &ent->hashent);\n> +\t}\n> +}\n> \n> +static int add_from_revs(struct last_modified *lm)\n> +{\n> +\tsize_t count = 0;\n> +\tstruct diff_options diffopt;\n> +\n> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tdiffopt.format_callback = add_from_diff;\n> +\tdiffopt.format_callback_data = lm;\n\nAs far as I understand we populate `paths` from the diff here, and\n`paths` later on acts as a filter of paths we're interested in? Might be\nnice to add a comment explaining the intent of this.\n\n> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n> +\n> +\t\tif (obj->item->flags & UNINTERESTING)\n> +\t\t\tcontinue;\n> +\n> +\t\tif (count++)\n> +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n\nIt's a bit funny that `count` is pretending to be a counter even though\nit ultimately is only a boolean flag whether we have already seen an\ninteresting item.\n\n> +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n> +\t\t\t      &obj->item->oid, \"\", &diffopt);\n> +\t\tdiff_flush(&diffopt);\n> +\t}\n> +\tclear_pathspec(&diffopt.pathspec);\n\nShouldn't we call `diff_free()` instead of `clear_pathspec` to clear the\nwhole `struct diff_options`?\n\n> +\n> +\treturn 0;\n> +}\n> +\n> +static int last_modified_entry_hashcmp(const void *unused UNUSED,\n> +\t\t\t\t    const struct hashmap_entry *hent1,\n> +\t\t\t\t    const struct hashmap_entry *hent2,\n> +\t\t\t\t    const void *path)\n> +{\n> +\tconst struct last_modified_entry *ent1 =\n> +\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n> +\tconst struct last_modified_entry *ent2 =\n> +\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n> +\treturn strcmp(ent1->path, path ? path : ent2->path);\n> +}\n> +\n> +void last_modified_init(struct last_modified *lm,\n> +\t\t     struct repository *r,\n> +\t\t     const char *prefix,\n> +\t\t     int argc, const char **argv)\n> +{\n> +\tmemset(lm, 0, sizeof(*lm));\n> +\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n> +\n> +\trepo_init_revisions(r, &lm->rev, prefix);\n> +\tlm->rev.def = \"HEAD\";\n> +\tlm->rev.combine_merges = 1;\n> +\tlm->rev.show_root_diff = 1;\n> +\tlm->rev.boundary = 1;\n> +\tlm->rev.no_commit_id = 1;\n> +\tlm->rev.diff = 1;\n> +\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n> +\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n> +\n> +\tif (add_from_revs(lm) < 0)\n> +\t\tdie(_(\"unable to setup last-modified\"));\n\nGiven that this is library code, do we rather want to have\n`last_modified_init()` return an error code and let the caller die?\n\n> +}\n> +\n> +void last_modified_release(struct last_modified *lm)\n> +{\n> +\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n> +\trelease_revisions(&lm->rev);\n> +}\n> +\n> +struct last_modified_callback_data {\n> +\tstruct commit *commit;\n> +\tstruct hashmap *paths;\n> +\n> +\tlast_modified_callback callback;\n> +\tvoid *callback_data;\n> +};\n> +\n> +static void mark_path(const char *path, const struct object_id *oid,\n> +\t\t      struct last_modified_callback_data *data)\n> +{\n> +\tstruct last_modified_entry *ent;\n> +\n> +\t/* Is it even a path that we are interested in? */\n> +\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n> +\t\t\t\t\t  struct last_modified_entry, hashent);\n> +\tif (!ent)\n> +\t\treturn;\n\nYup, so this here is the filter to figure out whether we care for a\npath, which uses the `paths` map we have populated at the beginning.\n\n> +\t/* Have we already found a commit? */\n> +\tif (ent->commit)\n> +\t\treturn;\n\nCan this case even be hit? We remove the entry from the map once we have\nseen it, so I'd expect that we never hit the same commit map entry\ntwice. If so, can this be converted to a `BUG()` or am I missing the\nobvious?\n\n> +\t/*\n> +\t * Is it arriving at a version of interest, or is it from a side branch\n> +\t * which did not contribute to the final state?\n> +\t */\n> +\tif (!oideq(oid, &ent->oid))\n> +\t\treturn;\n> +\n> +\tent->commit = data->commit;\n> +\tif (data->callback)\n> +\t\tdata->callback(path, data->commit, data->callback_data);\n> +\n> +\thashmap_remove(data->paths, &ent->hashent, path);\n\nAnd we end up removing that entry from paths so that we don't revisit it\nin the future. After all, we're only interested in a single commit per\npath.\n\n> +\tfree(ent);\n> +}\n> +\n> +static void last_modified_diff(struct diff_queue_struct *q,\n> +\t\t       struct diff_options *opt UNUSED, void *cbdata)\n> +{\n> +\tstruct last_modified_callback_data *data = cbdata;\n> +\n> +\tfor (int i = 0; i < q->nr; i++) {\n> +\t\tstruct diff_filepair *p = q->queue[i];\n> +\t\tswitch (p->status) {\n> +\t\tcase DIFF_STATUS_DELETED:\n> +\t\t\t/*\n> +\t\t\t * There's no point in feeding a deletion, as it could\n> +\t\t\t * not have resulted in our current state, which\n> +\t\t\t * actually has the file.\n> +\t\t\t */\n> +\t\t\tbreak;\n> +\n> +\t\tdefault:\n> +\t\t\t/*\n> +\t\t\t * Otherwise, we care only that we somehow arrived at\n> +\t\t\t * a final path/sha1 state. Note that this covers some\n> +\t\t\t * potentially controversial areas, including:\n> +\t\t\t *\n> +\t\t\t *  1. A rename or copy will be found, as it is the\n> +\t\t\t *     first time the content has arrived at the given\n> +\t\t\t *     path.\n> +\t\t\t *\n> +\t\t\t *  2. Even a non-content modification like a mode or\n> +\t\t\t *     type change will trigger it.\n\nCurious, but sensible. We're looking for the last time a specific tree\nentry was changed, and that of course includes modifications. I could\ntotally see that we may eventually want to add a flag that ignores such\nmode changes and only presents content changes. But for now I agree that\nthis is sensible.\n\n> +\t\t\t * We take the inclusive approach for now, and find\n> +\t\t\t * anything which impacts the path. Options to tweak\n> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n> +\t\t\t * renames) can come later.\n> +\t\t\t */\n> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n> +\t\t\tbreak;\n> +\t\t}\n> +\t}\n> +}\n> +\n> +int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n> +{\n> +\tstruct last_modified_callback_data data;\n> +\n> +\tdata.paths = &lm->paths;\n> +\tdata.callback = cb;\n> +\tdata.callback_data = cbdata;\n> +\n> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n> +\tlm->rev.diffopt.format_callback_data = &data;\n> +\n> +\tprepare_revision_walk(&lm->rev);\n> +\n> +\twhile (hashmap_get_size(&lm->paths)) {\n\nOkay, and this is the core of our logic: we continue walking the tree\nuntil there are no more paths that we care about.\n\n> diff --git a/last-modified.h b/last-modified.h\n> new file mode 100644\n> index 0000000000..42a819d979\n> --- /dev/null\n> +++ b/last-modified.h\n> @@ -0,0 +1,27 @@\n> +#ifndef LAST_MODIFIED_H\n> +#define LAST_MODIFIED_H\n> +\n> +#include \"commit.h\"\n> +#include \"revision.h\"\n> +#include \"hashmap.h\"\n> +\n> +struct last_modified {\n> +\tstruct hashmap paths;\n> +\tstruct rev_info rev;\n> +};\n> +\n> +void last_modified_init(struct last_modified *lm,\n> +\t\t     struct repository *r,\n> +\t\t     const char *prefix,\n> +\t\t     int argc, const char **argv);\n> +\n> +void last_modified_release(struct last_modified *);\n> +\n> +typedef void (*last_modified_callback)(const char *path,\n> +\t\t\t\t    const struct commit *commit,\n> +\t\t\t\t    void *data);\n> +int last_modified_run(struct last_modified *lm,\n> +\t\t   last_modified_callback cb,\n> +\t\t   void *cbdata);\n> +#endif /* LAST_MODIFIED_H */\n\nIt would be nice to have some documentation for each of these functions\nas well as a bit of a higher-level conceptual info.\n"},{"id":"518968","messageId":"aDWWf2YSgfPrNdrh@pks.im","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-5-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 5/5] last-modified: initialize revision machinery without walk","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-27T10:39:59Z","receivedAt":"2025-05-27T10:40:03Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, May 23, 2025 at 11:33:52AM +0200, Toon Claes wrote:\n> In a previous commit we inserted a call to 'prepare_revision_walk()'\n> before we started our traversal. This was done when we leveraged the\n> revision machinery more (at the time, we were leaning on\n> 'log_tree_commit()' which only worked after calling\n> 'prepare_revision_walk()').\n> \n> But, we have since dropped 'log_tree_commit()', so we don't need most of\n> the initialization work of 'prepare_revision_walk()'. Now we ask it to\n> do very little work during initialization by setting the '->no_walk'\n> flag to '1', which leaves its internal state alone enough that we can\n> still function as normal.\n> \n> Unfortunately, this means that we now no longer complain about\n> non-commit inputs, since the revision machinery check this for us (it\n> just silently ignores them).\n\nHm. Should we maybe have a manual check that all inputs are commits? It\ndoesn't feel right to me to silently ignore invalid queries.\n\nPatrick\n"},{"id":"518969","messageId":"aDWWgj8gTS9EM7v6@pks.im","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-3-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 3/5] last-modified: use Bloom filters when available","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-05-27T10:40:02Z","receivedAt":"2025-05-27T10:40:05Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, May 23, 2025 at 11:33:50AM +0200, Toon Claes wrote:\n> Our 'git last-modified' performs a revision walk, and computes a diff at\n> each point in the walk to figure out whether a given revision changed\n> any of the paths it considers interesting.\n> \n> When changed-path Bloom filters are available, we can avoid computing\n> many such diffs. Before computing a diff, we first check if any of the\n> remaining paths of interest were possibly changed at a given commit by\n> consulting its Bloom filter. If any of them are, we are resigned to\n> compute the diff.\n> \n> If none of those queries returned \"maybe\", we know that the given commit\n> doesn't contain any changed paths which are interesting to us. So, we\n> can avoid computing it in this case.\n> \n> This results in a substantial performance speed-up in common cases of\n> 'git last-modified'. In the kernel, here is the before and after (all\n> times computed with best-of-five):\n> \n> With commit-graphs (but no Bloom filters):\n> \n>     real\t0m5.133s\n>     user\t0m4.942s\n>     sys\t0m0.180s\n> \n> ...and with Bloom filters:\n> \n>     real\t0m0.936s\n>     user\t0m0.842s\n>     sys\t0m0.092s\n> \n> These times are with my development-version of Git, so it's compiled\n> without optimizations. Compiling instead with `-O3`, the results look\n> even better:\n> \n>     real\t0m0.754s\n>     user\t0m0.661s\n>     sys\t0m0.092s\n\nI'm sure that the old state without bloom filters will also improve a\nbit?\n\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  last-modified.c | 44 ++++++++++++++++++++++++++++++++++++++++++++\n>  1 file changed, 44 insertions(+)\n> \n> diff --git a/last-modified.c b/last-modified.c\n> index 9283f8fcae..f628434929 100644\n> --- a/last-modified.c\n> +++ b/last-modified.c\n> @@ -92,12 +99,21 @@ void last_modified_init(struct last_modified *lm,\n>  \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n>  \t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n>  \n> +\t(void)generation_numbers_enabled(lm->rev.repo);\n\nWhy the `(void)` cast? And why even call this in the first place? This\ndefinitely needs a comment and smells like funky design in our commit\ngraph subsystem where we rely on side effects of one function to leak\ninto a different function.\n\n> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n> +\n>  \tif (add_from_revs(lm) < 0)\n>  \t\tdie(_(\"unable to setup last-modified\"));\n>  }\n>  \n>  void last_modified_release(struct last_modified *lm)\n>  {\n> +\tstruct hashmap_iter iter;\n> +\tstruct last_modified_entry *ent;\n> +\n> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n> +\t\tclear_bloom_key(&ent->key);\n> +\t}\n\nThe curly braces shouldn't be needed.\n\n> @@ -180,6 +197,30 @@ static void last_modified_diff(struct diff_queue_struct *q,\n>  \t}\n>  }\n>  \n> +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n> +{\n> +\tstruct bloom_filter *filter;\n> +\tstruct last_modified_entry *ent;\n> +\tstruct hashmap_iter iter;\n> +\n> +\tif (!lm->rev.bloom_filter_settings)\n> +\t\treturn 1;\n> +\n> +\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n> +\t\treturn 1;\n\nHm, okay, so here we require generation numbers to exist. Why is that\nthough? Shouldn't we only care about bloom filters? I don't quite get\nthat part yet.\n\n> +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n> +\tif (!filter)\n> +\t\treturn 1;\n> +\n> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n> +\t\tif (bloom_filter_contains(filter, &ent->key,\n> +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n> +\t\t\treturn 1;\n> +\t}\n> +\treturn 0;\n> +}\n> +\n\nOkay, and here we check whether any of our desired paths may be\ncontained in the bloom filter.\n\n>  int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n>  {\n>  \tstruct last_modified_callback_data data;\n> @@ -199,6 +240,9 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n>  \t\tif (!data.commit)\n>  \t\t\tbreak;\n>  \n> +\t\tif (!maybe_changed_path(lm, data.commit))\n> +\t\t\tcontinue;\n\nIf there either are no bloom filters or in case none of them contain our\ncommit we can safely skip over the commit indeed. Otherwise we'll have\nto check whether the commit really is interesting.\n\nMakes sense.\n\nPatrick\n"},{"id":"519741","messageId":"8734ce37xw.fsf@iotcl.com","threadId":"63332","inReplyTo":"zz2xv667flcb46gp4scqu4wmrxviiecqz2ru35glalbsaluc3p@kmymzn5lxapk","subject":"Re: [PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-05T08:32:11Z","receivedAt":"2025-06-05T08:32:27Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Justin Tobler <jltobler@gmail.com> writes:\n\n> On 25/05/23 11:33AM, Toon Claes wrote:\n>> +-r::\n>> +\tRecurse into subtrees.\n>> +\n>> +-t::\n>> +\tShow tree entry itself as well as subtrees.  Implies `-r`.\n>\n> This left me wondering about the default behavior regarding displaying\n> trees when neither `-t` and `-r` are specified. If we omit showing when\n> a tree was last mostified?\n\nWhen omitting both the command returns the commit that last modified the\ntree. Basically without any of both options it return which commit\ntouched a tree entry last, it doesn't care if that tree entry is a tree\nor a blob.\n\nI shall add something to the docs for `-r` that explains what happens if\nit is omitted.\n\n>> +[--] <path>...::\n>> +\tFor each _<path>_ given, the commit which last modified it is returned.\n>> +\tWithout an optional path parameter, all files and subdirectories\n>> +\tof the current working directory are included in the\n>\n> are include in the? I assume you meant to say the search/operation.\n\nWhoops.\n\n>> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n>> new file mode 100644\n>> index 0000000000..0d4733f666\n>> --- /dev/null\n>> +++ b/builtin/last-modified.c\n>> @@ -0,0 +1,43 @@\n>> +#include \"git-compat-util.h\"\n>> +#include \"last-modified.h\"\n>> +#include \"hex.h\"\n>> +#include \"quote.h\"\n>> +#include \"config.h\"\n>> +#include \"object-name.h\"\n>> +#include \"parse-options.h\"\n>> +#include \"builtin.h\"\n>\n> For builtins, \"builtin.h\" should be included at the top and\n> \"git-compat-util.h\" should be omitted.\n\nThanks, will address.\n\n-- \nCheers,\nToon\n"},{"id":"520215","messageId":"87ldpw0yu3.fsf@iotcl.com","threadId":"63332","inReplyTo":"aDWWe6qCQXorPESd@pks.im","subject":"Re: [PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-13T09:34:28Z","receivedAt":"2025-06-13T09:34:42Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, May 23, 2025 at 11:33:48AM +0200, Toon Claes wrote:\n>> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n>> new file mode 100644\n>> index 0000000000..1af38f402e\n>> --- /dev/null\n>> +++ b/Documentation/git-last-modified.adoc\n>> @@ -0,0 +1,49 @@\n>> +git-last-modified(1)\n>> +====================\n>> +\n>> +NAME\n>> +----\n>> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n>\n> Nit: we don't have the EXPERIMENTAL label here for git-switch(1) or\n> git-restore(1).\n\nBut we do for `git-replay(1)`. Because I haven't gotten much feedback\nabout the usage of the command, I wanted to be on the safe side and not\ncommit to the behavior. Marking it EXPERIMENTAL would allow us to make\nchanges on it's interface without _breaking_. But I wouldn't mind\ndropping the experimental status.\n\n>> +\n>> +\n>> +SYNOPSIS\n>> +--------\n>> +[synopsis]\n>> +git last-modified [-r] [<revision-range>] [[--] <path>...]\n>> +\n>> +DESCRIPTION\n>> +-----------\n>> +\n>> +Shows which commit last modified each of the relevant files and subdirectories.\n>> +\n>> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n>> +\n>> +OPTIONS\n>> +-------\n>> +\n>> +-r::\n>> +\tRecurse into subtrees.\n>> +\n>> +-t::\n>> +\tShow tree entry itself as well as subtrees.  Implies `-r`.\n>\n> These flags aren't yet supported in this version, are they?\n\nThey are, but I see there are no tests for `-t`. I shall add them.\n\n>\n>> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n>> new file mode 100644\n>> index 0000000000..0d4733f666\n>> --- /dev/null\n>> +++ b/builtin/last-modified.c\n>> @@ -0,0 +1,43 @@\n>> +#include \"git-compat-util.h\"\n>> +#include \"last-modified.h\"\n>> +#include \"hex.h\"\n>> +#include \"quote.h\"\n>> +#include \"config.h\"\n>> +#include \"object-name.h\"\n>> +#include \"parse-options.h\"\n>> +#include \"builtin.h\"\n>> +\n>> +static void show_entry(const char *path, const struct commit *commit, void *d)\n>> +{\n>> +\tstruct last_modified *lm = d;\n>> +\n>> +\tif (commit->object.flags & BOUNDARY)\n>> +\t\tputchar('^');\n>> +\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n>> +\n>> +\tif (lm->rev.diffopt.line_termination)\n>> +\t\twrite_name_quoted(path, stdout, '\\n');\n>> +\telse\n>> +\t\tprintf(\"%s%c\", path, '\\0');\n>> +\n>> +\tfflush(stdout);\n>> +}\n>> +\n>> +int cmd_last_modified(int argc,\n>> +\t\t   const char **argv,\n>> +\t\t   const char *prefix,\n>> +\t\t   struct repository *repo)\n>> +{\n>> +\tint ret = 0;\n>\n> `ret` is basically unused here, we only use it to return 0.\n\nGood catch. Thanks!\n\n>> diff --git a/last-modified.c b/last-modified.c\n>> new file mode 100644\n>> index 0000000000..9283f8fcae\n>> --- /dev/null\n>> +++ b/last-modified.c\n>> @@ -0,0 +1,213 @@\n>> +#include \"git-compat-util.h\"\n>> +#include \"last-modified.h\"\n>> +#include \"commit.h\"\n>> +#include \"diffcore.h\"\n>> +#include \"diff.h\"\n>> +#include \"object.h\"\n>> +#include \"revision.h\"\n>> +#include \"repository.h\"\n>> +#include \"log-tree.h\"\n>> +\n>> +struct last_modified_entry {\n>> +\tstruct hashmap_entry hashent;\n>> +\tstruct object_id oid;\n>> +\tstruct commit *commit;\n>> +\tconst char path[FLEX_ARRAY];\n>> +};\n>> +\n>> +static void add_from_diff(struct diff_queue_struct *q,\n>> +\t\t\t  struct diff_options *opt UNUSED,\n>> +\t\t\t  void *data)\n>> +{\n>> +\tstruct last_modified *lm = data;\n>> +\n>> +\tfor (int i = 0; i < q->nr; i++) {\n>> +\t\tstruct diff_filepair *p = q->queue[i];\n>> +\t\tstruct last_modified_entry *ent;\n>> +\t\tconst char *path = p->two->path;\n>> +\n>> +\t\tFLEX_ALLOC_STR(ent, path, path);\n>> +\t\toidcpy(&ent->oid, &p->two->oid);\n>> +\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n>> +\t\thashmap_add(&lm->paths, &ent->hashent);\n>> +\t}\n>> +}\n>> \n>> +static int add_from_revs(struct last_modified *lm)\n>> +{\n>> +\tsize_t count = 0;\n>> +\tstruct diff_options diffopt;\n>> +\n>> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n>> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n>> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n>> +\tdiffopt.format_callback = add_from_diff;\n>> +\tdiffopt.format_callback_data = lm;\n>\n> As far as I understand we populate `paths` from the diff here, and\n> `paths` later on acts as a filter of paths we're interested in? Might be\n> nice to add a comment explaining the intent of this.\n\nWill do.\n\n\n>> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n>> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n>> +\n>> +\t\tif (obj->item->flags & UNINTERESTING)\n>> +\t\t\tcontinue;\n>> +\n>> +\t\tif (count++)\n>> +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n>\n> It's a bit funny that `count` is pretending to be a counter even though\n> it ultimately is only a boolean flag whether we have already seen an\n> interesting item.\n\nOkay, I'll refactor.\n\n>> +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n>> +\t\t\t      &obj->item->oid, \"\", &diffopt);\n>> +\t\tdiff_flush(&diffopt);\n>> +\t}\n>> +\tclear_pathspec(&diffopt.pathspec);\n>\n> Shouldn't we call `diff_free()` instead of `clear_pathspec` to clear the\n> whole `struct diff_options`?\n\nYeah, that's better.\n\n>> +\n>> +\treturn 0;\n>> +}\n>> +\n>> +static int last_modified_entry_hashcmp(const void *unused UNUSED,\n>> +\t\t\t\t    const struct hashmap_entry *hent1,\n>> +\t\t\t\t    const struct hashmap_entry *hent2,\n>> +\t\t\t\t    const void *path)\n>> +{\n>> +\tconst struct last_modified_entry *ent1 =\n>> +\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n>> +\tconst struct last_modified_entry *ent2 =\n>> +\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n>> +\treturn strcmp(ent1->path, path ? path : ent2->path);\n>> +}\n>> +\n>> +void last_modified_init(struct last_modified *lm,\n>> +\t\t     struct repository *r,\n>> +\t\t     const char *prefix,\n>> +\t\t     int argc, const char **argv)\n>> +{\n>> +\tmemset(lm, 0, sizeof(*lm));\n>> +\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n>> +\n>> +\trepo_init_revisions(r, &lm->rev, prefix);\n>> +\tlm->rev.def = \"HEAD\";\n>> +\tlm->rev.combine_merges = 1;\n>> +\tlm->rev.show_root_diff = 1;\n>> +\tlm->rev.boundary = 1;\n>> +\tlm->rev.no_commit_id = 1;\n>> +\tlm->rev.diff = 1;\n>> +\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n>> +\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n>> +\n>> +\tif (add_from_revs(lm) < 0)\n>> +\t\tdie(_(\"unable to setup last-modified\"));\n>\n> Given that this is library code, do we rather want to have\n> `last_modified_init()` return an error code and let the caller die?\n\nYes, agreed.\n\n>> +}\n>> +\n>> +void last_modified_release(struct last_modified *lm)\n>> +{\n>> +\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n>> +\trelease_revisions(&lm->rev);\n>> +}\n>> +\n>> +struct last_modified_callback_data {\n>> +\tstruct commit *commit;\n>> +\tstruct hashmap *paths;\n>> +\n>> +\tlast_modified_callback callback;\n>> +\tvoid *callback_data;\n>> +};\n>> +\n>> +static void mark_path(const char *path, const struct object_id *oid,\n>> +\t\t      struct last_modified_callback_data *data)\n>> +{\n>> +\tstruct last_modified_entry *ent;\n>> +\n>> +\t/* Is it even a path that we are interested in? */\n>> +\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n>> +\t\t\t\t\t  struct last_modified_entry, hashent);\n>> +\tif (!ent)\n>> +\t\treturn;\n>\n> Yup, so this here is the filter to figure out whether we care for a\n> path, which uses the `paths` map we have populated at the beginning.\n>\n>> +\t/* Have we already found a commit? */\n>> +\tif (ent->commit)\n>> +\t\treturn;\n>\n> Can this case even be hit? We remove the entry from the map once we have\n> seen it, so I'd expect that we never hit the same commit map entry\n> twice. If so, can this be converted to a `BUG()` or am I missing the\n> obvious?\n\nI was wondering about that. But took it over from the original code, and\nleft it in. I believe it's from a version where entries weren't removed\nfrom the hashmap. I agree a `BUG()` would be a better approach, or\neven better simply delete this condition.\n\n>> +\t/*\n>> +\t * Is it arriving at a version of interest, or is it from a side branch\n>> +\t * which did not contribute to the final state?\n>> +\t */\n>> +\tif (!oideq(oid, &ent->oid))\n>> +\t\treturn;\n>> +\n>> +\tent->commit = data->commit;\n>> +\tif (data->callback)\n>> +\t\tdata->callback(path, data->commit, data->callback_data);\n>> +\n>> +\thashmap_remove(data->paths, &ent->hashent, path);\n>\n> And we end up removing that entry from paths so that we don't revisit it\n> in the future. After all, we're only interested in a single commit per\n> path.\n\nTrue. So it doesn't even make sense a `struct last_modified_entry` has a\n`commit` attribute. I will delete that in next version.\n\n>> +\tfree(ent);\n>> +}\n>> +\n>> +static void last_modified_diff(struct diff_queue_struct *q,\n>> +\t\t       struct diff_options *opt UNUSED, void *cbdata)\n>> +{\n>> +\tstruct last_modified_callback_data *data = cbdata;\n>> +\n>> +\tfor (int i = 0; i < q->nr; i++) {\n>> +\t\tstruct diff_filepair *p = q->queue[i];\n>> +\t\tswitch (p->status) {\n>> +\t\tcase DIFF_STATUS_DELETED:\n>> +\t\t\t/*\n>> +\t\t\t * There's no point in feeding a deletion, as it could\n>> +\t\t\t * not have resulted in our current state, which\n>> +\t\t\t * actually has the file.\n>> +\t\t\t */\n>> +\t\t\tbreak;\n>> +\n>> +\t\tdefault:\n>> +\t\t\t/*\n>> +\t\t\t * Otherwise, we care only that we somehow arrived at\n>> +\t\t\t * a final path/sha1 state. Note that this covers some\n>> +\t\t\t * potentially controversial areas, including:\n>> +\t\t\t *\n>> +\t\t\t *  1. A rename or copy will be found, as it is the\n>> +\t\t\t *     first time the content has arrived at the given\n>> +\t\t\t *     path.\n>> +\t\t\t *\n>> +\t\t\t *  2. Even a non-content modification like a mode or\n>> +\t\t\t *     type change will trigger it.\n>\n> Curious, but sensible. We're looking for the last time a specific tree\n> entry was changed, and that of course includes modifications. I could\n> totally see that we may eventually want to add a flag that ignores such\n> mode changes and only presents content changes. But for now I agree that\n> this is sensible.\n>\n>> +\t\t\t * We take the inclusive approach for now, and find\n>> +\t\t\t * anything which impacts the path. Options to tweak\n>> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n>> +\t\t\t * renames) can come later.\n>> +\t\t\t */\n>> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n>> +\t\t\tbreak;\n>> +\t\t}\n>> +\t}\n>> +}\n>> +\n>> +int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n>> +{\n>> +\tstruct last_modified_callback_data data;\n>> +\n>> +\tdata.paths = &lm->paths;\n>> +\tdata.callback = cb;\n>> +\tdata.callback_data = cbdata;\n>> +\n>> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n>> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n>> +\tlm->rev.diffopt.format_callback_data = &data;\n>> +\n>> +\tprepare_revision_walk(&lm->rev);\n>> +\n>> +\twhile (hashmap_get_size(&lm->paths)) {\n>\n> Okay, and this is the core of our logic: we continue walking the tree\n> until there are no more paths that we care about.\n>\n>> diff --git a/last-modified.h b/last-modified.h\n>> new file mode 100644\n>> index 0000000000..42a819d979\n>> --- /dev/null\n>> +++ b/last-modified.h\n>> @@ -0,0 +1,27 @@\n>> +#ifndef LAST_MODIFIED_H\n>> +#define LAST_MODIFIED_H\n>> +\n>> +#include \"commit.h\"\n>> +#include \"revision.h\"\n>> +#include \"hashmap.h\"\n>> +\n>> +struct last_modified {\n>> +\tstruct hashmap paths;\n>> +\tstruct rev_info rev;\n>> +};\n>> +\n>> +void last_modified_init(struct last_modified *lm,\n>> +\t\t     struct repository *r,\n>> +\t\t     const char *prefix,\n>> +\t\t     int argc, const char **argv);\n>> +\n>> +void last_modified_release(struct last_modified *);\n>> +\n>> +typedef void (*last_modified_callback)(const char *path,\n>> +\t\t\t\t    const struct commit *commit,\n>> +\t\t\t\t    void *data);\n>> +int last_modified_run(struct last_modified *lm,\n>> +\t\t   last_modified_callback cb,\n>> +\t\t   void *cbdata);\n>> +#endif /* LAST_MODIFIED_H */\n>\n> It would be nice to have some documentation for each of these functions\n> as well as a bit of a higher-level conceptual info.\n\nWill do.\n\n-- \nCheers,\nToon\n"},{"id":"520216","messageId":"12b49804-946e-4bfe-a1ce-e7f712363076@app.fastmail.com","threadId":"63332","inReplyTo":"87ldpw0yu3.fsf@iotcl.com","subject":"Re: [PATCH RFC v2 1/5] last-modified: new subcommand to show when files were last modified","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2025-06-13T09:52:01Z","receivedAt":"2025-06-13T09:52:25Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Fri, Jun 13, 2025, at 11:34, Toon Claes wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n>\n>> On Fri, May 23, 2025 at 11:33:48AM +0200, Toon Claes wrote:\n>>> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n>>> new file mode 100644\n>>> index 0000000000..1af38f402e\n>>> --- /dev/null\n>>> +++ b/Documentation/git-last-modified.adoc\n>>> @@ -0,0 +1,49 @@\n>>> +git-last-modified(1)\n>>> +====================\n>>> +\n>>> +NAME\n>>> +----\n>>> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n>>\n>> Nit: we don't have the EXPERIMENTAL label here for git-switch(1) or\n>> git-restore(1).\n>\n> But we do for `git-replay(1)`. Because I haven't gotten much feedback\n> about the usage of the command, I wanted to be on the safe side and not\n> commit to the behavior. Marking it EXPERIMENTAL would allow us to make\n> changes on it's interface without _breaking_. But I wouldn't mind\n> dropping the experimental status.\n\nAs a user I appreciate that experimental commands are prominently called\nout as such, like it is here.  I don’t see much user testing (more as in\nDX/developer experience) on the mailing list for new commands.[1]\n“Experimental” in my interpretation means that I should be careful about\nusing it in scripts and that the developers are open to making changes\nto the command interface.\n\n† 1: I mean specifically by a slightly wider user base; those of us who\n    might not be able to hack on or review the relevant code much but might\n    be interested in what the command interface will be like.\n\nThanks\n-- \nKristoffer Haugsbakk\n"},{"id":"520218","messageId":"87h60j296g.fsf@iotcl.com","threadId":"63332","inReplyTo":"aDWWgj8gTS9EM7v6@pks.im","subject":"Re: [PATCH RFC v2 3/5] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-13T11:05:43Z","receivedAt":"2025-06-13T11:06:00Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, May 23, 2025 at 11:33:50AM +0200, Toon Claes wrote:\n>> Our 'git last-modified' performs a revision walk, and computes a diff at\n>> each point in the walk to figure out whether a given revision changed\n>> any of the paths it considers interesting.\n>> \n>> When changed-path Bloom filters are available, we can avoid computing\n>> many such diffs. Before computing a diff, we first check if any of the\n>> remaining paths of interest were possibly changed at a given commit by\n>> consulting its Bloom filter. If any of them are, we are resigned to\n>> compute the diff.\n>> \n>> If none of those queries returned \"maybe\", we know that the given commit\n>> doesn't contain any changed paths which are interesting to us. So, we\n>> can avoid computing it in this case.\n>> \n>> This results in a substantial performance speed-up in common cases of\n>> 'git last-modified'. In the kernel, here is the before and after (all\n>> times computed with best-of-five):\n>> \n>> With commit-graphs (but no Bloom filters):\n>> \n>>     real\t0m5.133s\n>>     user\t0m4.942s\n>>     sys\t0m0.180s\n>> \n>> ...and with Bloom filters:\n>> \n>>     real\t0m0.936s\n>>     user\t0m0.842s\n>>     sys\t0m0.092s\n>> \n>> These times are with my development-version of Git, so it's compiled\n>> without optimizations. Compiling instead with `-O3`, the results look\n>> even better:\n>> \n>>     real\t0m0.754s\n>>     user\t0m0.661s\n>>     sys\t0m0.092s\n>\n> I'm sure that the old state without bloom filters will also improve a\n> bit?\n\nThese are the benchmarks from the original commits I took over. They are\nno longer really relevant, I'll remove them.\n\n>> Signed-off-by: Toon Claes <toon@iotcl.com>\n>> ---\n>>  last-modified.c | 44 ++++++++++++++++++++++++++++++++++++++++++++\n>>  1 file changed, 44 insertions(+)\n>> \n>> diff --git a/last-modified.c b/last-modified.c\n>> index 9283f8fcae..f628434929 100644\n>> --- a/last-modified.c\n>> +++ b/last-modified.c\n>> @@ -92,12 +99,21 @@ void last_modified_init(struct last_modified *lm,\n>>  \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n>>  \t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n>>  \n>> +\t(void)generation_numbers_enabled(lm->rev.repo);\n>\n> Why the `(void)` cast? And why even call this in the first place? This\n> definitely needs a comment and smells like funky design in our commit\n> graph subsystem where we rely on side effects of one function to leak\n> into a different function.\n\nThis function calls `prepare_commit_graph()` which I think is the\nimportant side-effect. Let me add a comment. Or would you rather to see\na separate function?\n\n>> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n>> +\n>>  \tif (add_from_revs(lm) < 0)\n>>  \t\tdie(_(\"unable to setup last-modified\"));\n>>  }\n>>  \n>>  void last_modified_release(struct last_modified *lm)\n>>  {\n>> +\tstruct hashmap_iter iter;\n>> +\tstruct last_modified_entry *ent;\n>> +\n>> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n>> +\t\tclear_bloom_key(&ent->key);\n>> +\t}\n>\n> The curly braces shouldn't be needed.\n\nOkay.\n\n>> @@ -180,6 +197,30 @@ static void last_modified_diff(struct diff_queue_struct *q,\n>>  \t}\n>>  }\n>>  \n>> +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n>> +{\n>> +\tstruct bloom_filter *filter;\n>> +\tstruct last_modified_entry *ent;\n>> +\tstruct hashmap_iter iter;\n>> +\n>> +\tif (!lm->rev.bloom_filter_settings)\n>> +\t\treturn 1;\n>> +\n>> +\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n>> +\t\treturn 1;\n>\n> Hm, okay, so here we require generation numbers to exist. Why is that\n> though? Shouldn't we only care about bloom filters? I don't quite get\n> that part yet.\n\nThat's a good question. Because we're above ignoring the return value of\n`generation_numbers_enabled()` we shouldn't rely on generation numbers.\nI verified things and did some testing and it seems to me we can safely\nremove this condition.\n\n>> +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n>> +\tif (!filter)\n>> +\t\treturn 1;\n>> +\n>> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n>> +\t\tif (bloom_filter_contains(filter, &ent->key,\n>> +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n>> +\t\t\treturn 1;\n>> +\t}\n>> +\treturn 0;\n>> +}\n>> +\n>\n> Okay, and here we check whether any of our desired paths may be\n> contained in the bloom filter.\n>\n>>  int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n>>  {\n>>  \tstruct last_modified_callback_data data;\n>> @@ -199,6 +240,9 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n>>  \t\tif (!data.commit)\n>>  \t\t\tbreak;\n>>  \n>> +\t\tif (!maybe_changed_path(lm, data.commit))\n>> +\t\t\tcontinue;\n>\n> If there either are no bloom filters or in case none of them contain our\n> commit we can safely skip over the commit indeed. Otherwise we'll have\n> to check whether the commit really is interesting.\n>\n> Makes sense.\n>\n> Patrick\n>\n\n-- \nCheers,\nToon\n"},{"id":"520952","messageId":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","threadId":"63332","inReplyTo":"20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com","subject":"[PATCH RFC v3 0/3] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-30T18:49:22Z","receivedAt":"2025-06-30T18:49:46Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This series adds the git-last-modified(1) subcommand. In the past the\nsubcommand was proposed[1] to be named git-blame-tree(1). This version\nis based on the patches shared by the kind people at GitHub[2].\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[5] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I think it's a separate topic to\n  discuss and I'm not sure it needs to be part of series anyway. The\n  main patch was submitted in the previous attempt[3] and if people\n  consider it valuable, I'm happy to discuss that in a separate patch\n  series.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* Dropped the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. In my testing I've\n  seen both performance improvements *and* degradation with these\n  changes:\n\n  Test                                        HEAD~             HEAD\n  ------------------------------------------------------------------------------------\n  8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n  8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n  8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n\n  Before we include these patches, I want to make sure these changes\n  have positive impact in all/most scenarios. This can happen in a\n  separate series.\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* Fixed all memory leaks, and removed the use of\n  USE_THE_REPOSITORY_VARIABLE.\n\nI've set myself as the author and added Based-on-patch-by trailers to\ncredit the original authors. Let me know if you disagree.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n[3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[4]: git@github.com:ttaylorr/git.git\n[5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n\n--\nCheers,\nToon\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nChanges in v3:\n- Updated benchmarks in commit messages.\n- Removed the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. (see above)\n- Move handling failure in `last_modified_init()` to the caller.\n- Sorted #include clauses lexicographically.\n- Removed unneeded `commit` in `struct last_modified_entry`.\n- Renamed some functions/variables and added some comments to make it\n  easier to understand.\n- Removed unnecessary checking of the commit-graph generation number.\n- Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\n---\nToon Claes (3):\n      last-modified: new subcommand to show when files were last modified\n      t/perf: add last-modified perf script\n      last-modified: use Bloom filters when available\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 +++++++\n Documentation/meson.build            |   1 +\n Makefile                             |   2 +\n builtin.h                            |   1 +\n builtin/last-modified.c              |  44 ++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n last-modified.c                      | 257 +++++++++++++++++++++++++++++++++++\n last-modified.h                      |  35 +++++\n meson.build                          |   2 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  21 +++\n t/t8020-last-modified.sh             | 204 +++++++++++++++++++++++++++\n 14 files changed, 621 insertions(+)\n---\n\nRange-diff versus v2:\n\n1:  e77d1d65aa ! 1:  00e0ff81d9 last-modified: new subcommand to show when files were last modified\n    @@ builtin/last-modified.c (new)\n     +\t\t   const char *prefix,\n     +\t\t   struct repository *repo)\n     +{\n    -+\tint ret = 0;\n     +\tstruct last_modified lm;\n     +\n     +\trepo_config(repo, git_default_config, NULL);\n     +\n    -+\tlast_modified_init(&lm, repo, prefix, argc, argv);\n    ++\tif (last_modified_init(&lm, repo, prefix, argc, argv))\n    ++\t\tdie(_(\"error setting up last-modified traversal\"));\n    ++\n     +\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n     +\t\tdie(_(\"error running last-modified traversal\"));\n     +\n     +\tlast_modified_release(&lm);\n     +\n    -+\treturn ret;\n    ++\treturn 0;\n     +}\n     \n      ## command-list.txt ##\n    @@ git.c: static struct cmd_struct commands[] = {\n      ## last-modified.c (new) ##\n     @@\n     +#include \"git-compat-util.h\"\n    -+#include \"last-modified.h\"\n     +#include \"commit.h\"\n    -+#include \"diffcore.h\"\n     +#include \"diff.h\"\n    -+#include \"object.h\"\n    -+#include \"revision.h\"\n    -+#include \"repository.h\"\n    ++#include \"diffcore.h\"\n    ++#include \"last-modified.h\"\n     +#include \"log-tree.h\"\n    ++#include \"object.h\"\n    ++#include \"repository.h\"\n    ++#include \"revision.h\"\n     +\n     +struct last_modified_entry {\n     +\tstruct hashmap_entry hashent;\n     +\tstruct object_id oid;\n    -+\tstruct commit *commit;\n     +\tconst char path[FLEX_ARRAY];\n     +};\n     +\n    -+static void add_from_diff(struct diff_queue_struct *q,\n    -+\t\t\t  struct diff_options *opt UNUSED,\n    -+\t\t\t  void *data)\n    ++static void add_path_from_diff(struct diff_queue_struct *q,\n    ++\t\t\t       struct diff_options *opt UNUSED,\n    ++\t\t\t       void *data)\n     +{\n     +\tstruct last_modified *lm = data;\n     +\n    @@ last-modified.c (new)\n     +\t}\n     +}\n     +\n    -+static int add_from_revs(struct last_modified *lm)\n    ++static int populate_paths_from_revs(struct last_modified *lm)\n     +{\n    -+\tsize_t count = 0;\n    ++\tint num_interesting = 0;\n     +\tstruct diff_options diffopt;\n     +\n     +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n     +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n    ++\t/*\n    ++\t * Use a callback to populate the paths from revs\n    ++\t */\n     +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n    -+\tdiffopt.format_callback = add_from_diff;\n    ++\tdiffopt.format_callback = add_path_from_diff;\n     +\tdiffopt.format_callback_data = lm;\n     +\n     +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n    @@ last-modified.c (new)\n     +\t\tif (obj->item->flags & UNINTERESTING)\n     +\t\t\tcontinue;\n     +\n    -+\t\tif (count++)\n    ++\t\tif (num_interesting++)\n     +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n     +\n     +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     +\t\t\t      &obj->item->oid, \"\", &diffopt);\n     +\t\tdiff_flush(&diffopt);\n     +\t}\n    -+\tclear_pathspec(&diffopt.pathspec);\n    ++\tdiff_free(&diffopt);\n     +\n     +\treturn 0;\n     +}\n    @@ last-modified.c (new)\n     +\treturn strcmp(ent1->path, path ? path : ent2->path);\n     +}\n     +\n    -+void last_modified_init(struct last_modified *lm,\n    ++int last_modified_init(struct last_modified *lm,\n     +\t\t     struct repository *r,\n     +\t\t     const char *prefix,\n     +\t\t     int argc, const char **argv)\n    @@ last-modified.c (new)\n     +\tlm->rev.no_commit_id = 1;\n     +\tlm->rev.diff = 1;\n     +\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    -+\t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n    ++\t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n     +\n    -+\tif (add_from_revs(lm) < 0)\n    -+\t\tdie(_(\"unable to setup last-modified\"));\n    ++\tif (populate_paths_from_revs(lm) < 0)\n    ++\t\treturn error(_(\"unable to setup last-modified\"));\n    ++\n    ++\treturn 0;\n     +}\n     +\n     +void last_modified_release(struct last_modified *lm)\n    @@ last-modified.c (new)\n     +\tif (!ent)\n     +\t\treturn;\n     +\n    -+\t/* Have we already found a commit? */\n    -+\tif (ent->commit)\n    -+\t\treturn;\n    -+\n     +\t/*\n     +\t * Is it arriving at a version of interest, or is it from a side branch\n     +\t * which did not contribute to the final state?\n    @@ last-modified.c (new)\n     +\tif (!oideq(oid, &ent->oid))\n     +\t\treturn;\n     +\n    -+\tent->commit = data->commit;\n     +\tif (data->callback)\n     +\t\tdata->callback(path, data->commit, data->callback_data);\n     +\n    @@ last-modified.c (new)\n     +}\n     +\n     +static void last_modified_diff(struct diff_queue_struct *q,\n    -+\t\t       struct diff_options *opt UNUSED, void *cbdata)\n    ++\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n     +{\n     +\tstruct last_modified_callback_data *data = cbdata;\n     +\n    @@ last-modified.h (new)\n     +#define LAST_MODIFIED_H\n     +\n     +#include \"commit.h\"\n    -+#include \"revision.h\"\n     +#include \"hashmap.h\"\n    ++#include \"revision.h\"\n     +\n     +struct last_modified {\n     +\tstruct hashmap paths;\n     +\tstruct rev_info rev;\n     +};\n     +\n    -+void last_modified_init(struct last_modified *lm,\n    ++/*\n    ++ * Initialize the last-modified machinery using command line arguments.\n    ++ */\n    ++int last_modified_init(struct last_modified *lm,\n     +\t\t     struct repository *r,\n     +\t\t     const char *prefix,\n     +\t\t     int argc, const char **argv);\n    @@ last-modified.h (new)\n     +typedef void (*last_modified_callback)(const char *path,\n     +\t\t\t\t    const struct commit *commit,\n     +\t\t\t\t    void *data);\n    ++\n    ++/*\n    ++ * Run the last-modified traversal. For each path found the callback is called\n    ++ * passing the path, the commit, and the cbdata.\n    ++ */\n     +int last_modified_run(struct last_modified *lm,\n     +\t\t   last_modified_callback cb,\n     +\t\t   void *cbdata);\n    @@ t/t8020-last-modified.sh (new)\n     +\tEOF\n     +'\n     +\n    ++test_expect_success 'last-modified recursive with tree' '\n    ++\tcheck_last_modified -t <<-\\EOF\n    ++\t1 file\n    ++\t2 a/file\n    ++\t3 a\n    ++\t3 a/b\n    ++\t3 a/b/file\n    ++\tEOF\n    ++'\n    ++\n     +test_expect_success 'last-modified subdir' '\n     +\tcheck_last_modified a <<-\\EOF\n     +\t3 a\n2:  a9b69bf2f1 ! 2:  dceac8196a t/perf: add last-modified perf script\n    @@\n      ## Metadata ##\n    -Author: Jeff King <peff@peff.net>\n    +Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n         t/perf: add last-modified perf script\n    @@ Commit message\n         correctness in the regular suite, so this is just about finding\n         performance regressions from one version to another.\n     \n    +    Based-on-patch-by: Jeff King <peff@peff.net>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n      ## t/meson.build ##\n3:  ee2fe0200a ! 3:  a479ef7c40 last-modified: use Bloom filters when available\n    @@ Commit message\n         doesn't contain any changed paths which are interesting to us. So, we\n         can avoid computing it in this case.\n     \n    -    This results in a substantial performance speed-up in common cases of\n    -    'git last-modified'. In the kernel, here is the before and after (all\n    -    times computed with best-of-five):\n    +    Comparing the perf test results on git.git:\n     \n    -    With commit-graphs (but no Bloom filters):\n    -\n    -        real        0m5.133s\n    -        user        0m4.942s\n    -        sys 0m0.180s\n    -\n    -    ...and with Bloom filters:\n    -\n    -        real        0m0.936s\n    -        user        0m0.842s\n    -        sys 0m0.092s\n    -\n    -    These times are with my development-version of Git, so it's compiled\n    -    without optimizations. Compiling instead with `-O3`, the results look\n    -    even better:\n    -\n    -        real        0m0.754s\n    -        user        0m0.661s\n    -        sys 0m0.092s\n    +    Test                                        HEAD~             HEAD\n    +    ------------------------------------------------------------------------------------\n    +    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    +    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    +    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n     \n    +    Based-on-patch-by: Taylor Blau <me@ttaylorr.com>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n      ## last-modified.c ##\n     @@\n    - #include \"revision.h\"\n    - #include \"repository.h\"\n    - #include \"log-tree.h\"\n    -+#include \"dir.h\"\n    -+#include \"commit-graph.h\"\n    + #include \"git-compat-util.h\"\n     +#include \"bloom.h\"\n    - \n    ++#include \"commit-graph.h\"\n    + #include \"commit.h\"\n    + #include \"diff.h\"\n    + #include \"diffcore.h\"\n    ++#include \"dir.h\"\n    + #include \"last-modified.h\"\n    + #include \"log-tree.h\"\n    + #include \"object.h\"\n    +@@\n      struct last_modified_entry {\n      \tstruct hashmap_entry hashent;\n      \tstruct object_id oid;\n    - \tstruct commit *commit;\n     +\tstruct bloom_key key;\n      \tconst char path[FLEX_ARRAY];\n      };\n      \n    -@@ last-modified.c: static void add_from_diff(struct diff_queue_struct *q,\n    +@@ last-modified.c: static void add_path_from_diff(struct diff_queue_struct *q,\n      \n      \t\tFLEX_ALLOC_STR(ent, path, path);\n      \t\toidcpy(&ent->oid, &p->two->oid);\n    @@ last-modified.c: static void add_from_diff(struct diff_queue_struct *q,\n      \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n      \t\thashmap_add(&lm->paths, &ent->hashent);\n      \t}\n    -@@ last-modified.c: void last_modified_init(struct last_modified *lm,\n    +@@ last-modified.c: int last_modified_init(struct last_modified *lm,\n      \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    - \t\tdie(_(\"unknown last-modified argument: %s\"), argv[1]);\n    + \t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n      \n    ++\t/*\n    ++\t * We're not interested in generation numbers here,\n    ++\t * but calling this function to prepare the commit-graph.\n    ++\t */\n     +\t(void)generation_numbers_enabled(lm->rev.repo);\n     +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n     +\n    - \tif (add_from_revs(lm) < 0)\n    - \t\tdie(_(\"unable to setup last-modified\"));\n    - }\n    + \tif (populate_paths_from_revs(lm) < 0)\n    + \t\treturn error(_(\"unable to setup last-modified\"));\n    + \n    +@@ last-modified.c: int last_modified_init(struct last_modified *lm,\n      \n      void last_modified_release(struct last_modified *lm)\n      {\n     +\tstruct hashmap_iter iter;\n     +\tstruct last_modified_entry *ent;\n     +\n    -+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n    ++\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n     +\t\tclear_bloom_key(&ent->key);\n    -+\t}\n    ++\n      \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n      \trelease_revisions(&lm->rev);\n      }\n    @@ last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n     +\tif (!lm->rev.bloom_filter_settings)\n     +\t\treturn 1;\n     +\n    -+\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n    -+\t\treturn 1;\n    -+\n     +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n     +\tif (!filter)\n     +\t\treturn 1;\n4:  5dc990d49a < -:  ---------- last-modified: implement faster algorithm\n5:  6a5a921a41 < -:  ---------- last-modified: initialize revision machinery without walk\n\n\n---\n\nbase-commit: cf6f63ea6bf35173e02e18bdc6a4ba41288acff9\nchange-id: 20250410-toon-new-blame-tree-bcdbb78c1c0f\n\nThanks\n--\nToon\n\n"},{"id":"520953","messageId":"20250630-toon-new-blame-tree-v3-1-3516025dc3bc@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH RFC v3 1/3] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-30T18:49:23Z","receivedAt":"2025-06-30T18:49:49Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: \"Ævar Arnfjörð Bjarmason\" <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 ++++++++\n Documentation/meson.build            |   1 +\n Makefile                             |   2 +\n builtin.h                            |   1 +\n builtin/last-modified.c              |  44 ++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n last-modified.c                      | 212 +++++++++++++++++++++++++++++++++++\n last-modified.h                      |  35 ++++++\n meson.build                          |   2 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 204 +++++++++++++++++++++++++++++++++\n 13 files changed, 554 insertions(+)\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..1af38f402e\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,49 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [-r] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+\tRecurse into subtrees.\n+\n+-t::\n+\tShow tree entry itself as well as subtrees.  Implies `-r`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tof the current working directory are included in the\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 2fe1a1369d..99aeb6d0e0 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex 70d1543b6b..e611bbae51 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1052,6 +1052,7 @@ LIB_OBJS += hook.o\n LIB_OBJS += ident.o\n LIB_OBJS += json-writer.o\n LIB_OBJS += kwset.o\n+LIB_OBJS += last-modified.o\n LIB_OBJS += levenshtein.o\n LIB_OBJS += line-log.o\n LIB_OBJS += line-range.o\n@@ -1267,6 +1268,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..4ff058c302\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,44 @@\n+#include \"git-compat-util.h\"\n+#include \"last-modified.h\"\n+#include \"hex.h\"\n+#include \"quote.h\"\n+#include \"config.h\"\n+#include \"object-name.h\"\n+#include \"parse-options.h\"\n+#include \"builtin.h\"\n+\n+static void show_entry(const char *path, const struct commit *commit, void *d)\n+{\n+\tstruct last_modified *lm = d;\n+\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+int cmd_last_modified(int argc,\n+\t\t   const char **argv,\n+\t\t   const char *prefix,\n+\t\t   struct repository *repo)\n+{\n+\tstruct last_modified lm;\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (last_modified_init(&lm, repo, prefix, argc, argv))\n+\t\tdie(_(\"error setting up last-modified traversal\"));\n+\n+\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n+\t\tdie(_(\"error running last-modified traversal\"));\n+\n+\tlast_modified_release(&lm);\n+\n+\treturn 0;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 07a5fe39fb..76a0b2a1a4 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/last-modified.c b/last-modified.c\nnew file mode 100644\nindex 0000000000..4904d00d2a\n--- /dev/null\n+++ b/last-modified.c\n@@ -0,0 +1,212 @@\n+#include \"git-compat-util.h\"\n+#include \"commit.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"last-modified.h\"\n+#include \"log-tree.h\"\n+#include \"object.h\"\n+#include \"repository.h\"\n+#include \"revision.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static void add_path_from_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED,\n+\t\t\t       void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int populate_paths_from_revs(struct last_modified *lm)\n+{\n+\tint num_interesting = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\t/*\n+\t * Use a callback to populate the paths from revs\n+\t */\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_path_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (num_interesting++)\n+\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tdiff_free(&diffopt);\n+\n+\treturn 0;\n+}\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t    const struct hashmap_entry *hent1,\n+\t\t\t\t    const struct hashmap_entry *hent2,\n+\t\t\t\t    const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+int last_modified_init(struct last_modified *lm,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv)\n+{\n+\tmemset(lm, 0, sizeof(*lm));\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n+\t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\n+\tif (populate_paths_from_revs(lm) < 0)\n+\t\treturn error(_(\"unable to setup last-modified\"));\n+\n+\treturn 0;\n+}\n+\n+void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+struct last_modified_callback_data {\n+\tstruct commit *commit;\n+\tstruct hashmap *paths;\n+\n+\tlast_modified_callback callback;\n+\tvoid *callback_data;\n+};\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tif (data->callback)\n+\t\tdata->callback(path, data->commit, data->callback_data);\n+\n+\thashmap_remove(data->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final path/sha1 state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n+{\n+\tstruct last_modified_callback_data data;\n+\n+\tdata.paths = &lm->paths;\n+\tdata.callback = cb;\n+\tdata.callback_data = cbdata;\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t       &data.commit->object.oid,\n+\t\t\t\t       \"\", &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\ndiff --git a/last-modified.h b/last-modified.h\nnew file mode 100644\nindex 0000000000..04d5a1a5b6\n--- /dev/null\n+++ b/last-modified.h\n@@ -0,0 +1,35 @@\n+#ifndef LAST_MODIFIED_H\n+#define LAST_MODIFIED_H\n+\n+#include \"commit.h\"\n+#include \"hashmap.h\"\n+#include \"revision.h\"\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+};\n+\n+/*\n+ * Initialize the last-modified machinery using command line arguments.\n+ */\n+int last_modified_init(struct last_modified *lm,\n+\t\t     struct repository *r,\n+\t\t     const char *prefix,\n+\t\t     int argc, const char **argv);\n+\n+void last_modified_release(struct last_modified *);\n+\n+typedef void (*last_modified_callback)(const char *path,\n+\t\t\t\t    const struct commit *commit,\n+\t\t\t\t    void *data);\n+\n+/*\n+ * Run the last-modified traversal. For each path found the callback is called\n+ * passing the path, the commit, and the cbdata.\n+ */\n+int last_modified_run(struct last_modified *lm,\n+\t\t   last_modified_callback cb,\n+\t\t   void *cbdata);\n+\n+#endif /* LAST_MODIFIED_H */\ndiff --git a/meson.build b/meson.build\nindex 7fea4a34d6..fc84a3c008 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -363,6 +363,7 @@ libgit_sources = [\n   'ident.c',\n   'json-writer.c',\n   'kwset.c',\n+  'last-modified.c',\n   'levenshtein.c',\n   'line-log.c',\n   'line-range.c',\n@@ -607,6 +608,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex 50e89e764a..44eb2a693f 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -962,6 +962,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..921d2a0807\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,204 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n+\tsort tmp.3 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive with tree' '\n+\tcheck_last_modified -t <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a\n+\t3 a/b\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t1 file\n+\t2 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm1 m1.t\n+\tm2 m2.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb2 file\n+\tb5 file2\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n\n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"520954","messageId":"20250630-toon-new-blame-tree-v3-2-3516025dc3bc@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH RFC v3 2/3] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-30T18:49:24Z","receivedAt":"2025-06-30T18:49:53Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 21 +++++++++++++++++++++\n 2 files changed, 22 insertions(+)\n\ndiff --git a/t/meson.build b/t/meson.build\nindex 44eb2a693f..09f83d89ca 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1155,6 +1155,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..a02ec907d4\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,21 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n\n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"520955","messageId":"20250630-toon-new-blame-tree-v3-3-3516025dc3bc@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH RFC v3 3/3] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-06-30T18:49:25Z","receivedAt":"2025-06-30T18:49:56Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nComparing the perf test results on git.git:\n\nTest                                        HEAD~             HEAD\n------------------------------------------------------------------------------------\n8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n last-modified.c | 45 +++++++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 45 insertions(+)\n\ndiff --git a/last-modified.c b/last-modified.c\nindex 4904d00d2a..2097894c6e 100644\n--- a/last-modified.c\n+++ b/last-modified.c\n@@ -1,7 +1,10 @@\n #include \"git-compat-util.h\"\n+#include \"bloom.h\"\n+#include \"commit-graph.h\"\n #include \"commit.h\"\n #include \"diff.h\"\n #include \"diffcore.h\"\n+#include \"dir.h\"\n #include \"last-modified.h\"\n #include \"log-tree.h\"\n #include \"object.h\"\n@@ -11,6 +14,7 @@\n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -27,6 +31,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -94,6 +101,13 @@ int last_modified_init(struct last_modified *lm,\n \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n \t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n \n+\t/*\n+\t * We're not interested in generation numbers here,\n+\t * but calling this function to prepare the commit-graph.\n+\t */\n+\t(void)generation_numbers_enabled(lm->rev.repo);\n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (populate_paths_from_revs(lm) < 0)\n \t\treturn error(_(\"unable to setup last-modified\"));\n \n@@ -102,6 +116,12 @@ int last_modified_init(struct last_modified *lm,\n \n void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n+\t\tclear_bloom_key(&ent->key);\n+\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -136,6 +156,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n+\tclear_bloom_key(&ent->key);\n \tfree(ent);\n }\n \n@@ -179,6 +200,27 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n {\n \tstruct last_modified_callback_data data;\n@@ -198,6 +240,9 @@ int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n \t\tif (!data.commit)\n \t\t\tbreak;\n \n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n \t\t\t\t       &data.commit->object.oid,\n\n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"521102","messageId":"9b4742f6-9468-46a2-b612-5985d936ccff@app.fastmail.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-1-3516025dc3bc@iotcl.com","subject":"Re: [PATCH RFC v3 1/3] last-modified: new subcommand to show when files were last modified","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2025-07-01T20:20:14Z","receivedAt":"2025-07-01T20:20:36Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Mon, Jun 30, 2025, at 20:49, Toon Claes wrote:\n> +\tof the current working directory are included in the\n\nin the search/traversal?\n\n> [snip]\n> +\t\t\t * a final path/sha1 state. Note that this covers some\n\nnit: oid state?\n\n> +\t\t\t * potentially controversial areas, including:\n> +\t\t\t *\n> +\t\t\t *  1. A rename or copy will be found, as it is the\n> +\t\t\t *     first time the content has arrived at the given\n> +\t\t\t *     path.\n> +\t\t\t *\n> +\t\t\t *  2. Even a non-content modification like a mode or\n> +\t\t\t *     type change will trigger it.\n> +\t\t\t *\n> +\t\t\t * We take the inclusive approach for now, and find\n> +\t\t\t * anything which impacts the path. Options to tweak\n> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n> +\t\t\t * renames) can come later.\n> +\t\t\t */\n> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n> +\t\t\tbreak;\n> [snip]\n"},{"id":"521103","messageId":"f0c508cc-5c6b-4c4b-a3f3-0cdd8d1071e5@app.fastmail.com","threadId":"63332","inReplyTo":"20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2025-07-01T20:35:13Z","receivedAt":"2025-07-01T20:35:36Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Fri, May 23, 2025, at 11:33, Toon Claes wrote:\n> This is another attempt to upstream the ~~git-blame-tree(1)~~\n> git-last-modified(1) subcommand. After my previous attempt[1] the\n> people of GitHub shared their version of the subcommand, and this\n> version integrates those changes.\n>\n> What is different from the series shared by GitHub:\n>\n> * Renamed the subcommand from `blame-tree` to `last-modified`. There was\n>   some consensus[4] this name works better, so let's give it a try and\n>   see how this name feels.\n>\n> * Patches for --max-depth are excluded. I think it's a separate topic to\n>   discuss and I'm not sure it needs to be part of series anyway. The\n>   main patch was submitted in the previous attempt[2] and if people\n>   consider it valuable, I'm happy to discuss that in a separate patch\n>   series.\n>\n> * The patches in 'tb/blame-tree' at Taylor's fork[3] implements a\n>   caching layer. This feature reads/writes cached results in\n>   `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n>   size, that feature is excluded from this series. I think it's better\n>   to submit this as a separate series.\n>\n> * Squashed various commits together. Like they introduced a flag\n>   `--go-faster`, which later became the default and only implementation.\n>   That story was wrapped up in a single commit.\n>\n> * The last-modified command isn't recursive by default. If you want\n>   recurse into subtrees, you need to pass `-r`.\n>\n> * Fixed all memory leaks, and removed the use of\n>   USE_THE_REPOSITORY_VARIABLE.\n>\n> I've attempted to reuse commit messages as good as possible, but feel\n> free to correct me where you think I didn't give proper credit or messed\n> up. Although I have no idea what to do with the Signed-off-by trailers.\n>\n> I didn't modify the benchmark results in the commit messages, simply\n> because I didn't get comparable results. In my benchmarks the difference\n> between two implementations was negligible, and even in some scenarios\n> the performance was worse in the \"improved\" implementation. As far as I\n> can tell, I didn't break anything in my refactoring, because the version\n> in these patches acts similar to Taylor's branch. To be honest, I cannot\n> explain why...?\n>\n> Again thanks to Taylor and the people at GitHub for sharing these\n> patches. I hope we can work together to get this upstreamed.\n>\n> [1]:\n> https://lore.kernel.org/git/20250326-toon-blame-tree-v1-0-4173133f3786@iotcl.com/\n> [2]:\n> https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n> [3]: git@github.com:ttaylorr/git.git\n> [4]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n> --\n> Cheers,\n> Toon\n>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n> Changes in v2:\n> - The subcommand is renamed from `blame-tree` to `last-modified`\n> - Documentation is added. Here we mark the command as experimental.\n> - Some test cases are added related to merges.\n> - Link to v1:\n> https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\nIt feels like the command strays a bit from the usual patterns to me.  For paths/files \nthat is.  I like this:\n\n```\n$ git last-modified -r refs.c refs.h\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n```\n\nI ask for two files and I get those in the output.\n\nBut for individual files in subdirectories:\n\n```\n$ git last-modified refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n```\n\nSame as if I ask for `Documentation`:\n\n```\n$ git last-modified refs.c refs.h Documentation/\n3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n```\n\nBut I didn’t ask for the directory first.  I asked for two files.\n\nI have to use `-r` (recurse):\n\n```\n$ git last-modified -r refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation/git-last-modified.adoc\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n0fbe93b36c05bbf4156c157f27998938ce312265\tDocumentation/git-config.adoc\n```\n\nAnd `-r` with a directory like `Documentation` will recurse through that\ndirectory.\n\nAs a user I imagine I want `-r` to control whether to, say, only show\neach directory under `Documentation`.  But now you seem to get a special\ncase of allowing directly listing first-level files but not the ones in\nsubdirectories.\n\nI’m more used to being able to use individual files if I want that\nas well as pathspecs for recursion.  But now I just get the directory:\n\n```\ngit last-modified -- 't/*'\n532d9a0984e6464deadb6bdb0287fcce2990adc9\tt\n```\n\nBut I can still use pathspec magic which is nice:\n\n```\n$ git last-modified -r -- 't/*' ':^t/t1016*' | grep 1016\n<empty>\n```\n\nI’m imagining that you may want to feed a specific pathspec to the\ncommand and get only the output for those that match.  Without having\nworry about turning on recursion since that may cover some of the things\nyou want but also make it do too much.  Well maybe that is just as\ncontrollable here but it seems less obvious than for commands where the\npathspec is used more directly (?).  I appreciate when these commands\nallow me to express things directly without postprocessing (no excessive\npipelining).\n\nAlso you get a sort of trailing error if you give a non-existing option:\n\n```\n$ git last-modified --recursive\nerror: unknown last-modified argument: --recursive\nfatal: error setting up last-modified traversal\n```\n"},{"id":"521107","messageId":"xmqq5xgbk4d2.fsf@gitster.g","threadId":"63332","inReplyTo":"f0c508cc-5c6b-4c4b-a3f3-0cdd8d1071e5@app.fastmail.com","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-01T21:06:49Z","receivedAt":"2025-07-01T21:06:53Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Kristoffer Haugsbakk\" <kristofferhaugsbakk@fastmail.com> writes:\n\n> It feels like the command strays a bit from the usual patterns to me.  For paths/files \n> that is.  I like this:\n>\n> ```\n> $ git last-modified -r refs.c refs.h\n> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n> ```\n\nI am not getting this example.  Unless \"-r\" stands for \"reverse\",\nthe above looks totally expected.\n\n> I ask for two files and I get those in the output.\n>\n> But for individual files in subdirectories:\n>\n> ```\n> $ git last-modified refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n> 3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation\n> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n> ```\n\nI am indifferent with this outcome.  I do not mind the tool giving\nDocumentation/ even when paths inside it are asked about, when it is\nnot asked to go recursive.\n\n> Same as if I ask for `Documentation`:\n>\n> ```\n> $ git last-modified refs.c refs.h Documentation/\n> 3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation\n> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n> ```\n>\n> But I didn’t ask for the directory first.  I asked for two files.\n\nI do not see anything unexpected.  Have you seen \"git ls-tree\"\noutput without -r(ecursive) before?\n\n    $ git ls-tree HEAD refs.c refs.h Documentation\n    040000 tree a0f7113f63a19b70dff14bfd9f8f82809f5068e1\tDocumentation\n    100644 blob dce5c49ca2ba65fd6a2974e38f67134215bee369\trefs.c\n    100644 blob 46a6008e07f2624239139cd8b2ff712545f07d3f\trefs.h\n\nAs I understand that this tool was written primarily to implement\nscripts like repository browsers showing https://github.com/git/git\nI do not mind non-recursive behaviour being the default.  After all\nI view it as a plumbing.\n\n> I have to use `-r` (recurse):\n>\n> ```\n> $ git last-modified -r refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n> 3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation/git-last-modified.adoc\n> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n> 0fbe93b36c05bbf4156c157f27998938ce312265\tDocumentation/git-config.adoc\n> ```\n>\n> And `-r` with a directory like `Documentation` will recurse through that\n> directory.\n\nTotally expected.\n"},{"id":"521123","messageId":"d93c9199-80b1-4834-bfbb-8263a80d90d5@app.fastmail.com","threadId":"63332","inReplyTo":"xmqq5xgbk4d2.fsf@gitster.g","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2025-07-01T21:30:13Z","receivedAt":"2025-07-01T21:30:36Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Tue, Jul 1, 2025, at 23:06, Junio C Hamano wrote:\n> \"Kristoffer Haugsbakk\" <kristofferhaugsbakk@fastmail.com> writes:\n>\n>> It feels like the command strays a bit from the usual patterns to me.  For paths/files\n>> that is.  I like this:\n>>\n>> ```\n>> $ git last-modified -r refs.c refs.h\n>> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n>> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n>> ```\n>\n> I am not getting this example.  Unless \"-r\" stands for \"reverse\",\n> the above looks totally expected.\n\nSorry.  I meant this (withouth `-r`) and that it makes sense.\n\n```\n$ git last-modified refs.c refs.h\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n```\n\n> I do not see anything unexpected.  Have you seen \"git ls-tree\"\n> output without -r(ecursive) before?\n>\n>     $ git ls-tree HEAD refs.c refs.h Documentation\n>     040000 tree a0f7113f63a19b70dff14bfd9f8f82809f5068e1\tDocumentation\n>     100644 blob dce5c49ca2ba65fd6a2974e38f67134215bee369\trefs.c\n>     100644 blob 46a6008e07f2624239139cd8b2ff712545f07d3f\trefs.h\n\nNo. That’s my blindspot.\n\n>\n> As I understand that this tool was written primarily to implement\n> scripts like repository browsers showing https://github.com/git/git\n> I do not mind non-recursive behaviour being the default.  After all\n> I view it as a plumbing.\n>\n>> I have to use `-r` (recurse):\n>>\n>> ```\n>> $ git last-modified -r refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n>> 3691fe72d927658ae77ade7fe967544fc6739e67\tDocumentation/git-last-modified.adoc\n>> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n>> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n>> 0fbe93b36c05bbf4156c157f27998938ce312265\tDocumentation/git-config.adoc\n>> ```\n>>\n>> And `-r` with a directory like `Documentation` will recurse through that\n>> directory.\n>\n> Totally expected.\n"},{"id":"521128","messageId":"xmqqa55nikia.fsf@gitster.g","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"Re: [PATCH RFC v3 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-01T23:01:01Z","receivedAt":"2025-07-01T23:01:03Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Again thanks to Taylor and the people at GitHub for sharing these\n> patches. I hope we can work together to get this upstreamed.\n>\n> [1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n> [2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n> [3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n> [4]: git@github.com:ttaylorr/git.git\n> [5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n>\n> --\n> Cheers,\n> Toon\n>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n> Changes in v3:\n> - Updated benchmarks in commit messages.\n> - Removed the patches that attempt to increase performance for tree\n>   entries that have not been updated in a long time. (see above)\n> - Move handling failure in `last_modified_init()` to the caller.\n> - Sorted #include clauses lexicographically.\n> - Removed unneeded `commit` in `struct last_modified_entry`.\n> - Renamed some functions/variables and added some comments to make it\n>   easier to understand.\n> - Removed unnecessary checking of the commit-graph generation number.\n> - Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n>\n> Changes in v2:\n> - The subcommand is renamed from `blame-tree` to `last-modified`\n> - Documentation is added. Here we mark the command as experimental.\n> - Some test cases are added related to merges.\n> - Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n>\n> ---\n> Toon Claes (3):\n>       last-modified: new subcommand to show when files were last modified\n>       t/perf: add last-modified perf script\n>       last-modified: use Bloom filters when available\n>\n>  .gitignore                           |   1 +\n>  Documentation/git-last-modified.adoc |  49 +++++++\n>  Documentation/meson.build            |   1 +\n>  Makefile                             |   2 +\n>  builtin.h                            |   1 +\n>  builtin/last-modified.c              |  44 ++++++\n>  command-list.txt                     |   1 +\n>  git.c                                |   1 +\n>  last-modified.c                      | 257 +++++++++++++++++++++++++++++++++++\n>  last-modified.h                      |  35 +++++\n>  meson.build                          |   2 +\n>  t/meson.build                        |   2 +\n>  t/perf/p8020-last-modified.sh        |  21 +++\n>  t/t8020-last-modified.sh             | 204 +++++++++++++++++++++++++++\n>  14 files changed, 621 insertions(+)\n\nFWI, \"git last-modified -h\" does not work; its output is expected to\nmatch what is in \"git help last-modified\", and t0450 would not pass\nwithout it.\n\n"},{"id":"521168","messageId":"xmqqa55mg6a7.fsf@gitster.g","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-1-3516025dc3bc@iotcl.com","subject":"Re: [PATCH RFC v3 1/3] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-02T11:51:12Z","receivedAt":"2025-07-02T11:51:14Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> new file mode 100644\n> index 0000000000..4ff058c302\n> --- /dev/null\n> +++ b/builtin/last-modified.c\n> @@ -0,0 +1,44 @@\n> +#include \"git-compat-util.h\"\n> +#include \"last-modified.h\"\n> +#include \"hex.h\"\n> +#include \"quote.h\"\n> +#include \"config.h\"\n> +#include \"object-name.h\"\n> +#include \"parse-options.h\"\n> +#include \"builtin.h\"\n\nApparently \"parse-options.h\" is included but is never used.\nHow much of these include do you truly use in this step?\n\nI was looking at the code, since I was wondering why you forgot to\nhandle \"-h\", which comes absolutely for free when you use the\nparse-options API in the most natural way.\n\n> +int cmd_last_modified(int argc,\n> +\t\t   const char **argv,\n> +\t\t   const char *prefix,\n> +\t\t   struct repository *repo)\n> +{\n> +\tstruct last_modified lm;\n> +\n> +\trepo_config(repo, git_default_config, NULL);\n> +\n> +\tif (last_modified_init(&lm, repo, prefix, argc, argv))\n> +\t\tdie(_(\"error setting up last-modified traversal\"));\n> +\n> +\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n> +\t\tdie(_(\"error running last-modified traversal\"));\n> +\n> +\tlast_modified_release(&lm);\n> +\n> +\treturn 0;\n> +}\n\nIt is a bit unusual for the top-legvel cmd_foo() to totally give up\nthe responsibility of command line parsing, and let a helper\nfunction take over everything.\n\nIs the idea that the family of last_modified_foo() functions wants\nto form a library-ish API?  I think the primary reason I find the\narrangement a bit unusual is that such a library interface would not\ndeal with end-user interactions like command line parsing.  Even\ncommands that let setup_revisions() slurp the command line arguments\ntypically does necessary set-up (like discoverying the git directory\nand reading the configuration files) on the side of the caller.\n"},{"id":"521169","messageId":"8734berbmj.fsf@iotcl.com","threadId":"63332","inReplyTo":"d93c9199-80b1-4834-bfbb-8263a80d90d5@app.fastmail.com","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-02T13:00:20Z","receivedAt":"2025-07-02T13:00:32Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":">> I do not see anything unexpected.  Have you seen \"git ls-tree\"\n>> output without -r(ecursive) before?\n>>\n>>     $ git ls-tree HEAD refs.c refs.h Documentation\n>>     040000 tree a0f7113f63a19b70dff14bfd9f8f82809f5068e1\tDocumentation\n>>     100644 blob dce5c49ca2ba65fd6a2974e38f67134215bee369\trefs.c\n>>     100644 blob 46a6008e07f2624239139cd8b2ff712545f07d3f\trefs.h\n\nYou raise a good point here. Let's compare:\n\n$ git ls-tree HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n100644 blob 936e0c5130fe7d67f645501fbb9e70b94b437f54\tDocumentation/git-config.adoc\n100644 blob 1af38f402ed6437353fb5765f62251966d828df9\tDocumentation/git-last-modified.adoc\n100644 blob dce5c49ca2ba65fd6a2974e38f67134215bee369\trefs.c\n100644 blob 46a6008e07f2624239139cd8b2ff712545f07d3f\trefs.h\n\n$ git last-modified HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n56073a0af90be947cfefbfc3cf762b268e5e20a9\tDocumentation\n062b914c841329a003f74e1340ea5178391274a6\trefs.c\n47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n\nI have to agree with Kristoffer here, and the latter is not what I\nwould expect. Thanks for the testing! I will try to address in next\nversion.\n\n-- \nCheers,\nToon\n"},{"id":"521686","messageId":"20250709152628.1644521-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH v4 0/3] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-09T15:26:25Z","receivedAt":"2025-07-09T15:26:42Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This series adds the git-last-modified(1) subcommand. In the past the\nsubcommand was proposed[1] to be named git-blame-tree(1). This version\nis based on the patches shared by the kind people at GitHub[2].\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[5] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I think it's a separate topic to\n  discuss and I'm not sure it needs to be part of series anyway. The\n  main patch was submitted in the previous attempt[3] and if people\n  consider it valuable, I'm happy to discuss that in a separate patch\n  series.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* Dropped the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. In my testing I've\n  seen both performance improvements *and* degradation with these\n  changes:\n\n  Test                                        HEAD~             HEAD\n  ------------------------------------------------------------------------------------\n  8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n  8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n  8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n\n  Before we include these patches, I want to make sure these changes\n  have positive impact in all/most scenarios. This can happen in a\n  separate series.\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* Fixed all memory leaks, and removed the use of\n  USE_THE_REPOSITORY_VARIABLE.\n\nI've set myself as the author and added Based-on-patch-by trailers to\ncredit the original authors. Let me know if you disagree.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n[3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[4]: git@github.com:ttaylorr/git.git\n[5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n\n--\nCheers,\nToon\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nChanges in v4:\n- Removed root-level `last-modified.[ch]` library code and moved code to\n  `builtin/last-modified.c`. Historically we've had libary code (also because it\n  was used in testtool), but we no longer need that separation. I'm sorry this\n  makes the range-diff hard to read.\n- Added the use of parse_options() to get better usage messages.\n- Formatting fixes after conversation in\n  https://lore.kernel.org/git/xmqqh5zvk5h0.fsf@gitster.g/\n- Link to v3: https://lore.kernel.org/git/20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com/\n\nChanges in v3:\n- Updated benchmarks in commit messages.\n- Removed the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. (see above)\n- Move handling failure in `last_modified_init()` to the caller.\n- Sorted #include clauses lexicographically.\n- Removed unneeded `commit` in `struct last_modified_entry`.\n- Renamed some functions/variables and added some comments to make it\n  easier to understand.\n- Removed unnecessary checking of the commit-graph generation number.\n- Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\nToon Claes (3):\n  last-modified: new subcommand to show when files were last modified\n  t/perf: add last-modified perf script\n  last-modified: use Bloom filters when available\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 ++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 334 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  21 ++\n t/t8020-last-modified.sh             | 204 ++++++++++++++++\n 12 files changed, 617 insertions(+)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/perf/p8020-last-modified.sh\n create mode 100755 t/t8020-last-modified.sh\n\nRange-diff against v3:\n1:  26a2d9b5e0 ! 1:  0cc625f3f5 last-modified: new subcommand to show when files were last modified\n    @@ Documentation/git-last-modified.adoc (new)\n     +SYNOPSIS\n     +--------\n     +[synopsis]\n    -+git last-modified [-r] [<revision-range>] [[--] <path>...]\n    ++git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n     +\n     +DESCRIPTION\n     +-----------\n    @@ Documentation/git-last-modified.adoc (new)\n     +[--] <path>...::\n     +\tFor each _<path>_ given, the commit which last modified it is returned.\n     +\tWithout an optional path parameter, all files and subdirectories\n    -+\tof the current working directory are included in the\n    ++\tin path traversal the are included in the output.\n     +\n     +SEE ALSO\n     +--------\n    @@ Documentation/meson.build: manpages = {\n        'git-ls-remote.adoc' : 1,\n\n      ## Makefile ##\n    -@@ Makefile: LIB_OBJS += hook.o\n    - LIB_OBJS += ident.o\n    - LIB_OBJS += json-writer.o\n    - LIB_OBJS += kwset.o\n    -+LIB_OBJS += last-modified.o\n    - LIB_OBJS += levenshtein.o\n    - LIB_OBJS += line-log.o\n    - LIB_OBJS += line-range.o\n     @@ Makefile: BUILTIN_OBJS += builtin/hook.o\n      BUILTIN_OBJS += builtin/index-pack.o\n      BUILTIN_OBJS += builtin/init-db.o\n    @@ builtin.h: int cmd_hook(int argc, const char **argv, const char *prefix, struct\n      ## builtin/last-modified.c (new) ##\n     @@\n     +#include \"git-compat-util.h\"\n    -+#include \"last-modified.h\"\n    -+#include \"hex.h\"\n    -+#include \"quote.h\"\n    -+#include \"config.h\"\n    -+#include \"object-name.h\"\n    -+#include \"parse-options.h\"\n     +#include \"builtin.h\"\n    -+\n    -+static void show_entry(const char *path, const struct commit *commit, void *d)\n    -+{\n    -+\tstruct last_modified *lm = d;\n    -+\n    -+\tif (commit->object.flags & BOUNDARY)\n    -+\t\tputchar('^');\n    -+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    -+\n    -+\tif (lm->rev.diffopt.line_termination)\n    -+\t\twrite_name_quoted(path, stdout, '\\n');\n    -+\telse\n    -+\t\tprintf(\"%s%c\", path, '\\0');\n    -+\n    -+\tfflush(stdout);\n    -+}\n    -+\n    -+int cmd_last_modified(int argc,\n    -+\t\t   const char **argv,\n    -+\t\t   const char *prefix,\n    -+\t\t   struct repository *repo)\n    -+{\n    -+\tstruct last_modified lm;\n    -+\n    -+\trepo_config(repo, git_default_config, NULL);\n    -+\n    -+\tif (last_modified_init(&lm, repo, prefix, argc, argv))\n    -+\t\tdie(_(\"error setting up last-modified traversal\"));\n    -+\n    -+\tif (last_modified_run(&lm, show_entry, &lm) < 0)\n    -+\t\tdie(_(\"error running last-modified traversal\"));\n    -+\n    -+\tlast_modified_release(&lm);\n    -+\n    -+\treturn 0;\n    -+}\n    -\n    - ## command-list.txt ##\n    -@@ command-list.txt: git-index-pack                          plumbingmanipulators\n    - git-init                                mainporcelain           init\n    - git-instaweb                            ancillaryinterrogators          complete\n    - git-interpret-trailers                  purehelpers\n    -+git-last-modified                       plumbinginterrogators\n    - git-log                                 mainporcelain           info\n    - git-ls-files                            plumbinginterrogators\n    - git-ls-remote                           plumbinginterrogators\n    -\n    - ## git.c ##\n    -@@ git.c: static struct cmd_struct commands[] = {\n    - \t{ \"init\", cmd_init_db },\n    - \t{ \"init-db\", cmd_init_db },\n    - \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n    -+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n    - \t{ \"log\", cmd_log, RUN_SETUP },\n    - \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n    - \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\n    -\n    - ## last-modified.c (new) ##\n    -@@\n    -+#include \"git-compat-util.h\"\n     +#include \"commit.h\"\n    ++#include \"config.h\"\n     +#include \"diff.h\"\n     +#include \"diffcore.h\"\n    -+#include \"last-modified.h\"\n    ++#include \"hashmap.h\"\n    ++#include \"hex.h\"\n     +#include \"log-tree.h\"\n    ++#include \"object-name.h\"\n     +#include \"object.h\"\n    ++#include \"parse-options.h\"\n    ++#include \"quote.h\"\n     +#include \"repository.h\"\n     +#include \"revision.h\"\n     +\n    @@ last-modified.c (new)\n     +\tconst char path[FLEX_ARRAY];\n     +};\n     +\n    ++static int last_modified_entry_hashcmp(const void *unused UNUSED,\n    ++\t\t\t\t       const struct hashmap_entry *hent1,\n    ++\t\t\t\t       const struct hashmap_entry *hent2,\n    ++\t\t\t\t       const void *path)\n    ++{\n    ++\tconst struct last_modified_entry *ent1 =\n    ++\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n    ++\tconst struct last_modified_entry *ent2 =\n    ++\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n    ++\treturn strcmp(ent1->path, path ? path : ent2->path);\n    ++}\n    ++\n    ++struct last_modified {\n    ++\tstruct hashmap paths;\n    ++\tstruct rev_info rev;\n    ++\tint recursive, tree_in_recursive;\n    ++};\n    ++\n    ++static void last_modified_release(struct last_modified *lm)\n    ++{\n    ++\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n    ++\trelease_revisions(&lm->rev);\n    ++}\n    ++\n    ++typedef void (*last_modified_callback)(const char *path,\n    ++\t\t\t\t       const struct commit *commit, void *data);\n    ++\n    ++struct last_modified_callback_data {\n    ++\tstruct commit *commit;\n    ++\tstruct hashmap *paths;\n    ++\n    ++\tlast_modified_callback callback;\n    ++\tvoid *callback_data;\n    ++};\n    ++\n     +static void add_path_from_diff(struct diff_queue_struct *q,\n    -+\t\t\t       struct diff_options *opt UNUSED,\n    -+\t\t\t       void *data)\n    ++\t\t\t       struct diff_options *opt UNUSED, void *data)\n     +{\n     +\tstruct last_modified *lm = data;\n     +\n    @@ last-modified.c (new)\n     +\treturn 0;\n     +}\n     +\n    -+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n    -+\t\t\t\t    const struct hashmap_entry *hent1,\n    -+\t\t\t\t    const struct hashmap_entry *hent2,\n    -+\t\t\t\t    const void *path)\n    -+{\n    -+\tconst struct last_modified_entry *ent1 =\n    -+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n    -+\tconst struct last_modified_entry *ent2 =\n    -+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n    -+\treturn strcmp(ent1->path, path ? path : ent2->path);\n    -+}\n    -+\n    -+int last_modified_init(struct last_modified *lm,\n    -+\t\t     struct repository *r,\n    -+\t\t     const char *prefix,\n    -+\t\t     int argc, const char **argv)\n    -+{\n    -+\tmemset(lm, 0, sizeof(*lm));\n    -+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n    -+\n    -+\trepo_init_revisions(r, &lm->rev, prefix);\n    -+\tlm->rev.def = \"HEAD\";\n    -+\tlm->rev.combine_merges = 1;\n    -+\tlm->rev.show_root_diff = 1;\n    -+\tlm->rev.boundary = 1;\n    -+\tlm->rev.no_commit_id = 1;\n    -+\tlm->rev.diff = 1;\n    -+\tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    -+\t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n    -+\n    -+\tif (populate_paths_from_revs(lm) < 0)\n    -+\t\treturn error(_(\"unable to setup last-modified\"));\n    -+\n    -+\treturn 0;\n    -+}\n    -+\n    -+void last_modified_release(struct last_modified *lm)\n    -+{\n    -+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n    -+\trelease_revisions(&lm->rev);\n    -+}\n    -+\n    -+struct last_modified_callback_data {\n    -+\tstruct commit *commit;\n    -+\tstruct hashmap *paths;\n    -+\n    -+\tlast_modified_callback callback;\n    -+\tvoid *callback_data;\n    -+};\n    -+\n     +static void mark_path(const char *path, const struct object_id *oid,\n     +\t\t      struct last_modified_callback_data *data)\n     +{\n    @@ last-modified.c (new)\n     +\t\tdefault:\n     +\t\t\t/*\n     +\t\t\t * Otherwise, we care only that we somehow arrived at\n    -+\t\t\t * a final path/sha1 state. Note that this covers some\n    ++\t\t\t * a final oid state. Note that this covers some\n     +\t\t\t * potentially controversial areas, including:\n     +\t\t\t *\n     +\t\t\t *  1. A rename or copy will be found, as it is the\n    @@ last-modified.c (new)\n     +\t}\n     +}\n     +\n    -+int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n    ++static int last_modified_run(struct last_modified *lm,\n    ++\t\t\t     last_modified_callback cb, void *cbdata)\n     +{\n     +\tstruct last_modified_callback_data data;\n     +\n    @@ last-modified.c (new)\n     +\n     +\t\tif (data.commit->object.flags & BOUNDARY) {\n     +\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n    -+\t\t\t\t       &data.commit->object.oid,\n    -+\t\t\t\t       \"\", &lm->rev.diffopt);\n    ++\t\t\t\t      &data.commit->object.oid, \"\",\n    ++\t\t\t\t      &lm->rev.diffopt);\n     +\t\t\tdiff_flush(&lm->rev.diffopt);\n     +\t\t} else {\n     +\t\t\tlog_tree_commit(&lm->rev, data.commit);\n    @@ last-modified.c (new)\n     +\t}\n     +\n     +\treturn 0;\n    ++}\n    ++\n    ++static void show_entry(const char *path, const struct commit *commit, void *d)\n    ++{\n    ++\tstruct last_modified *lm = d;\n    ++\n    ++\tif (commit->object.flags & BOUNDARY)\n    ++\t\tputchar('^');\n    ++\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    ++\n    ++\tif (lm->rev.diffopt.line_termination)\n    ++\t\twrite_name_quoted(path, stdout, '\\n');\n    ++\telse\n    ++\t\tprintf(\"%s%c\", path, '\\0');\n    ++\n    ++\tfflush(stdout);\n    ++}\n    ++\n    ++static int last_modified_init(struct last_modified *lm, struct repository *r,\n    ++\t\t\t      const char *prefix, int argc, const char **argv)\n    ++{\n    ++\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n    ++\n    ++\trepo_init_revisions(r, &lm->rev, prefix);\n    ++\tlm->rev.def = \"HEAD\";\n    ++\tlm->rev.combine_merges = 1;\n    ++\tlm->rev.show_root_diff = 1;\n    ++\tlm->rev.boundary = 1;\n    ++\tlm->rev.no_commit_id = 1;\n    ++\tlm->rev.diff = 1;\n    ++\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n    ++\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n    ++\n    ++\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n    ++\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n    ++\t\treturn argc;\n    ++\t}\n    ++\n    ++\tif (populate_paths_from_revs(lm) < 0)\n    ++\t\treturn error(_(\"unable to setup last-modified\"));\n    ++\n    ++\treturn 0;\n    ++}\n    ++\n    ++int cmd_last_modified(int argc, const char **argv, const char *prefix,\n    ++\t\t      struct repository *repo)\n    ++{\n    ++\tint ret;\n    ++\tstruct last_modified lm;\n    ++\n    ++\tconst char * const last_modified_usage[] = {\n    ++\t\tN_(\"git last-modified [-r] [-t] \"\n    ++\t\t   \"[<revision-range>] [[--] <path>...]\"),\n    ++\t\tNULL\n    ++\t};\n    ++\n    ++\tstruct option last_modified_options[] = {\n    ++\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n    ++\t\t\t N_(\"recurse into subtrees\")),\n    ++\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n    ++\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n    ++\t\tOPT_END()\n    ++\t};\n    ++\n    ++\tmemset(&lm, 0, sizeof(lm));\n    ++\n    ++\targc = parse_options(argc, argv, prefix, last_modified_options,\n    ++\t\t\t     last_modified_usage,\n    ++\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n    ++\n    ++\trepo_config(repo, git_default_config, NULL);\n    ++\n    ++\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n    ++\t\tif (ret > 0)\n    ++\t\t\tusage_with_options(last_modified_usage,\n    ++\t\t\t\t\t   last_modified_options);\n    ++\t\tgoto out;\n    ++\t}\n    ++\n    ++\tif ((ret = last_modified_run(&lm, show_entry, &lm)))\n    ++\t\tgoto out;\n    ++\n    ++out:\n    ++\tlast_modified_release(&lm);\n    ++\n    ++\treturn ret;\n     +}\n\n    - ## last-modified.h (new) ##\n    -@@\n    -+#ifndef LAST_MODIFIED_H\n    -+#define LAST_MODIFIED_H\n    -+\n    -+#include \"commit.h\"\n    -+#include \"hashmap.h\"\n    -+#include \"revision.h\"\n    -+\n    -+struct last_modified {\n    -+\tstruct hashmap paths;\n    -+\tstruct rev_info rev;\n    -+};\n    -+\n    -+/*\n    -+ * Initialize the last-modified machinery using command line arguments.\n    -+ */\n    -+int last_modified_init(struct last_modified *lm,\n    -+\t\t     struct repository *r,\n    -+\t\t     const char *prefix,\n    -+\t\t     int argc, const char **argv);\n    -+\n    -+void last_modified_release(struct last_modified *);\n    -+\n    -+typedef void (*last_modified_callback)(const char *path,\n    -+\t\t\t\t    const struct commit *commit,\n    -+\t\t\t\t    void *data);\n    -+\n    -+/*\n    -+ * Run the last-modified traversal. For each path found the callback is called\n    -+ * passing the path, the commit, and the cbdata.\n    -+ */\n    -+int last_modified_run(struct last_modified *lm,\n    -+\t\t   last_modified_callback cb,\n    -+\t\t   void *cbdata);\n    -+\n    -+#endif /* LAST_MODIFIED_H */\n    + ## command-list.txt ##\n    +@@ command-list.txt: git-index-pack                          plumbingmanipulators\n    + git-init                                mainporcelain           init\n    + git-instaweb                            ancillaryinterrogators          complete\n    + git-interpret-trailers                  purehelpers\n    ++git-last-modified                       plumbinginterrogators\n    + git-log                                 mainporcelain           info\n    + git-ls-files                            plumbinginterrogators\n    + git-ls-remote                           plumbinginterrogators\n    +\n    + ## git.c ##\n    +@@ git.c: static struct cmd_struct commands[] = {\n    + \t{ \"init\", cmd_init_db },\n    + \t{ \"init-db\", cmd_init_db },\n    + \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n    ++\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n    + \t{ \"log\", cmd_log, RUN_SETUP },\n    + \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n    + \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\n\n      ## meson.build ##\n    -@@ meson.build: libgit_sources = [\n    -   'ident.c',\n    -   'json-writer.c',\n    -   'kwset.c',\n    -+  'last-modified.c',\n    -   'levenshtein.c',\n    -   'line-log.c',\n    -   'line-range.c',\n     @@ meson.build: builtin_sources = [\n        'builtin/index-pack.c',\n        'builtin/init-db.c',\n2:  0691884735 = 2:  a017f2c81c t/perf: add last-modified perf script\n3:  393f304a3f ! 3:  c739a7dbcc last-modified: use Bloom filters when available\n    @@ Commit message\n\n         Comparing the perf test results on git.git:\n\n    -    Test                                        HEAD~             HEAD\n    -    ------------------------------------------------------------------------------------\n    -    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    -    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    -    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n    +        Test                                        HEAD~             HEAD\n    +        ------------------------------------------------------------------------------------\n    +        8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    +        8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    +        8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\n         Based-on-patch-by: Taylor Blau <me@ttaylorr.com>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n\n    - ## last-modified.c ##\n    + ## builtin/last-modified.c ##\n     @@\n      #include \"git-compat-util.h\"\n     +#include \"bloom.h\"\n    + #include \"builtin.h\"\n     +#include \"commit-graph.h\"\n      #include \"commit.h\"\n    + #include \"config.h\"\n      #include \"diff.h\"\n    - #include \"diffcore.h\"\n    -+#include \"dir.h\"\n    - #include \"last-modified.h\"\n    - #include \"log-tree.h\"\n    - #include \"object.h\"\n     @@\n      struct last_modified_entry {\n      \tstruct hashmap_entry hashent;\n    @@ last-modified.c\n      \tconst char path[FLEX_ARRAY];\n      };\n\n    -@@ last-modified.c: static void add_path_from_diff(struct diff_queue_struct *q,\n    +@@ builtin/last-modified.c: struct last_modified {\n\n    - \t\tFLEX_ALLOC_STR(ent, path, path);\n    - \t\toidcpy(&ent->oid, &p->two->oid);\n    -+\t\tif (lm->rev.bloom_filter_settings)\n    -+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n    -+\t\t\t\t       lm->rev.bloom_filter_settings);\n    - \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n    - \t\thashmap_add(&lm->paths, &ent->hashent);\n    - \t}\n    -@@ last-modified.c: int last_modified_init(struct last_modified *lm,\n    - \tif (setup_revisions(argc, argv, &lm->rev, NULL) > 1)\n    - \t\treturn error(_(\"unknown last-modified argument: %s\"), argv[1]);\n    -\n    -+\t/*\n    -+\t * We're not interested in generation numbers here,\n    -+\t * but calling this function to prepare the commit-graph.\n    -+\t */\n    -+\t(void)generation_numbers_enabled(lm->rev.repo);\n    -+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n    -+\n    - \tif (populate_paths_from_revs(lm) < 0)\n    - \t\treturn error(_(\"unable to setup last-modified\"));\n    -\n    -@@ last-modified.c: int last_modified_init(struct last_modified *lm,\n    -\n    - void last_modified_release(struct last_modified *lm)\n    + static void last_modified_release(struct last_modified *lm)\n      {\n     +\tstruct hashmap_iter iter;\n     +\tstruct last_modified_entry *ent;\n    @@ last-modified.c: int last_modified_init(struct last_modified *lm,\n      \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n      \trelease_revisions(&lm->rev);\n      }\n    -@@ last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n    +@@ builtin/last-modified.c: static void add_path_from_diff(struct diff_queue_struct *q,\n    +\n    + \t\tFLEX_ALLOC_STR(ent, path, path);\n    + \t\toidcpy(&ent->oid, &p->two->oid);\n    ++\t\tif (lm->rev.bloom_filter_settings)\n    ++\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n    ++\t\t\t\t       lm->rev.bloom_filter_settings);\n    + \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n    + \t\thashmap_add(&lm->paths, &ent->hashent);\n    + \t}\n    +@@ builtin/last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n      \t\tdata->callback(path, data->commit, data->callback_data);\n\n      \thashmap_remove(data->paths, &ent->hashent, path);\n    @@ last-modified.c: static void mark_path(const char *path, const struct object_id\n      \tfree(ent);\n      }\n\n    -@@ last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n    +@@ builtin/last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n      \t}\n      }\n\n    ++\n     +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n     +{\n     +\tstruct bloom_filter *filter;\n    @@ last-modified.c: static void last_modified_diff(struct diff_queue_struct *q,\n     +\treturn 0;\n     +}\n     +\n    - int last_modified_run(struct last_modified *lm, last_modified_callback cb, void *cbdata)\n    + static int last_modified_run(struct last_modified *lm,\n    + \t\t\t     last_modified_callback cb, void *cbdata)\n      {\n    - \tstruct last_modified_callback_data data;\n    -@@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_callback cb, void\n    +@@ builtin/last-modified.c: static int last_modified_run(struct last_modified *lm,\n      \t\tif (!data.commit)\n      \t\t\tbreak;\n\n    @@ last-modified.c: int last_modified_run(struct last_modified *lm, last_modified_c\n     +\n      \t\tif (data.commit->object.flags & BOUNDARY) {\n      \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n    - \t\t\t\t       &data.commit->object.oid,\n    + \t\t\t\t      &data.commit->object.oid, \"\",\n    +@@ builtin/last-modified.c: static int last_modified_init(struct last_modified *lm, struct repository *r,\n    + \t\treturn argc;\n    + \t}\n    +\n    ++\t/*\n    ++\t * We're not interested in generation numbers here,\n    ++\t * but calling this function to prepare the commit-graph.\n    ++\t */\n    ++\t(void)generation_numbers_enabled(lm->rev.repo);\n    ++\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n    ++\n    + \tif (populate_paths_from_revs(lm) < 0)\n    + \t\treturn error(_(\"unable to setup last-modified\"));\n    +\n\nbase-commit: 41905d60226a0346b22f0d0d99428c746a5a3b14\n--\n2.50.0.rc0.18.gfcfe60668e\n"},{"id":"521687","messageId":"20250709152628.1644521-2-toon@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH v4 1/3] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-09T15:26:26Z","receivedAt":"2025-07-09T15:26:45Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 289 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 204 +++++++++++++++++++\n 11 files changed, 550 insertions(+)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/t8020-last-modified.sh\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..89138ebeb7\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,49 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+\tRecurse into subtrees.\n+\n+-t::\n+\tShow tree entry itself as well as subtrees.  Implies `-r`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tin path traversal the are included in the output.\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 2fe1a1369d..99aeb6d0e0 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex 70d1543b6b..11bf4fb55a 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1267,6 +1267,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..63993bc1c9\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,289 @@\n+#include \"git-compat-util.h\"\n+#include \"builtin.h\"\n+#include \"commit.h\"\n+#include \"config.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"hashmap.h\"\n+#include \"hex.h\"\n+#include \"log-tree.h\"\n+#include \"object-name.h\"\n+#include \"object.h\"\n+#include \"parse-options.h\"\n+#include \"quote.h\"\n+#include \"repository.h\"\n+#include \"revision.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t       const struct hashmap_entry *hent1,\n+\t\t\t\t       const struct hashmap_entry *hent2,\n+\t\t\t\t       const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+\tint recursive, tree_in_recursive;\n+};\n+\n+static void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+typedef void (*last_modified_callback)(const char *path,\n+\t\t\t\t       const struct commit *commit, void *data);\n+\n+struct last_modified_callback_data {\n+\tstruct commit *commit;\n+\tstruct hashmap *paths;\n+\n+\tlast_modified_callback callback;\n+\tvoid *callback_data;\n+};\n+\n+static void add_path_from_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int populate_paths_from_revs(struct last_modified *lm)\n+{\n+\tint num_interesting = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\t/*\n+\t * Use a callback to populate the paths from revs\n+\t */\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_path_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (num_interesting++)\n+\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tdiff_free(&diffopt);\n+\n+\treturn 0;\n+}\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tif (data->callback)\n+\t\tdata->callback(path, data->commit, data->callback_data);\n+\n+\thashmap_remove(data->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final oid state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+static int last_modified_run(struct last_modified *lm,\n+\t\t\t     last_modified_callback cb, void *cbdata)\n+{\n+\tstruct last_modified_callback_data data;\n+\n+\tdata.paths = &lm->paths;\n+\tdata.callback = cb;\n+\tdata.callback_data = cbdata;\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &data.commit->object.oid, \"\",\n+\t\t\t\t      &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void show_entry(const char *path, const struct commit *commit, void *d)\n+{\n+\tstruct last_modified *lm = d;\n+\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+static int last_modified_init(struct last_modified *lm, struct repository *r,\n+\t\t\t      const char *prefix, int argc, const char **argv)\n+{\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n+\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n+\n+\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n+\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\t\treturn argc;\n+\t}\n+\n+\tif (populate_paths_from_revs(lm) < 0)\n+\t\treturn error(_(\"unable to setup last-modified\"));\n+\n+\treturn 0;\n+}\n+\n+int cmd_last_modified(int argc, const char **argv, const char *prefix,\n+\t\t      struct repository *repo)\n+{\n+\tint ret;\n+\tstruct last_modified lm;\n+\n+\tconst char * const last_modified_usage[] = {\n+\t\tN_(\"git last-modified [-r] [-t] \"\n+\t\t   \"[<revision-range>] [[--] <path>...]\"),\n+\t\tNULL\n+\t};\n+\n+\tstruct option last_modified_options[] = {\n+\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n+\t\t\t N_(\"recurse into subtrees\")),\n+\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n+\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n+\t\tOPT_END()\n+\t};\n+\n+\tmemset(&lm, 0, sizeof(lm));\n+\n+\targc = parse_options(argc, argv, prefix, last_modified_options,\n+\t\t\t     last_modified_usage,\n+\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n+\t\tif (ret > 0)\n+\t\t\tusage_with_options(last_modified_usage,\n+\t\t\t\t\t   last_modified_options);\n+\t\tgoto out;\n+\t}\n+\n+\tif ((ret = last_modified_run(&lm, show_entry, &lm)))\n+\t\tgoto out;\n+\n+out:\n+\tlast_modified_release(&lm);\n+\n+\treturn ret;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 07a5fe39fb..76a0b2a1a4 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/meson.build b/meson.build\nindex 7fea4a34d6..a45608f0a4 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -607,6 +607,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex 6d7fe6b117..eee1863eb3 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -961,6 +961,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..921d2a0807\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,204 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n+\tsort tmp.3 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive with tree' '\n+\tcheck_last_modified -t <<-\\EOF\n+\t1 file\n+\t2 a/file\n+\t3 a\n+\t3 a/b\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t2 a/file\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t1 file\n+\t2 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t1 file\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm1 m1.t\n+\tm2 m2.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb2 file\n+\tb5 file2\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"521688","messageId":"20250709152628.1644521-3-toon@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH v4 2/3] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-09T15:26:27Z","receivedAt":"2025-07-09T15:26:48Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 21 +++++++++++++++++++++\n 2 files changed, 22 insertions(+)\n create mode 100755 t/perf/p8020-last-modified.sh\n\ndiff --git a/t/meson.build b/t/meson.build\nindex eee1863eb3..b41dfc41d7 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1154,6 +1154,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..a02ec907d4\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,21 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"521689","messageId":"20250709152628.1644521-4-toon@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH v4 3/3] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-09T15:26:28Z","receivedAt":"2025-07-09T15:26:51Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nComparing the perf test results on git.git:\n\n    Test                                        HEAD~             HEAD\n    ------------------------------------------------------------------------------------\n    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n builtin/last-modified.c | 45 +++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 45 insertions(+)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 63993bc1c9..466df04fba 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -1,5 +1,7 @@\n #include \"git-compat-util.h\"\n+#include \"bloom.h\"\n #include \"builtin.h\"\n+#include \"commit-graph.h\"\n #include \"commit.h\"\n #include \"config.h\"\n #include \"diff.h\"\n@@ -17,6 +19,7 @@\n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -40,6 +43,12 @@ struct last_modified {\n \n static void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n+\t\tclear_bloom_key(&ent->key);\n+\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -67,6 +76,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -126,6 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n+\tclear_bloom_key(&ent->key);\n \tfree(ent);\n }\n \n@@ -169,6 +182,28 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+\n+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n static int last_modified_run(struct last_modified *lm,\n \t\t\t     last_modified_callback cb, void *cbdata)\n {\n@@ -189,6 +224,9 @@ static int last_modified_run(struct last_modified *lm,\n \t\tif (!data.commit)\n \t\t\tbreak;\n \n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n \t\t\t\t      &data.commit->object.oid, \"\",\n@@ -238,6 +276,13 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n \t\treturn argc;\n \t}\n \n+\t/*\n+\t * We're not interested in generation numbers here,\n+\t * but calling this function to prepare the commit-graph.\n+\t */\n+\t(void)generation_numbers_enabled(lm->rev.repo);\n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (populate_paths_from_revs(lm) < 0)\n \t\treturn error(_(\"unable to setup last-modified\"));\n \n-- \n2.50.0.rc0.18.gfcfe60668e\n\n"},{"id":"521696","messageId":"87y0sxpdi4.fsf@iotcl.com","threadId":"63332","inReplyTo":"8734berbmj.fsf@iotcl.com","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-09T15:53:07Z","receivedAt":"2025-07-09T15:53:20Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> You raise a good point here. Let's compare:\n>\n> $ git ls-tree HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n> 100644 blob 936e0c5130fe7d67f645501fbb9e70b94b437f54\tDocumentation/git-config.adoc\n> 100644 blob 1af38f402ed6437353fb5765f62251966d828df9\tDocumentation/git-last-modified.adoc\n> 100644 blob dce5c49ca2ba65fd6a2974e38f67134215bee369\trefs.c\n> 100644 blob 46a6008e07f2624239139cd8b2ff712545f07d3f\trefs.h\n>\n> $ git last-modified HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n> 56073a0af90be947cfefbfc3cf762b268e5e20a9\tDocumentation\n> 062b914c841329a003f74e1340ea5178391274a6\trefs.c\n> 47478802daddf3f9916111307f153c6298ffc0bc\trefs.h\n>\n> I have to agree with Kristoffer here, and the latter is not what I\n> would expect. Thanks for the testing! I will try to address in next\n> version.\n\nAfter some more testing and tinkering with the code, I've decided to\nkeep the behavior for several reasons:\n\n1. While behavior differs from git-ls-tree(1) (see above), current\n   behavior is identical to git-diff-tree(1):\n\n   $ git diff-tree HEAD~1000 HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n   :040000 040000 810861a07e1360d3e3fa00db3c0d01e0604ff27a 1b01b770c15e7ae586452bb3587c3ce7c01abd91 M\tDocumentation\n   :100644 100644 55d2e0b2cb9e959443e98eb329fdf97eff9073a9 dce5c49ca2ba65fd6a2974e38f67134215bee369 M\trefs.c\n   :100644 100644 d278775e086bfa7990999c226ad1db2f488e890d 46a6008e07f2624239139cd8b2ff712545f07d3f M\trefs.h\n\n   Both git-diff-tree(1) and git-last-modified(1) are marked as plumbing\n   commands, git-ls-tree(1) isn't. So I think that okay.\n\n2. This command uses the diff machinery to walk the trees, so it's not\n   straightforward to change behavior.\n\n3. We can later introduce `--max-depth` as Peff has been trying to do in\n   the past, but that can happen outside this patch series. These has\n   been taken out in an previous attempt[1] to upsteam the\n   git-blame-tree(1) subcommand.\n   Having option `--max-depth` would allow users to get all tree entries\n   in for example the documentation directory, while not getting any\n   from it's subtrees.\n\nWith these considerations I've submitted[2] a new version of my patches.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/20250709152628.1644521-1-toon@iotcl.com/\n\n-- \nCheers,\nToon\n"},{"id":"521707","messageId":"xmqqy0sxb8or.fsf@gitster.g","threadId":"63332","inReplyTo":"87y0sxpdi4.fsf@iotcl.com","subject":"Re: [PATCH RFC v2 0/5] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-09T17:00:52Z","receivedAt":"2025-07-09T17:00:55Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> After some more testing and tinkering with the code, I've decided to\n> keep the behavior for several reasons:\n>\n> 1. While behavior differs from git-ls-tree(1) (see above), current\n>    behavior is identical to git-diff-tree(1):\n>\n>    $ git diff-tree HEAD~1000 HEAD -- refs.c refs.h Documentation/git-last-modified.adoc Documentation/git-config.adoc\n>    :040000 040000 810861a07e1360d3e3fa00db3c0d01e0604ff27a 1b01b770c15e7ae586452bb3587c3ce7c01abd91 M\tDocumentation\n>    :100644 100644 55d2e0b2cb9e959443e98eb329fdf97eff9073a9 dce5c49ca2ba65fd6a2974e38f67134215bee369 M\trefs.c\n>    :100644 100644 d278775e086bfa7990999c226ad1db2f488e890d 46a6008e07f2624239139cd8b2ff712545f07d3f M\trefs.h\n>\n>    Both git-diff-tree(1) and git-last-modified(1) are marked as plumbing\n>    commands, git-ls-tree(1) isn't. So I think that okay.\n\nGood.\n\nWe may want to \"fix\" this \"inconsistency\" someday, and I think it is\na bug that ls-tree is not marked as plumbing.  But this is a topic\nabout last-modified, so it is fine.\n\nThanks.\n"},{"id":"521715","messageId":"xmqqldoxauyk.fsf@gitster.g","threadId":"63332","inReplyTo":"20250709152628.1644521-1-toon@iotcl.com","subject":"Re: [PATCH v4 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-09T21:57:23Z","receivedAt":"2025-07-09T21:57:27Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> This series adds the git-last-modified(1) subcommand. In the past the\n> subcommand was proposed[1] to be named git-blame-tree(1). This version\n> is based on the patches shared by the kind people at GitHub[2].\n\nYou do not have to deal with it just yet, but FYI, another topic in\nflight renames away a few bloom API functions that this topic adds\nmore callers of.\n\nIf this topic needs to be rerolled after the other topic graduates\nto 'master', we may need to see this topic rebased on a newer\n'master' with something like the attached patch squashed in, but\nbecause the other topic is not close to 'next' yet, let's keep these\ntwo topics independent from each other as long as possible, and let\nme deal with this trivial semantic conflict resolution, at least for\nnow.\n\nThanks.\n\n builtin/last-modified.c | 6 +++---\n 1 file changed, 3 insertions(+), 3 deletions(-)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 466df04fba..2beae026cc 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -47,7 +47,7 @@ static void last_modified_release(struct last_modified *lm)\n \tstruct last_modified_entry *ent;\n \n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n-\t\tclear_bloom_key(&ent->key);\n+\t\tbloom_key_clear(&ent->key);\n \n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n@@ -77,7 +77,7 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n \t\tif (lm->rev.bloom_filter_settings)\n-\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\tbloom_key_fill(path, strlen(path), &ent->key,\n \t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n@@ -138,7 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n-\tclear_bloom_key(&ent->key);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n \n-- \n2.50.1-382-gda22511645\n\n\n"},{"id":"521766","messageId":"xmqq8qkv7uzq.fsf@gitster.g","threadId":"63332","inReplyTo":"xmqqldoxauyk.fsf@gitster.g","subject":"Re: [PATCH v4 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-10T18:37:13Z","receivedAt":"2025-07-10T18:37:16Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> This series adds the git-last-modified(1) subcommand. In the past the\n>> subcommand was proposed[1] to be named git-blame-tree(1). This version\n>> is based on the patches shared by the kind people at GitHub[2].\n>\n> You do not have to deal with it just yet, but FYI, another topic in\n> flight renames away a few bloom API functions that this topic adds\n> more callers of.\n>\n> If this topic needs to be rerolled after the other topic graduates\n> to 'master', we may need to see this topic rebased on a newer\n> 'master' with something like the attached patch squashed in, but\n> because the other topic is not close to 'next' yet, let's keep these\n> two topics independent from each other as long as possible, and let\n> me deal with this trivial semantic conflict resolution, at least for\n> now.\n>\n> Thanks.\n\nThe situation wrt what you need to do hasn't changed, but the other\ntopic reshuffled the order of parameters for a few API functions.\nThe result does make more sense to have the key structure as the\nfirst parameter, but a fallout is that the way the new calls added\nby this series are massaged to fit the new world order needs to be\nupdated.\n\nAgain, just FYI, if you ended up needing to rebase your topic on top\nof the other one, the following would become necessary.\n\n builtin/last-modified.c | 6 +++---\n 1 file changed, 3 insertions(+), 3 deletions(-)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 466df04fba..70e1e14f21 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -47,7 +47,7 @@ static void last_modified_release(struct last_modified *lm)\n \tstruct last_modified_entry *ent;\n \n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n-\t\tclear_bloom_key(&ent->key);\n+\t\tbloom_key_clear(&ent->key);\n \n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n@@ -77,7 +77,7 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n \t\tif (lm->rev.bloom_filter_settings)\n-\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\tbloom_key_fill(&ent->key, path, strlen(path),\n \t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n@@ -138,7 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n-\tclear_bloom_key(&ent->key);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n \n-- \n2.50.1-394-g0a41f16de2\n\n"},{"id":"522098","messageId":"20250716133206.1787549-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250709152628.1644521-1-toon@iotcl.com","subject":"[PATCH v5 0/6] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:32:00Z","receivedAt":"2025-07-16T13:33:07Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"On many forges the tree view is shown in combination with commit data.\nIn such a view each tree entry is accompanied with the commit message\nand date that last modified that tree entry. Something similar like:\n\n    | README.md   | README: *.txt -> *.adoc fixes                           | 4 months ago |\n    | RelNotes    | Start 2.51 cycle, the first batch                       | 4 weeks ago  |\n    | SECURITY.md | SECURITY: describe how to report vulnerabilities        | 4 years      |\n    | abspath.c   | abspath: move related functions to abspath              | 2 years      |\n    | abspath.h   | abspath: move related functions to abspath              | 2 years      |\n    | aclocal.m4  | configure: use AC_LANG_PROGRAM consistently             | 15 years ago |\n    | add-patch.c | pager: stop using `the_repository`                      | 7 months ago |\n    | advice.c    | advice: allow disabling default branch name advice      | 4 months ago |\n    | advice.h    | advice: allow disabling default branch name advice      | 4 months ago |\n    | alias.h     | rebase -m: fix serialization of strategy options        | 2 years      |\n    | alloc.h     | git-compat-util: move alloc macros to git-compat-util.h | 2 years ago  |\n    | apply.c     | apply: only write intents to add for new files          | 8 days ago   |\n    | archive.c   | Merge branch 'ps/parse-options-integers'                | 3 months ago |\n    | archive.h   | archive.h: remove unnecessary include                   | 1 year       |\n    | attr.h      | fuzz: port fuzz-parse-attr-line from OSS-Fuzz           | 9 months ago |\n    | banned.h    | banned.h: mark `strtok()` and `strtok_r()` as banned    | 2 years      |\n\nThis series adds the git-last-modified(1) to feed this view. In the past\nthe subcommand was proposed[1] to be named git-blame-tree(1). This\nversion is based on the patches shared by the kind people at GitHub[2].\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[5] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I think it's a separate topic to\n  discuss and I'm not sure it needs to be part of series anyway. The\n  main patch was submitted in the previous attempt[3] and if people\n  consider it valuable, I'm happy to discuss that in a separate patch\n  series.\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* Dropped the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. In my testing I've\n  seen both performance improvements *and* degradation with these\n  changes:\n\n  Test                                        HEAD~             HEAD\n  ------------------------------------------------------------------------------------\n  8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n  8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n  8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n\n  Before we include these patches, I want to make sure these changes\n  have positive impact in all/most scenarios. This can happen in a\n  separate series.\n\nI've set myself as the author and added Based-on-patch-by trailers to\ncredit the original authors. Let me know if you disagree.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n[3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[4]: git@github.com:ttaylorr/git.git\n[5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n\n---\nChanges in v5:\n\n- Added a patch to allow for an \"extended\" format. The name for this option is\n  open for debate (please, all input is welcome). But the main goal of this\n  series is to provide the data needed for the \"forge tree view\" as demoed at\n  the top of this cover letter. With this extra patch (and the prepatory patch\n  to pretty.[ch]), I hope the use-case because more clear. But because it wasn't\n  included in previous 4 versions I also wouldn't mind sending a separate patch\n  series for it.\n- Removed the call to sort(1) the t8020 tests. This was needed for the tests for\n  --extended.\n- I'm adding a fixup! commit to be compatible with in-flight patches for bloom\n  filter optimizations:\n  https://lore.kernel.org/git/20250712093517.17907-1-yldhome2d2@gmail.com/\n  This patch can be dropped if current series lands before those.\n\nChanges in v4:\n- Removed root-level `last-modified.[ch]` library code and moved code to\n  `builtin/last-modified.c`. Historically we've had libary code (also because it\n  was used in testtool), but we no longer need that separation. I'm sorry this\n  makes the range-diff hard to read.\n- Added the use of parse_options() to get better usage messages.\n- Formatting fixes after conversation in\n  https://lore.kernel.org/git/xmqqh5zvk5h0.fsf@gitster.g/\n- Link to v3: https://lore.kernel.org/git/20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com/\n\nChanges in v3:\n- Updated benchmarks in commit messages.\n- Removed the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. (see above)\n- Move handling failure in `last_modified_init()` to the caller.\n- Sorted #include clauses lexicographically.\n- Removed unneeded `commit` in `struct last_modified_entry`.\n- Renamed some functions/variables and added some comments to make it\n  easier to understand.\n- Removed unnecessary checking of the commit-graph generation number.\n- Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\nToon Claes (6):\n  last-modified: new subcommand to show when files were last modified\n  t/perf: add last-modified perf script\n  last-modified: use Bloom filters when available\n  pretty: allow caller to disable indentation\n  last-modified: support --extended format\n  fixup! last-modified: use Bloom filters when available\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  95 +++++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 366 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n pretty.c                             |   2 +-\n pretty.h                             |   1 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  21 ++\n t/t8020-last-modified.sh             | 225 ++++++++++++++++\n 14 files changed, 718 insertions(+), 1 deletion(-)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/perf/p8020-last-modified.sh\n create mode 100755 t/t8020-last-modified.sh\n\nRange-diff against v4:\n1:  0cc625f3f5 ! 1:  da0e391faa last-modified: new subcommand to show when files were last modified\n    @@ t/t8020-last-modified.sh (new)\n     +\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n     +\tgit name-rev --annotate-stdin --name-only --tags \\\n     +\t\t<tmp.1 >tmp.2 &&\n    -+\ttr '\\t' ' ' <tmp.2 >tmp.3 &&\n    -+\tsort tmp.3 >actual &&\n    ++\ttr '\\t' ' ' <tmp.2 >actual &&\n     +\ttest_cmp expect actual\n     +}\n     +\n     +test_expect_success 'last-modified non-recursive' '\n     +\tcheck_last_modified <<-\\EOF\n    -+\t1 file\n     +\t3 a\n    ++\t1 file\n     +\tEOF\n     +'\n     +\n     +test_expect_success 'last-modified recursive' '\n     +\tcheck_last_modified -r <<-\\EOF\n    -+\t1 file\n    -+\t2 a/file\n     +\t3 a/b/file\n    ++\t2 a/file\n    ++\t1 file\n     +\tEOF\n     +'\n     +\n     +test_expect_success 'last-modified recursive with tree' '\n     +\tcheck_last_modified -t <<-\\EOF\n    -+\t1 file\n    -+\t2 a/file\n     +\t3 a\n     +\t3 a/b\n     +\t3 a/b/file\n    ++\t2 a/file\n    ++\t1 file\n     +\tEOF\n     +'\n     +\n    @@ t/t8020-last-modified.sh (new)\n     +\n     +test_expect_success 'last-modified subdir recursive' '\n     +\tcheck_last_modified -r a <<-\\EOF\n    -+\t2 a/file\n     +\t3 a/b/file\n    ++\t2 a/file\n     +\tEOF\n     +'\n     +\n     +test_expect_success 'last-modified from non-HEAD commit' '\n     +\tcheck_last_modified HEAD^ <<-\\EOF\n    -+\t1 file\n     +\t2 a\n    ++\t1 file\n     +\tEOF\n     +'\n     +\n     +test_expect_success 'last-modified from subdir defaults to root' '\n     +\tcheck_last_modified -C a <<-\\EOF\n    -+\t1 file\n     +\t3 a\n    ++\t1 file\n     +\tEOF\n     +'\n     +\n    @@ t/t8020-last-modified.sh (new)\n     +\ttest_commit m2 &&\n     +\tgit merge m1 &&\n     +\tcheck_last_modified <<-\\EOF\n    -+\tm1 m1.t\n     +\tm2 m2.t\n    ++\tm1 m1.t\n     +\tEOF\n     +'\n     +\n    @@ t/t8020-last-modified.sh (new)\n     +\tgit checkout b2 -- file &&\n     +\tgit merge --continue &&\n     +\tcheck_last_modified <<-\\EOF\n    -+\tb2 file\n     +\tb5 file2\n    ++\tb2 file\n     +\tEOF\n     +'\n     +\n2:  a017f2c81c = 2:  9182f86440 t/perf: add last-modified perf script\n3:  c739a7dbcc = 3:  b4fa376572 last-modified: use Bloom filters when available\n-:  ---------- > 4:  3df7833d59 pretty: allow caller to disable indentation\n-:  ---------- > 5:  c2ac21c057 last-modified: support --extended format\n-:  ---------- > 6:  0be24d898d fixup! last-modified: use Bloom filters when available\n\nbase-commit: 32571a0222eb85ef265e136f27e44c302302b45c\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522099","messageId":"20250716133518.1788126-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:13Z","receivedAt":"2025-07-16T13:35:38Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 289 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 203 +++++++++++++++++++\n 11 files changed, 549 insertions(+)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/t8020-last-modified.sh\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..89138ebeb7\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,49 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+\tRecurse into subtrees.\n+\n+-t::\n+\tShow tree entry itself as well as subtrees.  Implies `-r`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tin path traversal the are included in the output.\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 2fe1a1369d..99aeb6d0e0 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex 5f7dd79dfa..b5ce55a703 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1265,6 +1265,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..63993bc1c9\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,289 @@\n+#include \"git-compat-util.h\"\n+#include \"builtin.h\"\n+#include \"commit.h\"\n+#include \"config.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"hashmap.h\"\n+#include \"hex.h\"\n+#include \"log-tree.h\"\n+#include \"object-name.h\"\n+#include \"object.h\"\n+#include \"parse-options.h\"\n+#include \"quote.h\"\n+#include \"repository.h\"\n+#include \"revision.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t       const struct hashmap_entry *hent1,\n+\t\t\t\t       const struct hashmap_entry *hent2,\n+\t\t\t\t       const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+\tint recursive, tree_in_recursive;\n+};\n+\n+static void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+typedef void (*last_modified_callback)(const char *path,\n+\t\t\t\t       const struct commit *commit, void *data);\n+\n+struct last_modified_callback_data {\n+\tstruct commit *commit;\n+\tstruct hashmap *paths;\n+\n+\tlast_modified_callback callback;\n+\tvoid *callback_data;\n+};\n+\n+static void add_path_from_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int populate_paths_from_revs(struct last_modified *lm)\n+{\n+\tint num_interesting = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\t/*\n+\t * Use a callback to populate the paths from revs\n+\t */\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_path_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (num_interesting++)\n+\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tdiff_free(&diffopt);\n+\n+\treturn 0;\n+}\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tif (data->callback)\n+\t\tdata->callback(path, data->commit, data->callback_data);\n+\n+\thashmap_remove(data->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final oid state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+static int last_modified_run(struct last_modified *lm,\n+\t\t\t     last_modified_callback cb, void *cbdata)\n+{\n+\tstruct last_modified_callback_data data;\n+\n+\tdata.paths = &lm->paths;\n+\tdata.callback = cb;\n+\tdata.callback_data = cbdata;\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &data.commit->object.oid, \"\",\n+\t\t\t\t      &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void show_entry(const char *path, const struct commit *commit, void *d)\n+{\n+\tstruct last_modified *lm = d;\n+\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+static int last_modified_init(struct last_modified *lm, struct repository *r,\n+\t\t\t      const char *prefix, int argc, const char **argv)\n+{\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n+\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n+\n+\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n+\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\t\treturn argc;\n+\t}\n+\n+\tif (populate_paths_from_revs(lm) < 0)\n+\t\treturn error(_(\"unable to setup last-modified\"));\n+\n+\treturn 0;\n+}\n+\n+int cmd_last_modified(int argc, const char **argv, const char *prefix,\n+\t\t      struct repository *repo)\n+{\n+\tint ret;\n+\tstruct last_modified lm;\n+\n+\tconst char * const last_modified_usage[] = {\n+\t\tN_(\"git last-modified [-r] [-t] \"\n+\t\t   \"[<revision-range>] [[--] <path>...]\"),\n+\t\tNULL\n+\t};\n+\n+\tstruct option last_modified_options[] = {\n+\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n+\t\t\t N_(\"recurse into subtrees\")),\n+\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n+\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n+\t\tOPT_END()\n+\t};\n+\n+\tmemset(&lm, 0, sizeof(lm));\n+\n+\targc = parse_options(argc, argv, prefix, last_modified_options,\n+\t\t\t     last_modified_usage,\n+\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n+\t\tif (ret > 0)\n+\t\t\tusage_with_options(last_modified_usage,\n+\t\t\t\t\t   last_modified_options);\n+\t\tgoto out;\n+\t}\n+\n+\tif ((ret = last_modified_run(&lm, show_entry, &lm)))\n+\t\tgoto out;\n+\n+out:\n+\tlast_modified_release(&lm);\n+\n+\treturn ret;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 07a5fe39fb..76a0b2a1a4 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/meson.build b/meson.build\nindex 9579377f3d..cda6bf67fe 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -607,6 +607,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex 1af289425d..fc77343331 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -961,6 +961,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..05c113a1f8\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,203 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive with tree' '\n+\tcheck_last_modified -t <<-\\EOF\n+\t3 a\n+\t3 a/b\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t2 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm2 m2.t\n+\tm1 m1.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb5 file2\n+\tb2 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522100","messageId":"20250716133518.1788126-2-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 2/6] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:14Z","receivedAt":"2025-07-16T13:35:48Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 21 +++++++++++++++++++++\n 2 files changed, 22 insertions(+)\n create mode 100755 t/perf/p8020-last-modified.sh\n\ndiff --git a/t/meson.build b/t/meson.build\nindex fc77343331..567d524e91 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1154,6 +1154,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..a02ec907d4\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,21 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522101","messageId":"20250716133518.1788126-3-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 3/6] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:15Z","receivedAt":"2025-07-16T13:35:53Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nComparing the perf test results on git.git:\n\n    Test                                        HEAD~             HEAD\n    ------------------------------------------------------------------------------------\n    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n builtin/last-modified.c | 45 +++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 45 insertions(+)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 63993bc1c9..466df04fba 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -1,5 +1,7 @@\n #include \"git-compat-util.h\"\n+#include \"bloom.h\"\n #include \"builtin.h\"\n+#include \"commit-graph.h\"\n #include \"commit.h\"\n #include \"config.h\"\n #include \"diff.h\"\n@@ -17,6 +19,7 @@\n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n\n@@ -40,6 +43,12 @@ struct last_modified {\n\n static void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n+\t\tclear_bloom_key(&ent->key);\n+\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -67,6 +76,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n\n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -126,6 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n\n \thashmap_remove(data->paths, &ent->hashent, path);\n+\tclear_bloom_key(&ent->key);\n \tfree(ent);\n }\n\n@@ -169,6 +182,28 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n\n+\n+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n static int last_modified_run(struct last_modified *lm,\n \t\t\t     last_modified_callback cb, void *cbdata)\n {\n@@ -189,6 +224,9 @@ static int last_modified_run(struct last_modified *lm,\n \t\tif (!data.commit)\n \t\t\tbreak;\n\n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n \t\t\t\t      &data.commit->object.oid, \"\",\n@@ -238,6 +276,13 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n \t\treturn argc;\n \t}\n\n+\t/*\n+\t * We're not interested in generation numbers here,\n+\t * but calling this function to prepare the commit-graph.\n+\t */\n+\t(void)generation_numbers_enabled(lm->rev.repo);\n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (populate_paths_from_revs(lm) < 0)\n \t\treturn error(_(\"unable to setup last-modified\"));\n\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522102","messageId":"20250716133518.1788126-4-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 4/6] pretty: allow caller to disable indentation","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:16Z","receivedAt":"2025-07-16T13:35:58Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Most pretty formats indent the commit message with 4 spaces. Add field\n`no_indent` to `struct pretty_print_context` to suppress this\nindentation.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n\n# Conflicts:\n#\tpretty.h\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n pretty.c | 2 +-\n pretty.h | 1 +\n 2 files changed, 2 insertions(+), 1 deletion(-)\n\ndiff --git a/pretty.c b/pretty.c\nindex 0bc8ad8a9a..9b1698417e 100644\n--- a/pretty.c\n+++ b/pretty.c\n@@ -2286,7 +2286,7 @@ void pretty_print_commit(struct pretty_print_context *pp,\n \t\t\t struct strbuf *sb)\n {\n \tunsigned long beginning_of_body;\n-\tint indent = 4;\n+\tint indent = pp->no_indent ? 0 : 4;\n \tconst char *msg;\n \tconst char *reencoded;\n \tconst char *encoding;\ndiff --git a/pretty.h b/pretty.h\nindex df267afe4a..5d25ae2320 100644\n--- a/pretty.h\n+++ b/pretty.h\n@@ -50,6 +50,7 @@ struct pretty_print_context {\n \tstruct ident_split *from_ident;\n \tunsigned encode_email_headers:1;\n \tstruct pretty_print_describe_status *describe_status;\n+\tint no_indent;\n\n \t/*\n \t * Fields below here are manipulated internally by pp_* functions and\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522103","messageId":"20250716133518.1788126-5-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 5/6] last-modified: support --extended format","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:17Z","receivedAt":"2025-07-16T13:36:08Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"On many forges when they display a tree view, they show which commit\nlast modified each entry of the tree. The command git-last-modified(1)\nwas introduced to feed the data for this view. But it only returned the\ncommit OID and the path.\n\nAdd option --extended to git-last-modified(1). In combination with the\npath and the commit OID, it shows the raw commit data which can be used\ndirectly to feed the tree view on a forge.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-last-modified.adoc | 46 ++++++++++++++++++++++++++++\n builtin/last-modified.c              | 46 +++++++++++++++++++++++-----\n t/t8020-last-modified.sh             | 22 +++++++++++++\n 3 files changed, 107 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nindex 89138ebeb7..af028c7b74 100644\n--- a/Documentation/git-last-modified.adoc\n+++ b/Documentation/git-last-modified.adoc\n@@ -27,6 +27,14 @@ OPTIONS\n -t::\n \tShow tree entry itself as well as subtrees.  Implies `-r`.\n\n+-z::\n+\n+\tInstead of separating output entries with newlines, use a NUL byte to\n+\tdelimit them. See 'OUTPUT' for more details.\n+\n+--extended::\n+\tShow output in extended format. See 'OUTPUT' below.\n+\n <revision-range>::\n \tOnly traverse commits in the specified revision range. When no\n \t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n@@ -39,6 +47,44 @@ OPTIONS\n \tWithout an optional path parameter, all files and subdirectories\n \tin path traversal the are included in the output.\n\n+OUTPUT\n+------\n+\n+The default format prints for each path:\n+\n+\t<oid> TAB <path> LF\n+\n+When the commit is at boundary, it's prefixed with a caret `^`.\n+\n+Or when option `-z` is given:\n+\n+\t<oid> TAB <path> NUL\n+\n+When `--extended` is provided, the output will be in the format:\n+\n+\tpath SP <path> LF\n+\tcommit SP <oid> LF\n+\ttree SP <tree> LF\n+\tparent SP <parent> LF\n+\tauthor SP <author> LF\n+\t    <message>\n+\n+Each line of the commit message is indented with four spaces.\n+\n+Unless together with `--extended` option `-z` is given, then the output is:\n+\n+\tpath SP <path> NUL\n+\tcommit SP <oid> LF\n+\ttree SP <tree> LF\n+\tparent SP <parent> LF\n+\tauthor SP <author> LF\n+\t<message>\n+\n+In this situation the commit message is not indented.\n+\n+A path containing SP or special characters is enclosed in double-quotes in the C\n+style as needed, unless option `-z` is provided.\n+\n SEE ALSO\n --------\n linkgit:git-blame[1],\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 466df04fba..71c66e8782 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -12,6 +12,7 @@\n #include \"object-name.h\"\n #include \"object.h\"\n #include \"parse-options.h\"\n+#include \"pretty.h\"\n #include \"quote.h\"\n #include \"repository.h\"\n #include \"revision.h\"\n@@ -39,6 +40,7 @@ struct last_modified {\n \tstruct hashmap paths;\n \tstruct rev_info rev;\n \tint recursive, tree_in_recursive;\n+\tint extended;\n };\n\n static void last_modified_release(struct last_modified *lm)\n@@ -244,14 +246,42 @@ static void show_entry(const char *path, const struct commit *commit, void *d)\n {\n \tstruct last_modified *lm = d;\n\n-\tif (commit->object.flags & BOUNDARY)\n-\t\tputchar('^');\n-\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\tif (lm->extended) {\n+\t\tstruct strbuf buf = STRBUF_INIT;\n+\t\tstruct pretty_print_context pp = { 0 };\n\n-\tif (lm->rev.diffopt.line_termination)\n-\t\twrite_name_quoted(path, stdout, '\\n');\n-\telse\n-\t\tprintf(\"%s%c\", path, '\\0');\n+\t\tpp.abbrev = lm->rev.abbrev;\n+\t\tpp.date_mode = lm->rev.date_mode;\n+\t\tpp.date_mode_explicit = lm->rev.date_mode_explicit;\n+\t\tpp.fmt = CMIT_FMT_RAW;\n+\t\tpp.color = lm->rev.diffopt.use_color;\n+\t\tpp.rev = &lm->rev;\n+\t\tpp.no_indent = !lm->rev.diffopt.line_termination;\n+\n+\t\tpretty_print_commit(&pp, commit, &buf);\n+\n+\t\tprintf(\"path \");\n+\t\tif (lm->rev.diffopt.line_termination)\n+\t\t\twrite_name_quoted(path, stdout, '\\n');\n+\t\telse\n+\t\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\t\tprintf(\"commit %s%s\\n\",\n+\t\t       (commit->object.flags & BOUNDARY) ? \"^\" : \"\",\n+\t\t       oid_to_hex(&commit->object.oid));\n+\t\tprintf(\"%s%c\", buf.buf, lm->rev.diffopt.line_termination);\n+\n+\t\tstrbuf_release(&buf);\n+\t} else {\n+\t\tprintf(\"%s%s\\t\",\n+\t\t       (commit->object.flags & BOUNDARY) ? \"^\" : \"\",\n+\t\t       oid_to_hex(&commit->object.oid));\n+\n+\t\tif (lm->rev.diffopt.line_termination)\n+\t\t\twrite_name_quoted(path, stdout, '\\n');\n+\t\telse\n+\t\t\tprintf(\"%s%c\", path, '\\0');\n+\t}\n\n \tfflush(stdout);\n }\n@@ -306,6 +336,8 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"recurse into subtrees\")),\n \t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n \t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n+\t\tOPT_BOOL(0, \"extended\", &lm.extended,\n+\t\t\t N_(\"extended format will include the commit message in the output\")),\n \t\tOPT_END()\n \t};\n\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nindex 05c113a1f8..008ea708ab 100755\n--- a/t/t8020-last-modified.sh\n+++ b/t/t8020-last-modified.sh\n@@ -48,6 +48,28 @@ test_expect_success 'last-modified non-recursive' '\n \tEOF\n '\n\n+test_expect_success 'last-modified extended output' '\n+\tcheck_last_modified --extended <<-\\EOF\n+\tpath a\n+\tcommit 3\n+\ttree e9a947598482012e54c9c5d3635d5b526b43a6a4\n+\tparent 2\n+\tauthor A U Thor <author@example.com> 1112912113 -0700\n+\tcommitter C O Mitter <committer@example.com> 1112912113 -0700\n+\n+\t    3\n+\n+\tpath file\n+\tcommit 1\n+\ttree f27c6ae26adb8396d3861976ba268f87ad8afa0b\n+\tauthor A U Thor <author@example.com> 1112911993 -0700\n+\tcommitter C O Mitter <committer@example.com> 1112911993 -0700\n+\n+\t    1\n+\n+\tEOF\n+'\n+\n test_expect_success 'last-modified recursive' '\n \tcheck_last_modified -r <<-\\EOF\n \t3 a/b/file\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522104","messageId":"20250716133518.1788126-6-toon@iotcl.com","threadId":"63332","inReplyTo":"20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com","subject":"[PATCH v5 6/6] fixup! last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:35:18Z","receivedAt":"2025-07-16T13:36:11Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Make changes compatible with the ongoing work in the bloom filter\noptimizations for multiple pathspec elements.\n\n[1]: https://lore.kernel.org/git/20250712093517.17907-1-yldhome2d2@gmail.com/\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n builtin/last-modified.c | 6 +++---\n 1 file changed, 3 insertions(+), 3 deletions(-)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 71c66e8782..7cc57f9ada 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -49,7 +49,7 @@ static void last_modified_release(struct last_modified *lm)\n \tstruct last_modified_entry *ent;\n \n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n-\t\tclear_bloom_key(&ent->key);\n+\t\tbloom_key_clear(&ent->key);\n \n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n@@ -79,7 +79,7 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n \t\tif (lm->rev.bloom_filter_settings)\n-\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\tbloom_key_fill(&ent->key, path, strlen(path),\n \t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n@@ -140,7 +140,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n \n \thashmap_remove(data->paths, &ent->hashent, path);\n-\tclear_bloom_key(&ent->key);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n \n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"522105","messageId":"20250716134213.1789445-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v5 6/6] fixup! last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-16T13:42:13Z","receivedAt":"2025-07-16T13:42:19Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Make changes compatible with the ongoing work in the bloom filter\noptimizations for multiple pathspec elements.\n\n[1]: https://lore.kernel.org/git/20250712093517.17907-1-yldhome2d2@gmail.com/\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nThis is a resend because I originally sent it with the wrong In-Reply-To and Cc.\n\n--\nToon\n\n builtin/last-modified.c | 6 +++---\n 1 file changed, 3 insertions(+), 3 deletions(-)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 71c66e8782..7cc57f9ada 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -49,7 +49,7 @@ static void last_modified_release(struct last_modified *lm)\n \tstruct last_modified_entry *ent;\n\n \thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n-\t\tclear_bloom_key(&ent->key);\n+\t\tbloom_key_clear(&ent->key);\n\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n@@ -79,7 +79,7 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n \t\tif (lm->rev.bloom_filter_settings)\n-\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n+\t\t\tbloom_key_fill(&ent->key, path, strlen(path),\n \t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n@@ -140,7 +140,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \t\tdata->callback(path, data->commit, data->callback_data);\n\n \thashmap_remove(data->paths, &ent->hashent, path);\n-\tclear_bloom_key(&ent->key);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n\n--\n2.50.1.327.g047016eb4a\n"},{"id":"522120","messageId":"xmqqbjpkcey4.fsf@gitster.g","threadId":"63332","inReplyTo":"20250716133518.1788126-4-toon@iotcl.com","subject":"Re: [PATCH v5 4/6] pretty: allow caller to disable indentation","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-16T15:50:43Z","receivedAt":"2025-07-16T15:50:46Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Most pretty formats indent the commit message with 4 spaces. Add field\n> `no_indent` to `struct pretty_print_context` to suppress this\n> indentation.\n>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n>\n> # Conflicts:\n> #\tpretty.h\n\nCareful.  There is no need to rush your patches to send a version\nthat hasn't been proof-read.\n\n>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n\nI doubt that this is what you want in this series anyway, though.\nYou use this for \"-z --extended\", which presumably is about giving\nthe output that is as faithful as possible to the original, but if\nyou look at pretty_print_commit(), it does a LOT MORE than just\nindent the log by 4 spaces.  I suspect you would rather want to\navoid even calling pretty_print_commit() in such a code path.\n\n>  pretty.c | 2 +-\n>  pretty.h | 1 +\n>  2 files changed, 2 insertions(+), 1 deletion(-)\n>\n> diff --git a/pretty.c b/pretty.c\n> index 0bc8ad8a9a..9b1698417e 100644\n> --- a/pretty.c\n> +++ b/pretty.c\n> @@ -2286,7 +2286,7 @@ void pretty_print_commit(struct pretty_print_context *pp,\n>  \t\t\t struct strbuf *sb)\n>  {\n>  \tunsigned long beginning_of_body;\n> -\tint indent = 4;\n> +\tint indent = pp->no_indent ? 0 : 4;\n>  \tconst char *msg;\n>  \tconst char *reencoded;\n>  \tconst char *encoding;\n> diff --git a/pretty.h b/pretty.h\n> index df267afe4a..5d25ae2320 100644\n> --- a/pretty.h\n> +++ b/pretty.h\n> @@ -50,6 +50,7 @@ struct pretty_print_context {\n>  \tstruct ident_split *from_ident;\n>  \tunsigned encode_email_headers:1;\n>  \tstruct pretty_print_describe_status *describe_status;\n> +\tint no_indent;\n>\n>  \t/*\n>  \t * Fields below here are manipulated internally by pp_* functions and\n> --\n> 2.50.1.327.g047016eb4a\n"},{"id":"522121","messageId":"xmqqldooazig.fsf@gitster.g","threadId":"63332","inReplyTo":"20250716133518.1788126-5-toon@iotcl.com","subject":"Re: [PATCH v5 5/6] last-modified: support --extended format","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-16T16:09:27Z","receivedAt":"2025-07-16T16:09:30Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> +OUTPUT\n> +------\n> +\n> +The default format prints for each path:\n> +\n> +\t<oid> TAB <path> LF\n> +\n> +When the commit is at boundary, it's prefixed with a caret `^`.\n> +\n> +Or when option `-z` is given:\n> +\n> +\t<oid> TAB <path> NUL\n> +\n> +When `--extended` is provided, the output will be in the format:\n> +\n> +\tpath SP <path> LF\n> +\tcommit SP <oid> LF\n> +\ttree SP <tree> LF\n> +\tparent SP <parent> LF\n> +\tauthor SP <author> LF\n> +\t    <message>\n> +\n> +Each line of the commit message is indented with four spaces.\n> +\n> +Unless together with `--extended` option `-z` is given, then the output is:\n\n\"If\" would probably have been more readable.\n\nI can see why you wrote \"Unless\" here, i.e.\n\n    We indent by four spaces.\n    Unless you use \"-z\" and \"--extended\" together, that is.\n\nbut I do not think it is a good idea to use such a construct here.\nThe reason why I do not think you want to phrase it that way is\nbecause the next block that illustrates what happens when \"-z\" and\n\"--extended\" are used together has more differences than just a mere\n\"is the message indented?\" single bit.  Unlike \"--extended\" without\n\"-z\" that uniformly use LF as inter-item separator, some items are\nNUL terminated while others are LF terminated.\n\n> +\tpath SP <path> NUL\n> +\tcommit SP <oid> LF\n> +\ttree SP <tree> LF\n> +\tparent SP <parent> LF\n> +\tauthor SP <author> LF\n> +\t<message>\n> +\n> +In this situation the commit message is not indented.\n> +\n> +A path containing SP or special characters is enclosed in double-quotes in the C\n> +style as needed, unless option `-z` is provided.\n\nAnother thing I find the above output description somewhat lacking\nis that, while it is clear how each output entry ends when\n\"--extended\" is not given (i.e. it shows what terminates each output\nentry.  The output is one entry per path and either LF or NUL\nterminates an entry), the description of \"--extended\", with or\nwithout \"-z\" is silent about how the reader program is expected to\nnotice when the message ends.\n\nWithout \"-z\" and indented, the end of the <message> part if either\nEOF or any unindented line, whichever comes earlier, I presume?  I\nam planning to teach pretty_print_commit() to stop indenting an\nempty line by 4 spaces, by the way---non-\"-z\" format needs to be\ndesigned to withstand such a change.\n\nHow would this extended format gain more fields in the future?  A\nfree-text <message> has to be at the end?  What if we later need to\nadd another free-text thing (e.g., notes ttached to the commit that\nis responsible for that latest state of the path)?  I suspect that\nyou'd want an explicit tag (perhaps \"message SP <message>\") so that\nthe log message does not have to be anything special among others.\n\nIn any case, the above considerations need to be documented.\n\nWith \"-z\", a message body can begin with \"path \", so you'd need to\narrange some terminator (like NUL) after the message body anyway.\n\nUnless your format is \"we tell about one path and then always exit\",\nthat is, but that is probably not what we want.\n\nThanks.\n\n"},{"id":"522212","messageId":"87wm86ixtf.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqbjpkcey4.fsf@gitster.g","subject":"Re: [PATCH v5 4/6] pretty: allow caller to disable indentation","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-17T16:31:08Z","receivedAt":"2025-07-17T16:31:26Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> Most pretty formats indent the commit message with 4 spaces. Add field\n>> `no_indent` to `struct pretty_print_context` to suppress this\n>> indentation.\n>>\n>> Signed-off-by: Toon Claes <toon@iotcl.com>\n>>\n>> # Conflicts:\n>> #\tpretty.h\n>\n> Careful.  There is no need to rush your patches to send a version\n> that hasn't been proof-read.\n\nWhoops, I missed that. No idea how that happened. I have\n`core.commentchar` set to `;`, because I used to write markdown headings\nin my commit messages (with leading `#`). But even then, this should not\nhappen. I'm curious if I'm able to figure out what went wrong...\n\n>>\n>> Signed-off-by: Toon Claes <toon@iotcl.com>\n>> ---\n>\n> I doubt that this is what you want in this series anyway, though.\n> You use this for \"-z --extended\", which presumably is about giving\n> the output that is as faithful as possible to the original, but if\n> you look at pretty_print_commit(), it does a LOT MORE than just\n> indent the log by 4 spaces.  I suspect you would rather want to\n> avoid even calling pretty_print_commit() in such a code path.\n\nThat's a good point. Before I submitted this version I was thinking to\nhave it support `--format` and `--pretty`, but that was even more\ncomplex than what we have here. Indeed, we probably don't need\npretty_print_commit(). \n\n-- \nCheers,\nToon\n"},{"id":"522213","messageId":"87v7nqixsv.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqldooazig.fsf@gitster.g","subject":"Re: [PATCH v5 5/6] last-modified: support --extended format","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-17T16:31:28Z","receivedAt":"2025-07-17T16:31:39Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> +OUTPUT\n>> +------\n>> +\n>> +The default format prints for each path:\n>> +\n>> +\t<oid> TAB <path> LF\n>> +\n>> +When the commit is at boundary, it's prefixed with a caret `^`.\n>> +\n>> +Or when option `-z` is given:\n>> +\n>> +\t<oid> TAB <path> NUL\n>> +\n>> +When `--extended` is provided, the output will be in the format:\n>> +\n>> +\tpath SP <path> LF\n>> +\tcommit SP <oid> LF\n>> +\ttree SP <tree> LF\n>> +\tparent SP <parent> LF\n>> +\tauthor SP <author> LF\n>> +\t    <message>\n>> +\n>> +Each line of the commit message is indented with four spaces.\n>> +\n>> +Unless together with `--extended` option `-z` is given, then the output is:\n>\n> \"If\" would probably have been more readable.\n>\n> I can see why you wrote \"Unless\" here, i.e.\n>\n>     We indent by four spaces.\n>     Unless you use \"-z\" and \"--extended\" together, that is.\n>\n> but I do not think it is a good idea to use such a construct here.\n> The reason why I do not think you want to phrase it that way is\n> because the next block that illustrates what happens when \"-z\" and\n> \"--extended\" are used together has more differences than just a mere\n> \"is the message indented?\" single bit.  Unlike \"--extended\" without\n> \"-z\" that uniformly use LF as inter-item separator, some items are\n> NUL terminated while others are LF terminated.\n>\n>> +\tpath SP <path> NUL\n>> +\tcommit SP <oid> LF\n>> +\ttree SP <tree> LF\n>> +\tparent SP <parent> LF\n>> +\tauthor SP <author> LF\n>> +\t<message>\n>> +\n>> +In this situation the commit message is not indented.\n>> +\n>> +A path containing SP or special characters is enclosed in double-quotes in the C\n>> +style as needed, unless option `-z` is provided.\n>\n> Another thing I find the above output description somewhat lacking\n> is that, while it is clear how each output entry ends when\n> \"--extended\" is not given (i.e. it shows what terminates each output\n> entry.  The output is one entry per path and either LF or NUL\n> terminates an entry), the description of \"--extended\", with or\n> without \"-z\" is silent about how the reader program is expected to\n> notice when the message ends.\n>\n> Without \"-z\" and indented, the end of the <message> part if either\n> EOF or any unindented line, whichever comes earlier, I presume?\n\nThat's the idea.\n\n> I\n> am planning to teach pretty_print_commit() to stop indenting an\n> empty line by 4 spaces, by the way---non-\"-z\" format needs to be\n> designed to withstand such a change.\n\nIt kind of is. A new entry should start with \"path \" (no leading space).\n\n> How would this extended format gain more fields in the future?  A\n> free-text <message> has to be at the end?  What if we later need to\n> add another free-text thing (e.g., notes ttached to the commit that\n> is responsible for that latest state of the path)?\n\nAhha, you mean a future field that's multi-line? I assume we'd indent\nthose lines, but I agree it would make parsing harder.\n\n> I suspect that\n> you'd want an explicit tag (perhaps \"message SP <message>\") so that\n> the log message does not have to be anything special among others.\n\nThe idea was to have the output compatible with the git-cat-file(1)\noutput. That would then no longe be the case. That's also why you see\nmixed use of NUL and LF delimited fields in \"-z\" mode.\n\n> In any case, the above considerations need to be documented.\n\nYeah, that's worth elaborating on.\n\n> With \"-z\", a message body can begin with \"path \", so you'd need to\n> arrange some terminator (like NUL) after the message body anyway.\n\nThat should be the case, but it seems I missed that in the docs.\n\n> Unless your format is \"we tell about one path and then always exit\",\n> that is, but that is probably not what we want.\n>\n> Thanks.\n\nThanks for this feedback. I've submitted this patch on top mainly to\ngather some feedback, and this is really valuable. I think in the next\nversion of this series I'm gonna leave out this patch because there are\ntoo many loose ends, and I think in our product we can easily integrate\ngit-last-modified(1) if it only supports the single-line output format.\n\n-- \nCheers,\nToon\n"},{"id":"522250","messageId":"xmqqseiu4f6p.fsf@gitster.g","threadId":"63332","inReplyTo":"20250716133518.1788126-5-toon@iotcl.com","subject":"Re: [PATCH v5 5/6] last-modified: support --extended format","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-17T22:37:18Z","receivedAt":"2025-07-17T22:37:22Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> diff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\n> index 05c113a1f8..008ea708ab 100755\n> --- a/t/t8020-last-modified.sh\n> +++ b/t/t8020-last-modified.sh\n> @@ -48,6 +48,28 @@ test_expect_success 'last-modified non-recursive' '\n>  \tEOF\n>  '\n>\n> +test_expect_success 'last-modified extended output' '\n> +\tcheck_last_modified --extended <<-\\EOF\n> +\tpath a\n> +\tcommit 3\n> +\ttree e9a947598482012e54c9c5d3635d5b526b43a6a4\n> +\tparent 2\n> +\tauthor A U Thor <author@example.com> 1112912113 -0700\n> +\tcommitter C O Mitter <committer@example.com> 1112912113 -0700\n> +\n> +\t    3\n> +\n> +\tpath file\n> +\tcommit 1\n> +\ttree f27c6ae26adb8396d3861976ba268f87ad8afa0b\n> +\tauthor A U Thor <author@example.com> 1112911993 -0700\n> +\tcommitter C O Mitter <committer@example.com> 1112911993 -0700\n> +\n> +\t    1\n> +\n> +\tEOF\n> +'\n\nHmph.  This hardcoding of everything does not look easy to maintain.\n\nBesides, the test will fail rather miserably when run with SHA-256\nhash (e.g., post Git 3.0 where the \"git init\" command by default\nwill give you a repository with new hash).\n\nIt looks somewhat inconsistent that tree is shown with its object\nname, but commit is not.\n"},{"id":"522261","messageId":"aHmJmwvYmAyi3Nlh@nand.local","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"Re: [PATCH v5 0/6] Introduce git-last-modified(1) command","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-07-17T23:39:07Z","receivedAt":"2025-07-17T23:39:09Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Jul 16, 2025 at 03:32:00PM +0200, Toon Claes wrote:\n> This series adds the git-last-modified(1) to feed this view. In the past\n> the subcommand was proposed[1] to be named git-blame-tree(1). This\n> version is based on the patches shared by the kind people at GitHub[2].\n\nSorry for completely dropping this from my review queue. Let me try and\ngive it a read...\n\n> What is different from the series shared by GitHub:\n>\n> * Renamed the subcommand from `blame-tree` to `last-modified`. There was\n>   some consensus[5] this name works better, so let's give it a try and\n>   see how this name feels.\n\nHmmph. I prefer the \"blame-tree\" name personally, but I am (a) biased,\nand (b) used to it over \"last-modified\", so I don't think my preference\nor bias should count for much here.\n\n> * Patches for --max-depth are excluded. I think it's a separate topic to\n>   discuss and I'm not sure it needs to be part of series anyway. The\n>   main patch was submitted in the previous attempt[3] and if people\n>   consider it valuable, I'm happy to discuss that in a separate patch\n>   series.\n\nYeah, makes sense.\n\n> * The last-modified command isn't recursive by default. If you want\n>   recurse into subtrees, you need to pass `-r`.\n\nOK.\n\n> * The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n>   caching layer. This feature reads/writes cached results in\n>   `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n>   size, that feature is excluded from this series. I think it's better\n>   to submit this as a separate series.\n\nMakes sense; the caching feature was primarily implemented by Stolee and\nI think for our purposes here can be considered additive and not\nessential to the basic functionality of this new command. For what it's\nworth, I *would* like[^1] to see those features sent to the list at some\npoint, but I agree that they are a significant source of additional\ncomplexity. So punting on them for now seems like the right direction to\nme.\n\n[^1]: My ulterior motive here would be to eventually ditch GitHub's\n  \"blame-tree\" command entirely and remove it from GitHub's diff to\n  upstream. I'm happy to help however I can with that effort once this\n  series lands.\n\n> * Squashed various commits together. Like they introduced a flag\n>   `--go-faster`, which later became the default and only implementation.\n>   That story was wrapped up in a single commit.\n\nPerfect, thank you. I figured that we would not want to keep temporary\nmeasures around like the \"--go-faster\" flag, but I also figured that\nthey may be helpful in unpacking the history of this command, hence why\nI sent them in the first place.\n\n> * Dropped the patches that attempt to increase performance for tree\n>   entries that have not been updated in a long time. In my testing I've\n>   seen both performance improvements *and* degradation with these\n>   changes:\n>\n>   Test                                        HEAD~             HEAD\n>   ------------------------------------------------------------------------------------\n>   8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n>   8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n>   8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n>\n>   Before we include these patches, I want to make sure these changes\n>   have positive impact in all/most scenarios. This can happen in a\n>   separate series.\n\nHmm. It's been long enough that I honestly don't remember the details\nhere, but I agree that this is worth looking into at some point in the\nfuture.\n\n> I've set myself as the author and added Based-on-patch-by trailers to\n> credit the original authors. Let me know if you disagree.\n\nI can't speak for the other authors of this command, but I have no issue\nbeing ~~blamed~~ credited with a \"Based-on-patch-by\" trailer ;-).\n\n> Again thanks to Taylor and the people at GitHub for sharing these\n> patches. I hope we can work together to get this upstreamed.\n\nDitto.\n\nThanks,\nTaylor\n"},{"id":"522262","messageId":"aHmPHcNQYlhGo8JB@nand.local","threadId":"63332","inReplyTo":"20250716133518.1788126-1-toon@iotcl.com","subject":"Re: [PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-07-18T00:02:37Z","receivedAt":"2025-07-18T00:02:39Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Jul 16, 2025 at 03:35:13PM +0200, Toon Claes wrote:\n>  11 files changed, 549 insertions(+)\n>  create mode 100644 Documentation/git-last-modified.adoc\n>  create mode 100644 builtin/last-modified.c\n>  create mode 100755 t/t8020-last-modified.sh\n\nI'm admittedly not entirely sure what the best way to review this patch\nis given its size and my previous exposure to (similar) code.\n\nFrom what I can tell, this does not include the optimizations that\nStolee and I worked on back in 2020-ish. Those would be nice to have,\nbut they are somewhat complex and I think more easily reviewed as an\nincremental change on top rather than as part of the initial version.\n\nAs I mentioned in my response to your the cover letter, I would be more\nthan happy to help you with an effort to introduce those optimizations\non top.\n\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> new file mode 100644\n> index 0000000000..63993bc1c9\n> --- /dev/null\n> +++ b/builtin/last-modified.c\n> @@ -0,0 +1,289 @@\n> +#include \"git-compat-util.h\"\n> +#include \"builtin.h\"\n> +#include \"commit.h\"\n> +#include \"config.h\"\n> +#include \"diff.h\"\n> +#include \"diffcore.h\"\n> +#include \"hashmap.h\"\n> +#include \"hex.h\"\n> +#include \"log-tree.h\"\n> +#include \"object-name.h\"\n> +#include \"object.h\"\n> +#include \"parse-options.h\"\n> +#include \"quote.h\"\n> +#include \"repository.h\"\n> +#include \"revision.h\"\n> +\n> +struct last_modified_entry {\n> +\tstruct hashmap_entry hashent;\n> +\tstruct object_id oid;\n> +\tconst char path[FLEX_ARRAY];\n> +};\n\nAs a general comment on this patch, I am a little sad to see that many\nof the implementation details have been moved back into the builtin\nitself and not in their own last-modified.ch file(s).\n\nApologies if this was already discussed earlier in the thread and I\nsimply missed it, but can you comment on why the last-modified internals\nwere moved into the builtin?\n\nEven in the earliest version of 'blame-tree' that I could find (from\n26999d045b (add blame-tree command, 2012-10-20) in my fork) many of the\ninternals were written in blame-tree.c instead of builtin/blame-tree.c.\n\n> +static int last_modified_entry_hashcmp(const void *unused UNUSED,\n> +\t\t\t\t       const struct hashmap_entry *hent1,\n> +\t\t\t\t       const struct hashmap_entry *hent2,\n> +\t\t\t\t       const void *path)\n> +{\n> +\tconst struct last_modified_entry *ent1 =\n> +\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n> +\tconst struct last_modified_entry *ent2 =\n> +\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n> +\treturn strcmp(ent1->path, path ? path : ent2->path);\n> +}\n> +\n> +struct last_modified {\n> +\tstruct hashmap paths;\n> +\tstruct rev_info rev;\n> +\tint recursive, tree_in_recursive;\n\nCan we either make these two part of a bitfield, or at least declare\nthem separately?\n\n> +};\n> +\n> +static void last_modified_release(struct last_modified *lm)\n> +{\n> +\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n> +\trelease_revisions(&lm->rev);\n> +}\n> +\n> +typedef void (*last_modified_callback)(const char *path,\n> +\t\t\t\t       const struct commit *commit, void *data);\n> +\n> +struct last_modified_callback_data {\n> +\tstruct commit *commit;\n> +\tstruct hashmap *paths;\n> +\n> +\tlast_modified_callback callback;\n> +\tvoid *callback_data;\n> +};\n\nI can't quite tell what the purpose of this struct is in conjunction\nwith the last_modified_callback type above.\n\nThe last_modified_callback type makes sense as a generic callback\nfunction that callers can pass to get <path, commit> pairs, along with\nan arbitrary \"data\" pointer.\n\nBut then you define a last_modified_callback_data struct that, which\nmade me think that it would be used as the data type passed to the\ncallback. In other words, given the existence of this struct, I would\nhave expected the function pointer above to be defined like:\n\n    typedef void (*last_modified_callback)(const char *path,\n                                           const struct commit *commit,\n                                           struct last_modified_callback_data *data);\n\nBut the fact that the _data struct contains a last_modified_callback\nfunction pointer gives us a hint at what's going on here. It seems like\nlast_modified_callback_data is used to store some bookkeeping\ninformation and dispatch calls to the \"callback\" function pointer.\n\nI think that the fact the struct's name ends with \"_data\" is what is\nconfusing to me. I think this would be a little clearer if you renamed\nthis \"struct last_modified_callback\" and the function pointer to\n\"last_modified_callback_fn\" or similar.\n\n(The irony is not lost on me that these comments would be applicable to\nGitHub's version of this code, too :-s).\n\n> +static int populate_paths_from_revs(struct last_modified *lm)\n> +{\n> +\tint num_interesting = 0;\n> +\tstruct diff_options diffopt;\n> +\n> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n> +\t/*\n> +\t * Use a callback to populate the paths from revs\n> +\t */\n> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tdiffopt.format_callback = add_path_from_diff;\n> +\tdiffopt.format_callback_data = lm;\n> +\n> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n> +\n> +\t\tif (obj->item->flags & UNINTERESTING)\n> +\t\t\tcontinue;\n> +\n> +\t\tif (num_interesting++)\n> +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n\nThis error text is a little difficult to parse, but I'm not sure that I\nhave a great suggestion for improving it. The equivalent from GitHub's\nfork is \"can only blame one tree at a time\", and I think the difficulty\nin parsing is that \"last-modified\" isn't a verb.\n\n> +static void mark_path(const char *path, const struct object_id *oid,\n> +\t\t      struct last_modified_callback_data *data)\n> +{\n> +\tstruct last_modified_entry *ent;\n> +\n> +\t/* Is it even a path that we are interested in? */\n> +\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n> +\t\t\t\t\t  struct last_modified_entry, hashent);\n> +\tif (!ent)\n> +\t\treturn;\n> +\n> +\t/*\n> +\t * Is it arriving at a version of interest, or is it from a side branch\n> +\t * which did not contribute to the final state?\n> +\t */\n> +\tif (!oideq(oid, &ent->oid))\n> +\t\treturn;\n\nGitHub's fork writes this as \"if (oid && !oideq(oid, &ent->oid))\", but\nthe commit that introduces the \"oid &&\" portion of that expression\ndoesn't provide us with any clues as to why the change was necessary.\n\nSince you have spent more time with these patches than I have recently,\nperhaps you can help shed some light on what's going on here?\n\nThe rest of the code roughly matches my memory of the early versions of\nthis command.\n\nThanks,\nTaylor\n"},{"id":"522263","messageId":"aHmQiJxfmlbDI+Hq@nand.local","threadId":"63332","inReplyTo":"20250716133518.1788126-2-toon@iotcl.com","subject":"Re: [PATCH v5 2/6] t/perf: add last-modified perf script","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-07-18T00:08:40Z","receivedAt":"2025-07-18T00:08:42Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Jul 16, 2025 at 03:35:14PM +0200, Toon Claes wrote:\n> diff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\n> new file mode 100755\n> index 0000000000..a02ec907d4\n> --- /dev/null\n> +++ b/t/perf/p8020-last-modified.sh\n> @@ -0,0 +1,21 @@\n> +#!/bin/sh\n> +\n> +test_description='last-modified perf tests'\n> +. ./perf-lib.sh\n> +\n> +test_perf_default_repo\n> +\n> +test_perf 'top-level last-modified' '\n> +\tgit last-modified HEAD\n> +'\n> +\n> +test_perf 'top-level recursive last-modified' '\n> +\tgit last-modified -r HEAD\n> +'\n\nThe only notable difference from GitHub's version here is that we do not\nhave a recursive option, so our test is just \"git blame-tree\n--max-depth=0\", which is obviously not applicable here.\n\nWhat you wrote (testing \"last-modified\" both with and without the \"-r\"\noption) makes sense to me.\n\n> +test_perf 'subdir last-modified' '\n> +\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n\nHmm. This line comes directly from the patches that I originally shared,\nbut seeing \"git\" on the left-hand side of a pipe makes me a little\nuneasy.\n\nWe could also use the \"-d\" flag here, which will only show us trees,\nthus eliminating the need for the \"grep ^040000\" portion above.\n\nI'd probably write this as:\n\n    git ls-tree -d HEAD >subtrees &&\n    path=\"$(head -n 1 subtrees | cut -f2)\" &&\n    git last-modified -- \"$path\"\n\nThanks,\nTaylor\n"},{"id":"522265","messageId":"aHmSW1pOUF0U3KFk@nand.local","threadId":"63332","inReplyTo":"20250716133518.1788126-3-toon@iotcl.com","subject":"Re: [PATCH v5 3/6] last-modified: use Bloom filters when available","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-07-18T00:16:27Z","receivedAt":"2025-07-18T00:16:29Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Jul 16, 2025 at 03:35:15PM +0200, Toon Claes wrote:\n> Our 'git last-modified' performs a revision walk, and computes a diff at\n> each point in the walk to figure out whether a given revision changed\n> any of the paths it considers interesting.\n>\n> When changed-path Bloom filters are available, we can avoid computing\n> many such diffs. Before computing a diff, we first check if any of the\n> remaining paths of interest were possibly changed at a given commit by\n> consulting its Bloom filter. If any of them are, we are resigned to\n> compute the diff.\n>\n> If none of those queries returned \"maybe\", we know that the given commit\n> doesn't contain any changed paths which are interesting to us. So, we\n> can avoid computing it in this case.\n>\n> Comparing the perf test results on git.git:\n>\n>     Test                                        HEAD~             HEAD\n>     ------------------------------------------------------------------------------------\n>     8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n>     8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n>     8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nAs an aside on 8020.3 (that I probably should have mentioned in the last\ncommit), I think that our \"| head -n1\" heuristic for picking a sub-tree\nis skewing these results down. In git.git, the lexicographically\nearliest sub-tree is \".github\", which is awfully tiny. I wonder if we\nshould be grabbing the *last* sub-tree, or maybe the largest one by\ncount of entries?\n\n> @@ -40,6 +43,12 @@ struct last_modified {\n>\n>  static void last_modified_release(struct last_modified *lm)\n>  {\n> +\tstruct hashmap_iter iter;\n> +\tstruct last_modified_entry *ent;\n> +\n> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n> +\t\tclear_bloom_key(&ent->key);\n> +\n\nI did a double-take to make sure that ent->key would always be\ninitialized here, but it is thanks to the FLEX_ALLOC_STR() call below.\n\n>  \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n>  \trelease_revisions(&lm->rev);\n>  }\n> @@ -67,6 +76,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n>\n>  \t\tFLEX_ALLOC_STR(ent, path, path);\n>  \t\toidcpy(&ent->oid, &p->two->oid);\n> +\t\tif (lm->rev.bloom_filter_settings)\n> +\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n> +\t\t\t\t       lm->rev.bloom_filter_settings);\n>  \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n>  \t\thashmap_add(&lm->paths, &ent->hashent);\n>  \t}\n> @@ -126,6 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n>  \t\tdata->callback(path, data->commit, data->callback_data);\n>\n>  \thashmap_remove(data->paths, &ent->hashent, path);\n> +\tclear_bloom_key(&ent->key);\n\nOK, we're calling clear_bloom_key() here, too, but it uses\nFREE_AND_NULL(), so calling it again in last_modified_release() may be a\nnoop, but won't ever be a double-free.\n\n> @@ -238,6 +276,13 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n>  \t\treturn argc;\n>  \t}\n>\n> +\t/*\n> +\t * We're not interested in generation numbers here,\n> +\t * but calling this function to prepare the commit-graph.\n> +\t */\n> +\t(void)generation_numbers_enabled(lm->rev.repo);\n> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n\nHmmph. I think when I originally wrote this I was using the side-effect\nof calling generation_numbers_enabled() as a hack. But I think that it\nmay be worth making \"prepare_commit_graph()\" a non-static function and\ncalling that instead.\n\nThanks,\nTaylor\n"},{"id":"522285","messageId":"xmqq5xfp4d15.fsf@gitster.g","threadId":"63332","inReplyTo":"xmqqseiu4f6p.fsf@gitster.g","subject":"Re: [PATCH v5 5/6] last-modified: support --extended format","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-18T17:36:06Z","receivedAt":"2025-07-18T17:36:10Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Hmph.  This hardcoding of everything does not look easy to maintain.\n>\n> Besides, the test will fail rather miserably when run with SHA-256\n> hash (e.g., post Git 3.0 where the \"git init\" command by default\n> will give you a repository with new hash).\n>\n> It looks somewhat inconsistent that tree is shown with its object\n> name, but commit is not.\n\n\nI do not address neither the first point or the last point above,\nbut at least something like the attached patch needs to be squashed\ninto this step to make the SHA-256 tests pass.\n\nThanks.\n\ncommit 86a64ae7a4b866db0f17f906ca5be95333d907ab\nAuthor: Junio C Hamano <gitster@pobox.com>\nDate:   Fri Jul 18 10:33:58 2025 -0700\n\n    fixup! last-modified: support --extended format\n\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nindex 008ea708ab..e737cb2505 100755\n--- a/t/t8020-last-modified.sh\n+++ b/t/t8020-last-modified.sh\n@@ -6,10 +6,12 @@ test_description='last-modified tests'\n \n test_expect_success 'setup' '\n \ttest_commit 1 file &&\n+\tTREE1=$(git rev-parse HEAD^{tree}) &&\n \tmkdir a &&\n \ttest_commit 2 a/file &&\n \tmkdir a/b &&\n-\ttest_commit 3 a/b/file\n+\ttest_commit 3 a/b/file &&\n+\tTREE3=$(git rev-parse HEAD^{tree})\n '\n \n test_expect_success 'cannot run last-modified on two trees' '\n@@ -49,10 +51,10 @@ test_expect_success 'last-modified non-recursive' '\n '\n \n test_expect_success 'last-modified extended output' '\n-\tcheck_last_modified --extended <<-\\EOF\n+\tcheck_last_modified --extended <<-EOF\n \tpath a\n \tcommit 3\n-\ttree e9a947598482012e54c9c5d3635d5b526b43a6a4\n+\ttree $TREE3\n \tparent 2\n \tauthor A U Thor <author@example.com> 1112912113 -0700\n \tcommitter C O Mitter <committer@example.com> 1112912113 -0700\n@@ -61,7 +63,7 @@ test_expect_success 'last-modified extended output' '\n \n \tpath file\n \tcommit 1\n-\ttree f27c6ae26adb8396d3861976ba268f87ad8afa0b\n+\ttree $TREE1\n \tauthor A U Thor <author@example.com> 1112911993 -0700\n \tcommitter C O Mitter <committer@example.com> 1112911993 -0700\n \n"},{"id":"522306","messageId":"20250719064412.GB705356@coredump.intra.peff.net","threadId":"63332","inReplyTo":"aHmPHcNQYlhGo8JB@nand.local","subject":"Re: [PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2025-07-19T06:44:12Z","receivedAt":"2025-07-19T06:44:14Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Thu, Jul 17, 2025 at 08:02:37PM -0400, Taylor Blau wrote:\n\n> > +\t/*\n> > +\t * Is it arriving at a version of interest, or is it from a side branch\n> > +\t * which did not contribute to the final state?\n> > +\t */\n> > +\tif (!oideq(oid, &ent->oid))\n> > +\t\treturn;\n> \n> GitHub's fork writes this as \"if (oid && !oideq(oid, &ent->oid))\", but\n> the commit that introduces the \"oid &&\" portion of that expression\n> doesn't provide us with any clues as to why the change was necessary.\n> \n> Since you have spent more time with these patches than I have recently,\n> perhaps you can help shed some light on what's going on here?\n\nIn the version from tb/blame-tree of your repo, the caching system\ncalls mark_path() with a NULL oid. But none of that code is in Toon's\nversion here. The only call to mark_path() in this series always passes\na pointer to a real struct.\n\n-Peff\n"},{"id":"522456","messageId":"87a54wkzll.fsf@iotcl.com","threadId":"63332","inReplyTo":"aHmJmwvYmAyi3Nlh@nand.local","subject":"Re: [PATCH v5 0/6] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-22T15:35:34Z","receivedAt":"2025-07-22T15:35:48Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Taylor Blau <me@ttaylorr.com> writes:\n\n> On Wed, Jul 16, 2025 at 03:32:00PM +0200, Toon Claes wrote:\n>> This series adds the git-last-modified(1) to feed this view. In the past\n>> the subcommand was proposed[1] to be named git-blame-tree(1). This\n>> version is based on the patches shared by the kind people at GitHub[2].\n>\n> Sorry for completely dropping this from my review queue. Let me try and\n> give it a read...\n\nNo worries, we all got work to do ;)\n\n>> What is different from the series shared by GitHub:\n>>\n>> * Renamed the subcommand from `blame-tree` to `last-modified`. There was\n>>   some consensus[5] this name works better, so let's give it a try and\n>>   see how this name feels.\n>\n> Hmmph. I prefer the \"blame-tree\" name personally, but I am (a) biased,\n> and (b) used to it over \"last-modified\", so I don't think my preference\n> or bias should count for much here.\n\nWell, for what it's worth I like \"blame-tree\" more as well. But didn't\nfeel strong enough to push it through.\n\n>> * Patches for --max-depth are excluded. I think it's a separate topic to\n>>   discuss and I'm not sure it needs to be part of series anyway. The\n>>   main patch was submitted in the previous attempt[3] and if people\n>>   consider it valuable, I'm happy to discuss that in a separate patch\n>>   series.\n>\n> Yeah, makes sense.\n\nI might be revisiting this, because recently I've integrating the WIP\ncommand in our tech stack I noticed having this option would be\nuseful/required.\n\n>> * The last-modified command isn't recursive by default. If you want\n>>   recurse into subtrees, you need to pass `-r`.\n>\n> OK.\n>\n>> * The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n>>   caching layer. This feature reads/writes cached results in\n>>   `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n>>   size, that feature is excluded from this series. I think it's better\n>>   to submit this as a separate series.\n>\n> Makes sense; the caching feature was primarily implemented by Stolee and\n> I think for our purposes here can be considered additive and not\n> essential to the basic functionality of this new command. For what it's\n> worth, I *would* like[^1] to see those features sent to the list at some\n> point, but I agree that they are a significant source of additional\n> complexity. So punting on them for now seems like the right direction to\n> me.\n>\n> [^1]: My ulterior motive here would be to eventually ditch GitHub's\n>   \"blame-tree\" command entirely and remove it from GitHub's diff to\n>   upstream. I'm happy to help however I can with that effort once this\n>   series lands.\n\nObviously. I know how it goes, more code maintained by the community is\nbetter for everyone.\n\n>> * Squashed various commits together. Like they introduced a flag\n>>   `--go-faster`, which later became the default and only implementation.\n>>   That story was wrapped up in a single commit.\n>\n> Perfect, thank you. I figured that we would not want to keep temporary\n> measures around like the \"--go-faster\" flag, but I also figured that\n> they may be helpful in unpacking the history of this command, hence why\n> I sent them in the first place.\n\nI wasn't sure about this. I've had a hard time unraveling what in the 55\npatches in your branch was valuable and what could be squashed into\nother commits.\n\n>> * Dropped the patches that attempt to increase performance for tree\n>>   entries that have not been updated in a long time. In my testing I've\n>>   seen both performance improvements *and* degradation with these\n>>   changes:\n>>\n>>   Test                                        HEAD~             HEAD\n>>   ------------------------------------------------------------------------------------\n>>   8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n>>   8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n>>   8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n>>\n>>   Before we include these patches, I want to make sure these changes\n>>   have positive impact in all/most scenarios. This can happen in a\n>>   separate series.\n>\n> Hmm. It's been long enough that I honestly don't remember the details\n> here, but I agree that this is worth looking into at some point in the\n> future.\n\nI've had this patch included in version 2[1]. I'd love to include it,\nbut it didn't give the results we were expecting. Over time I became\nmore confortable with these changes. Let me see if I can get more\ninsights about it.\n\n>> I've set myself as the author and added Based-on-patch-by trailers to\n>> credit the original authors. Let me know if you disagree.\n>\n> I can't speak for the other authors of this command, but I have no issue\n> being ~~blamed~~ credited with a \"Based-on-patch-by\" trailer ;-).\n>\n>> Again thanks to Taylor and the people at GitHub for sharing these\n>> patches. I hope we can work together to get this upstreamed.\n>\n> Ditto.\n\n<3\n\n[1]: https://lore.kernel.org/git/20250523-toon-new-blame-tree-v2-4-101e4ca4c1c9@iotcl.com/\n\n-- \nCheers,\nToon\n"},{"id":"522459","messageId":"877c00kyw6.fsf@iotcl.com","threadId":"63332","inReplyTo":"aHmPHcNQYlhGo8JB@nand.local","subject":"Re: [PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-22T15:50:49Z","receivedAt":"2025-07-22T15:51:01Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Taylor Blau <me@ttaylorr.com> writes:\n\n> On Wed, Jul 16, 2025 at 03:35:13PM +0200, Toon Claes wrote:\n>>  11 files changed, 549 insertions(+)\n>>  create mode 100644 Documentation/git-last-modified.adoc\n>>  create mode 100644 builtin/last-modified.c\n>>  create mode 100755 t/t8020-last-modified.sh\n>\n> I'm admittedly not entirely sure what the best way to review this patch\n> is given its size and my previous exposure to (similar) code.\n\nYeah, I wasn't sure how to approach this. I didn't want to come in with\na big bang with the final version, but give the reviewers the change to\nsee the improvements (and complexity) come in gradually.\n\n>> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n>> new file mode 100644\n>> index 0000000000..63993bc1c9\n>> --- /dev/null\n>> +++ b/builtin/last-modified.c\n>> @@ -0,0 +1,289 @@\n>> +#include \"git-compat-util.h\"\n>> +#include \"builtin.h\"\n>> +#include \"commit.h\"\n>> +#include \"config.h\"\n>> +#include \"diff.h\"\n>> +#include \"diffcore.h\"\n>> +#include \"hashmap.h\"\n>> +#include \"hex.h\"\n>> +#include \"log-tree.h\"\n>> +#include \"object-name.h\"\n>> +#include \"object.h\"\n>> +#include \"parse-options.h\"\n>> +#include \"quote.h\"\n>> +#include \"repository.h\"\n>> +#include \"revision.h\"\n>> +\n>> +struct last_modified_entry {\n>> +\tstruct hashmap_entry hashent;\n>> +\tstruct object_id oid;\n>> +\tconst char path[FLEX_ARRAY];\n>> +};\n>\n> As a general comment on this patch, I am a little sad to see that many\n> of the implementation details have been moved back into the builtin\n> itself and not in their own last-modified.ch file(s).\n>\n> Apologies if this was already discussed earlier in the thread and I\n> simply missed it, but can you comment on why the last-modified internals\n> were moved into the builtin?\n\nWasn't discussed yet, and this only happened in this last version.\nBasically my idea was: there's no one else using this, why put it at the\nroot level anyway? Also, it relies heavily on `setup_revisions()`. In my\nfirst iterations `argc` and `argv` from the builtin were passed on\ndirectly to the root-level `last-modified.[ch]` subsystem. This is a\nlittle awkward, putting so much raw user-input handling in the\nsubsystem.\n\n\n> Even in the earliest version of 'blame-tree' that I could find (from\n> 26999d045b (add blame-tree command, 2012-10-20) in my fork) many of the\n> internals were written in blame-tree.c instead of builtin/blame-tree.c.\n>\n>> +static int last_modified_entry_hashcmp(const void *unused UNUSED,\n>> +\t\t\t\t       const struct hashmap_entry *hent1,\n>> +\t\t\t\t       const struct hashmap_entry *hent2,\n>> +\t\t\t\t       const void *path)\n>> +{\n>> +\tconst struct last_modified_entry *ent1 =\n>> +\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n>> +\tconst struct last_modified_entry *ent2 =\n>> +\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n>> +\treturn strcmp(ent1->path, path ? path : ent2->path);\n>> +}\n>> +\n>> +struct last_modified {\n>> +\tstruct hashmap paths;\n>> +\tstruct rev_info rev;\n>> +\tint recursive, tree_in_recursive;\n>\n> Can we either make these two part of a bitfield, or at least declare\n> them separately?\n>\n>> +};\n>> +\n>> +static void last_modified_release(struct last_modified *lm)\n>> +{\n>> +\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n>> +\trelease_revisions(&lm->rev);\n>> +}\n>> +\n>> +typedef void (*last_modified_callback)(const char *path,\n>> +\t\t\t\t       const struct commit *commit, void *data);\n>> +\n>> +struct last_modified_callback_data {\n>> +\tstruct commit *commit;\n>> +\tstruct hashmap *paths;\n>> +\n>> +\tlast_modified_callback callback;\n>> +\tvoid *callback_data;\n>> +};\n>\n> I can't quite tell what the purpose of this struct is in conjunction\n> with the last_modified_callback type above.\n\nYeah, this is kind of a remnant of when there was a last-modified\nsubsystem. In current implementation, where all code lives in the\nbuiltin, there's no good reason to keep this callback struct.\n\n> The last_modified_callback type makes sense as a generic callback\n> function that callers can pass to get <path, commit> pairs, along with\n> an arbitrary \"data\" pointer.\n>\n> But then you define a last_modified_callback_data struct that, which\n> made me think that it would be used as the data type passed to the\n> callback. In other words, given the existence of this struct, I would\n> have expected the function pointer above to be defined like:\n>\n>     typedef void (*last_modified_callback)(const char *path,\n>                                            const struct commit *commit,\n>                                            struct last_modified_callback_data *data);\n>\n> But the fact that the _data struct contains a last_modified_callback\n> function pointer gives us a hint at what's going on here. It seems like\n> last_modified_callback_data is used to store some bookkeeping\n> information and dispatch calls to the \"callback\" function pointer.\n>\n> I think that the fact the struct's name ends with \"_data\" is what is\n> confusing to me. I think this would be a little clearer if you renamed\n> this \"struct last_modified_callback\" and the function pointer to\n> \"last_modified_callback_fn\" or similar.\n>\n> (The irony is not lost on me that these comments would be applicable to\n> GitHub's version of this code, too :-s).\n\nHey, that's no excuse to keep it like this. I think keeping the callback\ninfrastructure depends on whether bring back the last-modified\nsubsystem. In that case, I will address your comments. If not, I think\nwe can get rid of it completely.\n\n\n>> +static int populate_paths_from_revs(struct last_modified *lm)\n>> +{\n>> +\tint num_interesting = 0;\n>> +\tstruct diff_options diffopt;\n>> +\n>> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n>> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n>> +\t/*\n>> +\t * Use a callback to populate the paths from revs\n>> +\t */\n>> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n>> +\tdiffopt.format_callback = add_path_from_diff;\n>> +\tdiffopt.format_callback_data = lm;\n>> +\n>> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n>> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n>> +\n>> +\t\tif (obj->item->flags & UNINTERESTING)\n>> +\t\t\tcontinue;\n>> +\n>> +\t\tif (num_interesting++)\n>> +\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n>\n> This error text is a little difficult to parse, but I'm not sure that I\n> have a great suggestion for improving it. The equivalent from GitHub's\n> fork is \"can only blame one tree at a time\", and I think the difficulty\n> in parsing is that \"last-modified\" isn't a verb.\n\nOh yeah, I've been struggling with that myself as well. I'm open to a\nrename, if you've got a better name?\n\n-- \nCheers,\nToon\n"},{"id":"522460","messageId":"874iv4kyt0.fsf@iotcl.com","threadId":"63332","inReplyTo":"aHmQiJxfmlbDI+Hq@nand.local","subject":"Re: [PATCH v5 2/6] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-22T15:52:43Z","receivedAt":"2025-07-22T15:52:54Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Taylor Blau <me@ttaylorr.com> writes:\n\n> On Wed, Jul 16, 2025 at 03:35:14PM +0200, Toon Claes wrote:\n>> diff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\n>> new file mode 100755\n>> index 0000000000..a02ec907d4\n>> --- /dev/null\n>> +++ b/t/perf/p8020-last-modified.sh\n>> @@ -0,0 +1,21 @@\n>> +#!/bin/sh\n>> +\n>> +test_description='last-modified perf tests'\n>> +. ./perf-lib.sh\n>> +\n>> +test_perf_default_repo\n>> +\n>> +test_perf 'top-level last-modified' '\n>> +\tgit last-modified HEAD\n>> +'\n>> +\n>> +test_perf 'top-level recursive last-modified' '\n>> +\tgit last-modified -r HEAD\n>> +'\n>\n> The only notable difference from GitHub's version here is that we do not\n> have a recursive option, so our test is just \"git blame-tree\n> --max-depth=0\", which is obviously not applicable here.\n>\n> What you wrote (testing \"last-modified\" both with and without the \"-r\"\n> option) makes sense to me.\n>\n>> +test_perf 'subdir last-modified' '\n>> +\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n>\n> Hmm. This line comes directly from the patches that I originally shared,\n> but seeing \"git\" on the left-hand side of a pipe makes me a little\n> uneasy.\n>\n> We could also use the \"-d\" flag here, which will only show us trees,\n> thus eliminating the need for the \"grep ^040000\" portion above.\n>\n> I'd probably write this as:\n>\n>     git ls-tree -d HEAD >subtrees &&\n>     path=\"$(head -n 1 subtrees | cut -f2)\" &&\n>     git last-modified -- \"$path\"\n\nMakes sense, I shall pick this up when I reroll.\n\n-- \nCheers,\nToon\n"},{"id":"522464","messageId":"871pq8kyci.fsf@iotcl.com","threadId":"63332","inReplyTo":"aHmSW1pOUF0U3KFk@nand.local","subject":"Re: [PATCH v5 3/6] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-22T16:02:37Z","receivedAt":"2025-07-22T16:02:48Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Taylor Blau <me@ttaylorr.com> writes:\n\n> On Wed, Jul 16, 2025 at 03:35:15PM +0200, Toon Claes wrote:\n>> Our 'git last-modified' performs a revision walk, and computes a diff at\n>> each point in the walk to figure out whether a given revision changed\n>> any of the paths it considers interesting.\n>>\n>> When changed-path Bloom filters are available, we can avoid computing\n>> many such diffs. Before computing a diff, we first check if any of the\n>> remaining paths of interest were possibly changed at a given commit by\n>> consulting its Bloom filter. If any of them are, we are resigned to\n>> compute the diff.\n>>\n>> If none of those queries returned \"maybe\", we know that the given commit\n>> doesn't contain any changed paths which are interesting to us. So, we\n>> can avoid computing it in this case.\n>>\n>> Comparing the perf test results on git.git:\n>>\n>>     Test                                        HEAD~             HEAD\n>>     ------------------------------------------------------------------------------------\n>>     8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n>>     8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n>>     8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n>\n> As an aside on 8020.3 (that I probably should have mentioned in the last\n> commit), I think that our \"| head -n1\" heuristic for picking a sub-tree\n> is skewing these results down. In git.git, the lexicographically\n> earliest sub-tree is \".github\", which is awfully tiny. I wonder if we\n> should be grabbing the *last* sub-tree, or maybe the largest one by\n> count of entries?\n\nAh yes, that's automatable. Good suggestion.\n\n>\n>> @@ -40,6 +43,12 @@ struct last_modified {\n>>\n>>  static void last_modified_release(struct last_modified *lm)\n>>  {\n>> +\tstruct hashmap_iter iter;\n>> +\tstruct last_modified_entry *ent;\n>> +\n>> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n>> +\t\tclear_bloom_key(&ent->key);\n>> +\n>\n> I did a double-take to make sure that ent->key would always be\n> initialized here, but it is thanks to the FLEX_ALLOC_STR() call below.\n>\n>>  \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n>>  \trelease_revisions(&lm->rev);\n>>  }\n>> @@ -67,6 +76,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n>>\n>>  \t\tFLEX_ALLOC_STR(ent, path, path);\n>>  \t\toidcpy(&ent->oid, &p->two->oid);\n>> +\t\tif (lm->rev.bloom_filter_settings)\n>> +\t\t\tfill_bloom_key(path, strlen(path), &ent->key,\n>> +\t\t\t\t       lm->rev.bloom_filter_settings);\n>>  \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n>>  \t\thashmap_add(&lm->paths, &ent->hashent);\n>>  \t}\n>> @@ -126,6 +138,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n>>  \t\tdata->callback(path, data->commit, data->callback_data);\n>>\n>>  \thashmap_remove(data->paths, &ent->hashent, path);\n>> +\tclear_bloom_key(&ent->key);\n>\n> OK, we're calling clear_bloom_key() here, too, but it uses\n> FREE_AND_NULL(), so calling it again in last_modified_release() may be a\n> noop, but won't ever be a double-free.\n>\n>> @@ -238,6 +276,13 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n>>  \t\treturn argc;\n>>  \t}\n>>\n>> +\t/*\n>> +\t * We're not interested in generation numbers here,\n>> +\t * but calling this function to prepare the commit-graph.\n>> +\t */\n>> +\t(void)generation_numbers_enabled(lm->rev.repo);\n>> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n>\n> Hmmph. I think when I originally wrote this I was using the side-effect\n> of calling generation_numbers_enabled() as a hack. But I think that it\n> may be worth making \"prepare_commit_graph()\" a non-static function and\n> calling that instead.\n\nWell thanks for confirming this assumption. I wasn't 100%, and I agree\nhaving a non-static \"prepare_commit_graph()\" would be better.\n\nSomewhat related to that. In your branch you have this line in\n\"maybe_changed_path()\": \n\n\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n\t\treturn 1;\n\nI excluded this line because if we ignore the result of\n\"generation_numbers_enabled()\", why would it matter to have a generation\nnumber?\n\nThe thing is \"maybe_changed_path()\" in blame.c also has this line too,\nbut unfortunately git-blaming that line didn't learn me anything why\nit's there. Do you have any idea?\n\n-- \nCheers,\nToon\n"},{"id":"522467","messageId":"87y0sgjjlm.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqq5xfp4d15.fsf@gitster.g","subject":"Re: [PATCH v5 5/6] last-modified: support --extended format","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-22T16:06:29Z","receivedAt":"2025-07-22T16:06:54Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Junio C Hamano <gitster@pobox.com> writes:\n>\n>> Hmph.  This hardcoding of everything does not look easy to maintain.\n>>\n>> Besides, the test will fail rather miserably when run with SHA-256\n>> hash (e.g., post Git 3.0 where the \"git init\" command by default\n>> will give you a repository with new hash).\n>>\n>> It looks somewhat inconsistent that tree is shown with its object\n>> name, but commit is not.\n>\n>\n> I do not address neither the first point or the last point above,\n> but at least something like the attached patch needs to be squashed\n> into this step to make the SHA-256 tests pass.\n\nThank you for the suggestion. But I think we can expect this commit to\nbe excluded whenever I reroll this series. But I appreciate the effort.\nI should have taken SHA256 into account in the first place.\n\n\n-- \nCheers,\nToon\n"},{"id":"523020","messageId":"20250730175510.987383-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v6 0/4] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:55:06Z","receivedAt":"2025-07-30T17:55:32Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"On many forges the tree view is shown in combination with commit data.\nIn such a view each tree entry is accompanied with the commit message\nand date that last modified that tree entry. Something similar like:\n\n    | README.md   | README: *.txt -> *.adoc fixes                           | 4 months ago |\n    | RelNotes    | Start 2.51 cycle, the first batch                       | 4 weeks ago  |\n    | SECURITY.md | SECURITY: describe how to report vulnerabilities        | 4 years      |\n    | abspath.c   | abspath: move related functions to abspath              | 2 years      |\n    | abspath.h   | abspath: move related functions to abspath              | 2 years      |\n    | aclocal.m4  | configure: use AC_LANG_PROGRAM consistently             | 15 years ago |\n    | add-patch.c | pager: stop using `the_repository`                      | 7 months ago |\n    | advice.c    | advice: allow disabling default branch name advice      | 4 months ago |\n    | advice.h    | advice: allow disabling default branch name advice      | 4 months ago |\n    | alias.h     | rebase -m: fix serialization of strategy options        | 2 years      |\n    | alloc.h     | git-compat-util: move alloc macros to git-compat-util.h | 2 years ago  |\n    | apply.c     | apply: only write intents to add for new files          | 8 days ago   |\n    | archive.c   | Merge branch 'ps/parse-options-integers'                | 3 months ago |\n    | archive.h   | archive.h: remove unnecessary include                   | 1 year       |\n    | attr.h      | fuzz: port fuzz-parse-attr-line from OSS-Fuzz           | 9 months ago |\n    | banned.h    | banned.h: mark `strtok()` and `strtok_r()` as banned    | 2 years      |\n\nThis series adds the git-last-modified(1) to feed this view. In the past\nthe subcommand was proposed[1] to be named git-blame-tree(1). This\nversion is based on the patches shared by the kind people at GitHub[2].\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[5] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I've submitted them as a separate patch\n  series[6].\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* All the new last-modified machinery is no longer implemented in a library\n  layer (at the root of the project), but directly in the builtin. So far the\n  code is fairly small (little over 300 lines of code) and there are no other\n  users of this code anyway. Also the library level code taken from Taylor's\n  fork required to pass `argc` and `argv` into it. It's quite awkward the\n  library code was so tightly coupled with user interaction.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* Dropped the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. In my testing I've\n  seen both performance improvements *and* degradation with these\n  changes:\n\n  Test                                        HEAD~             HEAD\n  ------------------------------------------------------------------------------------\n  8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n  8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n  8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n\n  Before we include these patches, I want to make sure these changes\n  have positive impact in all/most scenarios. This can happen in a\n  separate series.\n\nI've set myself as the author and added Based-on-patch-by trailers to\ncredit the original authors. Let me know if you disagree.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n[3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[4]: git@github.com:ttaylorr/git.git\n[5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n[6]: https://lore.kernel.org/git/20250729-toon-max-depth-v1-0-c177e39c40fb@iotcl.com/\n\n---\nChanges in v6:\n- Only the first 3 patches are kept. The last 3 patches worked toward adding an\n  extra option `--format`. The way it was implemented was heavily debatable and\n  in the end it is not required for a first iteration, so they are dropped.\n- Function prepare_commit_graph() is exported and used in\n  generation_numbers_enabled().\n- Since the library layer was removed and all the code was moved into the\n  builtin, there was still some leftovers from using a callback mechanism to\n  display the results. This is removed (as far as possible) and instead\n  last_modified_emit() always, this function was called show_entry() previously.\n- Code is rebased to use refactoring in the bloom filter API.\n\nChanges in v5:\n- Added a patch to allow for an \"extended\" format. The name for this option is\n  open for debate (please, all input is welcome). But the main goal of this\n  series is to provide the data needed for the \"forge tree view\" as demoed at\n  the top of this cover letter. With this extra patch (and the prepatory patch\n  to pretty.[ch]), I hope the use-case because more clear. But because it wasn't\n  included in previous 4 versions I also wouldn't mind sending a separate patch\n  series for it.\n- Removed the call to sort(1) the t8020 tests. This was needed for the tests for\n  --extended.\n- I'm adding a fixup! commit to be compatible with in-flight patches for bloom\n  filter optimizations:\n  https://lore.kernel.org/git/20250712093517.17907-1-yldhome2d2@gmail.com/\n  This patch can be dropped if current series lands before those.\n\nChanges in v4:\n- Removed root-level `last-modified.[ch]` library code and moved code to\n  `builtin/last-modified.c`. Historically we've had libary code (also because it\n  was used in testtool), but we no longer need that separation. I'm sorry this\n  makes the range-diff hard to read.\n- Added the use of parse_options() to get better usage messages.\n- Formatting fixes after conversation in\n  https://lore.kernel.org/git/xmqqh5zvk5h0.fsf@gitster.g/\n- Link to v3: https://lore.kernel.org/git/20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com/\n\nChanges in v3:\n- Updated benchmarks in commit messages.\n- Removed the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. (see above)\n- Move handling failure in `last_modified_init()` to the caller.\n- Sorted #include clauses lexicographically.\n- Removed unneeded `commit` in `struct last_modified_entry`.\n- Renamed some functions/variables and added some comments to make it\n  easier to understand.\n- Removed unnecessary checking of the commit-graph generation number.\n- Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\nToon Claes (4):\n  last-modified: new subcommand to show when files were last modified\n  t/perf: add last-modified perf script\n  commit-graph: export prepare_commit_graph()\n  last-modified: use Bloom filters when available\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 318 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n commit-graph.c                       |   8 +-\n commit-graph.h                       |   8 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  22 ++\n t/t8020-last-modified.sh             | 203 +++++++++++++++++\n 14 files changed, 610 insertions(+), 7 deletions(-)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/perf/p8020-last-modified.sh\n create mode 100755 t/t8020-last-modified.sh\n\nRange-diff against v5:\n1:  8c6493d1d1 ! 1:  9d5ce06460 last-modified: new subcommand to show when files were last modified\n    @@ builtin/last-modified.c (new)\n     +struct last_modified {\n     +\tstruct hashmap paths;\n     +\tstruct rev_info rev;\n    -+\tint recursive, tree_in_recursive;\n    ++\tint recursive;\n    ++\tint tree_in_recursive;\n     +};\n     +\n     +static void last_modified_release(struct last_modified *lm)\n    @@ builtin/last-modified.c (new)\n     +\trelease_revisions(&lm->rev);\n     +}\n     +\n    -+typedef void (*last_modified_callback)(const char *path,\n    -+\t\t\t\t       const struct commit *commit, void *data);\n    -+\n     +struct last_modified_callback_data {\n    ++\tstruct last_modified *lm;\n     +\tstruct commit *commit;\n    -+\tstruct hashmap *paths;\n    -+\n    -+\tlast_modified_callback callback;\n    -+\tvoid *callback_data;\n     +};\n     +\n     +static void add_path_from_diff(struct diff_queue_struct *q,\n    @@ builtin/last-modified.c (new)\n     +\t\t\tcontinue;\n     +\n     +\t\tif (num_interesting++)\n    -+\t\t\treturn error(_(\"can only get last-modified one tree at a time\"));\n    ++\t\t\treturn error(_(\"last-modified can only operate on one tree at a time\"));\n     +\n     +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n     +\t\t\t      &obj->item->oid, \"\", &diffopt);\n    @@ builtin/last-modified.c (new)\n     +\treturn 0;\n     +}\n     +\n    ++static void last_modified_emit(struct last_modified *lm,\n    ++\t\t\t       const char *path, const struct commit *commit)\n    ++\n    ++{\n    ++\tif (commit->object.flags & BOUNDARY)\n    ++\t\tputchar('^');\n    ++\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    ++\n    ++\tif (lm->rev.diffopt.line_termination)\n    ++\t\twrite_name_quoted(path, stdout, '\\n');\n    ++\telse\n    ++\t\tprintf(\"%s%c\", path, '\\0');\n    ++\n    ++\tfflush(stdout);\n    ++}\n    ++\n     +static void mark_path(const char *path, const struct object_id *oid,\n     +\t\t      struct last_modified_callback_data *data)\n     +{\n     +\tstruct last_modified_entry *ent;\n     +\n     +\t/* Is it even a path that we are interested in? */\n    -+\tent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,\n    ++\tent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path,\n     +\t\t\t\t\t  struct last_modified_entry, hashent);\n     +\tif (!ent)\n     +\t\treturn;\n    @@ builtin/last-modified.c (new)\n     +\tif (!oideq(oid, &ent->oid))\n     +\t\treturn;\n     +\n    -+\tif (data->callback)\n    -+\t\tdata->callback(path, data->commit, data->callback_data);\n    ++\tlast_modified_emit(data->lm, path, data->commit);\n     +\n    -+\thashmap_remove(data->paths, &ent->hashent, path);\n    ++\thashmap_remove(&data->lm->paths, &ent->hashent, path);\n     +\tfree(ent);\n     +}\n     +\n    @@ builtin/last-modified.c (new)\n     +\t}\n     +}\n     +\n    -+static int last_modified_run(struct last_modified *lm,\n    -+\t\t\t     last_modified_callback cb, void *cbdata)\n    ++static int last_modified_run(struct last_modified *lm)\n     +{\n    -+\tstruct last_modified_callback_data data;\n    -+\n    -+\tdata.paths = &lm->paths;\n    -+\tdata.callback = cb;\n    -+\tdata.callback_data = cbdata;\n    ++\tstruct last_modified_callback_data data = { .lm = lm };\n     +\n     +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n     +\tlm->rev.diffopt.format_callback = last_modified_diff;\n    @@ builtin/last-modified.c (new)\n     +\treturn 0;\n     +}\n     +\n    -+static void show_entry(const char *path, const struct commit *commit, void *d)\n    -+{\n    -+\tstruct last_modified *lm = d;\n    -+\n    -+\tif (commit->object.flags & BOUNDARY)\n    -+\t\tputchar('^');\n    -+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n    -+\n    -+\tif (lm->rev.diffopt.line_termination)\n    -+\t\twrite_name_quoted(path, stdout, '\\n');\n    -+\telse\n    -+\t\tprintf(\"%s%c\", path, '\\0');\n    -+\n    -+\tfflush(stdout);\n    -+}\n    -+\n     +static int last_modified_init(struct last_modified *lm, struct repository *r,\n     +\t\t\t      const char *prefix, int argc, const char **argv)\n     +{\n    @@ builtin/last-modified.c (new)\n     +\t\tgoto out;\n     +\t}\n     +\n    -+\tif ((ret = last_modified_run(&lm, show_entry, &lm)))\n    ++\tif ((ret = last_modified_run(&lm)))\n     +\t\tgoto out;\n     +\n     +out:\n2:  dc34010bfb ! 2:  7c921d4344 t/perf: add last-modified perf script\n    @@ t/perf/p8020-last-modified.sh (new)\n     +'\n     +\n     +test_perf 'subdir last-modified' '\n    -+\tpath=$(git ls-tree HEAD | grep ^040000 | head -n 1 | cut -f2)\n    ++\tgit ls-tree -d HEAD >subtrees &&\n    ++\tpath=\"$(head -n 1 subtrees | cut -f2)\" &&\n     +\tgit last-modified -r HEAD -- \"$path\"\n     +'\n     +\n-:  ---------- > 3:  3c42043682 commit-graph: export prepare_commit_graph()\n3:  8cd05437f0 ! 4:  4d7376a46d last-modified: use Bloom filters when available\n    @@ builtin/last-modified.c: static void add_path_from_diff(struct diff_queue_struct\n      \t\thashmap_add(&lm->paths, &ent->hashent);\n      \t}\n     @@ builtin/last-modified.c: static void mark_path(const char *path, const struct object_id *oid,\n    - \t\tdata->callback(path, data->commit, data->callback_data);\n    + \tlast_modified_emit(data->lm, path, data->commit);\n\n    - \thashmap_remove(data->paths, &ent->hashent, path);\n    + \thashmap_remove(&data->lm->paths, &ent->hashent, path);\n     +\tbloom_key_clear(&ent->key);\n      \tfree(ent);\n      }\n    @@ builtin/last-modified.c: static void last_modified_diff(struct diff_queue_struct\n      \t}\n      }\n\n    -+\n     +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n     +{\n     +\tstruct bloom_filter *filter;\n    @@ builtin/last-modified.c: static void last_modified_diff(struct diff_queue_struct\n     +\treturn 0;\n     +}\n     +\n    - static int last_modified_run(struct last_modified *lm,\n    - \t\t\t     last_modified_callback cb, void *cbdata)\n    + static int last_modified_run(struct last_modified *lm)\n      {\n    -@@ builtin/last-modified.c: static int last_modified_run(struct last_modified *lm,\n    + \tstruct last_modified_callback_data data = { .lm = lm };\n    +@@ builtin/last-modified.c: static int last_modified_run(struct last_modified *lm)\n      \t\tif (!data.commit)\n      \t\t\tbreak;\n\n    @@ builtin/last-modified.c: static int last_modified_init(struct last_modified *lm,\n      \t\treturn argc;\n      \t}\n\n    -+\t/*\n    -+\t * We're not interested in generation numbers here,\n    -+\t * but calling this function to prepare the commit-graph.\n    -+\t */\n    -+\t(void)generation_numbers_enabled(lm->rev.repo);\n    ++\tprepare_commit_graph(lm->rev.repo);\n     +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n     +\n      \tif (populate_paths_from_revs(lm) < 0)\n\nbase-commit: e813a0200a7121b97fec535f0d0b460b0a33356c\n--\n2.50.1.327.g047016eb4a\n"},{"id":"523021","messageId":"20250730175510.987383-2-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:55:07Z","receivedAt":"2025-07-30T17:55:42Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  49 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 278 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 203 +++++++++++++++++++\n 11 files changed, 538 insertions(+)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/t8020-last-modified.sh\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..89138ebeb7\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,49 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+\tRecurse into subtrees.\n+\n+-t::\n+\tShow tree entry itself as well as subtrees.  Implies `-r`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tin path traversal the are included in the output.\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 4404c623f0..a8ac5285f0 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex 5f7dd79dfa..b5ce55a703 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1265,6 +1265,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..e4c73464c7\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,278 @@\n+#include \"git-compat-util.h\"\n+#include \"builtin.h\"\n+#include \"commit.h\"\n+#include \"config.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"hashmap.h\"\n+#include \"hex.h\"\n+#include \"log-tree.h\"\n+#include \"object-name.h\"\n+#include \"object.h\"\n+#include \"parse-options.h\"\n+#include \"quote.h\"\n+#include \"repository.h\"\n+#include \"revision.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t       const struct hashmap_entry *hent1,\n+\t\t\t\t       const struct hashmap_entry *hent2,\n+\t\t\t\t       const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+\tint recursive;\n+\tint tree_in_recursive;\n+};\n+\n+static void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+struct last_modified_callback_data {\n+\tstruct last_modified *lm;\n+\tstruct commit *commit;\n+};\n+\n+static void add_path_from_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int populate_paths_from_revs(struct last_modified *lm)\n+{\n+\tint num_interesting = 0;\n+\tstruct diff_options diffopt;\n+\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\t/*\n+\t * Use a callback to populate the paths from revs\n+\t */\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_path_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (num_interesting++)\n+\t\t\treturn error(_(\"last-modified can only operate on one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tdiff_free(&diffopt);\n+\n+\treturn 0;\n+}\n+\n+static void last_modified_emit(struct last_modified *lm,\n+\t\t\t       const char *path, const struct commit *commit)\n+\n+{\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+\n+\tfflush(stdout);\n+}\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tlast_modified_emit(data->lm, path, data->commit);\n+\n+\thashmap_remove(&data->lm->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final oid state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+static int last_modified_run(struct last_modified *lm)\n+{\n+\tstruct last_modified_callback_data data = { .lm = lm };\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tbreak;\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &data.commit->object.oid, \"\",\n+\t\t\t\t      &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int last_modified_init(struct last_modified *lm, struct repository *r,\n+\t\t\t      const char *prefix, int argc, const char **argv)\n+{\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n+\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n+\n+\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n+\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\t\treturn argc;\n+\t}\n+\n+\tif (populate_paths_from_revs(lm) < 0)\n+\t\treturn error(_(\"unable to setup last-modified\"));\n+\n+\treturn 0;\n+}\n+\n+int cmd_last_modified(int argc, const char **argv, const char *prefix,\n+\t\t      struct repository *repo)\n+{\n+\tint ret;\n+\tstruct last_modified lm;\n+\n+\tconst char * const last_modified_usage[] = {\n+\t\tN_(\"git last-modified [-r] [-t] \"\n+\t\t   \"[<revision-range>] [[--] <path>...]\"),\n+\t\tNULL\n+\t};\n+\n+\tstruct option last_modified_options[] = {\n+\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n+\t\t\t N_(\"recurse into subtrees\")),\n+\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n+\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n+\t\tOPT_END()\n+\t};\n+\n+\tmemset(&lm, 0, sizeof(lm));\n+\n+\targc = parse_options(argc, argv, prefix, last_modified_options,\n+\t\t\t     last_modified_usage,\n+\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n+\t\tif (ret > 0)\n+\t\t\tusage_with_options(last_modified_usage,\n+\t\t\t\t\t   last_modified_options);\n+\t\tgoto out;\n+\t}\n+\n+\tif ((ret = last_modified_run(&lm)))\n+\t\tgoto out;\n+\n+out:\n+\tlast_modified_release(&lm);\n+\n+\treturn ret;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 07a5fe39fb..76a0b2a1a4 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/meson.build b/meson.build\nindex 9bc1826cb6..77a3416b1c 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -607,6 +607,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex 660d780dcc..904455e3ab 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -961,6 +961,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..05c113a1f8\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,203 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive with tree' '\n+\tcheck_last_modified -t <<-\\EOF\n+\t3 a\n+\t3 a/b\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t2 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm2 m2.t\n+\tm1 m1.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb5 file2\n+\tb2 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523022","messageId":"20250730175510.987383-3-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v6 2/4] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:55:08Z","receivedAt":"2025-07-30T17:55:46Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 22 ++++++++++++++++++++++\n 2 files changed, 23 insertions(+)\n create mode 100755 t/perf/p8020-last-modified.sh\n\ndiff --git a/t/meson.build b/t/meson.build\nindex 904455e3ab..b74125b047 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1155,6 +1155,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..cb1f98d3db\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,22 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tgit ls-tree -d HEAD >subtrees &&\n+\tpath=\"$(head -n 1 subtrees | cut -f2)\" &&\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523023","messageId":"20250730175510.987383-4-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v6 3/4] commit-graph: export prepare_commit_graph()","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:55:09Z","receivedAt":"2025-07-30T17:55:52Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Allow users of the commit-graph to explicitly prepare the commit-graph.\nThis can be useful when users want to start using bloom keys before\ncalling functions like prepare_revision_walk(). We'll use this exported\nfunction in a subsequent commit.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n commit-graph.c | 8 +-------\n commit-graph.h | 8 ++++++++\n 2 files changed, 9 insertions(+), 7 deletions(-)\n\ndiff --git a/commit-graph.c b/commit-graph.c\nindex bd7b6f5338..a1f9fc22a4 100644\n--- a/commit-graph.c\n+++ b/commit-graph.c\n@@ -739,13 +739,7 @@ static void prepare_commit_graph_one(struct repository *r,\n \tr->objects->commit_graph = read_commit_graph_one(r, source);\n }\n \n-/*\n- * Return 1 if commit_graph is non-NULL, and 0 otherwise.\n- *\n- * On the first invocation, this function attempts to load the commit\n- * graph if the_repository is configured to have one.\n- */\n-static int prepare_commit_graph(struct repository *r)\n+int prepare_commit_graph(struct repository *r)\n {\n \tstruct odb_source *source;\n \ndiff --git a/commit-graph.h b/commit-graph.h\nindex 78ab7b875b..0f76681333 100644\n--- a/commit-graph.h\n+++ b/commit-graph.h\n@@ -131,6 +131,14 @@ struct repo_settings;\n struct commit_graph *parse_commit_graph(struct repo_settings *s,\n \t\t\t\t\tvoid *graph_map, size_t graph_size);\n \n+/*\n+ * Return 1 if commit_graph is non-NULL, and 0 otherwise.\n+ *\n+ * On the first invocation, this function attempts to load the commit\n+ * graph if the_repository is configured to have one.\n+ */\n+int prepare_commit_graph(struct repository *r);\n+\n /*\n  * Return 1 if and only if the repository has a commit-graph\n  * file and generation numbers are computed in that file.\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523024","messageId":"20250730175510.987383-5-toon@iotcl.com","threadId":"63332","inReplyTo":"20250716133206.1787549-1-toon@iotcl.com","subject":"[PATCH v6 4/4] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:55:10Z","receivedAt":"2025-07-30T17:55:55Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nComparing the perf test results on git.git:\n\n    Test                                        HEAD~             HEAD\n    ------------------------------------------------------------------------------------\n    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n builtin/last-modified.c | 40 ++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 40 insertions(+)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex e4c73464c7..19bf25f8a5 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -1,5 +1,7 @@\n #include \"git-compat-util.h\"\n+#include \"bloom.h\"\n #include \"builtin.h\"\n+#include \"commit-graph.h\"\n #include \"commit.h\"\n #include \"config.h\"\n #include \"diff.h\"\n@@ -17,6 +19,7 @@\n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -41,6 +44,12 @@ struct last_modified {\n \n static void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n+\t\tbloom_key_clear(&ent->key);\n+\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -62,6 +71,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tbloom_key_fill(&ent->key, path, strlen(path),\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -136,6 +148,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \tlast_modified_emit(data->lm, path, data->commit);\n \n \thashmap_remove(&data->lm->paths, &ent->hashent, path);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n \n@@ -179,6 +192,27 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn 1;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn 1;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn 1;\n+\t}\n+\treturn 0;\n+}\n+\n static int last_modified_run(struct last_modified *lm)\n {\n \tstruct last_modified_callback_data data = { .lm = lm };\n@@ -194,6 +228,9 @@ static int last_modified_run(struct last_modified *lm)\n \t\tif (!data.commit)\n \t\t\tbreak;\n \n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n \t\tif (data.commit->object.flags & BOUNDARY) {\n \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n \t\t\t\t      &data.commit->object.oid, \"\",\n@@ -227,6 +264,9 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n \t\treturn argc;\n \t}\n \n+\tprepare_commit_graph(lm->rev.repo);\n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (populate_paths_from_revs(lm) < 0)\n \t\treturn error(_(\"unable to setup last-modified\"));\n \n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523025","messageId":"87h5yta7bl.fsf@iotcl.com","threadId":"63332","inReplyTo":"87a54wkzll.fsf@iotcl.com","subject":"Re: [PATCH v5 0/6] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-07-30T17:59:10Z","receivedAt":"2025-07-30T17:59:22Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> I've had this patch included in version 2[1]. I'd love to include it,\n> but it didn't give the results we were expecting. Over time I became\n> more confortable with these changes. Let me see if I can get more\n> insights about it.\n\nI've spent a considerable amount of time on this, I didn't get to any\nbreakthrough. I just submitted v6[1] again without these patches. I\nstill love to figure it out and bring in the improvements, but for the\nfirst iteration I think we're okay without.\n\n[1]: https://lore.kernel.org/git/20250730175510.987383-1-toon@iotcl.com/\n\n-- \nCheers,\nToon\n"},{"id":"523052","messageId":"aIsQWcHf82ipHoWf@pks.im","threadId":"63332","inReplyTo":"20250730175510.987383-2-toon@iotcl.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-07-31T06:42:33Z","receivedAt":"2025-07-31T06:42:47Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Wed, Jul 30, 2025 at 07:55:07PM +0200, Toon Claes wrote:\n> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n> new file mode 100644\n> index 0000000000..89138ebeb7\n> --- /dev/null\n> +++ b/Documentation/git-last-modified.adoc\n> @@ -0,0 +1,49 @@\n> +git-last-modified(1)\n> +====================\n> +\n> +NAME\n> +----\n> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n> +\n> +\n> +SYNOPSIS\n> +--------\n> +[synopsis]\n> +git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n\nI think we typically list long options here, not the short single-letter\nones.\n\n> +\n> +DESCRIPTION\n> +-----------\n> +\n> +Shows which commit last modified each of the relevant files and subdirectories.\n> +\n> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n> +\n> +OPTIONS\n> +-------\n> +\n> +-r::\n\n-r, --recursive::\n\n> +\tRecurse into subtrees.\n> +\n> +-t::\n\n-t, --tree-in-recursive::\n\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> new file mode 100644\n> index 0000000000..e4c73464c7\n> --- /dev/null\n> +++ b/builtin/last-modified.c\n[snip]\n> +static int populate_paths_from_revs(struct last_modified *lm)\n> +{\n> +\tint num_interesting = 0;\n> +\tstruct diff_options diffopt;\n> +\n> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n> +\t/*\n> +\t * Use a callback to populate the paths from revs\n> +\t */\n> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tdiffopt.format_callback = add_path_from_diff;\n> +\tdiffopt.format_callback_data = lm;\n\nI feel like this whole block could use a comment that explains what\nwe're doing. Why do we copy `diffopt` around? Why is it fine to free\nthe struct at the end without unsetting `lm->rev.diffopt`? Couldn't that\ncause a double free?\n\n> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n> +\n> +\t\tif (obj->item->flags & UNINTERESTING)\n> +\t\t\tcontinue;\n> +\n> +\t\tif (num_interesting++)\n> +\t\t\treturn error(_(\"last-modified can only operate on one tree at a time\"));\n> +\n> +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n> +\t\t\t      &obj->item->oid, \"\", &diffopt);\n> +\t\tdiff_flush(&diffopt);\n> +\t}\n> +\tdiff_free(&diffopt);\n> +\n> +\treturn 0;\n> +}\n> +\n> +static void last_modified_emit(struct last_modified *lm,\n> +\t\t\t       const char *path, const struct commit *commit)\n> +\n> +{\n> +\tif (commit->object.flags & BOUNDARY)\n> +\t\tputchar('^');\n> +\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n> +\n> +\tif (lm->rev.diffopt.line_termination)\n> +\t\twrite_name_quoted(path, stdout, '\\n');\n> +\telse\n> +\t\tprintf(\"%s%c\", path, '\\0');\n> +\n> +\tfflush(stdout);\n\nIs there a reason why we have to explicitly flush output? This command\ndoesn't have any interactivity with the caller.\n\n> +static void last_modified_diff(struct diff_queue_struct *q,\n> +\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n> +{\n> +\tstruct last_modified_callback_data *data = cbdata;\n> +\n> +\tfor (int i = 0; i < q->nr; i++) {\n> +\t\tstruct diff_filepair *p = q->queue[i];\n> +\t\tswitch (p->status) {\n> +\t\tcase DIFF_STATUS_DELETED:\n> +\t\t\t/*\n> +\t\t\t * There's no point in feeding a deletion, as it could\n> +\t\t\t * not have resulted in our current state, which\n> +\t\t\t * actually has the file.\n> +\t\t\t */\n> +\t\t\tbreak;\n> +\n> +\t\tdefault:\n> +\t\t\t/*\n> +\t\t\t * Otherwise, we care only that we somehow arrived at\n> +\t\t\t * a final oid state. Note that this covers some\n> +\t\t\t * potentially controversial areas, including:\n> +\t\t\t *\n> +\t\t\t *  1. A rename or copy will be found, as it is the\n> +\t\t\t *     first time the content has arrived at the given\n> +\t\t\t *     path.\n\nMakes sense that we don't handle renames (yet). I think I didn't spot\nthis in the manual, so maybe this is something we should document there.\n\n> +\t\t\t *  2. Even a non-content modification like a mode or\n> +\t\t\t *     type change will trigger it.\n\nSeems sensible as a default, as well. And likewise, we can add\n`--ignore-mode-changes` at a later point if we ever have a use case for\nit.\n\n> +\t\t\t * We take the inclusive approach for now, and find\n> +\t\t\t * anything which impacts the path. Options to tweak\n> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n> +\t\t\t * renames) can come later.\n> +\t\t\t */\n> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n> +\t\t\tbreak;\n> +\t\t}\n> +\t}\n> +}\n> +\n> +static int last_modified_run(struct last_modified *lm)\n> +{\n> +\tstruct last_modified_callback_data data = { .lm = lm };\n> +\n> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n> +\tlm->rev.diffopt.format_callback_data = &data;\n> +\n> +\tprepare_revision_walk(&lm->rev);\n> +\n> +\twhile (hashmap_get_size(&lm->paths)) {\n> +\t\tdata.commit = get_revision(&lm->rev);\n> +\t\tif (!data.commit)\n> +\t\t\tbreak;\n\nSo in this case we have reached the end of our commit range. I assume we\nsimply print the oldest commit of that range in this case?\n\n> +\t\tif (data.commit->object.flags & BOUNDARY) {\n> +\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n> +\t\t\t\t      &data.commit->object.oid, \"\",\n> +\t\t\t\t      &lm->rev.diffopt);\n> +\t\t\tdiff_flush(&lm->rev.diffopt);\n> +\t\t} else {\n> +\t\t\tlog_tree_commit(&lm->rev, data.commit);\n> +\t\t}\n> +\t}\n> +\n> +\treturn 0;\n> +}\n> +\n> +static int last_modified_init(struct last_modified *lm, struct repository *r,\n> +\t\t\t      const char *prefix, int argc, const char **argv)\n> +{\n> +\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n> +\n> +\trepo_init_revisions(r, &lm->rev, prefix);\n> +\tlm->rev.def = \"HEAD\";\n> +\tlm->rev.combine_merges = 1;\n> +\tlm->rev.show_root_diff = 1;\n> +\tlm->rev.boundary = 1;\n> +\tlm->rev.no_commit_id = 1;\n> +\tlm->rev.diff = 1;\n> +\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n> +\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n> +\n> +\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n\nTiny nit: it's rather unusual in our codebase to assign values in\nconditionals. I personally don't mind this usage at all -- I think it\ncan make error handling way less verbose. But I'm not sure whether we\ndeem this style acceptable.\n\n        argc = setup_revisions(argc, argv, &lm->rev, NULL)\n        if (argc) {\n            ...\n        }\n\nI've seen this style several times in this patch. I think we should keep\nour typical style for now, but I wouldn't mind if you sent a patch for\nour coding style document so that we can discuss this.\n\n> +\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n> +\t\treturn argc;\n> +\t}\n> +\n> +\tif (populate_paths_from_revs(lm) < 0)\n> +\t\treturn error(_(\"unable to setup last-modified\"));\n> +\n> +\treturn 0;\n> +}\n> +\n> +int cmd_last_modified(int argc, const char **argv, const char *prefix,\n> +\t\t      struct repository *repo)\n> +{\n> +\tint ret;\n> +\tstruct last_modified lm;\n> +\n> +\tconst char * const last_modified_usage[] = {\n> +\t\tN_(\"git last-modified [-r] [-t] \"\n> +\t\t   \"[<revision-range>] [[--] <path>...]\"),\n> +\t\tNULL\n> +\t};\n> +\n> +\tstruct option last_modified_options[] = {\n> +\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n> +\t\t\t N_(\"recurse into subtrees\")),\n> +\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n> +\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n\nShould this maybe be called something like \"--recursive-with-trees\"?\n\"--tree-in-recursive\" reads somewhat strange to me.\n\n> +\t\tOPT_END()\n> +\t};\n> +\n> +\tmemset(&lm, 0, sizeof(lm));\n\nYou can avoid the `memset()` and directly zero-initialize the struct\nwhen it's declared. Alternatively, you can move this function call into\n`last_modified_init()` itself, where it would be more reasonable.\n\n> +\targc = parse_options(argc, argv, prefix, last_modified_options,\n> +\t\t\t     last_modified_usage,\n> +\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n> +\n> +\trepo_config(repo, git_default_config, NULL);\n> +\n> +\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n> +\t\tif (ret > 0)\n> +\t\t\tusage_with_options(last_modified_usage,\n> +\t\t\t\t\t   last_modified_options);\n> +\t\tgoto out;\n> +\t}\n> +\n> +\tif ((ret = last_modified_run(&lm)))\n> +\t\tgoto out;\n\nTwo more cases where we assign `if ((ret = ...))`.\n\nPatrick\n"},{"id":"523053","messageId":"aIsQZCLbpQVQmFF_@pks.im","threadId":"63332","inReplyTo":"20250730175510.987383-4-toon@iotcl.com","subject":"Re: [PATCH v6 3/4] commit-graph: export prepare_commit_graph()","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-07-31T06:42:44Z","receivedAt":"2025-07-31T06:42:51Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Wed, Jul 30, 2025 at 07:55:09PM +0200, Toon Claes wrote:\n> Allow users of the commit-graph to explicitly prepare the commit-graph.\n> This can be useful when users want to start using bloom keys before\n> calling functions like prepare_revision_walk(). We'll use this exported\n> function in a subsequent commit.\n\nHm. Ideally we wouldn't have to expose this low-level function and the\ncommit-graph subsystem would know to handle this. We typically have\npatterns like this in our codebase:\n\n\t\tif (repo_find_commit_pos_in_graph(r, c, &graph_pos))\n\t\t\tload_bloom_filter_from_graph(r->objects->commit_graph,\n\t\t\t\t\t\t     filter, graph_pos);\n\nThe call to `repo_find_commit_pos_in_graph()` knows to call\n`prepare_commit_graph()`, so no manual call to that function would be\nrequired.\n\nI haven't yet read the next commit though that adds the callsite. So\nlet's read on.\n\n> diff --git a/commit-graph.h b/commit-graph.h\n> index 78ab7b875b..0f76681333 100644\n> --- a/commit-graph.h\n> +++ b/commit-graph.h\n> @@ -131,6 +131,14 @@ struct repo_settings;\n>  struct commit_graph *parse_commit_graph(struct repo_settings *s,\n>  \t\t\t\t\tvoid *graph_map, size_t graph_size);\n>  \n> +/*\n> + * Return 1 if commit_graph is non-NULL, and 0 otherwise.\n> + *\n> + * On the first invocation, this function attempts to load the commit\n> + * graph if the_repository is configured to have one.\n> + */\n> +int prepare_commit_graph(struct repository *r);\n\nLet's fix the reference to `the_repository` while at it.\n\nPatrick\n"},{"id":"523054","messageId":"aIsQfZyMq0ltbMtz@pks.im","threadId":"63332","inReplyTo":"20250730175510.987383-5-toon@iotcl.com","subject":"Re: [PATCH v6 4/4] last-modified: use Bloom filters when available","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-07-31T06:43:09Z","receivedAt":"2025-07-31T06:43:15Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Wed, Jul 30, 2025 at 07:55:10PM +0200, Toon Claes wrote:\n> Our 'git last-modified' performs a revision walk, and computes a diff at\n> each point in the walk to figure out whether a given revision changed\n> any of the paths it considers interesting.\n> \n> When changed-path Bloom filters are available, we can avoid computing\n> many such diffs. Before computing a diff, we first check if any of the\n> remaining paths of interest were possibly changed at a given commit by\n> consulting its Bloom filter. If any of them are, we are resigned to\n> compute the diff.\n> \n> If none of those queries returned \"maybe\", we know that the given commit\n> doesn't contain any changed paths which are interesting to us. So, we\n> can avoid computing it in this case.\n> \n> Comparing the perf test results on git.git:\n> \n>     Test                                        HEAD~             HEAD\n>     ------------------------------------------------------------------------------------\n>     8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n>     8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n>     8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nNice results.\n\n> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> index e4c73464c7..19bf25f8a5 100644\n> --- a/builtin/last-modified.c\n> +++ b/builtin/last-modified.c\n> @@ -179,6 +192,27 @@ static void last_modified_diff(struct diff_queue_struct *q,\n>  \t}\n>  }\n>  \n> +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n> +{\n> +\tstruct bloom_filter *filter;\n> +\tstruct last_modified_entry *ent;\n> +\tstruct hashmap_iter iter;\n> +\n> +\tif (!lm->rev.bloom_filter_settings)\n> +\t\treturn 1;\n> +\n> +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n> +\tif (!filter)\n> +\t\treturn 1;\n> +\n> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n> +\t\tif (bloom_filter_contains(filter, &ent->key,\n> +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n> +\t\t\treturn 1;\n> +\t}\n> +\treturn 0;\n> +}\n\nThis function is basically the same as `maybe_changed_paths()` in\n\"blame.c\", but that isn't a huge issue from my point of view. What makes\nme wonder though is why we have an additional check over there for\nwhether or not the commit has a valid generation number.\n\n> @@ -227,6 +264,9 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n>  \t\treturn argc;\n>  \t}\n>  \n> +\tprepare_commit_graph(lm->rev.repo);\n> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n> +\n\nSo this here is why we export `prepare_commit_graph()`. How about we\ninstead expose `bloom_filters_enabled()` that mirrors what we do in\n`generation_numbers_enabled()` and `corrected_commit_dates_enabled()`?\nThat would both be on a higher level and do exactly what we want to\nachieve.\n\nPatrick\n"},{"id":"523074","messageId":"aIsfI1NZlmS3QYuh@pks.im","threadId":"63332","inReplyTo":"87h5yta7bl.fsf@iotcl.com","subject":"Re: [PATCH v5 0/6] Introduce git-last-modified(1) command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-07-31T07:45:39Z","receivedAt":"2025-07-31T07:45:47Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Wed, Jul 30, 2025 at 07:59:10PM +0200, Toon Claes wrote:\n> Toon Claes <toon@iotcl.com> writes:\n> \n> > I've had this patch included in version 2[1]. I'd love to include it,\n> > but it didn't give the results we were expecting. Over time I became\n> > more confortable with these changes. Let me see if I can get more\n> > insights about it.\n> \n> I've spent a considerable amount of time on this, I didn't get to any\n> breakthrough. I just submitted v6[1] again without these patches. I\n> still love to figure it out and bring in the improvements, but for the\n> first iteration I think we're okay without.\n> \n> [1]: https://lore.kernel.org/git/20250730175510.987383-1-toon@iotcl.com/\n\nI guess that's probably fine. The patches would go on top anyway, so I\ndon't see a reason why we shouldn't land the \"trivial\" implementation\nthat just works and then iterate from thereon. It's going to be way\nfaster than any scripted solution already, so it does provide benefit\neven without the additional performance boost.\n\nPatrick\n"},{"id":"523113","messageId":"xmqq1ppwryoy.fsf@gitster.g","threadId":"63332","inReplyTo":"20250730175510.987383-1-toon@iotcl.com","subject":"Re: [PATCH v6 0/4] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-31T18:40:29Z","receivedAt":"2025-07-31T18:40:33Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Changes in v6:\n> - Only the first 3 patches are kept. The last 3 patches worked toward adding an\n>   extra option `--format`. The way it was implemented was heavily debatable and\n>   in the end it is not required for a first iteration, so they are dropped.\n\nOK.\n\n> - Function prepare_commit_graph() is exported and used in\n>   generation_numbers_enabled().\n\nOK.\n\n> - Since the library layer was removed and all the code was moved into the\n>   builtin, there was still some leftovers from using a callback mechanism to\n>   display the results. This is removed (as far as possible) and instead\n>   last_modified_emit() always, this function was called show_entry() previously.\n\nOK.\n\n> - Code is rebased to use refactoring in the bloom filter API.\n\nAh, bloom_key_fill() and bloom_key_clear(); sorry to see you become\na victim of an unfortunate churn X-<, but hopefully it is for\ngreater good in the longer term.\n\nWill queue.  Thanks.\n"},{"id":"523155","messageId":"xmqqpldfoqvz.fsf@gitster.g","threadId":"63332","inReplyTo":"xmqq1ppwryoy.fsf@gitster.g","subject":"Re: [PATCH v6 0/4] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-07-31T23:57:20Z","receivedAt":"2025-07-31T23:57:23Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> Changes in v6:\n>> - Only the first 3 patches are kept. The last 3 patches worked toward adding an\n>>   extra option `--format`. The way it was implemented was heavily debatable and\n>>   in the end it is not required for a first iteration, so they are dropped.\n>\n> OK.\n>\n>> - Function prepare_commit_graph() is exported and used in\n>>   generation_numbers_enabled().\n>\n> OK.\n>\n>> - Since the library layer was removed and all the code was moved into the\n>>   builtin, there was still some leftovers from using a callback mechanism to\n>>   display the results. This is removed (as far as possible) and instead\n>>   last_modified_emit() always, this function was called show_entry() previously.\n>\n> OK.\n>\n>> - Code is rebased to use refactoring in the bloom filter API.\n>\n> Ah, bloom_key_fill() and bloom_key_clear(); sorry to see you become\n> a victim of an unfortunate churn X-<, but hopefully it is for\n> greater good in the longer term.\n>\n> Will queue.  Thanks.\n\nCI runs without and with this topic in 'seen'\n\n(without this topic)\nhttps://github.com/git/git/actions/runs/16661801008\n\n(with this topic)\nhttps://github.com/git/git/actions/runs/16662408099\n\nThe difference in trees of these two runs match what is in this\ntopic and nothing else.\n\n"},{"id":"523182","messageId":"CAP8UFD18Ds4zME_ZHRdTA0W7KXk57zo9664xaU2TbH7YsHbFUg@mail.gmail.com","threadId":"63332","inReplyTo":"aHmPHcNQYlhGo8JB@nand.local","subject":"Re: [PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2025-08-01T09:09:24Z","receivedAt":"2025-08-01T09:09:38Z","isPatch":true,"sender":{"key":"christian.couder@gmail.com","avatar":"https://avatars.githubusercontent.com/u/208954?v=4"},"body":"On Fri, Jul 18, 2025 at 2:02 AM Taylor Blau <me@ttaylorr.com> wrote:\n\n> > +struct last_modified {\n> > +     struct hashmap paths;\n> > +     struct rev_info rev;\n> > +     int recursive, tree_in_recursive;\n>\n> Can we either make these two part of a bitfield, or at least declare\n> them separately?\n\nI wonder if we could/should use the `bool` type from <stdbool.h> as\nDocumentation/CodingGuidelines says that it's now allowed.\n"},{"id":"523188","messageId":"CAP8UFD0AEKDmvQJTanL+ZS+U66WAZz=FKSJ3LPE1QHSEyH-zNw@mail.gmail.com","threadId":"63332","inReplyTo":"20250730175510.987383-2-toon@iotcl.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2025-08-01T10:18:39Z","receivedAt":"2025-08-01T10:18:52Z","isPatch":true,"sender":{"key":"christian.couder@gmail.com","avatar":"https://avatars.githubusercontent.com/u/208954?v=4"},"body":"On Wed, Jul 30, 2025 at 7:55 PM Toon Claes <toon@iotcl.com> wrote:\n\n> +[--] <path>...::\n> +       For each _<path>_ given, the commit which last modified it is returned.\n> +       Without an optional path parameter, all files and subdirectories\n> +       in path traversal the are included in the output.\n\ns/the are included/are included/\n\n> +static void last_modified_release(struct last_modified *lm)\n\nI think these days we tend to name those functions using \"clear\"\ninstead of \"release\"\n\n> +{\n> +       hashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n> +       release_revisions(&lm->rev);\n> +}\n"},{"id":"523189","messageId":"aIyVft9PdlorttzZ@pks.im","threadId":"63332","inReplyTo":"CAP8UFD0AEKDmvQJTanL+ZS+U66WAZz=FKSJ3LPE1QHSEyH-zNw@mail.gmail.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-01T10:22:54Z","receivedAt":"2025-08-01T10:23:02Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, Aug 01, 2025 at 12:18:39PM +0200, Christian Couder wrote:\n> On Wed, Jul 30, 2025 at 7:55 PM Toon Claes <toon@iotcl.com> wrote:\n> \n> > +[--] <path>...::\n> > +       For each _<path>_ given, the commit which last modified it is returned.\n> > +       Without an optional path parameter, all files and subdirectories\n> > +       in path traversal the are included in the output.\n> \n> s/the are included/are included/\n> \n> > +static void last_modified_release(struct last_modified *lm)\n> \n> I think these days we tend to name those functions using \"clear\"\n> instead of \"release\"\n\nIt actually depends: if the structure can be immediately reused\nafterwards without requiring another reinit it would be caller \"clear\"\nindeed. On the other hand, if we only release memory it's \"release\".\n\nI think this function here falls into the latter category, so it's\ncorrectly named.\n\nPatrick\n"},{"id":"523218","messageId":"87ms8jui3p.fsf@iotcl.com","threadId":"63332","inReplyTo":"aIsQWcHf82ipHoWf@pks.im","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-01T16:22:50Z","receivedAt":"2025-08-01T16:23:09Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Jul 30, 2025 at 07:55:07PM +0200, Toon Claes wrote:\n>> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n>> new file mode 100644\n>> index 0000000000..89138ebeb7\n>> --- /dev/null\n>> +++ b/Documentation/git-last-modified.adoc\n>> @@ -0,0 +1,49 @@\n>> +git-last-modified(1)\n>> +====================\n>> +\n>> +NAME\n>> +----\n>> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n>> +\n>> +\n>> +SYNOPSIS\n>> +--------\n>> +[synopsis]\n>> +git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n>\n> I think we typically list long options here, not the short single-letter\n> ones.\n\nOkay, makes sense.\n\n>> +\n>> +DESCRIPTION\n>> +-----------\n>> +\n>> +Shows which commit last modified each of the relevant files and subdirectories.\n>> +\n>> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n>> +\n>> +OPTIONS\n>> +-------\n>> +\n>> +-r::\n>\n> -r, --recursive::\n>\n>> +\tRecurse into subtrees.\n>> +\n>> +-t::\n>\n> -t, --tree-in-recursive::\n\nSure!\n\n>> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n>> new file mode 100644\n>> index 0000000000..e4c73464c7\n>> --- /dev/null\n>> +++ b/builtin/last-modified.c\n> [snip]\n>> +static int populate_paths_from_revs(struct last_modified *lm)\n>> +{\n>> +\tint num_interesting = 0;\n>> +\tstruct diff_options diffopt;\n>> +\n>> +\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n>> +\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n>> +\t/*\n>> +\t * Use a callback to populate the paths from revs\n>> +\t */\n>> +\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n>> +\tdiffopt.format_callback = add_path_from_diff;\n>> +\tdiffopt.format_callback_data = lm;\n>\n> I feel like this whole block could use a comment that explains what\n> we're doing. Why do we copy `diffopt` around? \n\nI can extend the comment. We simply don't want to touch the original,\nthat's why we copy. Do you think it would be better to simply set the\ncallback before and reset it after?\n\n> Why is it fine to free the struct at the end without unsetting\n> `lm->rev.diffopt`? Couldn't that cause a double free?\n\nOof, that's a good call. In an earlier version it was only calling\nclear_pathspec(). But then I got a comment[1] it would be better to call\ndiff_free(). I must I admit I didn't think it through further. Changing\nback to clear_pathspec() seems the most sensible to me.\n\n>> +\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n>> +\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n>> +\n>> +\t\tif (obj->item->flags & UNINTERESTING)\n>> +\t\t\tcontinue;\n>> +\n>> +\t\tif (num_interesting++)\n>> +\t\t\treturn error(_(\"last-modified can only operate on one tree at a time\"));\n>> +\n>> +\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n>> +\t\t\t      &obj->item->oid, \"\", &diffopt);\n>> +\t\tdiff_flush(&diffopt);\n>> +\t}\n>> +\tdiff_free(&diffopt);\n>> +\n>> +\treturn 0;\n>> +}\n>> +\n>> +static void last_modified_emit(struct last_modified *lm,\n>> +\t\t\t       const char *path, const struct commit *commit)\n>> +\n>> +{\n>> +\tif (commit->object.flags & BOUNDARY)\n>> +\t\tputchar('^');\n>> +\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n>> +\n>> +\tif (lm->rev.diffopt.line_termination)\n>> +\t\twrite_name_quoted(path, stdout, '\\n');\n>> +\telse\n>> +\t\tprintf(\"%s%c\", path, '\\0');\n>> +\n>> +\tfflush(stdout);\n>\n> Is there a reason why we have to explicitly flush output? This command\n> doesn't have any interactivity with the caller.\n\nNot that I'm aware of, yeah, shouldn't really be needed.\n\n>> +static void last_modified_diff(struct diff_queue_struct *q,\n>> +\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n>> +{\n>> +\tstruct last_modified_callback_data *data = cbdata;\n>> +\n>> +\tfor (int i = 0; i < q->nr; i++) {\n>> +\t\tstruct diff_filepair *p = q->queue[i];\n>> +\t\tswitch (p->status) {\n>> +\t\tcase DIFF_STATUS_DELETED:\n>> +\t\t\t/*\n>> +\t\t\t * There's no point in feeding a deletion, as it could\n>> +\t\t\t * not have resulted in our current state, which\n>> +\t\t\t * actually has the file.\n>> +\t\t\t */\n>> +\t\t\tbreak;\n>> +\n>> +\t\tdefault:\n>> +\t\t\t/*\n>> +\t\t\t * Otherwise, we care only that we somehow arrived at\n>> +\t\t\t * a final oid state. Note that this covers some\n>> +\t\t\t * potentially controversial areas, including:\n>> +\t\t\t *\n>> +\t\t\t *  1. A rename or copy will be found, as it is the\n>> +\t\t\t *     first time the content has arrived at the given\n>> +\t\t\t *     path.\n>\n> Makes sense that we don't handle renames (yet). I think I didn't spot\n> this in the manual, so maybe this is something we should document there.\n\nI'll add a line in the docs.\n\n>> +\t\t\t *  2. Even a non-content modification like a mode or\n>> +\t\t\t *     type change will trigger it.\n>\n> Seems sensible as a default, as well. And likewise, we can add\n> `--ignore-mode-changes` at a later point if we ever have a use case for\n> it.\n\nAgreed.\n\n>> +\t\t\t * We take the inclusive approach for now, and find\n>> +\t\t\t * anything which impacts the path. Options to tweak\n>> +\t\t\t * the behavior (e.g., to \"--follow\" the content across\n>> +\t\t\t * renames) can come later.\n>> +\t\t\t */\n>> +\t\t\tmark_path(p->two->path, &p->two->oid, data);\n>> +\t\t\tbreak;\n>> +\t\t}\n>> +\t}\n>> +}\n>> +\n>> +static int last_modified_run(struct last_modified *lm)\n>> +{\n>> +\tstruct last_modified_callback_data data = { .lm = lm };\n>> +\n>> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n>> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n>> +\tlm->rev.diffopt.format_callback_data = &data;\n>> +\n>> +\tprepare_revision_walk(&lm->rev);\n>> +\n>> +\twhile (hashmap_get_size(&lm->paths)) {\n>> +\t\tdata.commit = get_revision(&lm->rev);\n>> +\t\tif (!data.commit)\n>> +\t\t\tbreak;\n>\n> So in this case we have reached the end of our commit range. I assume we\n> simply print the oldest commit of that range in this case?\n\nLooking at this more in detail, I feel we should be calling BUG here.\nWhen we've hit the boundary commit, we should be printing the remaining\npaths with that commit, but with a caret `^` prepended. If we hit this\ncondition it means we went beyond the boundary, but still have paths\nremaining. That's a bug.\n\nBut... As a matter of fact. I had a test failing (on the commit using\nbloom filters). It didn't print remaining paths with the boundary commit\nwith a caret. This happens only when having GIT_TEST_COMMIT_GRAPH and\nGIT_TEST_COMMIT_GRAPH_CHANGED_PATHS set. And it's perfectly explainable\nnow:\n\nWith those set, we hit this exit condition. This happens because\nmaybe_changed_path() was called in previous loop, returning false. Then\nwe hit this exit, and un-printed paths remain. Big thanks for this hint.\n\n>> +\t\tif (data.commit->object.flags & BOUNDARY) {\n>> +\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n>> +\t\t\t\t      &data.commit->object.oid, \"\",\n>> +\t\t\t\t      &lm->rev.diffopt);\n>> +\t\t\tdiff_flush(&lm->rev.diffopt);\n>> +\t\t} else {\n>> +\t\t\tlog_tree_commit(&lm->rev, data.commit);\n>> +\t\t}\n>> +\t}\n>> +\n>> +\treturn 0;\n>> +}\n>> +\n>> +static int last_modified_init(struct last_modified *lm, struct repository *r,\n>> +\t\t\t      const char *prefix, int argc, const char **argv)\n>> +{\n>> +\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n>> +\n>> +\trepo_init_revisions(r, &lm->rev, prefix);\n>> +\tlm->rev.def = \"HEAD\";\n>> +\tlm->rev.combine_merges = 1;\n>> +\tlm->rev.show_root_diff = 1;\n>> +\tlm->rev.boundary = 1;\n>> +\tlm->rev.no_commit_id = 1;\n>> +\tlm->rev.diff = 1;\n>> +\tlm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;\n>> +\tlm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;\n>> +\n>> +\tif ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {\n>\n> Tiny nit: it's rather unusual in our codebase to assign values in\n> conditionals. I personally don't mind this usage at all -- I think it\n> can make error handling way less verbose. But I'm not sure whether we\n> deem this style acceptable.\n>\n>         argc = setup_revisions(argc, argv, &lm->rev, NULL)\n>         if (argc) {\n>             ...\n>         }\n\nI'm happy to adopt this change. I wasn't sure what the guideline is, for\nsome reason I assumed what I had. Personally I prefer the verbosity a\nlittle more.\n\n> I've seen this style several times in this patch. I think we should keep\n> our typical style for now, but I wouldn't mind if you sent a patch for\n> our coding style document so that we can discuss this.\n\nNo, let's follow typical style.\n\n>> +\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n>> +\t\treturn argc;\n>> +\t}\n>> +\n>> +\tif (populate_paths_from_revs(lm) < 0)\n>> +\t\treturn error(_(\"unable to setup last-modified\"));\n>> +\n>> +\treturn 0;\n>> +}\n>> +\n>> +int cmd_last_modified(int argc, const char **argv, const char *prefix,\n>> +\t\t      struct repository *repo)\n>> +{\n>> +\tint ret;\n>> +\tstruct last_modified lm;\n>> +\n>> +\tconst char * const last_modified_usage[] = {\n>> +\t\tN_(\"git last-modified [-r] [-t] \"\n>> +\t\t   \"[<revision-range>] [[--] <path>...]\"),\n>> +\t\tNULL\n>> +\t};\n>> +\n>> +\tstruct option last_modified_options[] = {\n>> +\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n>> +\t\t\t N_(\"recurse into subtrees\")),\n>> +\t\tOPT_BOOL('t', \"tree-in-recursive\", &lm.tree_in_recursive,\n>> +\t\t\t N_(\"recurse into subtrees and include the tree entries too\")),\n>\n> Should this maybe be called something like \"--recursive-with-trees\"?\n> \"--tree-in-recursive\" reads somewhat strange to me.\n\nI agree that sounds better. It seems we don't have either options yet,\nso we're still open to chose.\n\n>> +\t\tOPT_END()\n>> +\t};\n>> +\n>> +\tmemset(&lm, 0, sizeof(lm));\n>\n> You can avoid the `memset()` and directly zero-initialize the struct\n> when it's declared. Alternatively, you can move this function call into\n> `last_modified_init()` itself, where it would be more reasonable.\n\nBecause I read parse_options() results into this struct, I cannot do the\nmemset() in last_modified_init(). So I'm changing to the `{ 0 }`\nzero-init.\n\n>> +\targc = parse_options(argc, argv, prefix, last_modified_options,\n>> +\t\t\t     last_modified_usage,\n>> +\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n>> +\n>> +\trepo_config(repo, git_default_config, NULL);\n>> +\n>> +\tif ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {\n>> +\t\tif (ret > 0)\n>> +\t\t\tusage_with_options(last_modified_usage,\n>> +\t\t\t\t\t   last_modified_options);\n>> +\t\tgoto out;\n>> +\t}\n>> +\n>> +\tif ((ret = last_modified_run(&lm)))\n>> +\t\tgoto out;\n>\n> Two more cases where we assign `if ((ret = ...))`.\n\nYeah yeah, I've heard you ;-P. No no, joking, I appreciate you're\npointing this out.\n\n>\n> Patrick\n>\n\n[1]: https://lore.kernel.org/git/aDWWe6qCQXorPESd@pks.im/\n\n-- \nCheers,\nToon\n"},{"id":"523219","messageId":"87ldo3ui37.fsf@iotcl.com","threadId":"63332","inReplyTo":"aIsQfZyMq0ltbMtz@pks.im","subject":"Re: [PATCH v6 4/4] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-01T16:23:08Z","receivedAt":"2025-08-01T16:23:22Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Jul 30, 2025 at 07:55:10PM +0200, Toon Claes wrote:\n>> Our 'git last-modified' performs a revision walk, and computes a diff at\n>> each point in the walk to figure out whether a given revision changed\n>> any of the paths it considers interesting.\n>> \n>> When changed-path Bloom filters are available, we can avoid computing\n>> many such diffs. Before computing a diff, we first check if any of the\n>> remaining paths of interest were possibly changed at a given commit by\n>> consulting its Bloom filter. If any of them are, we are resigned to\n>> compute the diff.\n>> \n>> If none of those queries returned \"maybe\", we know that the given commit\n>> doesn't contain any changed paths which are interesting to us. So, we\n>> can avoid computing it in this case.\n>> \n>> Comparing the perf test results on git.git:\n>> \n>>     Test                                        HEAD~             HEAD\n>>     ------------------------------------------------------------------------------------\n>>     8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n>>     8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n>>     8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n>\n> Nice results.\n>\n>> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n>> index e4c73464c7..19bf25f8a5 100644\n>> --- a/builtin/last-modified.c\n>> +++ b/builtin/last-modified.c\n>> @@ -179,6 +192,27 @@ static void last_modified_diff(struct diff_queue_struct *q,\n>>  \t}\n>>  }\n>>  \n>> +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n>> +{\n>> +\tstruct bloom_filter *filter;\n>> +\tstruct last_modified_entry *ent;\n>> +\tstruct hashmap_iter iter;\n>> +\n>> +\tif (!lm->rev.bloom_filter_settings)\n>> +\t\treturn 1;\n>> +\n>> +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n>> +\tif (!filter)\n>> +\t\treturn 1;\n>> +\n>> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n>> +\t\tif (bloom_filter_contains(filter, &ent->key,\n>> +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n>> +\t\t\treturn 1;\n>> +\t}\n>> +\treturn 0;\n>> +}\n>\n> This function is basically the same as `maybe_changed_paths()` in\n> \"blame.c\", but that isn't a huge issue from my point of view. What makes\n> me wonder though is why we have an additional check over there for\n> whether or not the commit has a valid generation number.\n\nI've been asking me the same question. And I couldn't find a good reason\n(neither from the commit history, or from my reasoning). This check was\nin the version shared by Taylor, but because we were ignoring the return\nvalue from generation_numbers_enabled() in that version, it didn't make\nsense to me to do this check. That's why I removed it.\n\n>> @@ -227,6 +264,9 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n>>  \t\treturn argc;\n>>  \t}\n>>  \n>> +\tprepare_commit_graph(lm->rev.repo);\n>> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n>> +\n>\n> So this here is why we export `prepare_commit_graph()`. How about we\n> instead expose `bloom_filters_enabled()` that mirrors what we do in\n> `generation_numbers_enabled()` and `corrected_commit_dates_enabled()`?\n> That would both be on a higher level and do exactly what we want to\n> achieve.\n\nI've got another proposal, what if we let get_bloom_filter_settings()\ncall prepare_commit_graph()? Functions like\nrepo_find_commit_pos_in_graph() and lookup_commit_in_graph() do this\ntoo. \n\n-- \nCheers,\nToon\n"},{"id":"523227","messageId":"xmqqh5yrm0z9.fsf@gitster.g","threadId":"63332","inReplyTo":"CAP8UFD18Ds4zME_ZHRdTA0W7KXk57zo9664xaU2TbH7YsHbFUg@mail.gmail.com","subject":"Re: [PATCH v5 1/6] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-01T16:59:54Z","receivedAt":"2025-08-01T16:59:56Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> On Fri, Jul 18, 2025 at 2:02 AM Taylor Blau <me@ttaylorr.com> wrote:\n>\n>> > +struct last_modified {\n>> > +     struct hashmap paths;\n>> > +     struct rev_info rev;\n>> > +     int recursive, tree_in_recursive;\n>>\n>> Can we either make these two part of a bitfield, or at least declare\n>> them separately?\n>\n> I wonder if we could/should use the `bool` type from <stdbool.h> as\n> Documentation/CodingGuidelines says that it's now allowed.\n\nEven though \"allowed\" is different from \"encouraged\", I would say\nit is a good idea to declare them separately, i.e.\n\n\tbool recursive;\n\tbool show_trees_in_recursive;\n\nI am guessing 'tree-in-recursive' is one similar to 'git ls-tree -t'\nfeature but the name given in the patch requires such guessing, as\nthe name is a bit inadequate (it does not say what you want to do to\ntrees when recursive).\n\nRenaming to show_trees_in_recursive eliminates the need for such\nguessing.  The implementation of ls-tree calls the corresponding but\nas LS_SHOW_TREES which is a bit inadequate.\n"},{"id":"523228","messageId":"xmqq7bznm0nk.fsf@gitster.g","threadId":"63332","inReplyTo":"aIyVft9PdlorttzZ@pks.im","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-01T17:06:55Z","receivedAt":"2025-08-01T17:06:58Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n>> > +static void last_modified_release(struct last_modified *lm)\n>> \n>> I think these days we tend to name those functions using \"clear\"\n>> instead of \"release\"\n>\n> It actually depends: if the structure can be immediately reused\n> afterwards without requiring another reinit it would be caller \"clear\"\n> indeed. On the other hand, if we only release memory it's \"release\".\n>\n> I think this function here falls into the latter category, so it's\n> correctly named.\n\nGiven that even a long-time contributor gets confused (including me,\nwho needed to see where we documented this for our developers),\nperhaps a clarification patch is in order?\n\n--- >8 ---\nSubject: CodingGuidelines: clarify that S_release() does not reinitialize\n\nIn the section for naming various API functions, the fact that\nS_release() only releases the resources without preparing the\nstructure for immediate reuse becomes only apparent when you\nreadentries for S_release() and S_clear().\n\nClarify the description of S_release() a bit to make the entry self\nsufficient.\n\nSigned-off-by: Junio C Hamano <gitster@pobox.com>\n---\n Documentation/CodingGuidelines | 5 +++--\n 1 file changed, 3 insertions(+), 2 deletions(-)\n\ndiff --git c/Documentation/CodingGuidelines w/Documentation/CodingGuidelines\nindex c1046abfb7..76ec6268f2 100644\n--- c/Documentation/CodingGuidelines\n+++ w/Documentation/CodingGuidelines\n@@ -610,8 +610,9 @@ For C programs:\n     - `S_init()` initializes a structure without allocating the\n       structure itself.\n \n-    - `S_release()` releases a structure's contents without freeing the\n-      structure.\n+    - `S_release()` releases a structure's contents without reinitializing\n+      the structure for immediate reuse, and without freeing the structure\n+      itself.\n \n     - `S_clear()` is equivalent to `S_release()` followed by `S_init()`\n       such that the structure is directly usable after clearing it. When\n"},{"id":"523229","messageId":"xmqq34abm0iy.fsf@gitster.g","threadId":"63332","inReplyTo":"87ms8jui3p.fsf@iotcl.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-01T17:09:41Z","receivedAt":"2025-08-01T17:09:44Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n>>> +-t::\n>>\n>> -t, --tree-in-recursive::\n>\n> Sure!\n\nClarify *what* you do to trees in recursive by giving a verb, e.g.\n\n    --show-trees-in-recursive\n\nperhaps?\n"},{"id":"523238","messageId":"5029495.31r3eYUQgx@cayenne","threadId":"63332","inReplyTo":"87ms8jui3p.fsf@iotcl.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Jean-Noël AVILA","fromEmail":"jn.avila@free.fr","sentAt":"2025-08-01T20:34:09Z","receivedAt":"2025-08-01T20:34:20Z","isPatch":true,"sender":{"key":"jn.avila@free.fr","avatar":"https://avatars.githubusercontent.com/u/156172?v=4"},"body":"Hello,\n\nOn Friday, 1 August 2025 18:22:50 CEST Toon Claes wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> > On Wed, Jul 30, 2025 at 07:55:07PM +0200, Toon Claes wrote:\n> >> diff --git a/Documentation/git-last-modified.adoc\n> >> b/Documentation/git-last-modified.adoc new file mode 100644\n> >> index 0000000000..89138ebeb7\n> >> --- /dev/null\n> >> +++ b/Documentation/git-last-modified.adoc\n> >> @@ -0,0 +1,49 @@\n> >> +git-last-modified(1)\n> >> +====================\n> >> +\n> >> +NAME\n> >> +----\n> >> +git-last-modified - EXPERIMENTAL: Show when files were last modified\n> >> +\n> >> +\n> >> +SYNOPSIS\n> >> +--------\n> >> +[synopsis]\n> >> +git last-modified [-r] [-t] [<revision-range>] [[--] <path>...]\n> > \n> > I think we typically list long options here, not the short single-letter\n> > ones.\n> \n> Okay, makes sense.\n> \n> >> +\n> >> +DESCRIPTION\n> >> +-----------\n> >> +\n> >> +Shows which commit last modified each of the relevant files and \nsubdirectories.\n> >> +\n> >> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n> >> +\n> >> +OPTIONS\n> >> +-------\n> >> +\n> > \n> >> +-r::\n> > -r, --recursive::\n\nAs a newly introduced man page, please switch to full synopsis style and cite \nonly one form per line:\n\n`-r`::\n`--recurse`::\n\n> >> +\tRecurse into subtrees.\n> >> +\n> > \n> >> +-t::\n> > -t, --tree-in-recursive::\n> Sure!\n> \n\nIdem here.\n\n\n\n\n"},{"id":"523258","messageId":"CAP8UFD3rV5CmHbbmg9vQodJ3U=WxfmWHnaio+7RcBAKz=2Ar-A@mail.gmail.com","threadId":"63332","inReplyTo":"xmqq7bznm0nk.fsf@gitster.g","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2025-08-02T08:18:24Z","receivedAt":"2025-08-02T08:18:38Z","isPatch":true,"sender":{"key":"christian.couder@gmail.com","avatar":"https://avatars.githubusercontent.com/u/208954?v=4"},"body":"On Fri, Aug 1, 2025 at 7:06 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> Given that even a long-time contributor gets confused (including me,\n> who needed to see where we documented this for our developers),\n> perhaps a clarification patch is in order?\n>\n> --- >8 ---\n> Subject: CodingGuidelines: clarify that S_release() does not reinitialize\n>\n> In the section for naming various API functions, the fact that\n> S_release() only releases the resources without preparing the\n> structure for immediate reuse becomes only apparent when you\n> readentries for S_release() and S_clear().\n>\n> Clarify the description of S_release() a bit to make the entry self\n> sufficient.\n>\n> Signed-off-by: Junio C Hamano <gitster@pobox.com>\n> ---\n>  Documentation/CodingGuidelines | 5 +++--\n>  1 file changed, 3 insertions(+), 2 deletions(-)\n>\n> diff --git c/Documentation/CodingGuidelines w/Documentation/CodingGuidelines\n> index c1046abfb7..76ec6268f2 100644\n> --- c/Documentation/CodingGuidelines\n> +++ w/Documentation/CodingGuidelines\n> @@ -610,8 +610,9 @@ For C programs:\n>      - `S_init()` initializes a structure without allocating the\n>        structure itself.\n>\n> -    - `S_release()` releases a structure's contents without freeing the\n> -      structure.\n> +    - `S_release()` releases a structure's contents without reinitializing\n> +      the structure for immediate reuse, and without freeing the structure\n> +      itself.\n>\n>      - `S_clear()` is equivalent to `S_release()` followed by `S_init()`\n>        such that the structure is directly usable after clearing it. When\n\nYeah, I think that could help. Thanks!\n"},{"id":"523271","messageId":"CAP8UFD1hdykpEaFrs1VoHbU1f-vT7xL2R6uvFWzjPQKRp0eoEw@mail.gmail.com","threadId":"63332","inReplyTo":"CAP8UFD3rV5CmHbbmg9vQodJ3U=WxfmWHnaio+7RcBAKz=2Ar-A@mail.gmail.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2025-08-02T11:31:21Z","receivedAt":"2025-08-02T11:31:36Z","isPatch":true,"sender":{"key":"christian.couder@gmail.com","avatar":"https://avatars.githubusercontent.com/u/208954?v=4"},"body":"On Sat, Aug 2, 2025 at 10:18 AM Christian Couder\n<christian.couder@gmail.com> wrote:\n>\n> On Fri, Aug 1, 2025 at 7:06 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> > Given that even a long-time contributor gets confused (including me,\n> > who needed to see where we documented this for our developers),\n> > perhaps a clarification patch is in order?\n> >\n> > --- >8 ---\n> > Subject: CodingGuidelines: clarify that S_release() does not reinitialize\n> >\n> > In the section for naming various API functions, the fact that\n> > S_release() only releases the resources without preparing the\n> > structure for immediate reuse becomes only apparent when you\n> > readentries for S_release() and S_clear().\n> >\n> > Clarify the description of S_release() a bit to make the entry self\n> > sufficient.\n> >\n> > Signed-off-by: Junio C Hamano <gitster@pobox.com>\n> > ---\n> >  Documentation/CodingGuidelines | 5 +++--\n> >  1 file changed, 3 insertions(+), 2 deletions(-)\n> >\n> > diff --git c/Documentation/CodingGuidelines w/Documentation/CodingGuidelines\n> > index c1046abfb7..76ec6268f2 100644\n> > --- c/Documentation/CodingGuidelines\n> > +++ w/Documentation/CodingGuidelines\n> > @@ -610,8 +610,9 @@ For C programs:\n> >      - `S_init()` initializes a structure without allocating the\n> >        structure itself.\n> >\n> > -    - `S_release()` releases a structure's contents without freeing the\n> > -      structure.\n> > +    - `S_release()` releases a structure's contents without reinitializing\n> > +      the structure for immediate reuse, and without freeing the structure\n> > +      itself.\n> >\n> >      - `S_clear()` is equivalent to `S_release()` followed by `S_init()`\n> >        such that the structure is directly usable after clearing it. When\n>\n> Yeah, I think that could help. Thanks!\n\nFor information, I have asked ChatGPT o3-pro (which can analyse a\nwhole repo) about this and here is what it says:\n\n---------------------------\n\nQ: In the source code of the Git SCM software are there standard ways\nof naming functions that release memory from a struct or clear a\nstruct? Are they consistently named using \"clear\" or \"release\" or\n\"free\" or \"clear_and_free\" or other things like that depending on what\nthey actually do?\n\nA:\n\nWhat the names mean in Git’s C code – quick rules of thumb\n\n- *_clear():\n\n  effect: Free only the heap‑memory owned by the struct (arrays,\nstrings, hash‑table buckets, etc.) and reset its fields to an\ninitial/empty state.\n\n  reuse possible: Yes – the variable itself remains valid and can be\nfilled again.\n\n- *_release():\n\n  effect: Historical synonym for the same thing as *_clear(), kept for\nbackward compatibility in a few low‑level APIs.\n\n  reuse possible: Yes – same as above.\n\n- free_*() or *_free():\n\n  effect: Free the struct itself (it was malloc‑ed) and everything it\npoints at. After the call the pointer becomes dangling and must not be\nreused.\n\n  reuse possible: No – the storage is gone.\n\n- *_clear_and_free():\n\n  effect: Convenience wrapper that first calls the type’s *_clear() on\neach contained element and then frees those elements themselves.\nMainly used for containers implemented as macros.\n\n  reuse possible: Not applicable – it is meant to be the last\noperation on the container.\n\n\nHow consistent is the convention?\n\n- Within each mini‑API the convention is very strict\n  The strbuf API always pairs strbuf_init() / strbuf_release(), the\nstrvec API always pairs strvec_init() / strvec_clear(), etc.\n\n- Across the whole Git code‑base the spelling is mostly consistent but\nnot 100 % uniform\n  Very early core helpers (pre‑1.6): usually *_release()\n  Code written in the last decade: *_clear() for “empty in‑place”,\nfree_*() for “destroy”\n\n- No “mixed‑semantics” functions\n  You will not find a helper that sometimes frees the struct and\nsometimes only clears it depending on flags. The name alone tells you\nwhich category it belongs to.\n\n[...]\n\nBottom line\n\nGit uses two distinct verbs to convey intent:\n\n- clear / release – light‑weight reset, keep the container alive\n\n- free – heavy‑weight destruction of the container itself\n\nThe rule is consistent enough that you can rely on it when reading or\nwriting Git’s C code; the rare exceptions are historical, not\nambiguous.\n\n\nQ: Some say that if the structure can be immediately reused afterwards\nwithout requiring another reinit the function should be named using\n\"clear\", otherwise if it only releases memory then it should be called\n\"release\". Is it true?\n\nA:\n\nShort answer – no.\n\nIn the Git source tree the verbs clear and release are not used to\ndistinguish “can I reuse the struct afterwards?” vs. “is this the\nfinal teardown?”. Both variants free all heap memory owned by the\nstructure and then reset the in‑memory object to its pristine, empty\nstate, so the variable can be used again without an extra call to the\ncorresponding …_init() helper.\n\n[...]\n\nWhy two different verbs exist\n\n- Historical baggage – the very early helper that everybody still\nuses, strbuf, was written long before today’s “*_clear()” convention\nemerged, so its lifecycle pair is strbuf_init() / strbuf_release().\n\n- Later subsystems standardised on *_clear() when they were introduced\n(string‑list, strvec, oidset, hashmap, etc.).\n\n- Because Git’s internal APIs are used by thousands of call‑sites,\nthere has been no large‑scale renaming; both spellings therefore\ncoexist.\n\n[...]\n\nConclusion\n\nThe folk rule you quoted (“* use clear if the struct can be reused,\nrelease if not *”) does not match what the Git code actually does:\n\nstrbuf_release() allows immediate reuse – just like every *_clear() helper does.\n\nThe difference in naming is historical, not semantic.\n\nIf you want to know whether the variable itself has been freed, look\nfor free_*()/*_free(), not for the presence or absence of the word\nrelease.\n\n\nQ: Do you have other examples than strbuf_release using \"release\"\nwhere the function behaves like a \"clear\" function?\n\nA:\n\nYes – a handful of other helpers are spelled “*_release()”, yet they\nmerely clear the inside of an on‑stack/object‑lifetime struct and\nleave the container itself reusable\n\n- Line reader: void line_buffer_release(struct line_buffer *buf);\n  Closes the underlying file descriptor and frees its internal scratch\nspace; the struct line_buffer itself remains valid and can be\nre‑initialised with line_buffer_open() if desired.\n\n- Packet‑line writer: void packet_writer_release(struct packet_writer *writer);\n  Calls strbuf_release(&writer‑>scratch_buf) and clears a few flags;\nthe struct packet_writer lives on so the caller may point it at\nanother FILE * later.\n\n- FS‑monitor IPC channel: void fsmonitor_ipc__release(struct\nfsmonitor_ipc *ipc);\n  Shuts down the socket, frees small temp buffers, and zeroes the\nstruct for re‑use inside the long‑running daemon.\n\nAll of these match the behaviour of strbuf_release() rather than that\nof the many *_free() helpers which actually free() the struct itself.\n\nHow many are there?\n\nA quick git grep '_release(struct' over the Git 2.50 tree finds fewer\nthan a dozen hits, and every one of them follows this\n“clear‑but‑don’t‑free” pattern. In other words:\n\n- *_release() in Git never deallocates the container itself.\n\n- The form survives today only in subsystems that pre‑date the later\nproject‑wide switch to *_clear() (or that wrap strbuf_release()\ninternally).\n\n- Newer code consistently uses *_clear() for the same semantics.\n\nBottom line\n\nstrbuf_release() is not unique, but it is the template that the other\nrare *_release() helpers copy: they wipe the contents and reset the\nstruct so you can keep using the variable. If you are looking at Git C\ncode and see “_release” in a function name, you can safely assume\n“clear in place” – not “free the object.”\n\n---------------------------\n\nIn short it looks like it doesn't think there should be a distinction\nbetween *_release() and *_clear(), because in many cases the existing\n*_release() functions (starting with strbuf_release()) leave the\nstruct ready for immediate reuse.\n\nFor the documentation update, it suggests:\n\n  - `S_release()` releases a structure's contents without freeing the\n    structure. In older subsystems (e.g. *strbuf*), the helper also\n    resets the object to its initial, empty state; newer code should\n    prefer `S_clear()` for that purpose.\n\nBut if our intention is to tend towards a clear distinction between\n\"clear\" and \"release\" even if in practice there is not a clear\ndistinction right now (because of historical reasons), I think we\ncould compromise with something like:\n\n- `S_release()` releases a structure's contents without reinitializing\n  the structure for immediate reuse, and without freeing the structure\n  itself. In older subsystems (e.g. *strbuf*), the helper also\n  resets the object to its initial, empty state; newer code should\n  prefer `S_clear()` for that purpose.\n"},{"id":"523272","messageId":"CAP8UFD0Hrb-=TRNkueBw06Mgu6Hp1zO8qZVOFVLi8Qp4xkas+w@mail.gmail.com","threadId":"63332","inReplyTo":"CAP8UFD1hdykpEaFrs1VoHbU1f-vT7xL2R6uvFWzjPQKRp0eoEw@mail.gmail.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2025-08-02T13:38:37Z","receivedAt":"2025-08-02T13:38:51Z","isPatch":true,"sender":{"key":"christian.couder@gmail.com","avatar":"https://avatars.githubusercontent.com/u/208954?v=4"},"body":"On Sat, Aug 2, 2025 at 1:31 PM Christian Couder\n<christian.couder@gmail.com> wrote:\n\n> Q: Do you have other examples than strbuf_release using \"release\"\n> where the function behaves like a \"clear\" function?\n>\n> A:\n>\n> Yes – a handful of other helpers are spelled “*_release()”, yet they\n> merely clear the inside of an on‑stack/object‑lifetime struct and\n> leave the container itself reusable\n>\n> - Line reader: void line_buffer_release(struct line_buffer *buf);\n>   Closes the underlying file descriptor and frees its internal scratch\n> space; the struct line_buffer itself remains valid and can be\n> re‑initialised with line_buffer_open() if desired.\n>\n> - Packet‑line writer: void packet_writer_release(struct packet_writer *writer);\n>   Calls strbuf_release(&writer‑>scratch_buf) and clears a few flags;\n> the struct packet_writer lives on so the caller may point it at\n> another FILE * later.\n>\n> - FS‑monitor IPC channel: void fsmonitor_ipc__release(struct\n> fsmonitor_ipc *ipc);\n>   Shuts down the socket, frees small temp buffers, and zeroes the\n> struct for re‑use inside the long‑running daemon.\n>\n> All of these match the behaviour of strbuf_release() rather than that\n> of the many *_free() helpers which actually free() the struct itself.\n\nActually it looks like it hallucinated those examples. It's true that\nstrbuf_release() makes it possible to reuse the struct, but it's not\nefficient as memory needs to be reallocated.\n\nSorry for the noise.\n"},{"id":"523276","messageId":"xmqq8qk1k7v3.fsf@gitster.g","threadId":"63332","inReplyTo":"CAP8UFD0Hrb-=TRNkueBw06Mgu6Hp1zO8qZVOFVLi8Qp4xkas+w@mail.gmail.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-02T16:26:24Z","receivedAt":"2025-08-02T16:26:28Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> Sorry for the noise.\n\nWhen 6d0618a8 (Add Documentation/CodingGuidelines, 2007-11-08)\nstarted a written guideline, the project already had two-year's\nworth of accumulated code.  It was more like \"we have been operating\nwithout any written guideline, and so far it has been OK because\nmost of our contributors and reviewers were competent and\ninteraction among them amicable.  But now we are having more new\nfaces.  It is a good time to codify the rules that we have been\ntrying to adhere to.  It is possible we may have missed some\nviolations during our reviews and have already took bad apples in\nthe code base, but they are tolerated-but-undesirable exceptions.\nThese are the rules we have been trying to follow.\"  It is expected\nthat there are some corner cases that violate the writings without\nmeaning to.\n\nAnybody reading the document should take it as an aspirational\nguide, where existing violations (1) are not excuses to introduce\nmore deviations, (2) are \"once written, it is often not worth the\ncode churn to go and fix them only for the sake of fixing them\", and\n(3) are very welcome to be rewritten if you are rewriting the code\nthat covers (not merely overlaps) the area.\n\nAnd we writing or updating the document should try to make sure that\nthe aspirational nature is clear to readers.\n\nSo your intention to improve the wording of one single item was\nsurely appreciated, but I think the effort is better spent to make\nsure that readers are aware that not just that single item, but\neverything in the guideline, may have existing violations in the\ncode base, and they understand how they should treat these existing\nviolations, perhaps by polishing the preamble to the whole guideline\ndocument somehow.\n\nThanks.\n\n"},{"id":"523375","messageId":"aJBUIrPDx8Ds-8ni@pks.im","threadId":"63332","inReplyTo":"87ldo3ui37.fsf@iotcl.com","subject":"Re: [PATCH v6 4/4] last-modified: use Bloom filters when available","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-04T06:33:06Z","receivedAt":"2025-08-04T06:33:14Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, Aug 01, 2025 at 06:23:08PM +0200, Toon Claes wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> > On Wed, Jul 30, 2025 at 07:55:10PM +0200, Toon Claes wrote:\n> >> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> >> index e4c73464c7..19bf25f8a5 100644\n> >> --- a/builtin/last-modified.c\n> >> +++ b/builtin/last-modified.c\n> >> @@ -179,6 +192,27 @@ static void last_modified_diff(struct diff_queue_struct *q,\n> >>  \t}\n> >>  }\n> >>  \n> >> +static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n> >> +{\n> >> +\tstruct bloom_filter *filter;\n> >> +\tstruct last_modified_entry *ent;\n> >> +\tstruct hashmap_iter iter;\n> >> +\n> >> +\tif (!lm->rev.bloom_filter_settings)\n> >> +\t\treturn 1;\n> >> +\n> >> +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n> >> +\tif (!filter)\n> >> +\t\treturn 1;\n> >> +\n> >> +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n> >> +\t\tif (bloom_filter_contains(filter, &ent->key,\n> >> +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n> >> +\t\t\treturn 1;\n> >> +\t}\n> >> +\treturn 0;\n> >> +}\n> >\n> > This function is basically the same as `maybe_changed_paths()` in\n> > \"blame.c\", but that isn't a huge issue from my point of view. What makes\n> > me wonder though is why we have an additional check over there for\n> > whether or not the commit has a valid generation number.\n> \n> I've been asking me the same question. And I couldn't find a good reason\n> (neither from the commit history, or from my reasoning). This check was\n> in the version shared by Taylor, but because we were ignoring the return\n> value from generation_numbers_enabled() in that version, it didn't make\n> sense to me to do this check. That's why I removed it.\n\nOkay. It might make sense to point this out in the commit message.\n\n> >> @@ -227,6 +264,9 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n> >>  \t\treturn argc;\n> >>  \t}\n> >>  \n> >> +\tprepare_commit_graph(lm->rev.repo);\n> >> +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n> >> +\n> >\n> > So this here is why we export `prepare_commit_graph()`. How about we\n> > instead expose `bloom_filters_enabled()` that mirrors what we do in\n> > `generation_numbers_enabled()` and `corrected_commit_dates_enabled()`?\n> > That would both be on a higher level and do exactly what we want to\n> > achieve.\n> \n> I've got another proposal, what if we let get_bloom_filter_settings()\n> call prepare_commit_graph()? Functions like\n> repo_find_commit_pos_in_graph() and lookup_commit_in_graph() do this\n> too. \n\nYeah, I don't see any issue with that, either.\n\nPatrick\n"},{"id":"523376","messageId":"aJBUKa3KuxXlXMzl@pks.im","threadId":"63332","inReplyTo":"87ms8jui3p.fsf@iotcl.com","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-04T06:33:13Z","receivedAt":"2025-08-04T06:33:19Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, Aug 01, 2025 at 06:22:50PM +0200, Toon Claes wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> > On Wed, Jul 30, 2025 at 07:55:07PM +0200, Toon Claes wrote:\n> >> diff --git a/builtin/last-modified.c b/builtin/last-modified.c\n> >> new file mode 100644\n> >> index 0000000000..e4c73464c7\n> >> --- /dev/null\n> >> +++ b/builtin/last-modified.c\n> >> +static int last_modified_run(struct last_modified *lm)\n> >> +{\n> >> +\tstruct last_modified_callback_data data = { .lm = lm };\n> >> +\n> >> +\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n> >> +\tlm->rev.diffopt.format_callback = last_modified_diff;\n> >> +\tlm->rev.diffopt.format_callback_data = &data;\n> >> +\n> >> +\tprepare_revision_walk(&lm->rev);\n> >> +\n> >> +\twhile (hashmap_get_size(&lm->paths)) {\n> >> +\t\tdata.commit = get_revision(&lm->rev);\n> >> +\t\tif (!data.commit)\n> >> +\t\t\tbreak;\n> >\n> > So in this case we have reached the end of our commit range. I assume we\n> > simply print the oldest commit of that range in this case?\n> \n> Looking at this more in detail, I feel we should be calling BUG here.\n> When we've hit the boundary commit, we should be printing the remaining\n> paths with that commit, but with a caret `^` prepended. If we hit this\n> condition it means we went beyond the boundary, but still have paths\n> remaining. That's a bug.\n> \n> But... As a matter of fact. I had a test failing (on the commit using\n> bloom filters). It didn't print remaining paths with the boundary commit\n> with a caret. This happens only when having GIT_TEST_COMMIT_GRAPH and\n> GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS set. And it's perfectly explainable\n> now:\n> \n> With those set, we hit this exit condition. This happens because\n> maybe_changed_path() was called in previous loop, returning false. Then\n> we hit this exit, and un-printed paths remain. Big thanks for this hint.\n\nNice :)\n\nPatrick\n"},{"id":"523377","messageId":"aJBUWveulX7HKAMY@pks.im","threadId":"63332","inReplyTo":"xmqq34abm0iy.fsf@gitster.g","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-04T06:34:02Z","receivedAt":"2025-08-04T06:34:08Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, Aug 01, 2025 at 10:09:41AM -0700, Junio C Hamano wrote:\n> Toon Claes <toon@iotcl.com> writes:\n> \n> >>> +-t::\n> >>\n> >> -t, --tree-in-recursive::\n> >\n> > Sure!\n> \n> Clarify *what* you do to trees in recursive by giving a verb, e.g.\n> \n>     --show-trees-in-recursive\n\nAh, that's even better indeed! One question that this raises is whether\nthis option then should continue to imply `--recursive`. I think it\nrather shouldn't with this new wording, but don't feel overly strong\nabout it.\n\nPatrick\n"},{"id":"523379","messageId":"aJBUxj7li3XsokdR@pks.im","threadId":"63332","inReplyTo":"xmqq7bznm0nk.fsf@gitster.g","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-04T06:35:50Z","receivedAt":"2025-08-04T06:35:57Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Fri, Aug 01, 2025 at 10:06:55AM -0700, Junio C Hamano wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> >> > +static void last_modified_release(struct last_modified *lm)\n> >> \n> >> I think these days we tend to name those functions using \"clear\"\n> >> instead of \"release\"\n> >\n> > It actually depends: if the structure can be immediately reused\n> > afterwards without requiring another reinit it would be caller \"clear\"\n> > indeed. On the other hand, if we only release memory it's \"release\".\n> >\n> > I think this function here falls into the latter category, so it's\n> > correctly named.\n> \n> Given that even a long-time contributor gets confused (including me,\n> who needed to see where we documented this for our developers),\n> perhaps a clarification patch is in order?\n> \n> --- >8 ---\n> Subject: CodingGuidelines: clarify that S_release() does not reinitialize\n> \n> In the section for naming various API functions, the fact that\n> S_release() only releases the resources without preparing the\n> structure for immediate reuse becomes only apparent when you\n> readentries for S_release() and S_clear().\n> \n> Clarify the description of S_release() a bit to make the entry self\n> sufficient.\n> \n> Signed-off-by: Junio C Hamano <gitster@pobox.com>\n> ---\n>  Documentation/CodingGuidelines | 5 +++--\n>  1 file changed, 3 insertions(+), 2 deletions(-)\n> \n> diff --git c/Documentation/CodingGuidelines w/Documentation/CodingGuidelines\n> index c1046abfb7..76ec6268f2 100644\n> --- c/Documentation/CodingGuidelines\n> +++ w/Documentation/CodingGuidelines\n> @@ -610,8 +610,9 @@ For C programs:\n>      - `S_init()` initializes a structure without allocating the\n>        structure itself.\n>  \n> -    - `S_release()` releases a structure's contents without freeing the\n> -      structure.\n> +    - `S_release()` releases a structure's contents without reinitializing\n> +      the structure for immediate reuse, and without freeing the structure\n> +      itself.\n>  \n>      - `S_clear()` is equivalent to `S_release()` followed by `S_init()`\n>        such that the structure is directly usable after clearing it. When\n\nYup, this looks like a reasonable change to me, thanks!\n\nPatrick\n"},{"id":"523470","messageId":"xmqqikj357rl.fsf@gitster.g","threadId":"63332","inReplyTo":"aJBUWveulX7HKAMY@pks.im","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-04T17:14:22Z","receivedAt":"2025-08-04T17:14:25Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, Aug 01, 2025 at 10:09:41AM -0700, Junio C Hamano wrote:\n>> Toon Claes <toon@iotcl.com> writes:\n>> \n>> >>> +-t::\n>> >>\n>> >> -t, --tree-in-recursive::\n>> >\n>> > Sure!\n>> \n>> Clarify *what* you do to trees in recursive by giving a verb, e.g.\n>> \n>>     --show-trees-in-recursive\n>\n> Ah, that's even better indeed! One question that this raises is whether\n> this option then should continue to imply `--recursive`. I think it\n> rather shouldn't with this new wording, but don't feel overly strong\n> about it.\n\nI am somewhat indifferent.  We can call the option --show-trees,\nwhich I suspect would make it more consistent with ls-trees, while\nmaking it a bit more confusing at the same time.  I dunno.\n"},{"id":"523509","messageId":"878qjyuy8s.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqikj357rl.fsf@gitster.g","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T05:35:31Z","receivedAt":"2025-08-05T05:35:44Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> I am somewhat indifferent.  We can call the option --show-trees,\n> which I suspect would make it more consistent with ls-trees, while\n> making it a bit more confusing at the same time.  I dunno.\n\nI like this a lot. Gonna go with this.\n\n-- \nCheers,\nToon\n"},{"id":"523510","messageId":"875xf2uy73.fsf@iotcl.com","threadId":"63332","inReplyTo":"5029495.31r3eYUQgx@cayenne","subject":"Re: [PATCH v6 1/4] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T05:36:32Z","receivedAt":"2025-08-05T05:36:42Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Jean-Noël AVILA <jn.avila@free.fr> writes:\n\n>> >> +\n>> >> +DESCRIPTION\n>> >> +-----------\n>> >> +\n>> >> +Shows which commit last modified each of the relevant files and \n> subdirectories.\n>> >> +\n>> >> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n>> >> +\n>> >> +OPTIONS\n>> >> +-------\n>> >> +\n>> > \n>> >> +-r::\n>> > -r, --recursive::\n>\n> As a newly introduced man page, please switch to full synopsis style and cite \n> only one form per line:\n>\n> `-r`::\n> `--recurse`::\n\nThanks for pointing that out. I wasn't aware.\n\n-- \nCheers,\nToon\n"},{"id":"523524","messageId":"20250805093358.1791633-1-toon@iotcl.com","threadId":"63332","inReplyTo":"20250730175510.987383-1-toon@iotcl.com","subject":"[PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T09:33:55Z","receivedAt":"2025-08-05T09:34:22Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"On many forges the tree view is shown in combination with commit data.\nIn such a view each tree entry is accompanied with the commit message\nand date that last modified that tree entry. Something similar like:\n\n    | README.md   | README: *.txt -> *.adoc fixes                           | 4 months ago |\n    | RelNotes    | Start 2.51 cycle, the first batch                       | 4 weeks ago  |\n    | SECURITY.md | SECURITY: describe how to report vulnerabilities        | 4 years      |\n    | abspath.c   | abspath: move related functions to abspath              | 2 years      |\n    | abspath.h   | abspath: move related functions to abspath              | 2 years      |\n    | aclocal.m4  | configure: use AC_LANG_PROGRAM consistently             | 15 years ago |\n    | add-patch.c | pager: stop using `the_repository`                      | 7 months ago |\n    | advice.c    | advice: allow disabling default branch name advice      | 4 months ago |\n    | advice.h    | advice: allow disabling default branch name advice      | 4 months ago |\n    | alias.h     | rebase -m: fix serialization of strategy options        | 2 years      |\n    | alloc.h     | git-compat-util: move alloc macros to git-compat-util.h | 2 years ago  |\n    | apply.c     | apply: only write intents to add for new files          | 8 days ago   |\n    | archive.c   | Merge branch 'ps/parse-options-integers'                | 3 months ago |\n    | archive.h   | archive.h: remove unnecessary include                   | 1 year       |\n    | attr.h      | fuzz: port fuzz-parse-attr-line from OSS-Fuzz           | 9 months ago |\n    | banned.h    | banned.h: mark `strtok()` and `strtok_r()` as banned    | 2 years      |\n\nThis series adds the git-last-modified(1) to feed this view. In the past\nthe subcommand was proposed[1] to be named git-blame-tree(1). This\nversion is based on the patches shared by the kind people at GitHub[2].\n\nWhat is different from the series shared by GitHub:\n\n* Renamed the subcommand from `blame-tree` to `last-modified`. There was\n  some consensus[5] this name works better, so let's give it a try and\n  see how this name feels.\n\n* Patches for --max-depth are excluded. I've submitted them as a separate patch\n  series[6].\n\n* The last-modified command isn't recursive by default. If you want\n  recurse into subtrees, you need to pass `-r`.\n\n* The patches in 'tb/blame-tree' at Taylor's fork[4] implements a\n  caching layer. This feature reads/writes cached results in\n  `.git/blame-tree/<hash>.btc`. To keep this series to a reviewable\n  size, that feature is excluded from this series. I think it's better\n  to submit this as a separate series.\n\n* All the new last-modified machinery is no longer implemented in a library\n  layer (at the root of the project), but directly in the builtin. So far the\n  code is fairly small (little over 300 lines of code) and there are no other\n  users of this code anyway. Also the library level code taken from Taylor's\n  fork required to pass `argc` and `argv` into it. It's quite awkward the\n  library code was so tightly coupled with user interaction.\n\n* Squashed various commits together. Like they introduced a flag\n  `--go-faster`, which later became the default and only implementation.\n  That story was wrapped up in a single commit.\n\n* Dropped the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. In my testing I've\n  seen both performance improvements *and* degradation with these\n  changes:\n\n  Test                                        HEAD~             HEAD\n  ------------------------------------------------------------------------------------\n  8020.1: top-level last-modified             4.52(4.38+0.11)   2.03(1.93+0.08) -55.1%\n  8020.2: top-level recursive last-modified   5.79(5.64+0.11)   8.34(8.17+0.11) +44.0%\n  8020.3: subdir last-modified                0.15(0.09+0.06)   0.19(0.14+0.06) +26.7%\n\n  Before we include these patches, I want to make sure these changes\n  have positive impact in all/most scenarios. This can happen in a\n  separate series.\n\nI've set myself as the author and added Based-on-patch-by trailers to\ncredit the original authors. Let me know if you disagree.\n\nAgain thanks to Taylor and the people at GitHub for sharing these\npatches. I hope we can work together to get this upstreamed.\n\n[1]: https://lore.kernel.org/git/patch-1.1-0ea849d900b-20230205T204104Z-avarab@gmail.com/\n[2]: https://lore.kernel.org/git/Z+XJ+1L3PnC9Dyba@nand.local/\n[3]: https://lore.kernel.org/git/20250326-toon-blame-tree-v1-3-4173133f3786@iotcl.com/\n[4]: git@github.com:ttaylorr/git.git\n[5]: https://lore.kernel.org/git/aCbBKj7O9LjO3SMK@pks.im/\n[6]: https://lore.kernel.org/git/20250729-toon-max-depth-v1-0-c177e39c40fb@iotcl.com/\n\n---\nChanges in v7:\n- Fix case when bloom filters were used and a commit range was given. This bug\n  was uncovered in CI.\n- Rename the long option for `-t` to `--show-trees`. This option no longer\n  implies option `-r`. And resemble these changes in the documentation, with a\n  few other small documentation tweaks.\n- Move prepare_commit_graph() into get_bloom_filter_settings() which no longer\n  requires last-modified to worry about it itself. This is similar to\n  repo_find_commit_pos_in_graph() and lookup_commit_in_graph()\n- Bring back the call to commit_graph_generation() in maybe_changed_path(). This\n  is also called in the same function in blame.c and in\n  check_maybe_different_in_bloom_filter() in revision.c. I couldn't find a test\n  case that triggers this exit condition, but it should not have negative\n  side-effects.\n- No longer call diff_free() on the copy we make when populating the `paths` of\n  `struct last_modified`. Because we weren't doing a deep copy, this could clean\n  up fields used later on by the original. Instead only call clear_pathspec(). A\n  comment to clarify this mechanism better is added.\n- Add BUG() call to exit condition that shouldn't happen.\n- Switch some int types to bool types.\n\nChanges in v6:\n- Only the first 3 patches are kept. The last 3 patches worked toward adding an\n  extra option `--format`. The way it was implemented was heavily debatable and\n  in the end it is not required for a first iteration, so they are dropped.\n- Function prepare_commit_graph() is exported and used in\n  generation_numbers_enabled().\n- Since the library layer was removed and all the code was moved into the\n  builtin, there was still some leftovers from using a callback mechanism to\n  display the results. This is removed (as far as possible) and instead\n  last_modified_emit() always, this function was called show_entry() previously.\n- Code is rebased to use refactoring in the bloom filter API.\n\nChanges in v5:\n- Added a patch to allow for an \"extended\" format. The name for this option is\n  open for debate (please, all input is welcome). But the main goal of this\n  series is to provide the data needed for the \"forge tree view\" as demoed at\n  the top of this cover letter. With this extra patch (and the prepatory patch\n  to pretty.[ch]), I hope the use-case because more clear. But because it wasn't\n  included in previous 4 versions I also wouldn't mind sending a separate patch\n  series for it.\n- Removed the call to sort(1) the t8020 tests. This was needed for the tests for\n  --extended.\n- I'm adding a fixup! commit to be compatible with in-flight patches for bloom\n  filter optimizations:\n  https://lore.kernel.org/git/20250712093517.17907-1-yldhome2d2@gmail.com/\n  This patch can be dropped if current series lands before those.\n\nChanges in v4:\n- Removed root-level `last-modified.[ch]` library code and moved code to\n  `builtin/last-modified.c`. Historically we've had libary code (also because it\n  was used in testtool), but we no longer need that separation. I'm sorry this\n  makes the range-diff hard to read.\n- Added the use of parse_options() to get better usage messages.\n- Formatting fixes after conversation in\n  https://lore.kernel.org/git/xmqqh5zvk5h0.fsf@gitster.g/\n- Link to v3: https://lore.kernel.org/git/20250630-toon-new-blame-tree-v3-0-3516025dc3bc@iotcl.com/\n\nChanges in v3:\n- Updated benchmarks in commit messages.\n- Removed the patches that attempt to increase performance for tree\n  entries that have not been updated in a long time. (see above)\n- Move handling failure in `last_modified_init()` to the caller.\n- Sorted #include clauses lexicographically.\n- Removed unneeded `commit` in `struct last_modified_entry`.\n- Renamed some functions/variables and added some comments to make it\n  easier to understand.\n- Removed unnecessary checking of the commit-graph generation number.\n- Link to v2: https://lore.kernel.org/r/20250523-toon-new-blame-tree-v2-0-101e4ca4c1c9@iotcl.com\n\nChanges in v2:\n- The subcommand is renamed from `blame-tree` to `last-modified`\n- Documentation is added. Here we mark the command as experimental.\n- Some test cases are added related to merges.\n- Link to v1: https://lore.kernel.org/r/20250422-toon-new-blame-tree-v1-0-fdb51b8a394a@iotcl.com\n\nToon Claes (3):\n  last-modified: new subcommand to show when files were last modified\n  t/perf: add last-modified perf script\n  last-modified: use Bloom filters when available\n\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  54 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 325 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n commit-graph.c                       |   7 +-\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   2 +\n t/perf/p8020-last-modified.sh        |  22 ++\n t/t8020-last-modified.sh             | 210 +++++++++++++++++\n 13 files changed, 626 insertions(+), 1 deletion(-)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/perf/p8020-last-modified.sh\n create mode 100755 t/t8020-last-modified.sh\n\nRange-diff against v6:\n1:  9d5ce06460 < -:  ---------- last-modified: new subcommand to show when files were last modified\n-:  ---------- > 1:  d5a2359633 last-modified: new subcommand to show when files were last modified\n2:  7c921d4344 = 2:  7537f0e597 t/perf: add last-modified perf script\n3:  3c42043682 < -:  ---------- commit-graph: export prepare_commit_graph()\n4:  e3c2d5e3c1 ! 3:  ebc7b061df last-modified: use Bloom filters when available\n    @@ builtin/last-modified.c: static void last_modified_diff(struct diff_queue_struct\n      \t}\n      }\n\n    -+static int maybe_changed_path(struct last_modified *lm, struct commit *origin)\n    ++static bool maybe_changed_path(struct last_modified *lm, struct commit *origin)\n     +{\n     +\tstruct bloom_filter *filter;\n     +\tstruct last_modified_entry *ent;\n     +\tstruct hashmap_iter iter;\n     +\n     +\tif (!lm->rev.bloom_filter_settings)\n    -+\t\treturn 1;\n    ++\t\treturn true;\n    ++\n    ++\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n    ++\t\treturn true;\n     +\n     +\tfilter = get_bloom_filter(lm->rev.repo, origin);\n     +\tif (!filter)\n    -+\t\treturn 1;\n    ++\t\treturn true;\n     +\n     +\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n     +\t\tif (bloom_filter_contains(filter, &ent->key,\n     +\t\t\t\t\t  lm->rev.bloom_filter_settings))\n    -+\t\t\treturn 1;\n    ++\t\t\treturn true;\n     +\t}\n    -+\treturn 0;\n    ++\treturn false;\n     +}\n     +\n      static int last_modified_run(struct last_modified *lm)\n      {\n      \tstruct last_modified_callback_data data = { .lm = lm };\n     @@ builtin/last-modified.c: static int last_modified_run(struct last_modified *lm)\n    - \tlm->rev.diffopt.format_callback_data = &data;\n    -\n    - \tprepare_revision_walk(&lm->rev);\n    -+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n    -\n    - \twhile (hashmap_get_size(&lm->paths)) {\n    - \t\tdata.commit = get_revision(&lm->rev);\n    - \t\tif (!data.commit)\n    - \t\t\tbreak;\n    -\n    + \t\t\t\t      &data.commit->object.oid, \"\",\n    + \t\t\t\t      &lm->rev.diffopt);\n    + \t\t\tdiff_flush(&lm->rev.diffopt);\n    +-\t\t} else {\n    +-\t\t\tlog_tree_commit(&lm->rev, data.commit);\n    ++\n    ++\t\t\tbreak;\n    + \t\t}\n    ++\n     +\t\tif (!maybe_changed_path(lm, data.commit))\n     +\t\t\tcontinue;\n     +\n    - \t\tif (data.commit->object.flags & BOUNDARY) {\n    - \t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n    - \t\t\t\t      &data.commit->object.oid, \"\",\n    ++\t\tlog_tree_commit(&lm->rev, data.commit);\n    + \t}\n    +\n    + \treturn 0;\n     @@ builtin/last-modified.c: static int last_modified_init(struct last_modified *lm, struct repository *r,\n      \t\treturn argc;\n      \t}\n\n    -+\tprepare_commit_graph(lm->rev.repo);\n     +\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n     +\n      \tif (populate_paths_from_revs(lm) < 0)\n      \t\treturn error(_(\"unable to setup last-modified\"));\n\n    +\n    + ## commit-graph.c ##\n    +@@ commit-graph.c: int corrected_commit_dates_enabled(struct repository *r)\n    +\n    + struct bloom_filter_settings *get_bloom_filter_settings(struct repository *r)\n    + {\n    +-\tstruct commit_graph *g = r->objects->commit_graph;\n    ++\tstruct commit_graph *g;\n    ++\n    ++\tif (!prepare_commit_graph(r))\n    ++\t       return NULL;\n    ++\n    ++\tg = r->objects->commit_graph;\n    + \twhile (g) {\n    + \t\tif (g->bloom_filter_settings)\n    + \t\t\treturn g->bloom_filter_settings;\n\nbase-commit: 112648dd6bdd8e4f485cd0ae11636807959d48be\n--\n2.50.1.327.g047016eb4a\n"},{"id":"523525","messageId":"20250805093358.1791633-2-toon@iotcl.com","threadId":"63332","inReplyTo":"20250730175510.987383-1-toon@iotcl.com","subject":"[PATCH v7 1/3] last-modified: new subcommand to show when files were last modified","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T09:33:56Z","receivedAt":"2025-08-05T09:34:27Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Similar to git-blame(1), introduce a new subcommand\ngit-last-modified(1). This command shows the most recent modification to\npaths in a tree. It does so by expanding the tree at a given commit,\ntaking note of the current state of each path, and then walking\nbackwards through history looking for commits where each path changed\ninto its final commit ID.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nImproved-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n .gitignore                           |   1 +\n Documentation/git-last-modified.adoc |  54 +++++\n Documentation/meson.build            |   1 +\n Makefile                             |   1 +\n builtin.h                            |   1 +\n builtin/last-modified.c              | 281 +++++++++++++++++++++++++++\n command-list.txt                     |   1 +\n git.c                                |   1 +\n meson.build                          |   1 +\n t/meson.build                        |   1 +\n t/t8020-last-modified.sh             | 210 ++++++++++++++++++++\n 11 files changed, 553 insertions(+)\n create mode 100644 Documentation/git-last-modified.adoc\n create mode 100644 builtin/last-modified.c\n create mode 100755 t/t8020-last-modified.sh\n\ndiff --git a/.gitignore b/.gitignore\nindex 04c444404e..a36ee94443 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -87,6 +87,7 @@\n /git-init-db\n /git-interpret-trailers\n /git-instaweb\n+/git-last-modified\n /git-log\n /git-ls-files\n /git-ls-remote\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nnew file mode 100644\nindex 0000000000..35bd4a1dd0\n--- /dev/null\n+++ b/Documentation/git-last-modified.adoc\n@@ -0,0 +1,54 @@\n+git-last-modified(1)\n+====================\n+\n+NAME\n+----\n+git-last-modified - EXPERIMENTAL: Show when files were last modified\n+\n+\n+SYNOPSIS\n+--------\n+[synopsis]\n+git last-modified [--recursive] [--show-trees] [<revision-range>] [[--] <path>...]\n+\n+DESCRIPTION\n+-----------\n+\n+Shows which commit last modified each of the relevant files and subdirectories.\n+A commit renaming a path, or changing it's mode is also taken into account.\n+\n+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n+\n+OPTIONS\n+-------\n+\n+-r::\n+--recursive::\n+\tInstead of showing tree entries, step into subtrees and show all entries\n+\tinside them recursively.\n+\n+-t::\n+--show-trees::\n+\tShow tree entries even when recursing into them. It has no effect\n+\twithout `--recursive`.\n+\n+<revision-range>::\n+\tOnly traverse commits in the specified revision range. When no\n+\t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n+\thistory leading to the current commit). For a complete list of ways to\n+\tspell `<revision-range>`, see the 'Specifying Ranges' section of\n+\tlinkgit:gitrevisions[7].\n+\n+[--] <path>...::\n+\tFor each _<path>_ given, the commit which last modified it is returned.\n+\tWithout an optional path parameter, all files and subdirectories\n+\tin path traversal the are included in the output.\n+\n+SEE ALSO\n+--------\n+linkgit:git-blame[1],\n+linkgit:git-log[1].\n+\n+GIT\n+---\n+Part of the linkgit:git[1] suite\ndiff --git a/Documentation/meson.build b/Documentation/meson.build\nindex 4404c623f0..a8ac5285f0 100644\n--- a/Documentation/meson.build\n+++ b/Documentation/meson.build\n@@ -74,6 +74,7 @@ manpages = {\n   'git-init.adoc' : 1,\n   'git-instaweb.adoc' : 1,\n   'git-interpret-trailers.adoc' : 1,\n+  'git-last-modified.adoc' : 1,\n   'git-log.adoc' : 1,\n   'git-ls-files.adoc' : 1,\n   'git-ls-remote.adoc' : 1,\ndiff --git a/Makefile b/Makefile\nindex e11340c1ae..b0b3a30daa 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1265,6 +1265,7 @@ BUILTIN_OBJS += builtin/hook.o\n BUILTIN_OBJS += builtin/index-pack.o\n BUILTIN_OBJS += builtin/init-db.o\n BUILTIN_OBJS += builtin/interpret-trailers.o\n+BUILTIN_OBJS += builtin/last-modified.o\n BUILTIN_OBJS += builtin/log.o\n BUILTIN_OBJS += builtin/ls-files.o\n BUILTIN_OBJS += builtin/ls-remote.o\ndiff --git a/builtin.h b/builtin.h\nindex bff13e3069..6ed6759ec4 100644\n--- a/builtin.h\n+++ b/builtin.h\n@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository\n int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo);\n+int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);\n int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nnew file mode 100644\nindex 0000000000..364493ac69\n--- /dev/null\n+++ b/builtin/last-modified.c\n@@ -0,0 +1,281 @@\n+#include \"git-compat-util.h\"\n+#include \"builtin.h\"\n+#include \"commit.h\"\n+#include \"config.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"environment.h\"\n+#include \"hashmap.h\"\n+#include \"hex.h\"\n+#include \"log-tree.h\"\n+#include \"object-name.h\"\n+#include \"object.h\"\n+#include \"parse-options.h\"\n+#include \"quote.h\"\n+#include \"repository.h\"\n+#include \"revision.h\"\n+\n+struct last_modified_entry {\n+\tstruct hashmap_entry hashent;\n+\tstruct object_id oid;\n+\tconst char path[FLEX_ARRAY];\n+};\n+\n+static int last_modified_entry_hashcmp(const void *unused UNUSED,\n+\t\t\t\t       const struct hashmap_entry *hent1,\n+\t\t\t\t       const struct hashmap_entry *hent2,\n+\t\t\t\t       const void *path)\n+{\n+\tconst struct last_modified_entry *ent1 =\n+\t\tcontainer_of(hent1, const struct last_modified_entry, hashent);\n+\tconst struct last_modified_entry *ent2 =\n+\t\tcontainer_of(hent2, const struct last_modified_entry, hashent);\n+\treturn strcmp(ent1->path, path ? path : ent2->path);\n+}\n+\n+struct last_modified {\n+\tstruct hashmap paths;\n+\tstruct rev_info rev;\n+\tbool recursive;\n+\tbool show_trees;\n+};\n+\n+static void last_modified_release(struct last_modified *lm)\n+{\n+\thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n+\trelease_revisions(&lm->rev);\n+}\n+\n+struct last_modified_callback_data {\n+\tstruct last_modified *lm;\n+\tstruct commit *commit;\n+};\n+\n+static void add_path_from_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *data)\n+{\n+\tstruct last_modified *lm = data;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tstruct last_modified_entry *ent;\n+\t\tconst char *path = p->two->path;\n+\n+\t\tFLEX_ALLOC_STR(ent, path, path);\n+\t\toidcpy(&ent->oid, &p->two->oid);\n+\t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n+\t\thashmap_add(&lm->paths, &ent->hashent);\n+\t}\n+}\n+\n+static int populate_paths_from_revs(struct last_modified *lm)\n+{\n+\tint num_interesting = 0;\n+\tstruct diff_options diffopt;\n+\n+\t/*\n+\t * Create a copy of `struct diff_options`. In this copy a callback is\n+\t * set that when called adds entries to `paths` in `struct last_modified`.\n+\t * This copy is used to diff the tree of the target revision against an\n+\t * empty tree. This results in all paths in the target revision being\n+\t * listed. After `paths` is populated, we don't need this copy no more.\n+\t */\n+\tmemcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));\n+\tcopy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);\n+\tdiffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tdiffopt.format_callback = add_path_from_diff;\n+\tdiffopt.format_callback_data = lm;\n+\n+\tfor (size_t i = 0; i < lm->rev.pending.nr; i++) {\n+\t\tstruct object_array_entry *obj = lm->rev.pending.objects + i;\n+\n+\t\tif (obj->item->flags & UNINTERESTING)\n+\t\t\tcontinue;\n+\n+\t\tif (num_interesting++)\n+\t\t\treturn error(_(\"last-modified can only operate on one tree at a time\"));\n+\n+\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t      &obj->item->oid, \"\", &diffopt);\n+\t\tdiff_flush(&diffopt);\n+\t}\n+\tclear_pathspec(&diffopt.pathspec);\n+\n+\treturn 0;\n+}\n+\n+static void last_modified_emit(struct last_modified *lm,\n+\t\t\t       const char *path, const struct commit *commit)\n+\n+{\n+\tif (commit->object.flags & BOUNDARY)\n+\t\tputchar('^');\n+\tprintf(\"%s\\t\", oid_to_hex(&commit->object.oid));\n+\n+\tif (lm->rev.diffopt.line_termination)\n+\t\twrite_name_quoted(path, stdout, '\\n');\n+\telse\n+\t\tprintf(\"%s%c\", path, '\\0');\n+}\n+\n+static void mark_path(const char *path, const struct object_id *oid,\n+\t\t      struct last_modified_callback_data *data)\n+{\n+\tstruct last_modified_entry *ent;\n+\n+\t/* Is it even a path that we are interested in? */\n+\tent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path,\n+\t\t\t\t\t  struct last_modified_entry, hashent);\n+\tif (!ent)\n+\t\treturn;\n+\n+\t/*\n+\t * Is it arriving at a version of interest, or is it from a side branch\n+\t * which did not contribute to the final state?\n+\t */\n+\tif (!oideq(oid, &ent->oid))\n+\t\treturn;\n+\n+\tlast_modified_emit(data->lm, path, data->commit);\n+\n+\thashmap_remove(&data->lm->paths, &ent->hashent, path);\n+\tfree(ent);\n+}\n+\n+static void last_modified_diff(struct diff_queue_struct *q,\n+\t\t\t       struct diff_options *opt UNUSED, void *cbdata)\n+{\n+\tstruct last_modified_callback_data *data = cbdata;\n+\n+\tfor (int i = 0; i < q->nr; i++) {\n+\t\tstruct diff_filepair *p = q->queue[i];\n+\t\tswitch (p->status) {\n+\t\tcase DIFF_STATUS_DELETED:\n+\t\t\t/*\n+\t\t\t * There's no point in feeding a deletion, as it could\n+\t\t\t * not have resulted in our current state, which\n+\t\t\t * actually has the file.\n+\t\t\t */\n+\t\t\tbreak;\n+\n+\t\tdefault:\n+\t\t\t/*\n+\t\t\t * Otherwise, we care only that we somehow arrived at\n+\t\t\t * a final oid state. Note that this covers some\n+\t\t\t * potentially controversial areas, including:\n+\t\t\t *\n+\t\t\t *  1. A rename or copy will be found, as it is the\n+\t\t\t *     first time the content has arrived at the given\n+\t\t\t *     path.\n+\t\t\t *\n+\t\t\t *  2. Even a non-content modification like a mode or\n+\t\t\t *     type change will trigger it.\n+\t\t\t *\n+\t\t\t * We take the inclusive approach for now, and find\n+\t\t\t * anything which impacts the path. Options to tweak\n+\t\t\t * the behavior (e.g., to \"--follow\" the content across\n+\t\t\t * renames) can come later.\n+\t\t\t */\n+\t\t\tmark_path(p->two->path, &p->two->oid, data);\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+}\n+\n+static int last_modified_run(struct last_modified *lm)\n+{\n+\tstruct last_modified_callback_data data = { .lm = lm };\n+\n+\tlm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;\n+\tlm->rev.diffopt.format_callback = last_modified_diff;\n+\tlm->rev.diffopt.format_callback_data = &data;\n+\n+\tprepare_revision_walk(&lm->rev);\n+\n+\twhile (hashmap_get_size(&lm->paths)) {\n+\t\tdata.commit = get_revision(&lm->rev);\n+\t\tif (!data.commit)\n+\t\t\tBUG(\"paths remaining beyond boundary in last-modified\");\n+\n+\t\tif (data.commit->object.flags & BOUNDARY) {\n+\t\t\tdiff_tree_oid(lm->rev.repo->hash_algo->empty_tree,\n+\t\t\t\t      &data.commit->object.oid, \"\",\n+\t\t\t\t      &lm->rev.diffopt);\n+\t\t\tdiff_flush(&lm->rev.diffopt);\n+\t\t} else {\n+\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int last_modified_init(struct last_modified *lm, struct repository *r,\n+\t\t\t      const char *prefix, int argc, const char **argv)\n+{\n+\thashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);\n+\n+\trepo_init_revisions(r, &lm->rev, prefix);\n+\tlm->rev.def = \"HEAD\";\n+\tlm->rev.combine_merges = 1;\n+\tlm->rev.show_root_diff = 1;\n+\tlm->rev.boundary = 1;\n+\tlm->rev.no_commit_id = 1;\n+\tlm->rev.diff = 1;\n+\tlm->rev.diffopt.flags.recursive = lm->recursive;\n+\tlm->rev.diffopt.flags.tree_in_recursive = lm->show_trees;\n+\n+\targc = setup_revisions(argc, argv, &lm->rev, NULL);\n+\tif (argc > 1) {\n+\t\terror(_(\"unknown last-modified argument: %s\"), argv[1]);\n+\t\treturn argc;\n+\t}\n+\n+\tif (populate_paths_from_revs(lm) < 0)\n+\t\treturn error(_(\"unable to setup last-modified\"));\n+\n+\treturn 0;\n+}\n+\n+int cmd_last_modified(int argc, const char **argv, const char *prefix,\n+\t\t      struct repository *repo)\n+{\n+\tint ret;\n+\tstruct last_modified lm = { 0 };\n+\n+\tconst char * const last_modified_usage[] = {\n+\t\tN_(\"git last-modified [--recursive] [--show-trees] \"\n+\t\t   \"[<revision-range>] [[--] <path>...]\"),\n+\t\tNULL\n+\t};\n+\n+\tstruct option last_modified_options[] = {\n+\t\tOPT_BOOL('r', \"recursive\", &lm.recursive,\n+\t\t\t N_(\"recurse into subtrees\")),\n+\t\tOPT_BOOL('t', \"show-trees\", &lm.show_trees,\n+\t\t\t N_(\"show tree entries when recursing into subtrees\")),\n+\t\tOPT_END()\n+\t};\n+\n+\targc = parse_options(argc, argv, prefix, last_modified_options,\n+\t\t\t     last_modified_usage,\n+\t\t\t     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tret = last_modified_init(&lm, repo, prefix, argc, argv);\n+\tif (ret > 0)\n+\t\tusage_with_options(last_modified_usage,\n+\t\t\t\t   last_modified_options);\n+\tif (ret)\n+\t\tgoto out;\n+\n+\tret = last_modified_run(&lm);\n+\tif (ret)\n+\t\tgoto out;\n+\n+out:\n+\tlast_modified_release(&lm);\n+\n+\treturn ret;\n+}\ndiff --git a/command-list.txt b/command-list.txt\nindex b7ade3ab9f..b715777b24 100644\n--- a/command-list.txt\n+++ b/command-list.txt\n@@ -124,6 +124,7 @@ git-index-pack                          plumbingmanipulators\n git-init                                mainporcelain           init\n git-instaweb                            ancillaryinterrogators          complete\n git-interpret-trailers                  purehelpers\n+git-last-modified                       plumbinginterrogators\n git-log                                 mainporcelain           info\n git-ls-files                            plumbinginterrogators\n git-ls-remote                           plumbinginterrogators\ndiff --git a/git.c b/git.c\nindex 07a5fe39fb..76a0b2a1a4 100644\n--- a/git.c\n+++ b/git.c\n@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\n \t{ \"interpret-trailers\", cmd_interpret_trailers, RUN_SETUP_GENTLY },\n+\t{ \"last-modified\", cmd_last_modified, RUN_SETUP },\n \t{ \"log\", cmd_log, RUN_SETUP },\n \t{ \"ls-files\", cmd_ls_files, RUN_SETUP },\n \t{ \"ls-remote\", cmd_ls_remote, RUN_SETUP_GENTLY },\ndiff --git a/meson.build b/meson.build\nindex 5dd299b496..cff7125638 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -607,6 +607,7 @@ builtin_sources = [\n   'builtin/index-pack.c',\n   'builtin/init-db.c',\n   'builtin/interpret-trailers.c',\n+  'builtin/last-modified.c',\n   'builtin/log.c',\n   'builtin/ls-files.c',\n   'builtin/ls-remote.c',\ndiff --git a/t/meson.build b/t/meson.build\nindex bbeba1a8d5..68656fe08a 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -946,6 +946,7 @@ integration_tests = [\n   't8012-blame-colors.sh',\n   't8013-blame-ignore-revs.sh',\n   't8014-blame-ignore-fuzzy.sh',\n+  't8020-last-modified.sh',\n   't9001-send-email.sh',\n   't9002-column.sh',\n   't9003-help-autocorrect.sh',\ndiff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..5eb4cef035\n--- /dev/null\n+++ b/t/t8020-last-modified.sh\n@@ -0,0 +1,210 @@\n+#!/bin/sh\n+\n+test_description='last-modified tests'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\ttest_commit 1 file &&\n+\tmkdir a &&\n+\ttest_commit 2 a/file &&\n+\tmkdir a/b &&\n+\ttest_commit 3 a/b/file\n+'\n+\n+test_expect_success 'cannot run last-modified on two trees' '\n+\ttest_must_fail git last-modified HEAD HEAD~1\n+'\n+\n+check_last_modified() {\n+\tlocal indir= &&\n+\twhile test $# != 0\n+\tdo\n+\t\tcase \"$1\" in\n+\t\t-C)\n+\t\t\tindir=\"$2\"\n+\t\t\tshift\n+\t\t\t;;\n+\t\t*)\n+\t\t\tbreak\n+\t\t\t;;\n+\t\tesac &&\n+\t\tshift\n+\tdone &&\n+\n+\tcat >expect &&\n+\ttest_when_finished \"rm -f tmp.*\" &&\n+\tgit ${indir:+-C \"$indir\"} last-modified \"$@\" >tmp.1 &&\n+\tgit name-rev --annotate-stdin --name-only --tags \\\n+\t\t<tmp.1 >tmp.2 &&\n+\ttr '\\t' ' ' <tmp.2 >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'last-modified non-recursive' '\n+\tcheck_last_modified <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive' '\n+\tcheck_last_modified -r <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified recursive with show-trees' '\n+\tcheck_last_modified -r -t <<-\\EOF\n+\t3 a\n+\t3 a/b\n+\t3 a/b/file\n+\t2 a/file\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified non-recursive with show-trees' '\n+\tcheck_last_modified -t <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir' '\n+\tcheck_last_modified a <<-\\EOF\n+\t3 a\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified subdir recursive' '\n+\tcheck_last_modified -r a <<-\\EOF\n+\t3 a/b/file\n+\t2 a/file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from non-HEAD commit' '\n+\tcheck_last_modified HEAD^ <<-\\EOF\n+\t2 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir defaults to root' '\n+\tcheck_last_modified -C a <<-\\EOF\n+\t3 a\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified from subdir uses relative pathspecs' '\n+\tcheck_last_modified -C a -r b <<-\\EOF\n+\t3 a/b/file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by count' '\n+\tcheck_last_modified -1 <<-\\EOF\n+\t3 a\n+\t^2 file\n+\tEOF\n+'\n+\n+test_expect_success 'limit last-modified traversal by commit' '\n+\tcheck_last_modified HEAD~2..HEAD <<-\\EOF\n+\t3 a\n+\t^1 file\n+\tEOF\n+'\n+\n+test_expect_success 'only last-modified files in the current tree' '\n+\tgit rm -rf a &&\n+\tgit commit -m \"remove a\" &&\n+\tcheck_last_modified <<-\\EOF\n+\t1 file\n+\tEOF\n+'\n+\n+test_expect_success 'cross merge boundaries in blaming' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit m1 &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit m2 &&\n+\tgit merge m1 &&\n+\tcheck_last_modified <<-\\EOF\n+\tm2 m2.t\n+\tm1 m1.t\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified merge for resolved conflicts' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit c1 conflict &&\n+\tgit checkout HEAD^ &&\n+\tgit rm -rf . &&\n+\ttest_commit c2 conflict &&\n+\ttest_must_fail git merge c1 &&\n+\ttest_commit resolved conflict &&\n+\tcheck_last_modified conflict <<-\\EOF\n+\tresolved conflict\n+\tEOF\n+'\n+\n+\n+# Consider `file` with this content through history:\n+#\n+# A---B---B-------B---B\n+#          \\     /\n+#           C---D\n+test_expect_success 'last-modified merge ignores content from branch' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit a1 file A &&\n+\ttest_commit a2 file B &&\n+\ttest_commit a3 file C &&\n+\ttest_commit a4 file D &&\n+\tgit checkout a2 &&\n+\tgit merge --no-commit --no-ff a4 &&\n+\tgit checkout a2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\ta2 file\n+\tEOF\n+'\n+\n+# Consider `file` with this content through history:\n+#\n+#  A---B---B---C---D---B---B\n+#           \\         /\n+#            B-------B\n+test_expect_success 'last-modified merge undoes changes' '\n+\tgit checkout HEAD^0 &&\n+\tgit rm -rf . &&\n+\ttest_commit b1 file A &&\n+\ttest_commit b2 file B &&\n+\ttest_commit b3 file C &&\n+\ttest_commit b4 file D &&\n+\tgit checkout b2 &&\n+\ttest_commit b5 file2 2 &&\n+\tgit checkout b4 &&\n+\tgit merge --no-commit --no-ff b5 &&\n+\tgit checkout b2 -- file &&\n+\tgit merge --continue &&\n+\tcheck_last_modified <<-\\EOF\n+\tb5 file2\n+\tb2 file\n+\tEOF\n+'\n+\n+test_expect_success 'last-modified complains about unknown arguments' '\n+\ttest_must_fail git last-modified --foo 2>err &&\n+\tgrep \"unknown last-modified argument: --foo\" err\n+'\n+\n+test_done\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523526","messageId":"20250805093358.1791633-3-toon@iotcl.com","threadId":"63332","inReplyTo":"20250730175510.987383-1-toon@iotcl.com","subject":"[PATCH v7 2/3] t/perf: add last-modified perf script","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T09:33:57Z","receivedAt":"2025-08-05T09:34:30Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"This just runs some simple last-modified commands. We already test\ncorrectness in the regular suite, so this is just about finding\nperformance regressions from one version to another.\n\nBased-on-patch-by: Jeff King <peff@peff.net>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n t/meson.build                 |  1 +\n t/perf/p8020-last-modified.sh | 22 ++++++++++++++++++++++\n 2 files changed, 23 insertions(+)\n create mode 100755 t/perf/p8020-last-modified.sh\n\ndiff --git a/t/meson.build b/t/meson.build\nindex 68656fe08a..21d5e99bf5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -1140,6 +1140,7 @@ benchmarks = [\n   'perf/p7820-grep-engines.sh',\n   'perf/p7821-grep-engines-fixed.sh',\n   'perf/p7822-grep-perl-character.sh',\n+  'perf/p8020-last-modified.sh',\n   'perf/p9210-scalar.sh',\n   'perf/p9300-fast-import-export.sh',\n ]\ndiff --git a/t/perf/p8020-last-modified.sh b/t/perf/p8020-last-modified.sh\nnew file mode 100755\nindex 0000000000..cb1f98d3db\n--- /dev/null\n+++ b/t/perf/p8020-last-modified.sh\n@@ -0,0 +1,22 @@\n+#!/bin/sh\n+\n+test_description='last-modified perf tests'\n+. ./perf-lib.sh\n+\n+test_perf_default_repo\n+\n+test_perf 'top-level last-modified' '\n+\tgit last-modified HEAD\n+'\n+\n+test_perf 'top-level recursive last-modified' '\n+\tgit last-modified -r HEAD\n+'\n+\n+test_perf 'subdir last-modified' '\n+\tgit ls-tree -d HEAD >subtrees &&\n+\tpath=\"$(head -n 1 subtrees | cut -f2)\" &&\n+\tgit last-modified -r HEAD -- \"$path\"\n+'\n+\n+test_done\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523527","messageId":"20250805093358.1791633-4-toon@iotcl.com","threadId":"63332","inReplyTo":"20250730175510.987383-1-toon@iotcl.com","subject":"[PATCH v7 3/3] last-modified: use Bloom filters when available","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T09:33:58Z","receivedAt":"2025-08-05T09:34:35Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Our 'git last-modified' performs a revision walk, and computes a diff at\neach point in the walk to figure out whether a given revision changed\nany of the paths it considers interesting.\n\nWhen changed-path Bloom filters are available, we can avoid computing\nmany such diffs. Before computing a diff, we first check if any of the\nremaining paths of interest were possibly changed at a given commit by\nconsulting its Bloom filter. If any of them are, we are resigned to\ncompute the diff.\n\nIf none of those queries returned \"maybe\", we know that the given commit\ndoesn't contain any changed paths which are interesting to us. So, we\ncan avoid computing it in this case.\n\nComparing the perf test results on git.git:\n\n    Test                                        HEAD~             HEAD\n    ------------------------------------------------------------------------------------\n    8020.1: top-level last-modified             4.49(4.34+0.11)   2.22(2.05+0.09) -50.6%\n    8020.2: top-level recursive last-modified   5.64(5.45+0.11)   5.62(5.30+0.11) -0.4%\n    8020.3: subdir last-modified                0.11(0.06+0.04)   0.07(0.03+0.04) -36.4%\n\nBased-on-patch-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n builtin/last-modified.c | 48 +++++++++++++++++++++++++++++++++++++++--\n commit-graph.c          |  7 +++++-\n 2 files changed, 52 insertions(+), 3 deletions(-)\n\ndiff --git a/builtin/last-modified.c b/builtin/last-modified.c\nindex 364493ac69..82c5739827 100644\n--- a/builtin/last-modified.c\n+++ b/builtin/last-modified.c\n@@ -1,5 +1,7 @@\n #include \"git-compat-util.h\"\n+#include \"bloom.h\"\n #include \"builtin.h\"\n+#include \"commit-graph.h\"\n #include \"commit.h\"\n #include \"config.h\"\n #include \"diff.h\"\n@@ -18,6 +20,7 @@\n struct last_modified_entry {\n \tstruct hashmap_entry hashent;\n \tstruct object_id oid;\n+\tstruct bloom_key key;\n \tconst char path[FLEX_ARRAY];\n };\n \n@@ -42,6 +45,12 @@ struct last_modified {\n \n static void last_modified_release(struct last_modified *lm)\n {\n+\tstruct hashmap_iter iter;\n+\tstruct last_modified_entry *ent;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent)\n+\t\tbloom_key_clear(&ent->key);\n+\n \thashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);\n \trelease_revisions(&lm->rev);\n }\n@@ -63,6 +72,9 @@ static void add_path_from_diff(struct diff_queue_struct *q,\n \n \t\tFLEX_ALLOC_STR(ent, path, path);\n \t\toidcpy(&ent->oid, &p->two->oid);\n+\t\tif (lm->rev.bloom_filter_settings)\n+\t\t\tbloom_key_fill(&ent->key, path, strlen(path),\n+\t\t\t\t       lm->rev.bloom_filter_settings);\n \t\thashmap_entry_init(&ent->hashent, strhash(ent->path));\n \t\thashmap_add(&lm->paths, &ent->hashent);\n \t}\n@@ -139,6 +151,7 @@ static void mark_path(const char *path, const struct object_id *oid,\n \tlast_modified_emit(data->lm, path, data->commit);\n \n \thashmap_remove(&data->lm->paths, &ent->hashent, path);\n+\tbloom_key_clear(&ent->key);\n \tfree(ent);\n }\n \n@@ -182,6 +195,30 @@ static void last_modified_diff(struct diff_queue_struct *q,\n \t}\n }\n \n+static bool maybe_changed_path(struct last_modified *lm, struct commit *origin)\n+{\n+\tstruct bloom_filter *filter;\n+\tstruct last_modified_entry *ent;\n+\tstruct hashmap_iter iter;\n+\n+\tif (!lm->rev.bloom_filter_settings)\n+\t\treturn true;\n+\n+\tif (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY)\n+\t\treturn true;\n+\n+\tfilter = get_bloom_filter(lm->rev.repo, origin);\n+\tif (!filter)\n+\t\treturn true;\n+\n+\thashmap_for_each_entry(&lm->paths, &iter, ent, hashent) {\n+\t\tif (bloom_filter_contains(filter, &ent->key,\n+\t\t\t\t\t  lm->rev.bloom_filter_settings))\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n static int last_modified_run(struct last_modified *lm)\n {\n \tstruct last_modified_callback_data data = { .lm = lm };\n@@ -202,9 +239,14 @@ static int last_modified_run(struct last_modified *lm)\n \t\t\t\t      &data.commit->object.oid, \"\",\n \t\t\t\t      &lm->rev.diffopt);\n \t\t\tdiff_flush(&lm->rev.diffopt);\n-\t\t} else {\n-\t\t\tlog_tree_commit(&lm->rev, data.commit);\n+\n+\t\t\tbreak;\n \t\t}\n+\n+\t\tif (!maybe_changed_path(lm, data.commit))\n+\t\t\tcontinue;\n+\n+\t\tlog_tree_commit(&lm->rev, data.commit);\n \t}\n \n \treturn 0;\n@@ -231,6 +273,8 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,\n \t\treturn argc;\n \t}\n \n+\tlm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo);\n+\n \tif (populate_paths_from_revs(lm) < 0)\n \t\treturn error(_(\"unable to setup last-modified\"));\n \ndiff --git a/commit-graph.c b/commit-graph.c\nindex e0d92b816f..a74ac342b3 100644\n--- a/commit-graph.c\n+++ b/commit-graph.c\n@@ -821,7 +821,12 @@ int corrected_commit_dates_enabled(struct repository *r)\n \n struct bloom_filter_settings *get_bloom_filter_settings(struct repository *r)\n {\n-\tstruct commit_graph *g = r->objects->commit_graph;\n+\tstruct commit_graph *g;\n+\n+\tif (!prepare_commit_graph(r))\n+\t       return NULL;\n+\n+\tg = r->objects->commit_graph;\n \twhile (g) {\n \t\tif (g->bloom_filter_settings)\n \t\t\treturn g->bloom_filter_settings;\n-- \n2.50.1.327.g047016eb4a\n\n"},{"id":"523551","messageId":"aJIWfLdMOqEL861r@pks.im","threadId":"63332","inReplyTo":"20250805093358.1791633-1-toon@iotcl.com","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2025-08-05T14:34:36Z","receivedAt":"2025-08-05T14:34:44Z","isPatch":true,"sender":{"key":"ps@pks.im","avatar":"https://avatars.githubusercontent.com/u/4056630?v=4"},"body":"On Tue, Aug 05, 2025 at 11:33:55AM +0200, Toon Claes wrote:\n> Changes in v7:\n> - Fix case when bloom filters were used and a commit range was given. This bug\n>   was uncovered in CI.\n> - Rename the long option for `-t` to `--show-trees`. This option no longer\n>   implies option `-r`. And resemble these changes in the documentation, with a\n>   few other small documentation tweaks.\n> - Move prepare_commit_graph() into get_bloom_filter_settings() which no longer\n>   requires last-modified to worry about it itself. This is similar to\n>   repo_find_commit_pos_in_graph() and lookup_commit_in_graph()\n> - Bring back the call to commit_graph_generation() in maybe_changed_path(). This\n>   is also called in the same function in blame.c and in\n>   check_maybe_different_in_bloom_filter() in revision.c. I couldn't find a test\n>   case that triggers this exit condition, but it should not have negative\n>   side-effects.\n> - No longer call diff_free() on the copy we make when populating the `paths` of\n>   `struct last_modified`. Because we weren't doing a deep copy, this could clean\n>   up fields used later on by the original. Instead only call clear_pathspec(). A\n>   comment to clarify this mechanism better is added.\n> - Add BUG() call to exit condition that shouldn't happen.\n> - Switch some int types to bool types.\n\nThis version looks good to me, thanks!\n\nPatrick\n"},{"id":"523567","messageId":"xmqqtt2l20zw.fsf@gitster.g","threadId":"63332","inReplyTo":"aJIWfLdMOqEL861r@pks.im","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-05T16:21:07Z","receivedAt":"2025-08-05T16:21:11Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Tue, Aug 05, 2025 at 11:33:55AM +0200, Toon Claes wrote:\n>> Changes in v7:\n>> - Fix case when bloom filters were used and a commit range was given. This bug\n>>   was uncovered in CI.\n>> - Rename the long option for `-t` to `--show-trees`. This option no longer\n>>   implies option `-r`. And resemble these changes in the documentation, with a\n>>   few other small documentation tweaks.\n>> - Move prepare_commit_graph() into get_bloom_filter_settings() which no longer\n>>   requires last-modified to worry about it itself. This is similar to\n>>   repo_find_commit_pos_in_graph() and lookup_commit_in_graph()\n>> - Bring back the call to commit_graph_generation() in maybe_changed_path(). This\n>>   is also called in the same function in blame.c and in\n>>   check_maybe_different_in_bloom_filter() in revision.c. I couldn't find a test\n>>   case that triggers this exit condition, but it should not have negative\n>>   side-effects.\n>> - No longer call diff_free() on the copy we make when populating the `paths` of\n>>   `struct last_modified`. Because we weren't doing a deep copy, this could clean\n>>   up fields used later on by the original. Instead only call clear_pathspec(). A\n>>   comment to clarify this mechanism better is added.\n>> - Add BUG() call to exit condition that shouldn't happen.\n>> - Switch some int types to bool types.\n>\n> This version looks good to me, thanks!\n\nThanks, both of you.\n"},{"id":"523570","messageId":"xmqqjz3h20cs.fsf@gitster.g","threadId":"63332","inReplyTo":"20250805093358.1791633-1-toon@iotcl.com","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-05T16:34:59Z","receivedAt":"2025-08-05T16:35:01Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Changes in v7:\n> - Fix case when bloom filters were used and a commit range was given. This bug\n>   was uncovered in CI.\n> - Rename the long option for `-t` to `--show-trees`. This option no longer\n>   implies option `-r`. And resemble these changes in the documentation, with a\n>   few other small documentation tweaks.\n> - Move prepare_commit_graph() into get_bloom_filter_settings() which no longer\n>   requires last-modified to worry about it itself. This is similar to\n>   repo_find_commit_pos_in_graph() and lookup_commit_in_graph()\n> - Bring back the call to commit_graph_generation() in maybe_changed_path(). This\n>   is also called in the same function in blame.c and in\n>   check_maybe_different_in_bloom_filter() in revision.c. I couldn't find a test\n>   case that triggers this exit condition, but it should not have negative\n>   side-effects.\n> - No longer call diff_free() on the copy we make when populating the `paths` of\n>   `struct last_modified`. Because we weren't doing a deep copy, this could clean\n>   up fields used later on by the original. Instead only call clear_pathspec(). A\n>   comment to clarify this mechanism better is added.\n> - Add BUG() call to exit condition that shouldn't happen.\n> - Switch some int types to bool types.\n\nI am happy with the updates, but am wondering if documentation\nupdate along the lines of attached patch is also needed.  I am not\nsure about the last two, i.e. things that are not dash+option\nappearing as enumeration labels, though (and Cc'ing Jean-Noël to ask\nfor help).\n\nThanks.\n\n\n Documentation/git-last-modified.adoc | 12 ++++++------\n 1 file changed, 6 insertions(+), 6 deletions(-)\n\ndiff --git c/Documentation/git-last-modified.adoc w/Documentation/git-last-modified.adoc\nindex 35bd4a1dd0..602843e095 100644\n--- c/Documentation/git-last-modified.adoc\n+++ w/Documentation/git-last-modified.adoc\n@@ -22,24 +22,24 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n OPTIONS\n -------\n \n--r::\n---recursive::\n+`-r`::\n+`--recursive`::\n \tInstead of showing tree entries, step into subtrees and show all entries\n \tinside them recursively.\n \n--t::\n---show-trees::\n+`-t`::\n+`--show-trees`::\n \tShow tree entries even when recursing into them. It has no effect\n \twithout `--recursive`.\n \n-<revision-range>::\n+`<revision-range>`::\n \tOnly traverse commits in the specified revision range. When no\n \t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n \thistory leading to the current commit). For a complete list of ways to\n \tspell `<revision-range>`, see the 'Specifying Ranges' section of\n \tlinkgit:gitrevisions[7].\n \n-[--] <path>...::\n+`[--] <path>...`::\n \tFor each _<path>_ given, the commit which last modified it is returned.\n \tWithout an optional path parameter, all files and subdirectories\n \tin path traversal the are included in the output.\n"},{"id":"523571","messageId":"87tt2lu2rx.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqjz3h20cs.fsf@gitster.g","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-05T16:55:14Z","receivedAt":"2025-08-05T16:55:26Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> I am happy with the updates, but am wondering if documentation\n> update along the lines of attached patch is also needed.\n\nAh (annoyed grunt), I should have added backticks. Yes. I missed those,\nsorry about that.\n\n> I am not sure about the last two, i.e. things that are not dash+option\n> appearing as enumeration labels, though (and Cc'ing Jean-Noël to ask\n> for help).\n\nWell, this gave me a nice opportunity to test Jean-Noël proposed docs\nlinter[1].\n\n    $ make check-docs\n    [snip\n    git-last-modified.adoc:25: '-r::' synopsis style and definition list item not backquoted\n    git-last-modified.adoc:26: '--recursive::' synopsis style and definition list item not backquoted\n    git-last-modified.adoc:30: '-t::' synopsis style and definition list item not backquoted\n    git-last-modified.adoc:31: '--show-trees::' synopsis style and definition list item not backquoted\n\nIt seems only dashed options should be backquoted.\n\n[1]: https://lore.kernel.org/git/pull.1945.git.1754399033.gitgitgadget@gmail.com/\n\n-- \nCheers,\nToon\n"},{"id":"523577","messageId":"1929210.tdWV9SEqCh@cayenne","threadId":"63332","inReplyTo":"87tt2lu2rx.fsf@iotcl.com","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Jean-Noël AVILA","fromEmail":"jn.avila@free.fr","sentAt":"2025-08-05T17:20:05Z","receivedAt":"2025-08-05T17:20:09Z","isPatch":true,"sender":{"key":"jn.avila@free.fr","avatar":"https://avatars.githubusercontent.com/u/156172?v=4"},"body":"On Tuesday, 5 August 2025 18:55:14 CEST Toon Claes wrote:\n> Junio C Hamano <gitster@pobox.com> writes:\n> > I am happy with the updates, but am wondering if documentation\n> > update along the lines of attached patch is also needed.\n> \n> Ah (annoyed grunt), I should have added backticks. Yes. I missed those,\n> sorry about that.\n> \n> > I am not sure about the last two, i.e. things that are not dash+option\n> > appearing as enumeration labels, though (and Cc'ing Jean-Noël to ask\n> > for help).\n> \n> Well, this gave me a nice opportunity to test Jean-Noël proposed docs\n> linter[1].\n> \n>     $ make check-docs\n>     [snip\n>     git-last-modified.adoc:25: '-r::' synopsis style and definition list \nitem not\n> backquoted git-last-modified.adoc:26: '--recursive::' synopsis style and\n> definition list item not backquoted git-last-modified.adoc:30: '-t::' \nsynopsis\n> style and definition list item not backquoted git-last-modified.adoc:31:\n> '--show-trees::' synopsis style and definition list item not backquoted\n> \n> It seems only dashed options should be backquoted.\n> \n> [1]: https://lore.kernel.org/git/pull.1945.git.\n1754399033.gitgitgadget@gmail.com/\n\nWell, the check fails to catch all the missing cases: The last two terms \nshould also be formatted. For the <revision-range>, you can either enclose it \nwith underscores (as a placeholder) or with backticks (which the formatter \nformats like a placeholder). For the last one, backticks are definitely needed \nto differentiate the formatting between the placeholder and the syntax marks.\n\nAs for my patch series, this can definitely be checked. will reroll.\n\nThanks\n\nJean-Noël\n\n\n\n"},{"id":"523579","messageId":"xmqqikj1zkq0.fsf@gitster.g","threadId":"63332","inReplyTo":"87tt2lu2rx.fsf@iotcl.com","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-05T18:28:39Z","receivedAt":"2025-08-05T18:28:41Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n>> I am not sure about the last two, i.e. things that are not dash+option\n>> appearing as enumeration labels, though (and Cc'ing Jean-Noël to ask\n>> for help).\n>\n> Well, this gave me a nice opportunity to test Jean-Noël proposed docs\n> linter[1].\n\nYou'd need to be careful and account for the possibility that a\njust-off-the-press linter may not be complete, though ;-)\n\n>     $ make check-docs\n>     [snip\n>     git-last-modified.adoc:25: '-r::' synopsis style and definition list item not backquoted\n>     git-last-modified.adoc:26: '--recursive::' synopsis style and definition list item not backquoted\n>     git-last-modified.adoc:30: '-t::' synopsis style and definition list item not backquoted\n>     git-last-modified.adoc:31: '--show-trees::' synopsis style and definition list item not backquoted\n>\n> It seems only dashed options should be backquoted.\n\nMy go-to example has been git-commit.adoc where it has things like:\n\n    `--`::\n            Do not interpret any more arguments as options.\n\n    `<pathspec>...`::\n            When _<pathspec>_ is given on the command line, commit the contents of\n            the files that match the pathspec without recording the changes\n            already added to the index. The contents of these files are also\n            staged for the next commit on top of what have been staged before.\n"},{"id":"523609","messageId":"xmqqwm7hxx0a.fsf@gitster.g","threadId":"63332","inReplyTo":"1929210.tdWV9SEqCh@cayenne","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-05T21:46:13Z","receivedAt":"2025-08-05T21:46:16Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Jean-Noël AVILA <jn.avila@free.fr> writes:\n\n>> > I am not sure about the last two, i.e. things that are not dash+option\n>> > appearing as enumeration labels, though (and Cc'ing Jean-Noël to ask\n>> > for help).\n>>  ...\n> Well, the check fails to catch all the missing cases: The last two terms \n> should also be formatted. For the <revision-range>, you can either enclose it \n> with underscores (as a placeholder) or with backticks (which the formatter \n> formats like a placeholder). For the last one, backticks are definitely needed \n> to differentiate the formatting between the placeholder and the syntax marks.\n>\n> As for my patch series, this can definitely be checked. will reroll.\n\nThis is what I queued on top of your topic to prepare the\nintegration today.\n\n--- >8 ---\nFrom: Junio C Hamano <gitster@pobox.com>\nDate: Tue, 5 Aug 2025 14:37:25 -0700\nSubject: [PATCH] fixup! last-modified: new subcommand to show when files were last modified\n\n---\n Documentation/git-last-modified.adoc | 12 ++++++------\n 1 file changed, 6 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nindex 35bd4a1dd0..602843e095 100644\n--- a/Documentation/git-last-modified.adoc\n+++ b/Documentation/git-last-modified.adoc\n@@ -22,24 +22,24 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n OPTIONS\n -------\n \n--r::\n---recursive::\n+`-r`::\n+`--recursive`::\n \tInstead of showing tree entries, step into subtrees and show all entries\n \tinside them recursively.\n \n--t::\n---show-trees::\n+`-t`::\n+`--show-trees`::\n \tShow tree entries even when recursing into them. It has no effect\n \twithout `--recursive`.\n \n-<revision-range>::\n+`<revision-range>`::\n \tOnly traverse commits in the specified revision range. When no\n \t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n \thistory leading to the current commit). For a complete list of ways to\n \tspell `<revision-range>`, see the 'Specifying Ranges' section of\n \tlinkgit:gitrevisions[7].\n \n-[--] <path>...::\n+`[--] <path>...`::\n \tFor each _<path>_ given, the commit which last modified it is returned.\n \tWithout an optional path parameter, all files and subdirectories\n \tin path traversal the are included in the output.\n-- \n2.51.0-rc0-162-g220549999b\n\n"},{"id":"523653","messageId":"87qzxou0an.fsf@iotcl.com","threadId":"63332","inReplyTo":"xmqqwm7hxx0a.fsf@gitster.g","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2025-08-06T12:01:04Z","receivedAt":"2025-08-06T12:01:22Z","isPatch":true,"sender":{"key":"toon@iotcl.com","avatar":"https://avatars.githubusercontent.com/u/121621?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> This is what I queued on top of your topic to prepare the\n> integration today.\n>\n> --- >8 ---\n> From: Junio C Hamano <gitster@pobox.com>\n> Date: Tue, 5 Aug 2025 14:37:25 -0700\n> Subject: [PATCH] fixup! last-modified: new subcommand to show when files were last modified\n>\n> ---\n>  Documentation/git-last-modified.adoc | 12 ++++++------\n>  1 file changed, 6 insertions(+), 6 deletions(-)\n>\n> diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\n> index 35bd4a1dd0..602843e095 100644\n> --- a/Documentation/git-last-modified.adoc\n> +++ b/Documentation/git-last-modified.adoc\n> @@ -22,24 +22,24 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n>  OPTIONS\n>  -------\n>  \n> --r::\n> ---recursive::\n> +`-r`::\n> +`--recursive`::\n>  \tInstead of showing tree entries, step into subtrees and show all entries\n>  \tinside them recursively.\n>  \n> --t::\n> ---show-trees::\n> +`-t`::\n> +`--show-trees`::\n>  \tShow tree entries even when recursing into them. It has no effect\n>  \twithout `--recursive`.\n>  \n> -<revision-range>::\n> +`<revision-range>`::\n>  \tOnly traverse commits in the specified revision range. When no\n>  \t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n>  \thistory leading to the current commit). For a complete list of ways to\n>  \tspell `<revision-range>`, see the 'Specifying Ranges' section of\n>  \tlinkgit:gitrevisions[7].\n>  \n> -[--] <path>...::\n> +`[--] <path>...`::\n>  \tFor each _<path>_ given, the commit which last modified it is returned.\n>  \tWithout an optional path parameter, all files and subdirectories\n>  \tin path traversal the are included in the output.\n> -- \n> 2.51.0-rc0-162-g220549999b\n\nLooks good to me. Do you want me to reroll, or will you `--autosquash`\nyourself? \n\n-- \nCheers,\nToon\n"},{"id":"523661","messageId":"xmqqectowjdb.fsf@gitster.g","threadId":"63332","inReplyTo":"87qzxou0an.fsf@iotcl.com","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-06T15:38:24Z","receivedAt":"2025-08-06T15:38:27Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Looks good to me. Do you want me to reroll, or will you `--autosquash`\n> yourself? \n\nI can do the latter, unless there are other reasons that make it\nnecessary to update the patches.  We'll see.\n\nThanks.\n"},{"id":"525162","messageId":"xmqq5xe7t6au.fsf@gitster.g","threadId":"63332","inReplyTo":"xmqqectowjdb.fsf@gitster.g","subject":"Re: [PATCH v7 0/3] Introduce git-last-modified(1) command","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2025-08-28T22:44:41Z","receivedAt":"2025-08-28T22:44:44Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> Looks good to me. Do you want me to reroll, or will you `--autosquash`\n>> yourself? \n>\n> I can do the latter, unless there are other reasons that make it\n> necessary to update the patches.  We'll see.\n\nSorry, but it seems that I dropped the ball after this exchange.\n\nThe topic still has the fixup! sitting at the top.  If there are no\nfurther changes needed, let me squash it into the base commit and\nthen mark the topic for 'next'.\n\n Documentation/git-last-modified.adoc | 12 ++++++------\n 1 file changed, 6 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc\nindex 35bd4a1dd0..602843e095 100644\n--- a/Documentation/git-last-modified.adoc\n+++ b/Documentation/git-last-modified.adoc\n@@ -22,24 +22,24 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n OPTIONS\n -------\n \n--r::\n---recursive::\n+`-r`::\n+`--recursive`::\n \tInstead of showing tree entries, step into subtrees and show all entries\n \tinside them recursively.\n \n--t::\n---show-trees::\n+`-t`::\n+`--show-trees`::\n \tShow tree entries even when recursing into them. It has no effect\n \twithout `--recursive`.\n \n-<revision-range>::\n+`<revision-range>`::\n \tOnly traverse commits in the specified revision range. When no\n \t`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole\n \thistory leading to the current commit). For a complete list of ways to\n \tspell `<revision-range>`, see the 'Specifying Ranges' section of\n \tlinkgit:gitrevisions[7].\n \n-[--] <path>...::\n+`[--] <path>...`::\n \tFor each _<path>_ given, the commit which last modified it is returned.\n \tWithout an optional path parameter, all files and subdirectories\n \tin path traversal the are included in the output.\n-- \n2.51.0-262-gbae8ff527a\n\n\n"}]}