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
--- | |||||
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`. |
<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> |
"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 | ||||
// 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})") | ||||
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) { | |||||
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). | 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, | ||||
}) | }) | ||||
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()) | |||||
} |
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") | |||||
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.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) | |||||
} | |||||
}) | |||||
} | } |
ref: refs/heads/master |
[core] | |||||
repositoryformatversion = 0 | |||||
filemode = true | |||||
bare = true |
544d8f7a3b15927cddf2299b4b562d6ebd71b6a7 |
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 |
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" | ||||
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() | ||||
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) | |||||
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 | // Get Topics of this repo | ||||
renderRepoTopics(ctx) | renderRepoTopics(ctx) | ||||
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) |
{{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"> |