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>tags/v1.21.0-rc0
@@ -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`. |
@@ -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> |
@@ -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) | |||
} | |||
}) | |||
} |
@@ -0,0 +1 @@ | |||
ref: refs/heads/master |
@@ -0,0 +1,4 @@ | |||
[core] | |||
repositoryformatversion = 0 | |||
filemode = true | |||
bare = true |
@@ -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"> |