Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Documentation/blame-options.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,12 @@ take effect.
`--ignore-revs-file <file>`::

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Ravi Mistry via GitGitGadget" <gitgitgadget@gmail.com> writes:

> From: Ravi Mistry <rmistry@google.com>
>
> git-blame(1) can ignore a list of commits specified via
> --ignore-revs-file or the blame.ignoreRevsFile configuration option.
> This is useful for skipping uninteresting revisions such as tree-wide
> formatting changes, large-scale refactors, and code modernizations that
> would otherwise obscure genuine historical authorship.
>
> When revision-ignoring was introduced in commit ae3f36dea1 ("blame: add
> blame.ignoreRevsFile config option", 2019-10-18), it intentionally
> avoided adopting a default ignore file. At the time, the capability was
> new and unproven, so avoiding unrequested filesystem I/O or unexpected
> attribution shifts took priority over a project-wide default.
> Requiring explicit opt-in per clone was therefore the prudent design.
>
> Since then, maintaining a .git-blame-ignore-revs file in the repository
> root has become the de facto standard across the Git ecosystem, adopted
> by major hosting platforms (GitHub, GitLab, Gerrit) and prominent open
> source projects (such as Chromium and LLVM). As a consequence,
> developers frequently encounter a jarring mismatch: web interfaces
> seamlessly ignore formatting commits, but local git-blame(1) and
> git-annotate(1) runs do not, unless each user manually configures
> blame.ignoreRevsFile for every local checkout.
>
> Teach git-blame(1) and git-annotate(1) to automatically add the
> HEAD:.git-blame-ignore-revs blob, if it exists, as the initial element
> in the list of ignore-revs files in both bare and non-bare
> repositories. Reading the committed blob from HEAD rather than the
> working tree ensures that local runs match hosting platforms even when
> an untracked .git-blame-ignore-revs file is present or a tracked one
> has uncommitted local changes.
>
> To ensure consistent precedence and override semantics:
> - The default HEAD:.git-blame-ignore-revs entry is added before reading
>   configuration and CLI options, preserving user and repository config
>   overrides.
> - In git_blame_config(), blame.ignoreRevsFile entries are appended via
>   string_list_append() rather than inserted in sorted order via
>   string_list_insert() so that configuration entries preserve their
>   order relative to the initial default entry.
> - The HEAD:.git-blame-ignore-revs tree entry is resolved quietly via
>   get_oid_with_context(). Its mode is checked with S_ISREG() before
>   reading the object so that non-regular tree entries (such as a
>   committed symbolic link whose blob stores a target path rather than
>   revision IDs, a subdirectory, or a gitlink) are skipped instead of
>   being read and rejected as malformed object names. The blob is parsed
>   in memory via a new oidset_parse_buffer_carefully() helper in
>   oidset.c that shares line parsing with oidset_parse_file_carefully().
> - In build_ignorelist(), ignore-revs entries are processed starting
>   after the last empty string entry. This ensures setting
>   blame.ignoreRevsFile to "" or passing --ignore-revs-file "" or
>   --no-ignore-revs-file cleanly discards the default blob without
>   attempting to read or parse it, allowing users to bypass a malformed
>   default blob.
>
> Update documentation in blame-options.adoc and config/blame.adoc, and
> add comprehensive test coverage in t8013 for the default blob lookup,
> subdirectory invocations, bare repositories, uncommitted and untracked
> working-tree files, CLI and config overrides, committed symlink
> entries, and comments and whitespace handling.
>
> Based-on-patch-by: Abhijeetsingh Meena <abhijeet040403@gmail.com>
> Helped-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>
> Helped-by: Eric Sunshine <sunshine@sunshineco.com>

These Helped-by: drew my attention as none of these folks commented
on v1 of this series.  I do see they have helped the original series
<pull.1809.v2.git.1728707867.gitgitgadget@gmail.com>, but it is not
clear how much their inputs have survivied to this version.

They are all CC'ed so they can give their Acked-by: or Reviewed-by: 
on this round if they want.


[...]

> diff --git a/builtin/blame.c b/builtin/blame.c
> index 6741a7b9df..a730ee87ea 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;
>  	}

