diff --git a/docs/content/usage/blame.en-us.md b/docs/content/usage/blame.en-us.md new file mode 100644 index 000000000..7772bbc16 --- /dev/null +++ b/docs/content/usage/blame.en-us.md @@ -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`. diff --git a/docs/static/octicon-versions.svg b/docs/static/octicon-versions.svg new file mode 100644 index 000000000..aaf5f9cc2 --- /dev/null +++ b/docs/static/octicon-versions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/git/blame.go b/modules/git/blame.go index 4bd13dc32..6728a6bed 100644 --- a/modules/git/blame.go +++ b/modules/git/blame.go @@ -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()) +} diff --git a/modules/git/blame_test.go b/modules/git/blame_test.go index 1c0cd5c4a..013350ac2 100644 --- a/modules/git/blame_test.go +++ b/modules/git/blame_test.go @@ -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) + } + }) } diff --git a/modules/git/tests/repos/repo6_blame/HEAD b/modules/git/tests/repos/repo6_blame/HEAD new file mode 100644 index 000000000..cb089cd89 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo6_blame/config b/modules/git/tests/repos/repo6_blame/config new file mode 100644 index 000000000..07d359d07 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c new file mode 100644 index 000000000..6cde9108e Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c differ diff --git a/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 new file mode 100644 index 000000000..b8db01dc3 Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 new file mode 100644 index 000000000..6c0ae4723 Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 new file mode 100644 index 000000000..5c2b5641c Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 new file mode 100644 index 000000000..3c6471864 Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 new file mode 100644 index 000000000..847b7bc30 Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 new file mode 100644 index 000000000..206ef1efb Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 new file mode 100644 index 000000000..bb26889ed Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 differ diff --git a/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 new file mode 100644 index 000000000..1653ed954 Binary files /dev/null and b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 differ diff --git a/modules/git/tests/repos/repo6_blame/refs/heads/master b/modules/git/tests/repos/repo6_blame/refs/heads/master new file mode 100644 index 000000000..01c9922c5 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/refs/heads/master @@ -0,0 +1 @@ +544d8f7a3b15927cddf2299b4b562d6ebd71b6a7 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ad7d35127..c38c9d9e4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 .git-blame-ignore-revs. Click here to bypass and see the normal blame view. +blame.ignore_revs.failed = Failed to ignore revisions in .git-blame-ignore-revs. author_search_tooltip = Shows a maximum of 30 users transfer.accept = Accept Transfer diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index b1cb42297..e4506a857 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -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) diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index b253c4d90..3078e9bef 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -1,3 +1,15 @@ +{{if or .UsesIgnoreRevs .FaultyIgnoreRevsFile}} + {{$revsFileLink := URLJoin .RepoLink "src" .BranchNameSubURL "/.git-blame-ignore-revs"}} + {{if .UsesIgnoreRevs}} +
+ {{else}} + + {{end}} +{{end}}