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:
parent
c766140dad
commit
ed64f1c2b8
19 changed files with 306 additions and 52 deletions
38
docs/content/usage/blame.en-us.md
Normal file
38
docs/content/usage/blame.en-us.md
Normal 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
1
docs/static/octicon-versions.svg
vendored
Normal 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 |
|
@ -13,6 +13,7 @@ import (
|
|||
"regexp"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
type BlameReader struct {
|
||||
cmd *Command
|
||||
output io.WriteCloser
|
||||
reader io.ReadCloser
|
||||
bufferedReader *bufio.Reader
|
||||
done chan error
|
||||
lastSha *string
|
||||
ignoreRevsFile *string
|
||||
}
|
||||
|
||||
func (r *BlameReader) UsesIgnoreRevs() bool {
|
||||
return r.ignoreRevsFile != nil
|
||||
}
|
||||
|
||||
var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
|
||||
|
@ -101,28 +106,44 @@ func (r *BlameReader) Close() error {
|
|||
r.bufferedReader = nil
|
||||
_ = r.reader.Close()
|
||||
_ = r.output.Close()
|
||||
if r.ignoreRevsFile != nil {
|
||||
_ = util.Remove(*r.ignoreRevsFile)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateBlameReader creates reader for given repository, commit and file
|
||||
func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*BlameReader, error) {
|
||||
cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain").
|
||||
AddDynamicArguments(commitID).
|
||||
func CreateBlameReader(ctx context.Context, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
|
||||
var ignoreRevsFile *string
|
||||
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).
|
||||
SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath))
|
||||
reader, stdout, err := os.Pipe()
|
||||
if err != nil {
|
||||
if ignoreRevsFile != nil {
|
||||
_ = util.Remove(*ignoreRevsFile)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func(cmd *Command, dir string, stdout io.WriteCloser, done chan error) {
|
||||
go func() {
|
||||
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"
|
||||
err := cmd.Run(&RunOpts{
|
||||
UseContextTimeout: true,
|
||||
Dir: dir,
|
||||
Dir: repoPath,
|
||||
Stdout: stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
|
@ -131,15 +152,42 @@ func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*B
|
|||
if err != nil {
|
||||
log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
|
||||
}
|
||||
}(cmd, repoPath, stdout, done)
|
||||
}()
|
||||
|
||||
bufferedReader := bufio.NewReader(reader)
|
||||
|
||||
return &BlameReader{
|
||||
cmd: cmd,
|
||||
output: stdout,
|
||||
reader: reader,
|
||||
bufferedReader: bufferedReader,
|
||||
done: done,
|
||||
ignoreRevsFile: ignoreRevsFile,
|
||||
}, 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())
|
||||
}
|
||||
|
|
|
@ -14,27 +14,127 @@ func TestReadingBlameOutput(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
blameReader, err := CreateBlameReader(ctx, "./tests/repos/repo5_pulls", "f32b0a9dfd09a60f616f29158f772cedd89942d2", "README.md")
|
||||
assert.NoError(t, err)
|
||||
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()
|
||||
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
|
||||
repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
1
modules/git/tests/repos/repo6_blame/HEAD
Normal file
1
modules/git/tests/repos/repo6_blame/HEAD
Normal file
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/master
|
4
modules/git/tests/repos/repo6_blame/config
Normal file
4
modules/git/tests/repos/repo6_blame/config
Normal file
|
@ -0,0 +1,4 @@
|
|||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
modules/git/tests/repos/repo6_blame/refs/heads/master
Normal file
1
modules/git/tests/repos/repo6_blame/refs/heads/master
Normal file
|
@ -0,0 +1 @@
|
|||
544d8f7a3b15927cddf2299b4b562d6ebd71b6a7
|
|
@ -1007,6 +1007,8 @@ delete_preexisting = Delete pre-existing files
|
|||
delete_preexisting_content = Delete files in %s
|
||||
delete_preexisting_success = Deleted unadopted files in %s
|
||||
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
|
||||
|
||||
transfer.accept = Accept Transfer
|
||||
|
|
|
@ -8,9 +8,9 @@ import (
|
|||
gotemplate "html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
|
@ -45,10 +45,6 @@ func RefBlame(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
userName := ctx.Repo.Owner.Name
|
||||
repoName := ctx.Repo.Repository.Name
|
||||
commitID := ctx.Repo.CommitID
|
||||
|
||||
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
|
||||
treeLink := branchLink
|
||||
rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL()
|
||||
|
@ -101,26 +97,16 @@ func RefBlame(ctx *context.Context) {
|
|||
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 {
|
||||
ctx.NotFound("CreateBlameReader", err)
|
||||
return
|
||||
}
|
||||
defer blameReader.Close()
|
||||
|
||||
blameParts := make([]git.BlamePart, 0)
|
||||
|
||||
for {
|
||||
blamePart, err := blameReader.NextPart()
|
||||
if err != nil {
|
||||
ctx.NotFound("NextPart", err)
|
||||
return
|
||||
}
|
||||
if blamePart == nil {
|
||||
break
|
||||
}
|
||||
blameParts = append(blameParts, *blamePart)
|
||||
}
|
||||
ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs
|
||||
ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile
|
||||
|
||||
// Get Topics of this repo
|
||||
renderRepoTopics(ctx)
|
||||
|
@ -128,16 +114,77 @@ func RefBlame(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
commitNames, previousCommits := processBlameParts(ctx, blameParts)
|
||||
commitNames, previousCommits := processBlameParts(ctx, result.Parts)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
renderBlame(ctx, blameParts, commitNames, previousCommits)
|
||||
renderBlame(ctx, result.Parts, commitNames, previousCommits)
|
||||
|
||||
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) {
|
||||
// store commit data by SHA to look up avatar info etc
|
||||
commitNames := make(map[string]*user_model.UserCommit)
|
||||
|
|
|
@ -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">
|
||||
<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">
|
||||
|
|
Loading…
Reference in a new issue