Good, and the log message is clear why we make this change.

> @@ -946,17 +946,52 @@ static int peel_to_commit_oid(struct object_id *oid_ret, void *cbdata)
>  	}
>  }
>  
> +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(the_repository, name, GET_OID_QUIETLY,
> +				 &oid, &oc))
> +		goto out;
> +	if (!S_ISREG(oc.mode))
> +		goto out;
> +
> +	buf = odb_read_object(the_repository->objects, &oid, &type, &size);
> +	if (!buf)
> +		goto out;
> +	if (type == OBJ_BLOB)
> +		oidset_parse_buffer_carefully(&sb->ignore_list, buf, size,
> +					      the_repository->hash_algo,
> +					      peel_to_commit_oid, sb);
> +	free(buf);
> +
> +out:
> +	object_context_release(&oc);
> +}

OK.

>  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;
> +	}

OK, we make two passes, and during the first pass, we find where the
last "empty" entry that signals "forget everything you have seen" is.

>  	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++) {

And we scan starting from there.  Very clean.

> +		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 +1071,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;

Cute.  This takes advantage of the fact that everybody else just
appends to the string_list without populating the .util member.

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

Shouldn't the above refactoring have been part of the previous step
instead?

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eric Sunshine wrote on the Git mailing list (how to reply to this email):

On Fri, Oct 9, 2026 at 4:17 PM Junio C Hamano <gitster@pobox.com> wrote:
> "Ravi Mistry via GitGitGadget" <gitgitgadget@gmail.com> writes:
> > Based-on-patch-by: Abhijeetsingh Meena <abhijeet040403@gmail.com>
> > Helped-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
> > Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>
> > Helped-by: Eric Sunshine <sunshine@sunshineco.com>
>
> These Helped-by: drew my attention as none of these folks commented
> on v1 of this series.  I do see they have helped the original series
> <pull.1809.v2.git.1728707867.gitgitgadget@gmail.com>, but it is not
> clear how much their inputs have survivied to this version.
>
> They are all CC'ed so they can give their Acked-by: or Reviewed-by:
> on this round if they want.

For what it's worth, having skimmed the old thread, if anything did
survive from my reviews/recommendations, it would have been only
extremely minor style fixups.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Junio C Hamano <gitster@pobox.com> writes:

> These Helped-by: drew my attention as none of these folks commented
> on v1 of this series.  I do see they have helped the original series
> <pull.1809.v2.git.1728707867.gitgitgadget@gmail.com>, but it is not
> clear how much their inputs have survivied to this version.

From that earlier thread, Phillip suggested explaining why commit
ae3f36dea1 originally avoided a default file, Kristoffer suggested the
config override model and documenting --no-ignore-revs-file, and as
Eric noted, his comments were minor structural suggestions such as
updating the docs and placing the tests in t8013.

> > +	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);
> >  }
>
> Shouldn't the above refactoring have been part of the previous step
> instead?

I extracted parse_oidset_line() in patch 2/2 because that is where the
second caller (oidset_parse_buffer_carefully()) was added, though
extracting it in patch 1/2 would avoid touching that loop in both
patches. Happy to move this into patch 1/2 if preferred.

Thanks!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Ravi Mistry via GitGitGadget" <gitgitgadget@gmail.com> writes:

> +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(the_repository, name, GET_OID_QUIETLY,
> +				 &oid, &oc))
> +		goto out;
> +	if (!S_ISREG(oc.mode))
> +		goto out;
> +
> +	buf = odb_read_object(the_repository->objects, &oid, &type, &size);
> +	if (!buf)
> +		goto out;
> +	if (type == OBJ_BLOB)
> +		oidset_parse_buffer_carefully(&sb->ignore_list, buf, size,
> +					      the_repository->hash_algo,
> +					      peel_to_commit_oid, sb);
> +	free(buf);
> +
> +out:
> +	object_context_release(&oc);
> +}

Since the scoreboard is passed around throughout the subsystem and
has a pointer to the repository it is working with, a new function
like this one does not have to add new references to the_repository.

