diff options
Diffstat (limited to '')
-rw-r--r-- | modules/git/blame.go | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/modules/git/blame.go b/modules/git/blame.go new file mode 100644 index 00000000..69e1b08f --- /dev/null +++ b/modules/git/blame.go @@ -0,0 +1,211 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// BlamePart represents block of blame - continuous lines with one sha +type BlamePart struct { + Sha string + Lines []string + PreviousSha string + PreviousPath string +} + +// BlameReader returns part of file blame one by one +type BlameReader struct { + output io.WriteCloser + reader io.ReadCloser + bufferedReader *bufio.Reader + done chan error + lastSha *string + ignoreRevsFile *string + objectFormat ObjectFormat +} + +func (r *BlameReader) UsesIgnoreRevs() bool { + return r.ignoreRevsFile != nil +} + +// NextPart returns next part of blame (sequential code lines with the same commit) +func (r *BlameReader) NextPart() (*BlamePart, error) { + var blamePart *BlamePart + + if r.lastSha != nil { + blamePart = &BlamePart{ + Sha: *r.lastSha, + Lines: make([]string, 0), + } + } + + const previousHeader = "previous " + var lineBytes []byte + var isPrefix bool + var err error + + for err != io.EOF { + lineBytes, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + + if len(lineBytes) == 0 { + // isPrefix will be false + continue + } + + var objectID string + objectFormatLength := r.objectFormat.FullLength() + + if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) { + objectID = string(lineBytes[0:objectFormatLength]) + } + if len(objectID) > 0 { + if blamePart == nil { + blamePart = &BlamePart{ + Sha: objectID, + Lines: make([]string, 0), + } + } + + if blamePart.Sha != objectID { + r.lastSha = &objectID + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + return blamePart, nil + } + } else if lineBytes[0] == '\t' { + blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:])) + } else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) { + offset := len(previousHeader) // already includes a space + blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength]) + offset += objectFormatLength + 1 // +1 for space + blamePart.PreviousPath = string(lineBytes[offset:]) + } + + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + } + + r.lastSha = nil + + return blamePart, nil +} + +// Close BlameReader - don't run NextPart after invoking that +func (r *BlameReader) Close() error { + if r.bufferedReader == nil { + return nil + } + + err := <-r.done + 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, objectFormat ObjectFormat, 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() { + 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: repoPath, + Stdout: stdout, + Stderr: &stderr, + }) + done <- err + _ = stdout.Close() + if err != nil { + log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String()) + } + }() + + bufferedReader := bufio.NewReader(reader) + + return &BlameReader{ + output: stdout, + reader: reader, + bufferedReader: bufferedReader, + done: done, + ignoreRevsFile: ignoreRevsFile, + objectFormat: objectFormat, + }, 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()) +} |