Skip to content

Commit ed64f1c

Browse files
KN4CK3Rwxiaoguang
andauthored
Support .git-blame-ignore-revs file (#26395)
Closes #26329 This PR adds the ability to ignore revisions specified in the `.git-blame-ignore-revs` file in the root of the repository. ![grafik](https://github.com/go-gitea/gitea/assets/1666336/9e91be0c-6e9c-431c-bbe9-5f80154251c8) The banner is displayed in this case. I intentionally did not add a UI way to bypass the ignore file (same behaviour as Github) but you can add `?bypass-blame-ignore=true` to the url manually. --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent c766140 commit ed64f1c

19 files changed

+306
-52
lines changed

docs/content/usage/blame.en-us.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
date: "2023-08-14T00:00:00+00:00"
3+
title: "Blame File View"
4+
slug: "blame"
5+
sidebar_position: 13
6+
toc: false
7+
draft: false
8+
aliases:
9+
- /en-us/blame
10+
menu:
11+
sidebar:
12+
parent: "usage"
13+
name: "Blame"
14+
sidebar_position: 13
15+
identifier: "blame"
16+
---
17+
18+
# Blame File View
19+
20+
Gitea supports viewing the line-by-line revision history for a file also known as blame view.
21+
You can also use [`git blame`](https://git-scm.com/docs/git-blame) on the command line to view the revision history of lines within a file.
22+
23+
1. Navigate to and open the file whose line history you want to view.
24+
1. Click the `Blame` button in the file header bar.
25+
1. The new view shows the line-by-line revision history for a file with author and commit information on the left side.
26+
1. To navigate to an older commit, click the ![versions](/octicon-versions.svg) icon.
27+
28+
## Ignore commits in the blame view
29+
30+
All revisions specified in the `.git-blame-ignore-revs` file are hidden from the blame view.
31+
This is especially useful to hide reformatting changes and keep the benefits of `git blame`.
32+
Lines that were changed or added by an ignored commit will be blamed on the previous commit that changed that line or nearby lines.
33+
The `.git-blame-ignore-revs` file must be located in the root directory of the repository.
34+
For more information like the file format, see [the `git blame --ignore-revs-file` documentation](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt).
35+
36+
### Bypassing `.git-blame-ignore-revs` in the blame view
37+
38+
If the blame view for a file shows a message about ignored revisions, you can see the normal blame view by appending the url parameter `?bypass-blame-ignore=true`.

docs/static/octicon-versions.svg

Lines changed: 1 addition & 0 deletions
Loading

modules/git/blame.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"regexp"
1414

1515
"code.gitea.io/gitea/modules/log"
16+
"code.gitea.io/gitea/modules/util"
1617
)
1718

1819
// BlamePart represents block of blame - continuous lines with one sha
@@ -23,12 +24,16 @@ type BlamePart struct {
2324

2425
// BlameReader returns part of file blame one by one
2526
type BlameReader struct {
26-
cmd *Command
2727
output io.WriteCloser
2828
reader io.ReadCloser
2929
bufferedReader *bufio.Reader
3030
done chan error
3131
lastSha *string
32+
ignoreRevsFile *string
33+
}
34+
35+
func (r *BlameReader) UsesIgnoreRevs() bool {
36+
return r.ignoreRevsFile != nil
3237
}
3338

3439
var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
@@ -101,28 +106,44 @@ func (r *BlameReader) Close() error {
101106
r.bufferedReader = nil
102107
_ = r.reader.Close()
103108
_ = r.output.Close()
109+
if r.ignoreRevsFile != nil {
110+
_ = util.Remove(*r.ignoreRevsFile)
111+
}
104112
return err
105113
}
106114

107115
// CreateBlameReader creates reader for given repository, commit and file
108-
func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*BlameReader, error) {
109-
cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain").
110-
AddDynamicArguments(commitID).
116+
func CreateBlameReader(ctx context.Context, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
117+
var ignoreRevsFile *string
118+
if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore {
119+
ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
120+
}
121+
122+
cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain")
123+
if ignoreRevsFile != nil {
124+
// Possible improvement: use --ignore-revs-file /dev/stdin on unix
125+
// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
126+
cmd.AddOptionValues("--ignore-revs-file", *ignoreRevsFile)
127+
}
128+
cmd.AddDynamicArguments(commit.ID.String()).
111129
AddDashesAndList(file).
112130
SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath))
113131
reader, stdout, err := os.Pipe()
114132
if err != nil {
133+
if ignoreRevsFile != nil {
134+
_ = util.Remove(*ignoreRevsFile)
135+
}
115136
return nil, err
116137
}
117138

118139
done := make(chan error, 1)
119140

120-
go func(cmd *Command, dir string, stdout io.WriteCloser, done chan error) {
141+
go func() {
121142
stderr := bytes.Buffer{}
122143
// TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
123144
err := cmd.Run(&RunOpts{
124145
UseContextTimeout: true,
125-
Dir: dir,
146+
Dir: repoPath,
126147
Stdout: stdout,
127148
Stderr: &stderr,
128149
})
@@ -131,15 +152,42 @@ func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*B
131152
if err != nil {
132153
log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
133154
}
134-
}(cmd, repoPath, stdout, done)
155+
}()
135156

136157
bufferedReader := bufio.NewReader(reader)
137158

138159
return &BlameReader{
139-
cmd: cmd,
140160
output: stdout,
141161
reader: reader,
142162
bufferedReader: bufferedReader,
143163
done: done,
164+
ignoreRevsFile: ignoreRevsFile,
144165
}, nil
145166
}
167+
168+
func tryCreateBlameIgnoreRevsFile(commit *Commit) *string {
169+
entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
170+
if err != nil {
171+
return nil
172+
}
173+
174+
r, err := entry.Blob().DataAsync()
175+
if err != nil {
176+
return nil
177+
}
178+
defer r.Close()
179+
180+
f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs")
181+
if err != nil {
182+
return nil
183+
}
184+
185+
_, err = io.Copy(f, r)
186+
_ = f.Close()
187+
if err != nil {
188+
_ = util.Remove(f.Name())
189+
return nil
190+
}
191+
192+
return util.ToPointer(f.Name())
193+
}