I do not think it is ultra urgent, but I suspect that many of
existing references in this file to the_repository happen in
functions that take the scoreboard pointer, and they should be able
to replace the_repository with sb->repo more or less mechanically.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Junio C Hamano <gitster@pobox.com> writes:

> Since the scoreboard is passed around throughout the subsystem and
> has a pointer to the repository it is working with, a new function
> like this one does not have to add new references to the_repository.

Since there is an ongoing effort to reduce uses of the_repository in
the codebase, I went ahead and switched parse_default_ignore_revs_blob()
to use sb->repo in v3.

Thanks!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Ravi Mistry <rmistry@google.com> writes:

> Junio C Hamano <gitster@pobox.com> writes:
>
>> Since the scoreboard is passed around throughout the subsystem and
>> has a pointer to the repository it is working with, a new function
>> like this one does not have to add new references to the_repository.
>
> Since there is an ongoing effort to reduce uses of the_repository in
> the codebase, I went ahead and switched parse_default_ignore_revs_blob()
> to use sb->repo in v3.
>
> Thanks!

Sounds good, and v3 looks good.  Let me mark the topic ready for
'next' unless we hear objections in a few days.

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

On 12/10/2026 00:07, Junio C Hamano wrote:
> Ravi Mistry <rmistry@google.com> writes:
> >> Junio C Hamano <gitster@pobox.com> writes:
>>
>>> Since the scoreboard is passed around throughout the subsystem and
>>> has a pointer to the repository it is working with, a new function
>>> like this one does not have to add new references to the_repository.
>>
>> Since there is an ongoing effort to reduce uses of the_repository in
>> the codebase, I went ahead and switched parse_default_ignore_revs_blob()
>> to use sb->repo in v3.
>>
>> Thanks!
> > Sounds good, and v3 looks good.  Let me mark the topic ready for
> 'next' unless we hear objections in a few days.

I've left a couple of comments on v3. Ignoring uncommitted changes in the file in the working tree seems a bit odd to me and we seem to be prepared to load a file of any size from an untrusted source.

Thanks

Phillip

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Phillip Wood <phillip.wood123@gmail.com> writes:

> I've left a couple of comments on v3. Ignoring uncommitted changes in 
> the file in the working tree seems a bit odd to me and we seem to be 
> prepared to load a file of any size from an untrusted source.

The latter sounds like a sensible thing to worry about.  Thanks for
bringing it up.

I do not have strong opinions either way on the former myself (the
behaviour implemented is to attempt matching what the hosting sites
do, which obviously work from the committed copy).  It indeed feels
more intuitive if the checked out copy (or even the file before it
gets added for the first time) were consulted.

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Junio C Hamano <gitster@pobox.com> writes:

> Phillip Wood <phillip.wood123@gmail.com> writes:
>
> > I've left a couple of comments on v3. Ignoring uncommitted changes in
> > the file in the working tree seems a bit odd to me and we seem to be
> > prepared to load a file of any size from an untrusted source.
>
> The latter sounds like a sensible thing to worry about.  Thanks for
> bringing it up.
>
> I do not have strong opinions either way on the former myself (the
> behaviour implemented is to attempt matching what the hosting sites
> do, which obviously work from the committed copy).  It indeed feels
> more intuitive if the checked out copy (or even the file before it
> gets added for the first time) were consulted.

I have sent v4, which caps the size of the default
HEAD:.git-blame-ignore-revs blob at 100MB (matching ATTR_MAX_FILE_SIZE
from 3c50032ff5 ("attr: ignore overly large gitattributes files",
2022-12-01)) so an oversized blob is skipped with a warning without
being loaded into memory.

For v4, I left the behavior as reading from HEAD:.git-blame-ignore-revs
to match hosting sites for now. Maybe consulting the working tree could
be explored in the future in a separate series if desired, or please let
me know if you or Phillip would prefer to change it in this series.

Thanks!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Ravi Mistry <rmistry@google.com> writes:

> For v4, I left the behavior as reading from HEAD:.git-blame-ignore-revs
> to match hosting sites for now. Maybe consulting the working tree could
> be explored in the future in a separate series if desired, or please let
> me know if you or Phillip would prefer to change it in this series.

