Skip to content

Commit f8643ab

Browse files
To1negitster
authored andcommitted
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]> Signed-off-by: Junio C Hamano <[email protected]>
1 parent e813a02 commit f8643ab

File tree

11 files changed

+538
-0
lines changed

11 files changed

+538
-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
@@ -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: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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;
39+
int 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+
memcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt));
76+
copy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec);
77+
/*
78+
* Use a callback to populate the paths from revs
79+
*/
80+
diffopt.output_format = DIFF_FORMAT_CALLBACK;
81+
diffopt.format_callback = add_path_from_diff;
82+
diffopt.format_callback_data = lm;
83+
84+
for (size_t i = 0; i < lm->rev.pending.nr; i++) {
85+
struct object_array_entry *obj = lm->rev.pending.objects + i;
86+
87+
if (obj->item->flags & UNINTERESTING)
88+
continue;
89+
90+
if (num_interesting++)
91+
return error(_("last-modified can only operate on one tree at a time"));
92+
93+
diff_tree_oid(lm->rev.repo->hash_algo->empty_tree,
94+
&obj->item->oid, "", &diffopt);
95+
diff_flush(&diffopt);
96+
}
97+
diff_free(&diffopt);
98+
99+
return 0;
100+
}
101+
102+
static void last_modified_emit(struct last_modified *lm,
103+
const char *path, const struct commit *commit)
104+
105+
{
106+
if (commit->object.flags & BOUNDARY)
107+
putchar('^');
108+
printf("%s\t", oid_to_hex(&commit->object.oid));
109+
110+
if (lm->rev.diffopt.line_termination)
111+
write_name_quoted(path, stdout, '\n');
112+
else
113+
printf("%s%c", path, '\0');
114+
115+
fflush(stdout);
116+
}
117+
118+
static void mark_path(const char *path, const struct object_id *oid,
119+
struct last_modified_callback_data *data)
120+
{
121+
struct last_modified_entry *ent;
122+
123+
/* Is it even a path that we are interested in? */
124+
ent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path,
125+
struct last_modified_entry, hashent);
126+
if (!ent)
127+
return;
128+
129+
/*
130+
* Is it arriving at a version of interest, or is it from a side branch
131+
* which did not contribute to the final state?
132+
*/
133+
if (!oideq(oid, &ent->oid))
134+
return;
135+
136+
last_modified_emit(data->lm, path, data->commit);
137+
138+
hashmap_remove(&data->lm->paths, &ent->hashent, path);
139+
free(ent);
140+
}
141+
142+
static void last_modified_diff(struct diff_queue_struct *q,
143+
struct diff_options *opt UNUSED, void *cbdata)
144+
{
145+
struct last_modified_callback_data *data = cbdata;
146+
147+
for (int i = 0; i < q->nr; i++) {
148+
struct diff_filepair *p = q->queue[i];
149+
switch (p->status) {
150+
case DIFF_STATUS_DELETED:
151+
/*
152+
* There's no point in feeding a deletion, as it could
153+
* not have resulted in our current state, which
154+
* actually has the file.
155+
*/
156+
break;
157+
158+
default:
159+
/*
160+
* Otherwise, we care only that we somehow arrived at
161+
* a final oid state. Note that this covers some
162+
* potentially controversial areas, including:
163+
*
164+
* 1. A rename or copy will be found, as it is the
165+
* first time the content has arrived at the given
166+
* path.
167+
*
168+
* 2. Even a non-content modification like a mode or
169+
* type change will trigger it.
170+
*
171+
* We take the inclusive approach for now, and find
172+
* anything which impacts the path. Options to tweak
173+
* the behavior (e.g., to "--follow" the content across
174+
* renames) can come later.
175+
*/
176+
mark_path(p->two->path, &p->two->oid, data);
177+
break;
178+
}
179+
}
180+
}
181+
182+
static int last_modified_run(struct last_modified *lm)
183+
{
184+
struct last_modified_callback_data data = { .lm = lm };
185+
186+
lm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK;
187+
lm->rev.diffopt.format_callback = last_modified_diff;
188+
lm->rev.diffopt.format_callback_data = &data;
189+
190+
prepare_revision_walk(&lm->rev);
191+
192+
while (hashmap_get_size(&lm->paths)) {
193+
data.commit = get_revision(&lm->rev);
194+
if (!data.commit)
195+
break;
196+
197+
if (data.commit->object.flags & BOUNDARY) {
198+
diff_tree_oid(lm->rev.repo->hash_algo->empty_tree,
199+
&data.commit->object.oid, "",
200+
&lm->rev.diffopt);
201+
diff_flush(&lm->rev.diffopt);
202+
} else {
203+
log_tree_commit(&lm->rev, data.commit);
204+
}
205+
}
206+
207+
return 0;
208+
}
209+
210+
static int last_modified_init(struct last_modified *lm, struct repository *r,
211+
const char *prefix, int argc, const char **argv)
212+
{
213+
hashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0);
214+
215+
repo_init_revisions(r, &lm->rev, prefix);
216+
lm->rev.def = "HEAD";
217+
lm->rev.combine_merges = 1;
218+
lm->rev.show_root_diff = 1;
219+
lm->rev.boundary = 1;
220+
lm->rev.no_commit_id = 1;
221+
lm->rev.diff = 1;
222+
lm->rev.diffopt.flags.recursive = lm->recursive || lm->tree_in_recursive;
223+
lm->rev.diffopt.flags.tree_in_recursive = lm->tree_in_recursive;
224+
225+
if ((argc = setup_revisions(argc, argv, &lm->rev, NULL)) > 1) {
226+
error(_("unknown last-modified argument: %s"), argv[1]);
227+
return argc;
228+
}
229+
230+
if (populate_paths_from_revs(lm) < 0)
231+
return error(_("unable to setup last-modified"));
232+
233+
return 0;
234+
}
235+
236+
int cmd_last_modified(int argc, const char **argv, const char *prefix,
237+
struct repository *repo)
238+
{
239+
int ret;
240+
struct last_modified lm;
241+
242+
const char * const last_modified_usage[] = {
243+
N_("git last-modified [-r] [-t] "
244+
"[<revision-range>] [[--] <path>...]"),
245+
NULL
246+
};
247+
248+
struct option last_modified_options[] = {
249+
OPT_BOOL('r', "recursive", &lm.recursive,
250+
N_("recurse into subtrees")),
251+
OPT_BOOL('t', "tree-in-recursive", &lm.tree_in_recursive,
252+
N_("recurse into subtrees and include the tree entries too")),
253+
OPT_END()
254+
};
255+
256+
memset(&lm, 0, sizeof(lm));
257+
258+
argc = parse_options(argc, argv, prefix, last_modified_options,
259+
last_modified_usage,
260+
PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
261+
262+
repo_config(repo, git_default_config, NULL);
263+
264+
if ((ret = last_modified_init(&lm, repo, prefix, argc, argv))) {
265+
if (ret > 0)
266+
usage_with_options(last_modified_usage,
267+
last_modified_options);
268+
goto out;
269+
}
270+
271+
if ((ret = last_modified_run(&lm)))
272+
goto out;
273+
274+
out:
275+
last_modified_release(&lm);
276+
277+
return ret;
278+
}

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)