Skip to content

Commit a6de351

Browse files
committed
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 <[email protected]> Improved-by: Ævar Arnfjörð Bjarmason <[email protected]> Signed-off-by: Toon Claes <[email protected]>
1 parent e813a02 commit a6de351

File tree

11 files changed

+541
-0
lines changed

11 files changed

+541
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
/git-init-db
8888
/git-interpret-trailers
8989
/git-instaweb
90+
/git-last-modified
9091
/git-log
9192
/git-ls-files
9293
/git-ls-remote

Documentation/git-last-modified.adoc

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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] [--recursive-with-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, --recursive::
26+
Recurse into subtrees.
27+
28+
-t, --tree-in-recursive::
29+
Show tree entry itself as well as subtrees. Implies `-r`.
30+
31+
<revision-range>::
32+
Only traverse commits in the specified revision range. When no
33+
`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole
34+
history leading to the current commit). For a complete list of ways to
35+
spell `<revision-range>`, see the 'Specifying Ranges' section of
36+
linkgit:gitrevisions[7].
37+
38+
[--] <path>...::
39+
For each _<path>_ given, the commit which last modified it is returned.
40+
Without an optional path parameter, all files and subdirectories
41+
in path traversal the are included in the output.
42+
43+
SEE ALSO
44+
--------
45+
linkgit:git-blame[1],
46+
linkgit:git-log[1].
47+
48+
GIT
49+
---
50+
Part of the linkgit:git[1] suite

Documentation/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ manpages = {
7474
'git-init.adoc' : 1,
7575
'git-instaweb.adoc' : 1,
7676
'git-interpret-trailers.adoc' : 1,
77+
'git-last-modified.adoc' : 1,
7778
'git-log.adoc' : 1,
7879
'git-ls-files.adoc' : 1,
7980
'git-ls-remote.adoc' : 1,

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,7 @@ BUILTIN_OBJS += builtin/hook.o
12651265
BUILTIN_OBJS += builtin/index-pack.o
12661266
BUILTIN_OBJS += builtin/init-db.o
12671267
BUILTIN_OBJS += builtin/interpret-trailers.o
1268+
BUILTIN_OBJS += builtin/last-modified.o
12681269
BUILTIN_OBJS += builtin/log.o
12691270
BUILTIN_OBJS += builtin/ls-files.o
12701271
BUILTIN_OBJS += builtin/ls-remote.o

builtin.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ int cmd_hook(int argc, const char **argv, const char *prefix, struct repository
176176
int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
177177
int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
178178
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);
179180
int cmd_log_reflog(int argc, const char **argv, const char *prefix, struct repository *repo);
180181
int cmd_log(int argc, const char **argv, const char *prefix, struct repository *repo);
181182
int cmd_ls_files(int argc, const char **argv, const char *prefix, struct repository *repo);

builtin/last-modified.c

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
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 "hashmap.h"
8+
#include "hex.h"
9+
#include "log-tree.h"
10+
#include "object-name.h"
11+
#include "object.h"
12+
#include "parse-options.h"
13+
#include "quote.h"
14+
#include "repository.h"
15+
#include "revision.h"
16+
17+
struct last_modified_entry {
18+
struct hashmap_entry hashent;
19+
struct object_id oid;
20+
const char path[FLEX_ARRAY];
21+
};
22+
23+
static int last_modified_entry_hashcmp(const void *unused UNUSED,
24+
const struct hashmap_entry *hent1,
25+
const struct hashmap_entry *hent2,
26+
const void *path)
27+
{
28+
const struct last_modified_entry *ent1 =
29+
container_of(hent1, const struct last_modified_entry, hashent);
30+
const struct last_modified_entry *ent2 =
31+
container_of(hent2, const struct last_modified_entry, hashent);
32+
return strcmp(ent1->path, path ? path : ent2->path);
33+
}
34+
35+
struct last_modified {
36+
struct hashmap paths;
37+
struct rev_info rev;
38+
bool recursive;
39+
bool tree_in_recursive;
40+
};
41+
42+
static void last_modified_release(struct last_modified *lm)
43+
{
44+
hashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);
45+
release_revisions(&lm->rev);
46+
}
47+
48+
struct last_modified_callback_data {
49+
struct last_modified *lm;
50+
struct commit *commit;
51+
};
52+
53+
static void add_path_from_diff(struct diff_queue_struct *q,
54+
struct diff_options *opt UNUSED, void *data)
55+
{
56+
struct last_modified *lm = data;
57+
58+
for (int i = 0; i < q->nr; i++) {
59+
struct diff_filepair *p = q->queue[i];
60+
struct last_modified_entry *ent;
61+
const char *path = p->two->path;
62+
63+
FLEX_ALLOC_STR(ent, path, path);
64+
oidcpy(&ent->oid, &p->two->oid);
65+
hashmap_entry_init(&ent->hashent, strhash(ent->path));
66+
hashmap_add(&lm->paths, &ent->hashent);
67+
}
68+
}
69+
70+
static int populate_paths_from_revs(struct last_modified *lm)
71+
{
72+
int num_interesting = 0;
73+
struct diff_options diffopt;
74+
75+
/*
76+
* Create a copy of `struct diff_options`. In this copy a callback is
77+
* set that when called adds entries to `paths` in `struct last_modified`.
78+
* This copy is used to diff the tree of the target revision against an
79+
* empty tree. This results in all paths in the target revision being
80+
* listed. After `paths` is populated, we don't need this copy no more.
81+
*/
82+
memcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));
83+
copy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);
84+
diffopt.output_format = DIFF_FORMAT_CALLBACK;
85+
diffopt.format_callback = add_path_from_diff;
86+
diffopt.format_callback_data = lm;
87+
88+
for (size_t i = 0; i < lm->rev.pending.nr; i++) {
89+
struct object_array_entry *obj = lm->rev.pending.objects + i;
90+
91+
if (obj->item->flags & UNINTERESTING)
92+
continue;
93+
94+
if (num_interesting++)
95+
return error(_("last-modified can only operate on one tree at a time"));
96+
97+
diff_tree_oid(lm->rev.repo->hash_algo->empty_tree,
98+
&obj->item->oid, "", &diffopt);
99+
diff_flush(&diffopt);
100+
}
101+
clear_pathspec(&diffopt.pathspec);
102+
103+
return 0;
104+
}
105+
106+
static void last_modified_emit(struct last_modified *lm,
107+
const char *path, const struct commit *commit)
108+
109+
{
110+
if (commit->object.flags & BOUNDARY)
111+
putchar('^');
112+
printf("%s\t", oid_to_hex(&commit->object.oid));
113+
114+
if (lm->rev.diffopt.line_termination)
115+
write_name_quoted(path, stdout, '\n');
116+
else
117+
printf("%s%c", path, '\0');
118+
}
119+
120+
static void mark_path(const char *path, const struct object_id *oid,
121+
struct last_modified_callback_data *data)
122+
{
123+
struct last_modified_entry *ent;
124+
125+
/* Is it even a path that we are interested in? */
126+
ent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path,
127+
struct last_modified_entry, hashent);
128+
if (!ent)
129+
return;
130+
131+
/*
132+
* Is it arriving at a version of interest, or is it from a side branch
133+
* which did not contribute to the final state?
134+
*/
135+
if (!oideq(oid, &ent->oid))
136+
return;
137+
138+
last_modified_emit(data->lm, path, data->commit);
139+
140+
hashmap_remove(&data->lm->paths, &ent->hashent, path);
141+
free(ent);
142+
}
143+
144+
static void last_modified_diff(struct diff_queue_struct *q,
145+
struct diff_options *opt UNUSED, void *cbdata)
146+
{
147+
struct last_modified_callback_data *data = cbdata;
148+
149+
for (int i = 0; i < q->nr; i++) {
150+
struct diff_filepair *p = q->queue[i];
151+
switch (p->status) {
152+
case DIFF_STATUS_DELETED:
153+
/*
154+
* There's no point in feeding a deletion, as it could
155+
* not have resulted in our current state, which
156+
* actually has the file.
157+
*/
158+
break;
159+
160+
default:
161+
/*
162+
* Otherwise, we care only that we somehow arrived at
163+
* a final oid state. Note that this covers some
164+
* potentially controversial areas, including:
165+
*
166+
* 1. A rename or copy will be found, as it is the
167+
* first time the content has arrived at the given
168+
* path.
169+
*
170+
* 2. Even a non-content modification like a mode or
171+
* type change will trigger it.
172+
*
173+
* We take the inclusive approach for now, and find
174+
* anything which impacts the path. Options to tweak
175+
* the behavior (e.g., to "--follow" the content across
176+
* renames) can come later.
177+
*/
178+
mark_path(p->two->path, &p->two->oid, data);
179+
break;
180+
}
181+
}
182+
}
183+
184+
static int last_modified_run(struct last_modified *lm)
185+
{
186+
struct last_modified_callback_data data = { .lm = lm };
187+
188+
lm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;
189+
lm->rev.diffopt.format_callback = last_modified_diff;
190+
lm->rev.diffopt.format_callback_data = &data;
191+
192+
prepare_revision_walk(&lm->rev);
193+
194+
while (hashmap_get_size(&lm->paths)) {
195+
data.commit = get_revision(&lm->rev);
196+
if (!data.commit)
197+
BUG("paths remaining beyond boundary in last-modified");
198+
199+
if (data.commit->object.flags & BOUNDARY) {
200+
diff_tree_oid(lm->rev.repo->hash_algo->empty_tree,
201+
&data.commit->object.oid, "",
202+
&lm->rev.diffopt);
203+
diff_flush(&lm->rev.diffopt);
204+
} else {
205+
log_tree_commit(&lm->rev, data.commit);
206+
}
207+
}
208+
209+
return 0;
210+
}
211+
212+
static int last_modified_init(struct last_modified *lm, struct repository *r,
213+
const char *prefix, int argc, const char **argv)
214+
{
215+
hashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);
216+
217+
repo_init_revisions(r, &lm->rev, prefix);
218+
lm->rev.def = "HEAD";
219+
lm->rev.combine_merges = 1;
220+
lm->rev.show_root_diff = 1;
221+
lm->rev.boundary = 1;
222+
lm->rev.no_commit_id = 1;
223+
lm->rev.diff = 1;
224+
lm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;
225+
lm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;
226+
227+
argc = setup_revisions(argc, argv, &lm->rev, NULL);
228+
if (argc > 1) {
229+
error(_("unknown last-modified argument: %s"), argv[1]);
230+
return argc;
231+
}
232+
233+
if (populate_paths_from_revs(lm) < 0)
234+
return error(_("unable to setup last-modified"));
235+
236+
return 0;
237+
}
238+
239+
int cmd_last_modified(int argc, const char **argv, const char *prefix,
240+
struct repository *repo)
241+
{
242+
int ret;
243+
struct last_modified lm = { 0 };
244+
245+
const char * const last_modified_usage[] = {
246+
N_("git last-modified [--recursive] [--recursive-with-trees] "
247+
"[<revision-range>] [[--] <path>...]"),
248+
NULL
249+
};
250+
251+
struct option last_modified_options[] = {
252+
OPT_BOOL('r', "recursive", &lm.recursive,
253+
N_("recurse into subtrees")),
254+
OPT_BOOL('t', "recursive-with-trees", &lm.tree_in_recursive,
255+
N_("recurse into subtrees and include the tree entries too")),
256+
OPT_END()
257+
};
258+
259+
argc = parse_options(argc, argv, prefix, last_modified_options,
260+
last_modified_usage,
261+
PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
262+
263+
repo_config(repo, git_default_config, NULL);
264+
265+
ret = last_modified_init(&lm, repo, prefix, argc, argv);
266+
if (ret > 0)
267+
usage_with_options(last_modified_usage,
268+
last_modified_options);
269+
if (ret)
270+
goto out;
271+
272+
ret = last_modified_run(&lm);
273+
if (ret)
274+
goto out;
275+
276+
out:
277+
last_modified_release(&lm);
278+
279+
return ret;
280+
}

command-list.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ git-index-pack plumbingmanipulators
124124
git-init mainporcelain init
125125
git-instaweb ancillaryinterrogators complete
126126
git-interpret-trailers purehelpers
127+
git-last-modified plumbinginterrogators
127128
git-log mainporcelain info
128129
git-ls-files plumbinginterrogators
129130
git-ls-remote plumbinginterrogators

git.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ static struct cmd_struct commands[] = {
565565
{ "init", cmd_init_db },
566566
{ "init-db", cmd_init_db },
567567
{ "interpret-trailers", cmd_interpret_trailers, RUN_SETUP_GENTLY },
568+
{ "last-modified", cmd_last_modified, RUN_SETUP },
568569
{ "log", cmd_log, RUN_SETUP },
569570
{ "ls-files", cmd_ls_files, RUN_SETUP },
570571
{ "ls-remote", cmd_ls_remote, RUN_SETUP_GENTLY },

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ builtin_sources = [
607607
'builtin/index-pack.c',
608608
'builtin/init-db.c',
609609
'builtin/interpret-trailers.c',
610+
'builtin/last-modified.c',
610611
'builtin/log.c',
611612
'builtin/ls-files.c',
612613
'builtin/ls-remote.c',

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,7 @@ integration_tests = [
961961
't8012-blame-colors.sh',
962962
't8013-blame-ignore-revs.sh',
963963
't8014-blame-ignore-fuzzy.sh',
964+
't8020-last-modified.sh',
964965
't9001-send-email.sh',
965966
't9002-column.sh',
966967
't9003-help-autocorrect.sh',

0 commit comments

Comments
 (0)