It needs to be justified in some way other than "match what the
forges do (which is to use the one from HEAD)", but other than that,
I think it would be more ergonomic to pick up the copy from the
working tree (with local modifications) in non-bare case.

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Junio C Hamano <gitster@pobox.com> writes:

> Ravi Mistry <rmistry@google.com> writes:
> > For v4, I left the behavior as reading from HEAD:.git-blame-ignore-revs
> > to match hosting sites for now. Maybe consulting the working tree could
> > be explored in the future in a separate series if desired, or please let
> > me know if you or Phillip would prefer to change it in this series.
>
> It needs to be justified in some way other than "match what the
> forges do (which is to use the one from HEAD)", but other than that,
> I think it would be more ergonomic to pick up the copy from the
> working tree (with local modifications) in non-bare case.

Makes sense, I did not have a justification for sticking with HEAD other
than matching hosting sites.

I have sent v5, which reads .git-blame-ignore-revs from the root of the
working tree in non-bare repositories (and HEAD:.git-blame-ignore-revs
in bare repositories) and updates the commit message to justify the
behavior on ergonomic grounds.

Thanks!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

Hi Ravi

On 11/10/2026 23:21, Ravi Mistry via GitGitGadget wrote:
> From: Ravi Mistry <rmistry@google.com>
> > git-blame(1) can ignore a list of commits specified via
> --ignore-revs-file or the blame.ignoreRevsFile configuration option.
> This is useful for skipping uninteresting revisions such as tree-wide
> formatting changes, large-scale refactors, and code modernizations that
> would otherwise obscure genuine historical authorship.
> > When revision-ignoring was introduced in commit ae3f36dea1 ("blame: add
> blame.ignoreRevsFile config option", 2019-10-18), it intentionally
> avoided adopting a default ignore file. At the time, the capability was
> new and unproven, so avoiding unrequested filesystem I/O or unexpected
> attribution shifts took priority over a project-wide default.
> Requiring explicit opt-in per clone was therefore the prudent design.
> > Since then, maintaining a .git-blame-ignore-revs file in the repository
> root has become the de facto standard across the Git ecosystem, adopted
> by major hosting platforms (GitHub, GitLab, Gerrit) and prominent open
> source projects (such as Chromium and LLVM). As a consequence,
> developers frequently encounter a jarring mismatch: web interfaces
> seamlessly ignore formatting commits, but local git-blame(1) and
> git-annotate(1) runs do not, unless each user manually configures
> blame.ignoreRevsFile for every local checkout.

This is nicely explained

> Teach git-blame(1) and git-annotate(1) to automatically add the
> HEAD:.git-blame-ignore-revs blob, if it exists, as the initial element
> in the list of ignore-revs files in both bare and non-bare
> repositories. Reading the committed blob from HEAD rather than the
> working tree ensures that local runs match hosting platforms even when
> an untracked .git-blame-ignore-revs file is present or a tracked one
> has uncommitted local changes.

I can see we'd want to use the committed version in a bare repository, but it seems strange to me to ignore uncommitted changes in a non-bare repository. We don't do that for other special files like .gitattributes. It is rather inconvinient if the user has to explicitly pass the file inorder to test the effect of local modification to .git-brame-ignore-revs. To me it would make more sense to automatically use .git-blame-ignore-revs file from the working tree when that path is tracked by git.

I did wonder if we wanted to automatically support .git-blame-ignore-revs in the root of a subtree, but I don't think there is an easy way to do that without visiting all the directories containing tracked files which makes it too expensive.

One final thought - do we want to limit the size of the blob that we're prepared to parse as we do for .gitattributes? I had a very quick look at the khash code which is used by 'struct oidset' and it seems to use 32 bit integers for the number of entries. I did not spend anytime trying to figure out what happens if you try and store 2^32 entires.

Thanks

Phillip

> To ensure consistent precedence and override semantics:
> - The default HEAD:.git-blame-ignore-revs entry is added before reading
>    configuration and CLI options, preserving user and repository config
>    overrides.
> - In git_blame_config(), blame.ignoreRevsFile entries are appended via
>    string_list_append() rather than inserted in sorted order via
>    string_list_insert() so that configuration entries preserve their
>    order relative to the initial default entry.
> - The HEAD:.git-blame-ignore-revs tree entry is resolved quietly via
>    get_oid_with_context(). Its mode is checked with S_ISREG() before
>    reading the object so that non-regular tree entries (such as a
>    committed symbolic link whose blob stores a target path rather than
>    revision IDs, a subdirectory, or a gitlink) are skipped instead of
>    being read and rejected as malformed object names. The blob is parsed
>    in memory via a new oidset_parse_buffer_carefully() helper in
>    oidset.c that shares line parsing with oidset_parse_file_carefully().
> - In build_ignorelist(), ignore-revs entries are processed starting
>    after the last empty string entry. This ensures setting
>    blame.ignoreRevsFile to "" or passing --ignore-revs-file "" or
>    --no-ignore-revs-file cleanly discards the default blob without
>    attempting to read or parse it, allowing users to bypass a malformed
>    default blob.
> > Update documentation in blame-options.adoc and config/blame.adoc, and
> add comprehensive test coverage in t8013 for the default blob lookup,
> subdirectory invocations, bare repositories, uncommitted and untracked
> working-tree files, CLI and config overrides, committed symlink
> entries, and comments and whitespace handling.
> > Based-on-patch-by: Abhijeetsingh Meena <abhijeet040403@gmail.com>
> Helped-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>
> Helped-by: Eric Sunshine <sunshine@sunshineco.com>
> Signed-off-by: Ravi Mistry <rmistry@google.com>
> ---
>   Documentation/blame-options.adoc |   8 +-
>   Documentation/config/blame.adoc  |   9 +-
>   builtin/blame.c                  |  45 +++++++-
>   oidset.c                         |  84 +++++++++-----
>   oidset.h                         |   9 ++
>   t/t8013-blame-ignore-revs.sh     | 182 +++++++++++++++++++++++++++++++
>   6 files changed, 301 insertions(+), 36 deletions(-)
> > 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 <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
> 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..f5b944f182 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,52 @@ static int peel_to_commit_oid(struct object_id *oid_ret, void *cbdata)
>   	}
>   }
>   > +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;
> +
> +	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 +1071,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..3e1b5291aa 100755
> --- a/t/t8013-blame-ignore-revs.sh
> +++ b/t/t8013-blame-ignore-revs.sh
> @@ -365,4 +365,186 @@ 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' '
> +	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_done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Hi Phillip!

