diff --git a/Documentation/blame-options.adoc b/Documentation/blame-options.adoc index 1ae1222b6b..8d59dcc8f3 100644 --- a/Documentation/blame-options.adoc +++ b/Documentation/blame-options.adoc @@ -132,9 +132,11 @@ take effect. `--ignore-revs-file `:: Ignore revisions listed in __, which must be in the same format as an `fsck.skipList`. This option may be repeated, and these files will be - processed after any files specified with the `blame.ignoreRevsFile` config - option. An empty file name, `""`, will clear the list of revs from - previously processed files. + processed after the default `HEAD:.git-blame-ignore-revs` blob (if it + exists) and any files specified with the `blame.ignoreRevsFile` config + option. An empty file name, `""`, or `--no-ignore-revs-file` will clear + the list of revs from previously processed files, including the default + `HEAD:.git-blame-ignore-revs` blob. `--color-lines`:: Color line annotations in the default format differently if they come from diff --git a/Documentation/config/blame.adoc b/Documentation/config/blame.adoc index 4d047c1790..153d4ab524 100644 --- a/Documentation/config/blame.adoc +++ b/Documentation/config/blame.adoc @@ -23,9 +23,12 @@ blame.showRoot:: blame.ignoreRevsFile:: Ignore revisions listed in the file, one unabbreviated object name per line, in linkgit:git-blame[1]. Whitespace and comments beginning with - `#` are ignored. This option may be repeated multiple times. Empty - file names will reset the list of ignored revisions. This option will - be handled before the command line option `--ignore-revs-file`. + `#` are ignored. If the `HEAD:.git-blame-ignore-revs` blob exists, it + is added as the initial element in the list of ignore-revs files. + Other files listed in the configuration are also used, but an empty + element makes all elements that appeared before in the list forgotten. + This option will be handled before the command line option + `--ignore-revs-file`. blame.markUnblamableLines:: Mark lines that were changed by an ignored revision that we could not diff --git a/builtin/blame.c b/builtin/blame.c index 6741a7b9df..13a9dcc2e7 100644 --- a/builtin/blame.c +++ b/builtin/blame.c @@ -769,7 +769,7 @@ static int git_blame_config(const char *var, const char *value, if (ret) return ret; if (str) - string_list_insert(&ignore_revs_file_list, str); + string_list_append(&ignore_revs_file_list, str); free(str); return 0; } @@ -946,17 +946,61 @@ static int peel_to_commit_oid(struct object_id *oid_ret, void *cbdata) } } +#define IGNORE_REVS_MAX_FILE_SIZE (100 * 1024 * 1024) + +static void parse_default_ignore_revs_blob(struct blame_scoreboard *sb, + const char *name) +{ + struct object_context oc; + struct object_id oid; + enum object_type type; + size_t size; + char *buf; + + if (get_oid_with_context(sb->repo, name, GET_OID_QUIETLY, + &oid, &oc)) + goto out; + if (!S_ISREG(oc.mode)) + goto out; + + if (odb_read_object_info(sb->repo->objects, &oid, &size) != OBJ_BLOB) + goto out; + if (size >= IGNORE_REVS_MAX_FILE_SIZE) { + warning(_("ignoring overly large ignore-revs blob '%s'"), name); + goto out; + } + + buf = odb_read_object(sb->repo->objects, &oid, &type, &size); + if (!buf) + goto out; + if (type == OBJ_BLOB) + oidset_parse_buffer_carefully(&sb->ignore_list, buf, size, + sb->repo->hash_algo, + peel_to_commit_oid, sb); + free(buf); + +out: + object_context_release(&oc); +} + static void build_ignorelist(struct blame_scoreboard *sb, struct string_list *ignore_revs_file_list, struct string_list *ignore_rev_list) { struct string_list_item *i; struct object_id oid; + size_t start_idx = 0, idx; + + for (idx = 0; idx < ignore_revs_file_list->nr; idx++) { + if (!*ignore_revs_file_list->items[idx].string) + start_idx = idx + 1; + } oidset_init(&sb->ignore_list, 0); - for_each_string_list_item(i, ignore_revs_file_list) { - if (!strcmp(i->string, "")) - oidset_clear(&sb->ignore_list); + for (idx = start_idx; idx < ignore_revs_file_list->nr; idx++) { + i = &ignore_revs_file_list->items[idx]; + if (i->util) + parse_default_ignore_revs_blob(sb, i->string); else oidset_parse_file_carefully(&sb->ignore_list, i->string, the_repository->hash_algo, @@ -1036,6 +1080,8 @@ int cmd_blame(int argc, const char *const *opt_usage = cmd_is_annotate ? annotate_opt_usage : blame_opt_usage; setup_default_color_by_age(); + string_list_append(&ignore_revs_file_list, + "HEAD:.git-blame-ignore-revs")->util = &sb; repo_config(the_repository, git_blame_config, &output_option); repo_init_revisions(the_repository, &revs, NULL); revs.date_mode = blame_date_mode; diff --git a/oidset.c b/oidset.c index 90d39204d3..8469d03b9b 100644 --- a/oidset.c +++ b/oidset.c @@ -70,44 +70,76 @@ void oidset_parse_file(struct oidset *set, const char *path, oidset_parse_file_carefully(set, path, algop, NULL, NULL); } +static void parse_oidset_line(struct oidset *set, struct strbuf *sb, + const struct git_hash_algo *algop, + oidset_parse_tweak_fn fn, void *cbdata) +{ + const char *p; + const char *name; + struct object_id oid; + + if (memchr(sb->buf, '\0', sb->len)) + die("invalid object name: %s", sb->buf); + + /* + * Allow trailing comments, leading whitespace + * (including before commits), and empty or whitespace + * only lines. + */ + name = strchr(sb->buf, '#'); + if (name) + strbuf_setlen(sb, name - sb->buf); + strbuf_trim(sb); + if (!sb->len) + return; + + if (parse_oid_hex_algop(sb->buf, &oid, &p, algop) || *p != '\0') + die("invalid object name: %s", sb->buf); + if (fn && fn(&oid, cbdata)) + return; + oidset_insert(set, &oid); +} + void oidset_parse_file_carefully(struct oidset *set, const char *path, const struct git_hash_algo *algop, oidset_parse_tweak_fn fn, void *cbdata) { FILE *fp; struct strbuf sb = STRBUF_INIT; - struct object_id oid; fp = fopen(path, "r"); if (!fp) die("could not open object name list: %s", path); - while (!strbuf_getline(&sb, fp)) { - const char *p; - const char *name; - - if (memchr(sb.buf, '\0', sb.len)) - die("invalid object name: %s", sb.buf); - - /* - * Allow trailing comments, leading whitespace - * (including before commits), and empty or whitespace - * only lines. - */ - name = strchr(sb.buf, '#'); - if (name) - strbuf_setlen(&sb, name - sb.buf); - strbuf_trim(&sb); - if (!sb.len) - continue; - - if (parse_oid_hex_algop(sb.buf, &oid, &p, algop) || *p != '\0') - die("invalid object name: %s", sb.buf); - if (fn && fn(&oid, cbdata)) - continue; - oidset_insert(set, &oid); - } + while (!strbuf_getline(&sb, fp)) + parse_oidset_line(set, &sb, algop, fn, cbdata); if (ferror(fp)) die_errno("Could not read '%s'", path); fclose(fp); strbuf_release(&sb); } + +void oidset_parse_buffer_carefully(struct oidset *set, const char *buf, + size_t size, + const struct git_hash_algo *algop, + oidset_parse_tweak_fn fn, void *cbdata) +{ + struct strbuf sb = STRBUF_INIT; + const char *p = buf, *end; + + if (!size) + return; + end = buf + size; + + while (p < end) { + const char *nl = memchr(p, '\n', end - p); + size_t len = (nl ? nl : end) - p; + + strbuf_reset(&sb); + if (len && p[len - 1] == '\r') + len--; + strbuf_add(&sb, p, len); + parse_oidset_line(set, &sb, algop, fn, cbdata); + p = nl ? nl + 1 : end; + } + strbuf_release(&sb); +} diff --git a/oidset.h b/oidset.h index e0f1a6ff4f..667c390842 100644 --- a/oidset.h +++ b/oidset.h @@ -98,6 +98,15 @@ void oidset_parse_file_carefully(struct oidset *set, const char *path, const struct git_hash_algo *algop, oidset_parse_tweak_fn fn, void *cbdata); +/* + * Similar to oidset_parse_file_carefully(), but parses lines from an + * in-memory buffer of 'size' bytes. + */ +void oidset_parse_buffer_carefully(struct oidset *set, const char *buf, + size_t size, + const struct git_hash_algo *algop, + oidset_parse_tweak_fn fn, void *cbdata); + struct oidset_iter { const kh_oid_set_t *set; khiter_t iter; diff --git a/t/t8013-blame-ignore-revs.sh b/t/t8013-blame-ignore-revs.sh index 70fe509a64..f2eae809cb 100755 --- a/t/t8013-blame-ignore-revs.sh +++ b/t/t8013-blame-ignore-revs.sh @@ -365,4 +365,201 @@ test_expect_success 'ignore-revs-file peels chained tags and skips missing tag t test_cmp expect actual ' +# Tests for default HEAD:.git-blame-ignore-revs blob +test_expect_success 'setup default HEAD:.git-blame-ignore-revs' ' + git checkout -b default-file-branch && + test_write_lines line1 line2 >def-file && + git add def-file && + test_tick && + git commit -m "default base" && + git tag DEF_A && + + test_write_lines line1-modified line2-modified >def-file && + git add def-file && + test_tick && + git commit -m "default mod" && + git tag DEF_B && + + git rev-parse DEF_B >.git-blame-ignore-revs && + git add .git-blame-ignore-revs && + test_tick && + git commit -m "add .git-blame-ignore-revs" +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs is used by default' ' + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual && + test_cmp expect actual +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs respected by git annotate' ' + git rev-parse --short DEF_A >expect_sha && + git annotate def-file >actual && + test_grep "^$(cat expect_sha)" actual +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs works from subdirectory' ' + mkdir -p sub && + ( + cd sub && + git blame --line-porcelain ../def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual + ) +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs respected in bare repo' ' + test_when_finished "rm -rf bare.git" && + git clone --bare . bare.git && + git -C bare.git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual +' + +test_expect_success 'uncommitted .git-blame-ignore-revs changes in working tree are ignored' ' + test_when_finished "git checkout -- .git-blame-ignore-revs" && + echo "invalid-oid-value" >.git-blame-ignore-revs && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual +' + +test_expect_success 'disable default HEAD:.git-blame-ignore-revs with --no-ignore-revs-file' ' + git blame --line-porcelain --no-ignore-revs-file def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual && + test_cmp expect actual +' + +test_expect_success 'disable default HEAD:.git-blame-ignore-revs with --ignore-revs-file ""' ' + git blame --line-porcelain --ignore-revs-file "" def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual && + test_cmp expect actual +' + +test_expect_success 'disable default HEAD:.git-blame-ignore-revs with blame.ignoreRevsFile=""' ' + test_config blame.ignoreRevsFile "" && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual && + test_cmp expect actual +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs handles comments and whitespace' ' + rev_def_b=$(git rev-parse DEF_B) && + { + echo "# Leading comment" && + echo "" && + echo " $rev_def_b # inline comment" && + echo "# Trailing comment" + } >.git-blame-ignore-revs && + git add .git-blame-ignore-revs && + test_tick && + git commit -m "comments and whitespace in .git-blame-ignore-revs" && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual +' + +test_expect_success 'empty default HEAD:.git-blame-ignore-revs is harmless' ' + : >.git-blame-ignore-revs && + git add .git-blame-ignore-revs && + test_tick && + git commit -m "empty .git-blame-ignore-revs" && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual +' + +test_expect_success 'committed symlink .git-blame-ignore-revs in HEAD is ignored' ' + git rm -f .git-blame-ignore-revs && + git rev-parse DEF_B >target_file && + test_ln_s_add target_file .git-blame-ignore-revs && + test_tick && + git commit -m "symlink .git-blame-ignore-revs" && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual +' + +test_expect_success 'malformed default HEAD:.git-blame-ignore-revs fails but can be bypassed' ' + git rm -f .git-blame-ignore-revs && + echo "invalid-oid-value" >.git-blame-ignore-revs && + git add .git-blame-ignore-revs && + test_tick && + git commit -m "malformed .git-blame-ignore-revs" && + test_must_fail git blame def-file && + git blame --no-ignore-revs-file def-file && + git blame --ignore-revs-file "" def-file && + git -c blame.ignoreRevsFile="" blame def-file && + + rev_def_b=$(git rev-parse DEF_B) && + printf "%sQgarbage\n" "$rev_def_b" | q_to_nul >.git-blame-ignore-revs && + git add .git-blame-ignore-revs && + test_tick && + git commit -m "NUL in .git-blame-ignore-revs" && + test_must_fail git blame def-file 2>err && + test_grep "invalid object name:" err +' + +test_expect_success 'default HEAD:.git-blame-ignore-revs combined with config blame.ignoreRevsFile' ' + git rev-parse DEF_B >.git-blame-ignore-revs && + test_write_lines line1-modified line2-c >def-file && + git add .git-blame-ignore-revs def-file && + test_tick && + git commit -m C && + git tag DEF_C && + git rev-parse DEF_C >custom_ignore && + test_config blame.ignoreRevsFile custom_ignore && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual && + git rev-parse DEF_A >expect && + test_cmp expect actual +' + +test_expect_success 'blame works when HEAD:.git-blame-ignore-revs does not exist and ignores untracked file' ' + test_when_finished "rm -f .git-blame-ignore-revs" && + git rm -f .git-blame-ignore-revs && + test_tick && + git commit -m "remove .git-blame-ignore-revs" && + git rev-parse DEF_B >.git-blame-ignore-revs && + git blame --line-porcelain def-file >blame_raw && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual +' + +test_expect_success EXPENSIVE 'overly large default HEAD:.git-blame-ignore-revs blob is ignored' ' + blob=$(dd if=/dev/zero bs=1048576 count=101 2>/dev/null | + git hash-object -w --stdin) && + git update-index --add --cacheinfo 100644,$blob,.git-blame-ignore-revs && + test_tick && + git commit -m "overly large .git-blame-ignore-revs" && + test_when_finished "git reset --hard HEAD~" && + git blame --line-porcelain def-file >blame_raw 2>err && + sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 1/s/ .*//p" blame_raw >actual && + git rev-parse DEF_B >expect && + test_cmp expect actual && + test_grep "warning: ignoring overly large ignore-revs blob '\''HEAD:.git-blame-ignore-revs'\''" err +' + test_done