Skip to content

Commit 0cc625f

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 41905d6 commit 0cc625f

File tree

11 files changed

+550
-0
lines changed

11 files changed

+550
-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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 [-r] [-t] [<revision-range>] [[--] <path>...]
13+
14+
DESCRIPTION
15+
-----------
16+
17+
Shows which commit last modified each of the relevant files and subdirectories.
18+
19+
THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
20+
21+
OPTIONS
22+
-------
23+
24+
-r::
25+
Recurse into subtrees.
26+
27+
-t::
28+
Show tree entry itself as well as subtrees. Implies `-r`.
29+
30+
<revision-range>::
31+
Only traverse commits in the specified revision range. When no
32+
`<revision-range>` is specified, it defaults to `HEAD` (i.e. the whole
33+
history leading to the current commit). For a complete list of ways to
34+
spell `<revision-range>`, see the 'Specifying Ranges' section of
35+
linkgit:gitrevisions[7].
36+
37+
[--] <path>...::
38+
For each _<path>_ given, the commit which last modified it is returned.
39+
Without an optional path parameter, all files and subdirectories
40+
in path traversal the are included in the output.
41+
42+
SEE ALSO
43+
--------
44+
linkgit:git-blame[1],
45+
linkgit:git-log[1].
46+
47+
GIT
48+
---
49+
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
@@ -1267,6 +1267,7 @@ BUILTIN_OBJS += builtin/hook.o
12671267
BUILTIN_OBJS += builtin/index-pack.o
12681268
BUILTIN_OBJS += builtin/init-db.o
12691269
BUILTIN_OBJS += builtin/interpret-trailers.o
1270+
BUILTIN_OBJS += builtin/last-modified.o
12701271
BUILTIN_OBJS += builtin/log.o
12711272
BUILTIN_OBJS += builtin/ls-files.o
12721273
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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
int recursive, tree_in_recursive;
39+
};
40+
41+
static void last_modified_release(struct last_modified *lm)
42+
{
43+
hashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent);
44+
release_revisions(&lm->rev);
45+
}
46+
47+
typedef void (*last_modified_callback)(const char *path,
48+
const struct commit *commit, void *data);
49+
50+
struct last_modified_callback_data {
51+
struct commit *commit;
52+
struct hashmap *paths;
53+
54+
last_modified_callback callback;
55+
void *callback_data;
56+
};
57+
58+
static void add_path_from_diff(struct diff_queue_struct *q,
59+
struct diff_options *opt UNUSED, void *data)
60+
{
61+
struct last_modified *lm = data;
62+
63+
for (int i = 0; i < q->nr; i++) {
64+
struct diff_filepair *p = q->queue[i];
65+
struct last_modified_entry *ent;
66+
const char *path = p->two->path;
67+
68+
FLEX_ALLOC_STR(ent, path, path);
69+
oidcpy(&ent->oid, &p->two->oid);
70+
hashmap_entry_init(&ent->hashent, strhash(ent->path));
71+
hashmap_add(&lm->paths, &ent->hashent);
72+
}
73+
}
74+
75+
static int populate_paths_from_revs(struct last_modified *lm)
76+
{
77+
int num_interesting = 0;
78+
struct diff_options diffopt;
79+
80+
memcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));
81+
copy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);
82+
/*
83+
* Use a callback to populate the paths from revs
84+
*/
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(_("can only get last-modified 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+
diff_free(&diffopt);
103+
104+
return 0;
105+
}
106+
107+
static void mark_path(const char *path, const struct object_id *oid,
108+
struct last_modified_callback_data *data)
109+
{
110+
struct last_modified_entry *ent;
111+
112+
/* Is it even a path that we are interested in? */
113+
ent = hashmap_get_entry_from_hash(data->paths, strhash(path), path,
114+
struct last_modified_entry, hashent);
115+
if (!ent)
116+
return;
117+
118+
/*
119+
* Is it arriving at a version of interest, or is it from a side branch
120+
* which did not contribute to the final state?
121+
*/
122+
if (!oideq(oid, &ent->oid))
123+
return;
124+
125+
if (data->callback)
126+
data->callback(path, data->commit, data->callback_data);
127+
128+
hashmap_remove(data->paths, &ent->hashent, path);
129+
free(ent);
130+
}
131+
132+
static void last_modified_diff(struct diff_queue_struct *q,
133+
struct diff_options *opt UNUSED, void *cbdata)
134+
{
135+
struct last_modified_callback_data *data = cbdata;
136+
137+
for (int i = 0; i < q->nr; i++) {
138+
struct diff_filepair *p = q->queue[i];
139+
switch (p->status) {
140+
case DIFF_STATUS_DELETED:
141+
/*
142+
* There's no point in feeding a deletion, as it could
143+
* not have resulted in our current state, which
144+
* actually has the file.
145+
*/
146+
break;
147+
148+
default:
149+
/*
150+
* Otherwise, we care only that we somehow arrived at
151+
* a final oid state. Note that this covers some
152+
* potentially controversial areas, including:
153+
*
154+
* 1. A rename or copy will be found, as it is the
155+
* first time the content has arrived at the given
156+
* path.
157+
*
158+
* 2. Even a non-content modification like a mode or
159+
* type change will trigger it.
160+
*
161+
* We take the inclusive approach for now, and find
162+
* anything which impacts the path. Options to tweak
163+
* the behavior (e.g., to "--follow" the content across
164+
* renames) can come later.
165+
*/
166+
mark_path(p->two->path, &p->two->oid, data);
167+
break;
168+
}
169+
}
170+
}
171+
172+
static int last_modified_run(struct last_modified *lm,
173+
last_modified_callback cb, void *cbdata)
174+
{
175+
struct last_modified_callback_data data;
176+
177+
data.paths = &lm->paths;
178+
data.callback = cb;
179+
data.callback_data = cbdata;
180+
181+
lm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;
182+
lm->rev.diffopt.format_callback = last_modified_diff;
183+
lm->rev.diffopt.format_callback_data = &data;
184+
185+
prepare_revision_walk(&lm->rev);
186+
187+
while (hashmap_get_size(&lm->paths)) {
188+
data.commit = get_revision(&lm->rev);
189+
if (!data.commit)
190+
break;
191+
192+
if (data.commit->object.flags & BOUNDARY) {
193+
diff_tree_oid(lm->rev.repo->hash_algo->empty_tree,
194+
&data.commit->object.oid, "",
195+
&lm->rev.diffopt);
196+
diff_flush(&lm->rev.diffopt);
197+
} else {
198+
log_tree_commit(&lm->rev, data.commit);
199+
}
200+
}
201+
202+
return 0;
203+
}
204+
205+
static void show_entry(const char *path, const struct commit *commit, void *d)
206+
{
207+
struct last_modified *lm = d;
208+
209+
if (commit->object.flags & BOUNDARY)
210+
putchar('^');
211+
printf("%s\t", oid_to_hex(&commit->object.oid));
212+
213+
if (lm->rev.diffopt.line_termination)
214+
write_name_quoted(path, stdout, '\n');
215+
else
216+
printf("%s%c", path, '\0');
217+
218+
fflush(stdout);
219+
}
220+
221+
static int last_modified_init(struct last_modified *lm, struct repository *r,
222+
const char *prefix, int argc, const char **argv)
223+
{
224+
hashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);
225+
226+
repo_init_revisions(r, &lm->rev, prefix);
227+
lm->rev.def = "HEAD";
228+
lm->rev.combine_merges = 1;
229+
lm->rev.show_root_diff = 1;
230+
lm->rev.boundary = 1;
231+
lm->rev.no_commit_id = 1;
232+
lm->rev.diff = 1;
233+
lm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;
234+
lm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;
235+
236+
if ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {
237+
error(_("unknown last-modified argument: %s"), argv[1]);
238+
return argc;
239+
}
240+
241+
if (populate_paths_from_revs(lm) < 0)
242+
return error(_("unable to setup last-modified"));
243+
244+
return 0;
245+
}
246+
247+
int cmd_last_modified(int argc, const char **argv, const char *prefix,
248+
struct repository *repo)
249+
{
250+
int ret;
251+
struct last_modified lm;
252+
253+
const char * const last_modified_usage[] = {
254+
N_("git last-modified [-r] [-t] "
255+
"[<revision-range>] [[--] <path>...]"),
256+
NULL
257+
};
258+
259+
struct option last_modified_options[] = {
260+
OPT_BOOL('r', "recursive", &lm.recursive,
261+
N_("recurse into subtrees")),
262+
OPT_BOOL('t', "tree-in-recursive", &lm.tree_in_recursive,
263+
N_("recurse into subtrees and include the tree entries too")),
264+
OPT_END()
265+
};
266+
267+
memset(&lm, 0, sizeof(lm));
268+
269+
argc = parse_options(argc, argv, prefix, last_modified_options,
270+
last_modified_usage,
271+
PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
272+
273+
repo_config(repo, git_default_config, NULL);
274+
275+
if ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {
276+
if (ret > 0)
277+
usage_with_options(last_modified_usage,
278+
last_modified_options);
279+
goto out;
280+
}
281+
282+
if ((ret = last_modified_run(&lm, show_entry, &lm)))
283+
goto out;
284+
285+
out:
286+
last_modified_release(&lm);
287+
288+
return ret;
289+
}

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',

0 commit comments

Comments
 (0)