Phillip Wood <phillip.wood123@gmail.com> writes:

> I can see we'd want to use the committed version in a bare repository,
> but it seems strange to me to ignore uncommitted changes in a non-bare
> repository. We don't do that for other special files like
> .gitattributes. It is rather inconvinient if the user has to explicitly
> pass the file inorder to test the effect of local modification to
> .git-brame-ignore-revs. To me it would make more sense to automatically
> use .git-blame-ignore-revs file from the working tree when that path is
> tracked by git.

In v1, this patch originally read .git-blame-ignore-revs from the
working tree in non-bare repositories. I switched to reading the
HEAD:.git-blame-ignore-revs blob in v2 based on Junio's review and
suggestions on v1
(https://lore.kernel.org/git/xmqqse2kma4o.fsf@gitster.g/).

> I did wonder if we wanted to automatically support
> .git-blame-ignore-revs in the root of a subtree, but I don't think there
> is an easy way to do that without visiting all the directories
> containing tracked files which makes it too expensive.

Agreed. By convention across the ecosystem (GitHub, GitLab, and soon
Gerrit and Gitiles), .git-blame-ignore-revs is read from the root of
the repository, so only looking at the root keeps it simple and avoids
the overhead of walking subdirectories.

> One final thought - do we want to limit the size of the blob that we're
> prepared to parse as we do for .gitattributes? I had a very quick look
> at the khash code which is used by 'struct oidset' and it seems to use
> 32 bit integers for the number of entries. I did not spend anytime
> trying to figure out what happens if you try and store 2^32 entires.

In parse_oidset_line(), peel_to_commit_oid() runs before
oidset_insert() and skips any OID that is not an existing local commit
in the repository, and duplicate OIDs hash to the same entry. So the
oidset (and khash) will not have more entries than the number of local
commits in the repository. That said, if you and Junio would like a
blob size cap in parse_default_ignore_revs_blob() (similar to
ATTR_MAX_FILE_SIZE), I am happy to add one.

Thanks!

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 `.git-blame-ignore-revs` file at the root of
the working tree (or `HEAD:.git-blame-ignore-revs` in a bare repository),
if it exists, and any files specified with the `blame.ignoreRevsFile`
configuration variable. An empty file name, `""`, or
`--no-ignore-revs-file` will clear the list of revs from previously
processed files, including the default ignore-revs file.

`--color-lines`::
Color line annotations in the default format differently if they come from
Expand Down
10 changes: 7 additions & 3 deletions Documentation/config/blame.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ 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 `.git-blame-ignore-revs` exists at the root of the
working tree (or `HEAD:.git-blame-ignore-revs` in a bare repository), 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
Expand Down
122 changes: 116 additions & 6 deletions builtin/blame.c
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
#include "object-name.h"
#include "odb.h"
#include "pager.h"
#include "path.h"
#include "blame.h"
#include "refs.h"
#include "setup.h"
Expand Down Expand Up @@ -769,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;
}
Expand Down Expand Up @@ -911,36 +912,139 @@ static int is_a_rev(const char *name)
static int peel_to_commit_oid(struct object_id *oid_ret, void *cbdata)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Ravi Mistry via GitGitGadget" <gitgitgadget@gmail.com> writes:

> - In oidset_parse_file_carefully(), strbuf_getline() reads up to the
>   next newline and records the full line length in sb.len, including
>   any embedded NUL bytes. However, strchr(sb.buf, '#') and
>   parse_oid_hex_algop(sb.buf, &oid, &p, algop) treat sb.buf as a
>   NUL-terminated string. If a line contains an embedded NUL byte after
>   a valid object name (such as "<oid>\0garbage" or "<oid>\0# comment"),
>   *p is '\0' and trailing bytes on the line are silently ignored.
>   Reject any line containing an embedded NUL byte via memchr() before
>   stripping comments and whitespace.

Maybe I am slow, but I do not immediately see why ignoring
everything after the first NUL is a problem.  A call to
strbuf_trim() is ineffective at trimming whitespace that appears
immediately before such a NUL.  For example, while

    cf9bdb1...f6092d # comment LF

would feed the leading 'cf9bdb1...f6092d' part (after stripping
whitespace before '#') to parse_oid_hex_algop(), this

    cf9bdb1...f6092d NUL comment LF

would keep the whitespace after '92d' and cause the parsing to
fail.  I do not see any security implications here.

On the other hand ...

> - In peel_to_commit_oid(), odb_read_object_info() is called without
>   OBJECT_INFO_SKIP_FETCH_OBJECT or OBJECT_INFO_QUICK, and deref_tag()
>   calls parse_object() on tag targets without checking whether the
>   target object exists locally first. In a partial clone, any missing
>   commit OID or tag target listed in the ignore-revs file would trigger
>   lazy promisor fetches and pack directory rescans during git-blame(1).
>   Use odb_read_object_info_extended() with OBJECT_INFO_LOOKUP_REPLACE |
>   OBJECT_INFO_SKIP_FETCH_OBJECT | OBJECT_INFO_QUICK and peel OBJ_TAG
>   objects one layer per iteration, verifying that each target object
>   exists locally and matches the tag's declared type before parsing it.

... this may be a very reasonable thing to do, I would think.  In a
shallow clone, if we are not auto-deepening the shallow boundary
during a "git blame" session, we have no reason to lazy fetch
entries in the ignore file that are older than the shallow boundary.

