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 <wxiaoguang@gmail.com>
This commit is contained in:
KN4CK3R 2023-09-16 19:42:34 +02:00 committed by GitHub
parent c766140dad
commit ed64f1c2b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 306 additions and 52 deletions

View File

@ -0,0 +1,38 @@
---
date: "2023-08-14T00:00:00+00:00"
title: "Blame File View"
slug: "blame"
sidebar_position: 13
toc: false
draft: false
aliases:
- /en-us/blame
menu:
sidebar:
parent: "usage"
name: "Blame"
sidebar_position: 13
identifier: "blame"
---
# Blame File View
Gitea supports viewing the line-by-line revision history for a file also known as blame view.
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.
1. Navigate to and open the file whose line history you want to view.
1. Click the `Blame` button in the file header bar.
1. The new view shows the line-by-line revision history for a file with author and commit information on the left side.
1. To navigate to an older commit, click the ![versions](/octicon-versions.svg) icon.
## Ignore commits in the blame view
All revisions specified in the `.git-blame-ignore-revs` file are hidden from the blame view.
This is especially useful to hide reformatting changes and keep the benefits of `git blame`.
Lines that were changed or added by an ignored commit will be blamed on the previous commit that changed that line or nearby lines.
The `.git-blame-ignore-revs` file must be located in the root directory of the repository.
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).
### Bypassing `.git-blame-ignore-revs` in the blame view
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`.

1
docs/static/octicon-versions.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M7.75 14A1.75 1.75 0 0 1 6 12.25v-8.5C6 2.784 6.784 2 7.75 2h6.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14Zm-.25-1.75c0 .138.112.25.25.25h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25h-6.5a.25.25 0 0 0-.25.25ZM4.9 3.508a.75.75 0 0 1-.274 1.025.249.249 0 0 0-.126.217v6.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.75 1.75 0 0 1 3 11.25v-6.5c0-.649.353-1.214.874-1.516a.75.75 0 0 1 1.025.274ZM1.625 5.533h.001a.249.249 0 0 0-.126.217v4.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 0 10.25v-4.5a1.748 1.748 0 0 1 .873-1.516.75.75 0 1 1 .752 1.299Z"></path></svg>

After

Width:  |  Height:  |  Size: 696 B

View File

@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
) )
// BlamePart represents block of blame - continuous lines with one sha // BlamePart represents block of blame - continuous lines with one sha
@ -23,12 +24,16 @@ type BlamePart struct {
// BlameReader returns part of file blame one by one // BlameReader returns part of file blame one by one
type BlameReader struct { type BlameReader struct {
cmd *Command
output io.WriteCloser output io.WriteCloser
reader io.ReadCloser reader io.ReadCloser
bufferedReader *bufio.Reader bufferedReader *bufio.Reader
done chan error done chan error
lastSha *string lastSha *string
ignoreRevsFile *string
}
func (r *BlameReader) UsesIgnoreRevs() bool {
return r.ignoreRevsFile != nil
} }
var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})") var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
@ -101,28 +106,44 @@ func (r *BlameReader) Close() error {
r.bufferedReader = nil r.bufferedReader = nil
_ = r.reader.Close() _ = r.reader.Close()
_ = r.output.Close() _ = r.output.Close()
if r.ignoreRevsFile != nil {
_ = util.Remove(*r.ignoreRevsFile)
}
return err return err
} }
// CreateBlameReader creates reader for given repository, commit and file // CreateBlameReader creates reader for given repository, commit and file
func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*BlameReader, error) { func CreateBlameReader(ctx context.Context, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain"). var ignoreRevsFile *string
AddDynamicArguments(commitID). if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore {
ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
}
cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain")
if ignoreRevsFile != nil {
// Possible improvement: use --ignore-revs-file /dev/stdin on unix
// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
cmd.AddOptionValues("--ignore-revs-file", *ignoreRevsFile)
}
cmd.AddDynamicArguments(commit.ID.String()).
AddDashesAndList(file). AddDashesAndList(file).
SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath)) SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath))
reader, stdout, err := os.Pipe() reader, stdout, err := os.Pipe()
if err != nil { if err != nil {
if ignoreRevsFile != nil {
_ = util.Remove(*ignoreRevsFile)
}
return nil, err return nil, err
} }
done := make(chan error, 1) done := make(chan error, 1)
go func(cmd *Command, dir string, stdout io.WriteCloser, done chan error) { go func() {
stderr := bytes.Buffer{} stderr := bytes.Buffer{}
// 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" // 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"
err := cmd.Run(&RunOpts{ err := cmd.Run(&RunOpts{
UseContextTimeout: true, UseContextTimeout: true,
Dir: dir, Dir: repoPath,
Stdout: stdout, Stdout: stdout,
Stderr: &stderr, Stderr: &stderr,
}) })
@ -131,15 +152,42 @@ func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*B
if err != nil { if err != nil {
log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String()) log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
} }
}(cmd, repoPath, stdout, done) }()
bufferedReader := bufio.NewReader(reader) bufferedReader := bufio.NewReader(reader)
return &BlameReader{ return &BlameReader{
cmd: cmd,
output: stdout, output: stdout,
reader: reader, reader: reader,
bufferedReader: bufferedReader, bufferedReader: bufferedReader,
done: done, done: done,
ignoreRevsFile: ignoreRevsFile,
}, nil }, nil
} }
func tryCreateBlameIgnoreRevsFile(commit *Commit) *string {
entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
if err != nil {
return nil
}
r, err := entry.Blob().DataAsync()
if err != nil {
return nil
}
defer r.Close()
f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs")
if err != nil {
return nil
}
_, err = io.Copy(f, r)
_ = f.Close()
if err != nil {
_ = util.Remove(f.Name())
return nil
}
return util.ToPointer(f.Name())
}

View File

@ -14,27 +14,127 @@ func TestReadingBlameOutput(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo5_pulls", "f32b0a9dfd09a60f616f29158f772cedd89942d2", "README.md") t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
assert.NoError(t, err) repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
defer blameReader.Close()
parts := []*BlamePart{
{
"72866af952e98d02a73003501836074b286a78f6",
[]string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
"f32b0a9dfd09a60f616f29158f772cedd89942d2",
[]string{"", "Do not make any changes to this repo it is used for unit testing"},
},
}
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, part, actualPart) defer repo.Close()
}
commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2")
assert.NoError(t, err)
parts := []*BlamePart{
{
"72866af952e98d02a73003501836074b286a78f6",
[]string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
"f32b0a9dfd09a60f616f29158f772cedd89942d2",
[]string{"", "Do not make any changes to this repo it is used for unit testing"},
},
}
for _, bypass := range []bool{false, true} {
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo5_pulls", commit, "README.md", bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.False(t, blameReader.UsesIgnoreRevs())
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame")
assert.NoError(t, err)
defer repo.Close()
full := []*BlamePart{
{
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
[]string{"line", "line"},
},
{
"45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
[]string{"changed line"},
},
{
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
[]string{"line", "line", ""},
},
}
cases := []struct {
CommitID string
UsesIgnoreRevs bool
Bypass bool
Parts []*BlamePart
}{
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: true,
Bypass: false,
Parts: []*BlamePart{
{
"af7486bd54cfc39eea97207ca666aa69c9d6df93",
[]string{"line", "line", "changed line", "line", "line", ""},
},
},
},
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: false,
Bypass: true,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
}
for _, c := range cases {
commit, err := repo.GetCommit(c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
for _, part := range c.Parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
} }

View File

@ -0,0 +1 @@
ref: refs/heads/master

View File

@ -0,0 +1,4 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true

View File

@ -0,0 +1 @@
544d8f7a3b15927cddf2299b4b562d6ebd71b6a7

View File

@ -1007,6 +1007,8 @@ delete_preexisting = Delete pre-existing files
delete_preexisting_content = Delete files in %s delete_preexisting_content = Delete files in %s
delete_preexisting_success = Deleted unadopted files in %s delete_preexisting_success = Deleted unadopted files in %s
blame_prior = View blame prior to this change blame_prior = View blame prior to this change
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.
blame.ignore_revs.failed = Failed to ignore revisions in <a href="%s">.git-blame-ignore-revs</a>.
author_search_tooltip = Shows a maximum of 30 users author_search_tooltip = Shows a maximum of 30 users
transfer.accept = Accept Transfer transfer.accept = Accept Transfer

View File

@ -8,9 +8,9 @@ import (
gotemplate "html/template" gotemplate "html/template"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
@ -45,10 +45,6 @@ func RefBlame(ctx *context.Context) {
return return
} }
userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name
commitID := ctx.Repo.CommitID
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
treeLink := branchLink treeLink := branchLink
rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL()
@ -101,26 +97,16 @@ func RefBlame(ctx *context.Context) {
return return
} }
blameReader, err := git.CreateBlameReader(ctx, repo_model.RepoPath(userName, repoName), commitID, fileName) bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
result, err := performBlame(ctx, ctx.Repo.Repository.RepoPath(), ctx.Repo.Commit, fileName, bypassBlameIgnore)
if err != nil { if err != nil {
ctx.NotFound("CreateBlameReader", err) ctx.NotFound("CreateBlameReader", err)
return return
} }
defer blameReader.Close()
blameParts := make([]git.BlamePart, 0) ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs
ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile
for {
blamePart, err := blameReader.NextPart()
if err != nil {
ctx.NotFound("NextPart", err)
return
}
if blamePart == nil {
break
}
blameParts = append(blameParts, *blamePart)
}
// Get Topics of this repo // Get Topics of this repo
renderRepoTopics(ctx) renderRepoTopics(ctx)
@ -128,16 +114,77 @@ func RefBlame(ctx *context.Context) {
return return
} }
commitNames, previousCommits := processBlameParts(ctx, blameParts) commitNames, previousCommits := processBlameParts(ctx, result.Parts)
if ctx.Written() { if ctx.Written() {
return return
} }
renderBlame(ctx, blameParts, commitNames, previousCommits) renderBlame(ctx, result.Parts, commitNames, previousCommits)
ctx.HTML(http.StatusOK, tplRepoHome) ctx.HTML(http.StatusOK, tplRepoHome)
} }
type blameResult struct {
Parts []git.BlamePart
UsesIgnoreRevs bool
FaultyIgnoreRevsFile bool
}
func performBlame(ctx *context.Context, repoPath string, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) {
blameReader, err := git.CreateBlameReader(ctx, repoPath, commit, file, bypassBlameIgnore)
if err != nil {
return nil, err
}
r := &blameResult{}
if err := fillBlameResult(blameReader, r); err != nil {
_ = blameReader.Close()
return nil, err
}
err = blameReader.Close()
if err != nil {
if len(r.Parts) == 0 && r.UsesIgnoreRevs {
// try again without ignored revs
blameReader, err = git.CreateBlameReader(ctx, repoPath, commit, file, true)
if err != nil {
return nil, err
}
r := &blameResult{
FaultyIgnoreRevsFile: true,
}
if err := fillBlameResult(blameReader, r); err != nil {
_ = blameReader.Close()
return nil, err
}
return r, blameReader.Close()
}
return nil, err
}
return r, nil
}
func fillBlameResult(br *git.BlameReader, r *blameResult) error {
r.UsesIgnoreRevs = br.UsesIgnoreRevs()
r.Parts = make([]git.BlamePart, 0, 5)
for {
blamePart, err := br.NextPart()
if err != nil {
return fmt.Errorf("BlameReader.NextPart failed: %w", err)
}
if blamePart == nil {
break
}
r.Parts = append(r.Parts, *blamePart)
}
return nil
}
func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[string]*user_model.UserCommit, map[string]string) { func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[string]*user_model.UserCommit, map[string]string) {
// store commit data by SHA to look up avatar info etc // store commit data by SHA to look up avatar info etc
commitNames := make(map[string]*user_model.UserCommit) commitNames := make(map[string]*user_model.UserCommit)

View File

@ -1,3 +1,15 @@
{{if or .UsesIgnoreRevs .FaultyIgnoreRevsFile}}
{{$revsFileLink := URLJoin .RepoLink "src" .BranchNameSubURL "/.git-blame-ignore-revs"}}
{{if .UsesIgnoreRevs}}
<div class="ui info message">
<p>{{.locale.Tr "repo.blame.ignore_revs" $revsFileLink (print $revsFileLink "?bypass-blame-ignore=true") | Str2html}}</p>
</div>
{{else}}
<div class="ui error message">
<p>{{.locale.Tr "repo.blame.ignore_revs.failed" $revsFileLink | Str2html}}</p>
</div>
{{end}}
{{end}}
<div class="{{TabSizeClass .Editorconfig .FileName}} non-diff-file-content"> <div class="{{TabSizeClass .Editorconfig .FileName}} non-diff-file-content">
<h4 class="file-header ui top attached header gt-df gt-ac gt-sb gt-fw"> <h4 class="file-header ui top attached header gt-df gt-ac gt-sb gt-fw">
<div class="file-header-left gt-df gt-ac gt-py-3 gt-pr-4"> <div class="file-header-left gt-df gt-ac gt-py-3 gt-pr-4">