Git fork

Merge branch 'tc/last-modified'

A new command "git last-modified" has been added to show the closest
ancestor commit that touched each path.

* tc/last-modified:
last-modified: use Bloom filters when available
t/perf: add last-modified perf script
last-modified: new subcommand to show when files were last modified

+627 -1
+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);
+326
builtin/last-modified.c
··· 1 + #include "git-compat-util.h" 2 + #include "bloom.h" 3 + #include "builtin.h" 4 + #include "commit-graph.h" 5 + #include "commit.h" 6 + #include "config.h" 7 + #include "environment.h" 8 + #include "diff.h" 9 + #include "diffcore.h" 10 + #include "environment.h" 11 + #include "hashmap.h" 12 + #include "hex.h" 13 + #include "log-tree.h" 14 + #include "object-name.h" 15 + #include "object.h" 16 + #include "parse-options.h" 17 + #include "quote.h" 18 + #include "repository.h" 19 + #include "revision.h" 20 + 21 + struct last_modified_entry { 22 + struct hashmap_entry hashent; 23 + struct object_id oid; 24 + struct bloom_key key; 25 + const char path[FLEX_ARRAY]; 26 + }; 27 + 28 + static int last_modified_entry_hashcmp(const void *unused UNUSED, 29 + const struct hashmap_entry *hent1, 30 + const struct hashmap_entry *hent2, 31 + const void *path) 32 + { 33 + const struct last_modified_entry *ent1 = 34 + container_of(hent1, const struct last_modified_entry, hashent); 35 + const struct last_modified_entry *ent2 = 36 + container_of(hent2, const struct last_modified_entry, hashent); 37 + return strcmp(ent1->path, path ? path : ent2->path); 38 + } 39 + 40 + struct last_modified { 41 + struct hashmap paths; 42 + struct rev_info rev; 43 + bool recursive; 44 + bool show_trees; 45 + }; 46 + 47 + static void last_modified_release(struct last_modified *lm) 48 + { 49 + struct hashmap_iter iter; 50 + struct last_modified_entry *ent; 51 + 52 + hashmap_for_each_entry(&lm->paths, &iter, ent, hashent) 53 + bloom_key_clear(&ent->key); 54 + 55 + hashmap_clear_and_free(&lm->paths, struct last_modified_entry, hashent); 56 + release_revisions(&lm->rev); 57 + } 58 + 59 + struct last_modified_callback_data { 60 + struct last_modified *lm; 61 + struct commit *commit; 62 + }; 63 + 64 + static void add_path_from_diff(struct diff_queue_struct *q, 65 + struct diff_options *opt UNUSED, void *data) 66 + { 67 + struct last_modified *lm = data; 68 + 69 + for (int i = 0; i < q->nr; i++) { 70 + struct diff_filepair *p = q->queue[i]; 71 + struct last_modified_entry *ent; 72 + const char *path = p->two->path; 73 + 74 + FLEX_ALLOC_STR(ent, path, path); 75 + oidcpy(&ent->oid, &p->two->oid); 76 + if (lm->rev.bloom_filter_settings) 77 + bloom_key_fill(&ent->key, path, strlen(path), 78 + lm->rev.bloom_filter_settings); 79 + hashmap_entry_init(&ent->hashent, strhash(ent->path)); 80 + hashmap_add(&lm->paths, &ent->hashent); 81 + } 82 + } 83 + 84 + static int populate_paths_from_revs(struct last_modified *lm) 85 + { 86 + int num_interesting = 0; 87 + struct diff_options diffopt; 88 + 89 + /* 90 + * Create a copy of `struct diff_options`. In this copy a callback is 91 + * set that when called adds entries to `paths` in `struct last_modified`. 92 + * This copy is used to diff the tree of the target revision against an 93 + * empty tree. This results in all paths in the target revision being 94 + * listed. After `paths` is populated, we don't need this copy no more. 95 + */ 96 + memcpy(&diffopt, &lm->rev.diffopt, sizeof(diffopt)); 97 + copy_pathspec(&diffopt.pathspec, &lm->rev.diffopt.pathspec); 98 + diffopt.output_format = DIFF_FORMAT_CALLBACK; 99 + diffopt.format_callback = add_path_from_diff; 100 + diffopt.format_callback_data = lm; 101 + 102 + for (size_t i = 0; i < lm->rev.pending.nr; i++) { 103 + struct object_array_entry *obj = lm->rev.pending.objects + i; 104 + 105 + if (obj->item->flags & UNINTERESTING) 106 + continue; 107 + 108 + if (num_interesting++) 109 + return error(_("last-modified can only operate on one tree at a time")); 110 + 111 + diff_tree_oid(lm->rev.repo->hash_algo->empty_tree, 112 + &obj->item->oid, "", &diffopt); 113 + diff_flush(&diffopt); 114 + } 115 + clear_pathspec(&diffopt.pathspec); 116 + 117 + return 0; 118 + } 119 + 120 + static void last_modified_emit(struct last_modified *lm, 121 + const char *path, const struct commit *commit) 122 + 123 + { 124 + if (commit->object.flags & BOUNDARY) 125 + putchar('^'); 126 + printf("%s\t", oid_to_hex(&commit->object.oid)); 127 + 128 + if (lm->rev.diffopt.line_termination) 129 + write_name_quoted(path, stdout, '\n'); 130 + else 131 + printf("%s%c", path, '\0'); 132 + } 133 + 134 + static void mark_path(const char *path, const struct object_id *oid, 135 + struct last_modified_callback_data *data) 136 + { 137 + struct last_modified_entry *ent; 138 + 139 + /* Is it even a path that we are interested in? */ 140 + ent = hashmap_get_entry_from_hash(&data->lm->paths, strhash(path), path, 141 + struct last_modified_entry, hashent); 142 + if (!ent) 143 + return; 144 + 145 + /* 146 + * Is it arriving at a version of interest, or is it from a side branch 147 + * which did not contribute to the final state? 148 + */ 149 + if (!oideq(oid, &ent->oid)) 150 + return; 151 + 152 + last_modified_emit(data->lm, path, data->commit); 153 + 154 + hashmap_remove(&data->lm->paths, &ent->hashent, path); 155 + bloom_key_clear(&ent->key); 156 + free(ent); 157 + } 158 + 159 + static void last_modified_diff(struct diff_queue_struct *q, 160 + struct diff_options *opt UNUSED, void *cbdata) 161 + { 162 + struct last_modified_callback_data *data = cbdata; 163 + 164 + for (int i = 0; i < q->nr; i++) { 165 + struct diff_filepair *p = q->queue[i]; 166 + switch (p->status) { 167 + case DIFF_STATUS_DELETED: 168 + /* 169 + * There's no point in feeding a deletion, as it could 170 + * not have resulted in our current state, which 171 + * actually has the file. 172 + */ 173 + break; 174 + 175 + default: 176 + /* 177 + * Otherwise, we care only that we somehow arrived at 178 + * a final oid state. Note that this covers some 179 + * potentially controversial areas, including: 180 + * 181 + * 1. A rename or copy will be found, as it is the 182 + * first time the content has arrived at the given 183 + * path. 184 + * 185 + * 2. Even a non-content modification like a mode or 186 + * type change will trigger it. 187 + * 188 + * We take the inclusive approach for now, and find 189 + * anything which impacts the path. Options to tweak 190 + * the behavior (e.g., to "--follow" the content across 191 + * renames) can come later. 192 + */ 193 + mark_path(p->two->path, &p->two->oid, data); 194 + break; 195 + } 196 + } 197 + } 198 + 199 + static bool maybe_changed_path(struct last_modified *lm, struct commit *origin) 200 + { 201 + struct bloom_filter *filter; 202 + struct last_modified_entry *ent; 203 + struct hashmap_iter iter; 204 + 205 + if (!lm->rev.bloom_filter_settings) 206 + return true; 207 + 208 + if (commit_graph_generation(origin) == GENERATION_NUMBER_INFINITY) 209 + return true; 210 + 211 + filter = get_bloom_filter(lm->rev.repo, origin); 212 + if (!filter) 213 + return true; 214 + 215 + hashmap_for_each_entry(&lm->paths, &iter, ent, hashent) { 216 + if (bloom_filter_contains(filter, &ent->key, 217 + lm->rev.bloom_filter_settings)) 218 + return true; 219 + } 220 + return false; 221 + } 222 + 223 + static int last_modified_run(struct last_modified *lm) 224 + { 225 + struct last_modified_callback_data data = { .lm = lm }; 226 + 227 + lm->rev.diffopt.output_format = DIFF_FORMAT_CALLBACK; 228 + lm->rev.diffopt.format_callback = last_modified_diff; 229 + lm->rev.diffopt.format_callback_data = &data; 230 + 231 + prepare_revision_walk(&lm->rev); 232 + 233 + while (hashmap_get_size(&lm->paths)) { 234 + data.commit = get_revision(&lm->rev); 235 + if (!data.commit) 236 + BUG("paths remaining beyond boundary in last-modified"); 237 + 238 + if (data.commit->object.flags & BOUNDARY) { 239 + diff_tree_oid(lm->rev.repo->hash_algo->empty_tree, 240 + &data.commit->object.oid, "", 241 + &lm->rev.diffopt); 242 + diff_flush(&lm->rev.diffopt); 243 + 244 + break; 245 + } 246 + 247 + if (!maybe_changed_path(lm, data.commit)) 248 + continue; 249 + 250 + log_tree_commit(&lm->rev, data.commit); 251 + } 252 + 253 + return 0; 254 + } 255 + 256 + static int last_modified_init(struct last_modified *lm, struct repository *r, 257 + const char *prefix, int argc, const char **argv) 258 + { 259 + hashmap_init(&lm->paths, last_modified_entry_hashcmp, NULL, 0); 260 + 261 + repo_init_revisions(r, &lm->rev, prefix); 262 + lm->rev.def = "HEAD"; 263 + lm->rev.combine_merges = 1; 264 + lm->rev.show_root_diff = 1; 265 + lm->rev.boundary = 1; 266 + lm->rev.no_commit_id = 1; 267 + lm->rev.diff = 1; 268 + lm->rev.diffopt.flags.recursive = lm->recursive; 269 + lm->rev.diffopt.flags.tree_in_recursive = lm->show_trees; 270 + 271 + argc = setup_revisions(argc, argv, &lm->rev, NULL); 272 + if (argc > 1) { 273 + error(_("unknown last-modified argument: %s"), argv[1]); 274 + return argc; 275 + } 276 + 277 + lm->rev.bloom_filter_settings = get_bloom_filter_settings(lm->rev.repo); 278 + 279 + if (populate_paths_from_revs(lm) < 0) 280 + return error(_("unable to setup last-modified")); 281 + 282 + return 0; 283 + } 284 + 285 + int cmd_last_modified(int argc, const char **argv, const char *prefix, 286 + struct repository *repo) 287 + { 288 + int ret; 289 + struct last_modified lm = { 0 }; 290 + 291 + const char * const last_modified_usage[] = { 292 + N_("git last-modified [--recursive] [--show-trees] " 293 + "[<revision-range>] [[--] <path>...]"), 294 + NULL 295 + }; 296 + 297 + struct option last_modified_options[] = { 298 + OPT_BOOL('r', "recursive", &lm.recursive, 299 + N_("recurse into subtrees")), 300 + OPT_BOOL('t', "show-trees", &lm.show_trees, 301 + N_("show tree entries when recursing into subtrees")), 302 + OPT_END() 303 + }; 304 + 305 + argc = parse_options(argc, argv, prefix, last_modified_options, 306 + last_modified_usage, 307 + PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); 308 + 309 + repo_config(repo, git_default_config, NULL); 310 + 311 + ret = last_modified_init(&lm, repo, prefix, argc, argv); 312 + if (ret > 0) 313 + usage_with_options(last_modified_usage, 314 + last_modified_options); 315 + if (ret) 316 + goto out; 317 + 318 + ret = last_modified_run(&lm); 319 + if (ret) 320 + goto out; 321 + 322 + out: 323 + last_modified_release(&lm); 324 + 325 + return ret; 326 + }
+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
+6 -1
commit-graph.c
··· 812 812 813 813 struct bloom_filter_settings *get_bloom_filter_settings(struct repository *r) 814 814 { 815 - struct commit_graph *g = r->objects->commit_graph; 815 + struct commit_graph *g; 816 + 817 + if (!prepare_commit_graph(r)) 818 + return NULL; 819 + 820 + g = r->objects->commit_graph; 816 821 while (g) { 817 822 if (g->bloom_filter_settings) 818 823 return g->bloom_filter_settings;
+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',
+2
t/meson.build
··· 951 951 't8012-blame-colors.sh', 952 952 't8013-blame-ignore-revs.sh', 953 953 't8014-blame-ignore-fuzzy.sh', 954 + 't8020-last-modified.sh', 954 955 't9001-send-email.sh', 955 956 't9002-column.sh', 956 957 't9003-help-autocorrect.sh', ··· 1144 1145 'perf/p7820-grep-engines.sh', 1145 1146 'perf/p7821-grep-engines-fixed.sh', 1146 1147 'perf/p7822-grep-perl-character.sh', 1148 + 'perf/p8020-last-modified.sh', 1147 1149 'perf/p9210-scalar.sh', 1148 1150 'perf/p9300-fast-import-export.sh', 1149 1151 ]
+22
t/perf/p8020-last-modified.sh
··· 1 + #!/bin/sh 2 + 3 + test_description='last-modified perf tests' 4 + . ./perf-lib.sh 5 + 6 + test_perf_default_repo 7 + 8 + test_perf 'top-level last-modified' ' 9 + git last-modified HEAD 10 + ' 11 + 12 + test_perf 'top-level recursive last-modified' ' 13 + git last-modified -r HEAD 14 + ' 15 + 16 + test_perf 'subdir last-modified' ' 17 + git ls-tree -d HEAD >subtrees && 18 + path="$(head -n 1 subtrees | cut -f2)" && 19 + git last-modified -r HEAD -- "$path" 20 + ' 21 + 22 + test_done
+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