> diff --git a/oidset.c b/oidset.c
> index c8ff0b385c..90d39204d3 100644
> --- a/oidset.c
> +++ b/oidset.c
> @@ -85,6 +85,9 @@ void oidset_parse_file_carefully(struct oidset *set, const char *path,
>  		const char *p;
>  		const char *name;
>  
> +		if (memchr(sb.buf, '\0', sb.len))
> +			die("invalid object name: %s", sb.buf);

A file with such an entry is rejected and the entire operation is
aborted as suspected attack attempt, which feels like striking the
balance between usability and security at a wrong place.

But a line with broken object name already is rejected with "die()"
with the existing code, so it may be OK.

> diff --git a/t/t8013-blame-ignore-revs.sh b/t/t8013-blame-ignore-revs.sh
> index cace00ae8d..70fe509a64 100755
> --- a/t/t8013-blame-ignore-revs.sh
> +++ b/t/t8013-blame-ignore-revs.sh
> @@ -327,4 +327,42 @@ 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
> +'
> +
>  test_done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ravi Mistry wrote on the Git mailing list (how to reply to this email):

Junio C Hamano <gitster@pobox.com> writes:

> Maybe I am slow, but I do not immediately see why ignoring
> everything after the first NUL is a problem.  A call to
> strbuf_trim() is ineffective at trimming whitespace that appears
> immediately before such a NUL.  For example, while
>
>     cf9bdb1...f6092d # comment LF
>
> would feed the leading 'cf9bdb1...f6092d' part (after stripping
> whitespace before '#') to parse_oid_hex_algop(), this
>
>     cf9bdb1...f6092d NUL comment LF
>
> would keep the whitespace after '92d' and cause the parsing to
> fail.  I do not see any security implications here.

> > +		if (memchr(sb.buf, '\0', sb.len))
> > +			die("invalid object name: %s", sb.buf);
>
> A file with such an entry is rejected and the entire operation is
> aborted as suspected attack attempt, which feels like striking the
> balance between usability and security at a wrong place.
>
> But a line with broken object name already is rejected with "die()"
> with the existing code, so it may be OK.

Agreed, there is no security implication there. Rejecting
"<oid>\0garbage" (when there is no space before the NUL) was only for
consistency with how other non-comment trailing garbage on a line is
rejected with die(). If we end up rerolling the series, I am happy to
drop the NUL check if you prefer.

Thanks!

{
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 *path)
{
char *name = xstrfmt("HEAD:%s", path);
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);
free(name);
}

static void parse_default_ignore_revs_file(struct blame_scoreboard *sb,
const char *name)
{
char *path = repo_worktree_path(sb->repo, "%s", name);
struct stat st;
int fd = -1;
size_t size;
char *buf = NULL;

if (lstat(path, &st) || !S_ISREG(st.st_mode))
goto out;
fd = open_nofollow(path, O_RDONLY);
if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode))
goto out;
if (st.st_size >= IGNORE_REVS_MAX_FILE_SIZE) {
warning(_("ignoring overly large ignore-revs file '%s'"), name);
goto out;
}
size = xsize_t(st.st_size);
buf = xmalloc(size);
if (read_in_full(fd, buf, size) == (ssize_t)size)
oidset_parse_buffer_carefully(&sb->ignore_list, buf, size,
sb->repo->hash_algo,
peel_to_commit_oid, sb);

out:
if (fd >= 0)
close(fd);
free(buf);
free(path);
}

static void parse_default_ignore_revs(struct blame_scoreboard *sb,
const char *name)
{
if (repo_get_work_tree(sb->repo))
parse_default_ignore_revs_file(sb, name);
else
parse_default_ignore_revs_blob(sb, name);
}

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(sb, i->string);
else
oidset_parse_file_carefully(&sb->ignore_list, i->string,
the_repository->hash_algo,
Expand Down Expand Up @@ -1020,6 +1124,12 @@ int cmd_blame(int argc,
const char *const *opt_usage = cmd_is_annotate ? annotate_opt_usage : blame_opt_usage;

setup_default_color_by_age();
/*
* An entry with a non-NULL .util member names the default
* ignore-revs file at the project root; see build_ignorelist().
*/
string_list_append(&ignore_revs_file_list,
".git-blame-ignore-revs")->util = (void *)1;
repo_config(the_repository, git_blame_config, &output_option);
repo_init_revisions(the_repository, &revs, NULL);
revs.date_mode = blame_date_mode;
Expand Down
81 changes: 58 additions & 23 deletions oidset.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
9 changes: 9 additions & 0 deletions oidset.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading