Git fork

last-modified: new subcommand to show when files were last modified

Similar to git-blame(1), introduce a new subcommand
git-last-modified(1). This command shows the most recent modification to
paths in a tree. It does so by expanding the tree at a given commit,
taking note of the current state of each path, and then walking
backwards through history looking for commits where each path changed
into its final commit ID.

Based-on-patch-by: Jeff King <peff@peff.net>
Improved-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>
Signed-off-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>

authored by

Toon Claes and committed by
Junio C Hamano
32f74582 e813a020

+553
+1
.gitignore
··· 87 87 /git-init-db 88 88 /git-interpret-trailers 89 89 /git-instaweb 90 + /git-last-modified 90 91 /git-log 91 92 /git-ls-files 92 93 /git-ls-remote
+54
Documentation/git-last-modified.adoc
··· 1 + git-last-modified(1) 2 + ==================== 3 + 4 + NAME 5 + ---- 6 + git-last-modified - EXPERIMENTAL: Show when files were last modified 7 + 8 + 9 + SYNOPSIS 10 + -------- 11 + [synopsis] 12 + git last-modified [--recursive] [--show-trees] [<revision-range>] [[--] <path>...] 13 + 14 + DESCRIPTION 15 + ----------- 16 + 17 + Shows which commit last modified each of the relevant files and subdirectories. 18 + A commit renaming a path, or changing it's mode is also taken into account. 19 + 20 + THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE. 21 + 22 + OPTIONS 23 + ------- 24 + 25 + `-r`:: 26 + `--recursive`:: 27 + Instead of showing tree entries, step into subtrees and show all entries 28 + inside them recursively. 29 + 30 + `-t`:: 31 + `--show-trees`:: 32 + Show tree entries even when recursing into them. It has no effect 33 + without `--recursive`. 34 + 35 + `<revision-range>`:: 36 + Only traverse commits in the specified revision range. When no 37 + `<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole 38 + history leading to the current commit). For a complete list of ways to 39 + spell `<revision-range>`, see the 'Specifying Ranges' section of 40 + linkgit:gitrevisions[7]. 41 + 42 + `[--] <path>...`:: 43 + For each _<path>_ given, the commit which last modified it is returned. 44 + Without an optional path parameter, all files and subdirectories 45 + in path traversal the are included in the output. 46 + 47 + SEE ALSO 48 + -------- 49 + linkgit:git-blame[1], 50 + linkgit:git-log[1]. 51 + 52 + GIT 53 + --- 54 + Part of the linkgit:git[1] suite
+1
Documentation/meson.build
··· 74 74 'git-init.adoc' : 1, 75 75 'git-instaweb.adoc' : 1, 76 76 'git-interpret-trailers.adoc' : 1, 77 + 'git-last-modified.adoc' : 1, 77 78 'git-log.adoc' : 1, 78 79 'git-ls-files.adoc' : 1, 79 80 'git-ls-remote.adoc' : 1,
+1
Makefile
··· 1265 1265 BUILTIN_OBJS += builtin/index-pack.o 1266 1266 BUILTIN_OBJS += builtin/init-db.o 1267 1267 BUILTIN_OBJS += builtin/interpret-trailers.o 1268 + BUILTIN_OBJS += builtin/last-modified.o 1268 1269 BUILTIN_OBJS += builtin/log.o 1269 1270 BUILTIN_OBJS += builtin/ls-files.o 1270 1271 BUILTIN_OBJS += builtin/ls-remote.o
+1
builtin.h
··· 176 176 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo); 177 177 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo); 178 178 int cmd_interpret_trailers(int argc, const char **argv, const char *prefix, struct repository *repo); 179 + int cmd_last_modified(int argc, const char **argv, const char *prefix, struct repository *repo); 179 180 int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo); 180 181 int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo); 181 182 int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);
+281
builtin/last-modified.c
··· 1 + #include "git-compat-util.h" 2 + #include "builtin.h" 3 + #include "commit.h" 4 + #include "config.h" 5 + #include "diff.h" 6 + #include "diffcore.h" 7 + #include "environment.h" 8 + #include "hashmap.h" 9 + #include "hex.h" 10 + #include "log-tree.h" 11 + #include "object-name.h" 12 + #include "object.h" 13 + #include "parse-options.h" 14 + #include "quote.h" 15 + #include "repository.h" 16 + #include "revision.h" 17 + 18 + struct last_modified_entry { 19 + struct hashmap_entry hashent; 20 + struct object_id oid; 21 + const char path[FLEX_ARRAY]; 22 + }; 23 + 24 + static int last_modified_entry_hashcmp(const void *unused UNUSED, 25 + const struct hashmap_entry *hent1, 26 + const struct hashmap_entry *hent2, 27 + const void *path) 28 + { 29 + const struct last_modified_entry *ent1 = 30 + container_of(hent1, const struct last_modified_entry, hashent); 31 + const struct last_modified_entry *ent2 = 32 + container_of(hent2, const struct last_modified_entry, hashent); 33 + return strcmp(ent1->path, path ? path : ent2->path); 34 + } 35 + 36 + struct last_modified { 37 + struct hashmap paths; 38 + struct rev_info rev; 39 + bool recursive; 40 + bool show_trees; 41 + }; 42 + 43 + static void last_modified_release(struct last_modified *lm) 44 + { 45 + hashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent); 46 + release_revisions(&lm->rev); 47 + } 48 + 49 + struct last_modified_callback_data { 50 + struct last_modified *lm; 51 + struct commit *commit; 52 + }; 53 + 54 + static void add_path_from_diff(struct diff_queue_struct *q, 55 + struct diff_options *opt UNUSED, void *data) 56 + { 57 + struct last_modified *lm = data; 58 + 59 + for (int i = 0; i < q->nr; i++) { 60 + struct diff_filepair *p = q->queue[i]; 61 + struct last_modified_entry *ent; 62 + const char *path = p->two->path; 63 + 64 + FLEX_ALLOC_STR(ent, path, path); 65 + oidcpy(&ent->oid, &p->two->oid); 66 + hashmap_entry_init(&ent->hashent, strhash(ent->path)); 67 + hashmap_add(&lm->paths, &ent->hashent); 68 + } 69 + } 70 + 71 + static int populate_paths_from_revs(struct last_modified *lm) 72 + { 73 + int num_interesting = 0; 74 + struct diff_options diffopt; 75 + 76 + /* 77 + * Create a copy of `struct diff_options`. In this copy a callback is 78 + * set that when called adds entries to `paths` in `struct last_modified`. 79 + * This copy is used to diff the tree of the target revision against an 80 + * empty tree. This results in all paths in the target revision being 81 + * listed. After `paths` is populated, we don't need this copy no more. 82 + */ 83 + memcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt)); 84 + copy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec); 85 + diffopt.output_format = DIFF_FORMAT_CALLBACK; 86 + diffopt.format_callback = add_path_from_diff; 87 + diffopt.format_callback_data = lm; 88 + 89 + for (size_t i = 0; i < lm->rev.pending.nr; i++) { 90 + struct object_array_entry *obj = lm->rev.pending.objects + i; 91 + 92 + if (obj->item->flags & UNINTERESTING) 93 + continue; 94 + 95 + if (num_interesting++) 96 + return error(_("last-modified can only operate on one tree at a time")); 97 + 98 + diff_tree_oid(lm->rev.repo->hash_algo->empty_tree, 99 + &obj->item->oid, "", &diffopt); 100 + diff_flush(&diffopt); 101 + } 102 + clear_pathspec(&diffopt.pathspec); 103 + 104 + return 0; 105 + } 106 + 107 + static void last_modified_emit(struct last_modified *lm, 108 + const char *path, const struct commit *commit) 109 + 110 + { 111 + if (commit->object.flags & BOUNDARY) 112 + putchar('^'); 113 + printf("%s\t", oid_to_hex(&commit->object.oid)); 114 + 115 + if (lm->rev.diffopt.line_termination) 116 + write_name_quoted(path, stdout, '\n'); 117 + else 118 + printf("%s%c", path, '\0'); 119 + } 120 + 121 + static void mark_path(const char *path, const struct object_id *oid, 122 + struct last_modified_callback_data *data) 123 + { 124 + struct last_modified_entry *ent; 125 + 126 + /* Is it even a path that we are interested in? */ 127 + ent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path, 128 + struct last_modified_entry, hashent); 129 + if (!ent) 130 + return; 131 + 132 + /* 133 + * Is it arriving at a version of interest, or is it from a side branch 134 + * which did not contribute to the final state? 135 + */ 136 + if (!oideq(oid, &ent->oid)) 137 + return; 138 + 139 + last_modified_emit(data->lm, path, data->commit); 140 + 141 + hashmap_remove(&data->lm->paths, &ent->hashent, path); 142 + free(ent); 143 + } 144 + 145 + static void last_modified_diff(struct diff_queue_struct *q, 146 + struct diff_options *opt UNUSED, void *cbdata) 147 + { 148 + struct last_modified_callback_data *data = cbdata; 149 + 150 + for (int i = 0; i < q->nr; i++) { 151 + struct diff_filepair *p = q->queue[i]; 152 + switch (p->status) { 153 + case DIFF_STATUS_DELETED: 154 + /* 155 + * There's no point in feeding a deletion, as it could 156 + * not have resulted in our current state, which 157 + * actually has the file. 158 + */ 159 + break; 160 + 161 + default: 162 + /* 163 + * Otherwise, we care only that we somehow arrived at 164 + * a final oid state. Note that this covers some 165 + * potentially controversial areas, including: 166 + * 167 + * 1. A rename or copy will be found, as it is the 168 + * first time the content has arrived at the given 169 + * path. 170 + * 171 + * 2. Even a non-content modification like a mode or 172 + * type change will trigger it. 173 + * 174 + * We take the inclusive approach for now, and find 175 + * anything which impacts the path. Options to tweak 176 + * the behavior (e.g., to "--follow" the content across 177 + * renames) can come later. 178 + */ 179 + mark_path(p->two->path, &p->two->oid, data); 180 + break; 181 + } 182 + } 183 + } 184 + 185 + static int last_modified_run(struct last_modified *lm) 186 + { 187 + struct last_modified_callback_data data = { .lm = lm }; 188 + 189 + lm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK; 190 + lm->rev.diffopt.format_callback = last_modified_diff; 191 + lm->rev.diffopt.format_callback_data = &data; 192 + 193 + prepare_revision_walk(&lm->rev); 194 + 195 + while (hashmap_get_size(&lm->paths)) { 196 + data.commit = get_revision(&lm->rev); 197 + if (!data.commit) 198 + BUG("paths remaining beyond boundary in last-modified"); 199 + 200 + if (data.commit->object.flags & BOUNDARY) { 201 + diff_tree_oid(lm->rev.repo->hash_algo->empty_tree, 202 + &data.commit->object.oid, "", 203 + &lm->rev.diffopt); 204 + diff_flush(&lm->rev.diffopt); 205 + } else { 206 + log_tree_commit(&lm->rev, data.commit); 207 + } 208 + } 209 + 210 + return 0; 211 + } 212 + 213 + static int last_modified_init(struct last_modified *lm, struct repository *r, 214 + const char *prefix, int argc, const char **argv) 215 + { 216 + hashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0); 217 + 218 + repo_init_revisions(r, &lm->rev, prefix); 219 + lm->rev.def = "HEAD"; 220 + lm->rev.combine_merges = 1; 221 + lm->rev.show_root_diff = 1; 222 + lm->rev.boundary = 1; 223 + lm->rev.no_commit_id = 1; 224 + lm->rev.diff = 1; 225 + lm->rev.diffopt.flags.recursive = lm->recursive; 226 + lm->rev.diffopt.flags.tree_in_recursive = lm->show_trees; 227 + 228 + argc = setup_revisions(argc, argv, &lm->rev, NULL); 229 + if (argc > 1) { 230 + error(_("unknown last-modified argument: %s"), argv[1]); 231 + return argc; 232 + } 233 + 234 + if (populate_paths_from_revs(lm) < 0) 235 + return error(_("unable to setup last-modified")); 236 + 237 + return 0; 238 + } 239 + 240 + int cmd_last_modified(int argc, const char **argv, const char *prefix, 241 + struct repository *repo) 242 + { 243 + int ret; 244 + struct last_modified lm = { 0 }; 245 + 246 + const char * const last_modified_usage[] = { 247 + N_("git last-modified [--recursive] [--show-trees] " 248 + "[<revision-range>] [[--] <path>...]"), 249 + NULL 250 + }; 251 + 252 + struct option last_modified_options[] = { 253 + OPT_BOOL('r', "recursive", &lm.recursive, 254 + N_("recurse into subtrees")), 255 + OPT_BOOL('t', "show-trees", &lm.show_trees, 256 + N_("show tree entries when recursing into subtrees")), 257 + OPT_END() 258 + }; 259 + 260 + argc = parse_options(argc, argv, prefix, last_modified_options, 261 + last_modified_usage, 262 + PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); 263 + 264 + repo_config(repo, git_default_config, NULL); 265 + 266 + ret = last_modified_init(&lm, repo, prefix, argc, argv); 267 + if (ret > 0) 268 + usage_with_options(last_modified_usage, 269 + last_modified_options); 270 + if (ret) 271 + goto out; 272 + 273 + ret = last_modified_run(&lm); 274 + if (ret) 275 + goto out; 276 + 277 + out: 278 + last_modified_release(&lm); 279 + 280 + return ret; 281 + }
+1
command-list.txt
··· 124 124 git-init mainporcelain init 125 125 git-instaweb ancillaryinterrogators complete 126 126 git-interpret-trailers purehelpers 127 + git-last-modified plumbinginterrogators 127 128 git-log mainporcelain info 128 129 git-ls-files plumbinginterrogators 129 130 git-ls-remote plumbinginterrogators
+1
git.c
··· 565 565 { "init", cmd_init_db }, 566 566 { "init-db", cmd_init_db }, 567 567 { "interpret-trailers", cmd_interpret_trailers, RUN_SETUP_GENTLY }, 568 + { "last-modified", cmd_last_modified, RUN_SETUP }, 568 569 { "log", cmd_log, RUN_SETUP }, 569 570 { "ls-files", cmd_ls_files, RUN_SETUP }, 570 571 { "ls-remote", cmd_ls_remote, RUN_SETUP_GENTLY },
+1
meson.build
··· 607 607 'builtin/index-pack.c', 608 608 'builtin/init-db.c', 609 609 'builtin/interpret-trailers.c', 610 + 'builtin/last-modified.c', 610 611 'builtin/log.c', 611 612 'builtin/ls-files.c', 612 613 'builtin/ls-remote.c',
+1
t/meson.build
··· 961 961 't8012-blame-colors.sh', 962 962 't8013-blame-ignore-revs.sh', 963 963 't8014-blame-ignore-fuzzy.sh', 964 + 't8020-last-modified.sh', 964 965 't9001-send-email.sh', 965 966 't9002-column.sh', 966 967 't9003-help-autocorrect.sh',
+210
t/t8020-last-modified.sh
··· 1 + #!/bin/sh 2 + 3 + test_description='last-modified tests' 4 + 5 + . ./test-lib.sh 6 + 7 + test_expect_success 'setup' ' 8 + test_commit 1 file && 9 + mkdir a && 10 + test_commit 2 a/file && 11 + mkdir a/b && 12 + test_commit 3 a/b/file 13 + ' 14 + 15 + test_expect_success 'cannot run last-modified on two trees' ' 16 + test_must_fail git last-modified HEAD HEAD~1 17 + ' 18 + 19 + check_last_modified() { 20 + local indir= && 21 + while test $# != 0 22 + do 23 + case "$1" in 24 + -C) 25 + indir="$2" 26 + shift 27 + ;; 28 + *) 29 + break 30 + ;; 31 + esac && 32 + shift 33 + done && 34 + 35 + cat >expect && 36 + test_when_finished "rm -f tmp.*" && 37 + git ${indir:+-C "$indir"} last-modified "$@" >tmp.1 && 38 + git name-rev --annotate-stdin --name-only --tags \ 39 + <tmp.1 >tmp.2 && 40 + tr '\t' ' ' <tmp.2 >actual && 41 + test_cmp expect actual 42 + } 43 + 44 + test_expect_success 'last-modified non-recursive' ' 45 + check_last_modified <<-\EOF 46 + 3 a 47 + 1 file 48 + EOF 49 + ' 50 + 51 + test_expect_success 'last-modified recursive' ' 52 + check_last_modified -r <<-\EOF 53 + 3 a/b/file 54 + 2 a/file 55 + 1 file 56 + EOF 57 + ' 58 + 59 + test_expect_success 'last-modified recursive with show-trees' ' 60 + check_last_modified -r -t <<-\EOF 61 + 3 a 62 + 3 a/b 63 + 3 a/b/file 64 + 2 a/file 65 + 1 file 66 + EOF 67 + ' 68 + 69 + test_expect_success 'last-modified non-recursive with show-trees' ' 70 + check_last_modified -t <<-\EOF 71 + 3 a 72 + 1 file 73 + EOF 74 + ' 75 + 76 + test_expect_success 'last-modified subdir' ' 77 + check_last_modified a <<-\EOF 78 + 3 a 79 + EOF 80 + ' 81 + 82 + test_expect_success 'last-modified subdir recursive' ' 83 + check_last_modified -r a <<-\EOF 84 + 3 a/b/file 85 + 2 a/file 86 + EOF 87 + ' 88 + 89 + test_expect_success 'last-modified from non-HEAD commit' ' 90 + check_last_modified HEAD^ <<-\EOF 91 + 2 a 92 + 1 file 93 + EOF 94 + ' 95 + 96 + test_expect_success 'last-modified from subdir defaults to root' ' 97 + check_last_modified -C a <<-\EOF 98 + 3 a 99 + 1 file 100 + EOF 101 + ' 102 + 103 + test_expect_success 'last-modified from subdir uses relative pathspecs' ' 104 + check_last_modified -C a -r b <<-\EOF 105 + 3 a/b/file 106 + EOF 107 + ' 108 + 109 + test_expect_success 'limit last-modified traversal by count' ' 110 + check_last_modified -1 <<-\EOF 111 + 3 a 112 + ^2 file 113 + EOF 114 + ' 115 + 116 + test_expect_success 'limit last-modified traversal by commit' ' 117 + check_last_modified HEAD~2..HEAD <<-\EOF 118 + 3 a 119 + ^1 file 120 + EOF 121 + ' 122 + 123 + test_expect_success 'only last-modified files in the current tree' ' 124 + git rm -rf a && 125 + git commit -m "remove a" && 126 + check_last_modified <<-\EOF 127 + 1 file 128 + EOF 129 + ' 130 + 131 + test_expect_success 'cross merge boundaries in blaming' ' 132 + git checkout HEAD^0 && 133 + git rm -rf . && 134 + test_commit m1 && 135 + git checkout HEAD^ && 136 + git rm -rf . && 137 + test_commit m2 && 138 + git merge m1 && 139 + check_last_modified <<-\EOF 140 + m2 m2.t 141 + m1 m1.t 142 + EOF 143 + ' 144 + 145 + test_expect_success 'last-modified merge for resolved conflicts' ' 146 + git checkout HEAD^0 && 147 + git rm -rf . && 148 + test_commit c1 conflict && 149 + git checkout HEAD^ && 150 + git rm -rf . && 151 + test_commit c2 conflict && 152 + test_must_fail git merge c1 && 153 + test_commit resolved conflict && 154 + check_last_modified conflict <<-\EOF 155 + resolved conflict 156 + EOF 157 + ' 158 + 159 + 160 + # Consider `file` with this content through history: 161 + # 162 + # A---B---B-------B---B 163 + # \ / 164 + # C---D 165 + test_expect_success 'last-modified merge ignores content from branch' ' 166 + git checkout HEAD^0 && 167 + git rm -rf . && 168 + test_commit a1 file A && 169 + test_commit a2 file B && 170 + test_commit a3 file C && 171 + test_commit a4 file D && 172 + git checkout a2 && 173 + git merge --no-commit --no-ff a4 && 174 + git checkout a2 -- file && 175 + git merge --continue && 176 + check_last_modified <<-\EOF 177 + a2 file 178 + EOF 179 + ' 180 + 181 + # Consider `file` with this content through history: 182 + # 183 + # A---B---B---C---D---B---B 184 + # \ / 185 + # B-------B 186 + test_expect_success 'last-modified merge undoes changes' ' 187 + git checkout HEAD^0 && 188 + git rm -rf . && 189 + test_commit b1 file A && 190 + test_commit b2 file B && 191 + test_commit b3 file C && 192 + test_commit b4 file D && 193 + git checkout b2 && 194 + test_commit b5 file2 2 && 195 + git checkout b4 && 196 + git merge --no-commit --no-ff b5 && 197 + git checkout b2 -- file && 198 + git merge --continue && 199 + check_last_modified <<-\EOF 200 + b5 file2 201 + b2 file 202 + EOF 203 + ' 204 + 205 + test_expect_success 'last-modified complains about unknown arguments' ' 206 + test_must_fail git last-modified --foo 2>err && 207 + grep "unknown last-modified argument: --foo" err 208 + ' 209 + 210 + test_done