From: Aleksei Sviridkin <f@lex.la>
To: git@vger.kernel.org
Cc: Aleksei Sviridkin <f@lex.la>
Subject: [PATCH] git-contacts: ignore blame boundary commits
Date: Thu, 3 Sep 2026 15:55:27 +0300 [thread overview]
Message-ID: <20260903125527.67934-1-f@lex.la> (raw)
git-contacts asks "git blame" which commits last touched the lines a
patch modifies, limiting the annotation to the last five years. Blame
charges lines that did not change inside a limited range to the commit
where its traversal stopped and marks that entry with a "boundary"
line in the porcelain output, which the parser has ignored since
4d06402b1b (contrib: add git-contacts helper, 2013-07-21). The
boundary commit is therefore imported like any other, and its author
and the people named in its trailers end up in the list.
Such a commit usually did not touch the file at all. Blaming a change
to builtin/receive-pack.c stops on a commit that only touched
builtin/fetch.c, and its author is proposed as a reviewer while the
people who actually wrote the lines are left out, as the age limit
intends. Those commits also pad the commit count each name is weighed
against, hiding real contacts under the ten percent threshold. Over
the hundred non-merge commits below 3cb9185f65 (The 22nd batch,
2026-09-02), twenty-seven lists lose a name, nine of them becoming
empty because nothing inside the window touched the lines, and three
gain a name the padding had hidden.
Read each blame run to the end before deciding, and register only the
commits it did not mark as a boundary, so that a commit stays a
contact as long as some hunk is really blamed on it. Ask for --root as
well, because blame marks the initial commit of a repository as a
boundary too, and in a repository younger than the window that commit
did write the lines it is blamed for. The cut-off commit of a shallow
clone is parentless in the same way, so it still passes as a contact
inside the window, a wrong answer this change leaves alone. The manual
page says that every commit "git blame" mentions is consulted, so note
the exception there as well.
Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
---
Notes:
Notes for the list, deliberately kept out of the commit message.
Where this is written down, since it bears on how the bug survived.
git-blame(1) documents the behaviour under SPECIFYING RANGES: lines
that have not changed since the range boundary are blamed for that
range boundary commit, with --since=3.weeks given as the example. So
the script has been relying on documented blame behaviour and reading
it wrongly, not fighting blame. The porcelain marker itself is not
documented anywhere: THE PORCELAIN FORMAT lists author, committer,
filename and summary, and mentions neither boundary nor previous.
That is the likely reason the field was ignored in 2013.
Why no --is-shallow-repository gate was added. In a shallow clone
blame does attribute the truncated history, and it attributes it to
the cut-off commit, which is grafted parentless. The marking is
decided in blame.c: a commit is reported as a boundary when it
carries UNINTERESTING, which is set either because its date is older
than --since or because it has no parents and --root was not given.
Since --root is now always passed, the age limit is in practice the
only source of boundary marks this script can still see.
Asking for --root therefore spares the shallow cut-off commit, so it
stays a contact, and it stays exactly as false a contact as it was
before this change. Gating on git rev-parse --is-shallow-repository
would only let the script pick a different wrong answer, and picking
one is a separate decision from the boundary bug fixed here.
On the window condition in that sentence. The age test runs before
the root test, so --root cannot rescue a cut-off commit that is
already older than five years, and such a commit is skipped like any
other out-of-window commit. Checked both ways on a four-commit
repository shallow-cloned at depth two: with the cut-off dated 2012
the old script offers it and this one does not, and with the cut-off
inside the window both scripts offer it. That is what a non-shallow
repository does with an equally old commit, and it is what the age
limit is for.
Why the skip is per blame run rather than global. %blamed and
%boundary are lexicals inside get_blame() and are rebuilt on every
call, while %seen stays file-scoped. One hunk's blame may stop on a
commit that another hunk's blame attributes real lines to.
Accumulating boundary commits across runs would suppress the second
attribution too, dropping a commit that genuinely wrote part of the
patch because an unrelated hunk happened to end on it. Per-run
state registers the commit as soon as any single run blames it for
real.
Running the script on this patch demonstrates the change, and the
result looks alarming until you work it out. The script itself was
last modified in 2017, so nothing inside the five-year window touched
the lines this patch changes. The current script prints one name,
the author of whatever commit sits at the five-year mark, and the
patched script prints nothing at all. The empty list is the honest
answer: inside the window there is no contact to offer, and the name
the current script offers was never one.
Falling back to the boundary commit when the list comes out empty was
considered and rejected. Under git send-email --cc-cmd an empty list
means no Cc line, but the boundary commit is a stranger by
construction, so a wrong Cc is worse than none, and the list address
itself does not come from this script.
The three lists that gain a name gain it because the denominator
shrinks. For 429dd07aa0 the current script weighs each name against
eleven commits, one of them a boundary, and Robin Jarry lands at one
mention in eleven, 9.1 percent, just under the threshold. Drop the
boundary commit and the same single mention is one in ten, and he is
printed.
The 100 commits measured are master's history rather than maint's,
which is where the recent traffic is. The proportions do not move
elsewhere. Over maint's own last 100 non-merge commits: 29 lists
change, 24 lose a name, 8 of those go empty, 5 gain one. Over 300
commits of master: 88, 76, 27 and 12. In every sample the emptied
lists are a subset of the ones that lost a name, and no list both
loses and gains.
An unrelated defect in the same file, left alone on purpose. Every
object-name regex in the script demands exactly forty hex
characters:
if ($line =~ /^([0-9a-f]{40}) commit (\d+)/) {
if (/^([0-9a-f]{40}) \d+ \d+ \d+$/) {
if (/^From ([0-9a-f]{40}) Mon Sep 17 00:00:00 2001$/) {
In a SHA-256 repository the object names are sixty-four characters,
nothing matches, and the script prints an empty list and exits
successfully. This is also why the boundary branch added here tests
defined($cur) first: the group header does not match, so $cur is
never set, while the boundary line itself still matches and would
otherwise index the hash with undef and warn under use warnings. The
guard keeps such a repository as silent as it was before this patch
rather than half-fixing it. Behaviour today:
$ git init --object-format=sha256 r && cd r
$ ... two commits, each carrying a Signed-off-by ...
$ git contacts 'HEAD^!'
$ echo $?
0
That silence is there before this patch as well, so it is not a
regression, and repairing it means all three regexes at once. It
wants its own patch.
A second one, noted for the same reason. Three subprocess pipes are
closed without checking the exit status -- in get_blame(),
parse_rev_args() and scan_rev_args() -- while import_commits() and
mailmap_contacts() both die on a non-zero $?. A blame that fails is
therefore indistinguishable from a blame that found nothing. This
patch does sharpen the consequence without causing it: an empty list
is now a legitimate answer, so the silent-failure case and the
correct case look alike where before an empty list was already
suspicious. Still older than this patch, and still not repaired
here.
A third, and the cheapest of them. The final loop prints keys
%$contacts unsorted, so Perl's hash randomisation reorders the output
between runs: five runs over the same commit here produced five
different orderings of the same four names. That makes the output
awkward to diff and awkward to pin in a test, which matters if anyone
wants to add the test surface this directory has never had. One sort
fixes it. This patch does not go near the print loop.
Based on maint rather than master: the behaviour has been wrong in
released versions since 4d06402b1b in 2013, and SubmittingPatches asks
for the oldest integration branch a change is relevant to.
contrib/contacts/git-contacts | 16 +++++++++++-----
contrib/contacts/git-contacts.adoc | 12 +++++++-----
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/contrib/contacts/git-contacts b/contrib/contacts/git-contacts
index 85ad732fc0..25a918ae92 100755
--- a/contrib/contacts/git-contacts
+++ b/contrib/contacts/git-contacts
@@ -62,18 +62,24 @@ sub get_blame {
my ($commits, $source, $from, $ranges) = @_;
return unless @$ranges;
open my $f, '-|',
- qw(git blame --porcelain -C),
+ qw(git blame --porcelain -C --root),
map({"-L$_->[0],+$_->[1]"} @$ranges),
'--since', $since, "$from^", '--', $source or die;
+ my ($cur, %blamed, %boundary);
while (<$f>) {
if (/^([0-9a-f]{40}) \d+ \d+ \d+$/) {
- my $id = $1;
- $commits->{$id} = { id => $id, contacts => {} }
- unless $seen{$id};
- $seen{$id} = 1;
+ $cur = $1;
+ $blamed{$cur} = 1;
+ } elsif (defined($cur) && /^boundary$/) {
+ $boundary{$cur} = 1;
}
}
close $f;
+ for my $id (keys %blamed) {
+ next if $boundary{$id} || $seen{$id};
+ $commits->{$id} = { id => $id, contacts => {} };
+ $seen{$id} = 1;
+ }
}
sub blame_sources {
diff --git a/contrib/contacts/git-contacts.adoc b/contrib/contacts/git-contacts.adoc
index dd914d1261..725e2be6f4 100644
--- a/contrib/contacts/git-contacts.adoc
+++ b/contrib/contacts/git-contacts.adoc
@@ -37,11 +37,13 @@ DISCUSSION
`git blame` is invoked for each hunk in a patch file or revision. For each
commit mentioned by `git blame`, the commit message is consulted for people who
-authored, reviewed, signed, acknowledged, or were Cc:'d. Once the list of
-participants is known, each person's relevance is computed by considering how
-many commits mentioned that person compared with the total number of commits
-under consideration. The final output consists only of participants who exceed
-a minimum threshold of participation.
+authored, reviewed, signed, acknowledged, or were Cc:'d. Commits that `git
+blame` marks as a boundary of its search are skipped, since they need not have
+touched the lines at all, so a patch may end up with no participants. Once the
+list of participants is known, each person's relevance is computed by
+considering how many commits mentioned that person compared with the total
+number of commits under consideration. The final output consists only of
+participants who exceed a minimum threshold of participation.
OUTPUT
base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc
--
2.55.0
reply other threads:[~2026-09-03 12:55 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260903125527.67934-1-f@lex.la \
--to=f@lex.la \
--cc=git@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox