Merge branch 'rm/blame-default-ignore-revs' into seen

'git blame' and 'git annotate' have been updated to automatically
use the 'HEAD:.git-blame-ignore-revs' blob as the default ignore
list if it exists, aligning local behavior with major hosting
platforms.  The parser and tag-peeling machinery have been hardened
against missing objects and malformed files.

* rm/blame-default-ignore-revs:
  blame: ignore revs in HEAD:.git-blame-ignore-revs
  blame: harden ignore-revs parser and tag peeling
seen
Junio C Hamano 2026-10-12 13:00:04 -07:00
commit 084100f874
6 changed files with 381 additions and 35 deletions

View File

@ -132,9 +132,11 @@ take effect.
`--ignore-revs-file <file>`::
Ignore revisions listed in _<file>_, 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

View File

@ -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

View File

@ -770,7 +770,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;
}
@ -912,36 +912,96 @@ static int is_a_rev(const char *name)
static int peel_to_commit_oid(struct object_id *oid_ret, void *cbdata)
{
struct repository *r = ((struct blame_scoreboard *)cbdata)->repo;
enum object_type expected_type = OBJ_ANY;
struct object_id oid;

oidcpy(&oid, oid_ret);
while (1) {
unsigned flags = OBJECT_INFO_LOOKUP_REPLACE |
OBJECT_INFO_SKIP_FETCH_OBJECT |
OBJECT_INFO_QUICK;
struct object_info oi = OBJECT_INFO_INIT;
enum object_type kind;
struct object *obj;
int kind = odb_read_object_info(r->objects, &oid, NULL);

oi.typep = &kind;
if (odb_read_object_info_extended(r->objects, &oid, &oi,
flags) < 0)
return -1;
if (expected_type != OBJ_ANY && kind != expected_type)
return -1;
if (kind == OBJ_COMMIT) {
oidcpy(oid_ret, &oid);
return 0;
}
if (kind != OBJ_TAG)
return -1;
obj = deref_tag(r, parse_object(r, &oid), NULL, 0);
obj = parse_object(r, &oid);
if (!obj || obj->type != OBJ_TAG)
return -1;
obj = ((struct tag *)obj)->tagged;
if (!obj)
return -1;
expected_type = obj->type;
oidcpy(&oid, &obj->oid);
}
}

#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,
@ -1021,6 +1081,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;

View File

@ -70,41 +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;

/*
* 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);
}

View File

@ -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;

View File

@ -327,4 +327,239 @@ test_expect_success ignore_merge '
test_cmp expect actual
'

test_expect_success 'ignore-revs-file rejects lines with embedded NUL bytes' '
rev_b=$(git rev-parse B) &&
printf "%sQgarbage\n" "$rev_b" | q_to_nul >ignore_nul &&
test_must_fail git blame file --ignore-revs-file ignore_nul 2>err &&
test_grep "invalid object name:" err &&

printf "%sQ# comment\n" "$rev_b" | q_to_nul >ignore_nul_comment &&
test_must_fail git blame file --ignore-revs-file ignore_nul_comment 2>err &&
test_grep "invalid object name:" err
'

test_expect_success 'ignore-revs-file peels chained tags and skips missing tag targets' '
test_write_lines BB L2-modified L3 L4 L5 L6 L7 L8 CC >file &&
git add file &&
test_tick &&
git commit -m D &&
git tag -a -m "tag 1" D_TAG1 HEAD &&
git tag -a -m "tag 2" D_TAG2 D_TAG1 &&
git rev-parse D_TAG2 >ignore_tag_chain &&
git blame --line-porcelain file --ignore-revs-file ignore_tag_chain >blame_raw &&
sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual &&
git rev-parse A >expect &&
test_cmp expect actual &&

test_config extensions.partialClone origin &&
test_config remote.origin.promisor true &&
test_config remote.origin.url /nonexistent &&
missing_oid=$(test_oid deadbeef) &&
bad_tag=$(printf "object %s\ntype commit\ntag bad-tag\ntagger T <t@example.com> 0 +0000\n\nmsg\n" "$missing_oid" |
git hash-object -t tag -w --stdin) &&
test_write_lines "$missing_oid" "$bad_tag" >ignore_bad_tag &&
git blame --line-porcelain file --ignore-revs-file ignore_bad_tag >blame_raw 2>err &&
test_must_be_empty err &&
sed -ne "/^[0-9a-f][0-9a-f]* [0-9][0-9]* 2/s/ .*//p" blame_raw >actual &&
git rev-parse HEAD >expect &&
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