Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support .git-blame-ignore-revs file #26395

Merged
merged 14 commits into from
Sep 16, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/content/usage/blame.en-us.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions docs/static/octicon-versions.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 58 additions & 8 deletions modules/git/blame.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 *os.File
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems strange to store a closed File here. Maybe using ignoreRevsFileName string is good enough?

Otherwise it's not easy to understand when the file should be closed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used os.File here to have an explicit nil check. Sure a != "" check works too but my intent was to have a real file linked to the object. How about changing this member to os.FileInfo? That should be the best of both worlds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but my intent was to have a real file linked to the object.

But the file has been closed, the object itself can't do other things except accessing the f.Name(), which is simply { return f.name }

}

func (r *BlameReader) UsesIgnoreRevs() bool {
return r.ignoreRevsFile != nil
}

var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
Expand Down Expand Up @@ -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.Name())
}
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
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 *os.File
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
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.Name())
}
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.Name())
}
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,
})
Expand All @@ -131,15 +152,44 @@ 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) *os.File {
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")
delvh marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil
}
defer f.Close()

_, err = io.Copy(f, r)
if err != nil {
defer func() {
_ = util.Remove(f.Name())
}()
return nil
}
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved

return f
}
144 changes: 122 additions & 22 deletions modules/git/blame_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 change: 1 addition & 0 deletions modules/git/tests/repos/repo6_blame/HEAD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ref: refs/heads/master
4 changes: 4 additions & 0 deletions modules/git/tests/repos/repo6_blame/config
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions modules/git/tests/repos/repo6_blame/refs/heads/master
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
544d8f7a3b15927cddf2299b4b562d6ebd71b6a7
2 changes: 2 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,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>.
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
Expand Down
Loading