modules/git/blame_test.go

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,127 @@ func TestReadingBlameOutput(t *testing.T) {
1414
ctx, cancel := context.WithCancel(context.Background())
1515
defer cancel()
1616

17-
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo5_pulls", "f32b0a9dfd09a60f616f29158f772cedd89942d2", "README.md")
18-
assert.NoError(t, err)
19-
defer blameReader.Close()
20-
21-
parts := []*BlamePart{
22-
{
23-
"72866af952e98d02a73003501836074b286a78f6",
24-
[]string{
25-
"# test_repo",
26-
"Test repository for testing migration from github to gitea",
27-
},
28-
},
29-
{
30-
"f32b0a9dfd09a60f616f29158f772cedd89942d2",
31-
[]string{"", "Do not make any changes to this repo it is used for unit testing"},
32-
},
33-
}
34-
35-
for _, part := range parts {
36-
actualPart, err := blameReader.NextPart()
17+
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
18+
repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
3719
assert.NoError(t, err)
38-
assert.Equal(t, part, actualPart)
39-
}
20+
defer repo.Close()
21+
22+
commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2")
23+
assert.NoError(t, err)
24+
25+
parts := []*BlamePart{
26+
{
27+
"72866af952e98d02a73003501836074b286a78f6",
28+
[]string{
29+
"# test_repo",
30+
"Test repository for testing migration from github to gitea",
31+
},
32+
},
33+
{
34+
"f32b0a9dfd09a60f616f29158f772cedd89942d2",
35+
[]string{"", "Do not make any changes to this repo it is used for unit testing"},
36+
},
37+
}
38+
39+
for _, bypass := range []bool{false, true} {
40+
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo5_pulls", commit, "README.md", bypass)
41+
assert.NoError(t, err)
42+
assert.NotNil(t, blameReader)
43+
defer blameReader.Close()
44+
45+
assert.False(t, blameReader.UsesIgnoreRevs())
46+
47+
for _, part := range parts {
48+
actualPart, err := blameReader.NextPart()
49+
assert.NoError(t, err)
50+
assert.Equal(t, part, actualPart)
51+
}
52+
53+
// make sure all parts have been read
54+
actualPart, err := blameReader.NextPart()
55+
assert.Nil(t, actualPart)
56+
assert.NoError(t, err)
57+
}
58+
})
59+
60+
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
61+
repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame")
62+
assert.NoError(t, err)
63+
defer repo.Close()
64+
65+
full := []*BlamePart{
66+
{
67+
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
68+
[]string{"line", "line"},
69+
},
70+
{
71+
"45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
72+
[]string{"changed line"},
73+
},
74+
{
75+
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
76+
[]string{"line", "line", ""},
77+
},
78+
}
79+
80+
cases := []struct {
81+
CommitID string
82+
UsesIgnoreRevs bool
83+
Bypass bool
84+
Parts []*BlamePart
85+
}{
86+
{
87+
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
88+
UsesIgnoreRevs: true,
89+
Bypass: false,
90+
Parts: []*BlamePart{
91+
{
92+
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
93+
[]string{"line", "line", "changed line", "line", "line", ""},
94+
},
95+
},
96+
},
97+
{
98+
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
99+
UsesIgnoreRevs: false,
100+
Bypass: true,
101+
Parts: full,
102+
},
103+
{
104+
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
105+
UsesIgnoreRevs: false,
106+
Bypass: false,
107+
Parts: full,
108+
},
109+
{
110+
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
111+
UsesIgnoreRevs: false,
112+
Bypass: false,
113+
Parts: full,
114+
},
115+
}
116+
117+
for _, c := range cases {
118+
commit, err := repo.GetCommit(c.CommitID)
119+
assert.NoError(t, err)
120+
121+
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass)
122+
assert.NoError(t, err)
123+
assert.NotNil(t, blameReader)
124+
defer blameReader.Close()
125+
126+
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
127+
128+
for _, part := range c.Parts {
129+
actualPart, err := blameReader.NextPart()
130+
assert.NoError(t, err)
131+
assert.Equal(t, part, actualPart)
132+
}
133+
134+
// make sure all parts have been read
135+
actualPart, err := blameReader.NextPart()
136+
assert.Nil(t, actualPart)
137+
assert.NoError(t, err)
138+
}
139+
})
40140
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ref: refs/heads/master
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[core]
2+
repositoryformatversion = 0
3+
filemode = true
4+
bare = true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
544d8f7a3b15927cddf2299b4b562d6ebd71b6a7

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,8 @@ delete_preexisting = Delete pre-existing files
10071007
delete_preexisting_content = Delete files in %s
10081008
delete_preexisting_success = Deleted unadopted files in %s
10091009
blame_prior = View blame prior to this change
1010+
blame.ignore_revs = Ignoring revisions in <a href="%s">.git-blame-ignore-revs</a>. Click <a href="%s">here to bypass</a> and see the normal blame view.
1011+
blame.ignore_revs.failed = Failed to ignore revisions in <a href="%s">.git-blame-ignore-revs</a>.
10101012
author_search_tooltip = Shows a maximum of 30 users
10111013
10121014
transfer.accept = Accept Transfer

0 commit comments

Comments
 (0)