diff --git a/blame.go b/blame.go new file mode 100644 index 000000000..a97ab48f0 --- /dev/null +++ b/blame.go @@ -0,0 +1,146 @@ +// Copyright 2020 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bufio" + "bytes" + "strconv" + "strings" + "time" +) + +type ( + Blame struct { + commits map[int]*Commit + } + BlameOptions struct { + } +) + +// BlameFile returns map of line number and the Commit changed that line. +func (r *Repository) BlameFile(rev, file string, opts ...BlameOptions) (*Blame, error) { + cmd := NewCommand("blame", "-p", rev, "--", file) + stdout, err := cmd.RunInDir(r.path) + if err != nil { + return nil, err + } + return BlameContent(stdout) +} + +// BlameContent parse content of `git blame` in porcelain format +func BlameContent(content []byte) (*Blame, error) { + var commits = make(map[[20]byte]*Commit) + var commit = &Commit{} + var details = make(map[string]string) + var result = createBlame() + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + if string(line[0]) != "\t" { + words := strings.Fields(line) + sha, err := NewIDFromString(words[0]) + if err == nil { + // SHA and rows numbers line + commit = getCommit(sha, commits) + commit.fill(details) + details = make(map[string]string) // empty all details + i, err := strconv.Atoi(words[2]) + if err != nil { + return nil, err + } + result.commits[i] = commit + } else { + // commit details line + switch words[0] { + case "summary": + commit.Message = line[len(words[0])+1:] + case "previous": + commit.parents = []*SHA1{MustIDFromString(words[1])} + default: + if len(words) > 1 { + details[words[0]] = line[len(words[0])+1:] + } + } + } + } else { + // needed for last line in blame + commit.fill(details) + } + } + + return result, nil +} + +func createBlame() *Blame { + var blame = Blame{} + blame.commits = make(map[int]*Commit) + return &blame +} + +// Return commit from map or creates a new one +func getCommit(sha *SHA1, commits map[[20]byte]*Commit) *Commit { + commit, ok := commits[sha.bytes] + if !ok { + commit = &Commit{ + ID: sha, + } + commits[sha.bytes] = commit + } + + return commit +} + +func (c *Commit) fill(data map[string]string) { + author, ok := data["author"] + if ok && c.Author == nil { + t, err := parseBlameTime(data, "author") + if err != nil { + c.Author = &Signature{ + Name: author, + Email: data["author-mail"], + } + } else { + c.Author = &Signature{ + Name: author, + Email: data["author-mail"], + When: t, + } + } + } + committer, ok := data["committer"] + if ok && c.Committer == nil { + t, err := parseBlameTime(data, "committer") + if err != nil { + c.Committer = &Signature{ + Name: committer, + Email: data["committer-mail"], + } + } else { + c.Committer = &Signature{ + Name: committer, + Email: data["committer-mail"], + When: t, + } + } + } +} + +func parseBlameTime(data map[string]string, prefix string) (time.Time, error) { + atoi, err := strconv.ParseInt(data[prefix+"-time"], 10, 64) + if err != nil { + return time.Time{}, err + } + t := time.Unix(atoi, 0) + + if len(data["author-tz"]) == 5 { + hours, ok1 := strconv.ParseInt(data[prefix+"-tz"][:3], 10, 0) + mins, ok2 := strconv.ParseInt(data[prefix+"-tz"][3:5], 10, 0) + if ok1 == nil && ok2 == nil { + t = t.In(time.FixedZone("Fixed", int((hours*60+mins)*60))) + } + } + return t, nil +} diff --git a/blame_test.go b/blame_test.go new file mode 100644 index 000000000..126cdcf20 --- /dev/null +++ b/blame_test.go @@ -0,0 +1,106 @@ +// Copyright 2020 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var oneRowBlame = `2c49687c5b06776a44e2a8c0635428f647909472 3 3 4 +author ᴜɴᴋɴᴡᴏɴ +author-mail +author-time 1585383299 +author-tz +0800 +committer GitHub +committer-mail +committer-time 1585383299 +committer-tz +0800 +summary ci: migrate from Travis to GitHub Actions (#50) +previous 0d17b78404b7432905a58a235d875e9d28969ee3 README.md +filename README.md + [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/gogs/git-module/Go?logo=github&style=for-the-badge)](https://github.com/gogs/git-module/actions?query=workflow%3AGo) +` + +var twoRowsBlame = `f29bce1e3a666c02175d080892be185405dd3af4 1 1 2 +author Unknwon +author-mail +author-time 1573967409 +author-tz -0800 +committer Unknwon +committer-mail +committer-time 1573967409 +committer-tz -0800 +summary README: update badges +previous 065699e51f42559ab0c3ad22c1f2c789b2def8fb README.md +filename README.md + # Git Module +f29bce1e3a666c02175d080892be185405dd3af4 2 2 + +` + +var commit1 = &Commit{ + ID: MustIDFromString("f29bce1e3a666c02175d080892be185405dd3af4"), + Message: "README: update badges", + Author: &Signature{ + Name: "Unknwon", + Email: "", + When: time.Unix(1573967409, 0).In(time.FixedZone("Fixed", -8*60*60)), + }, + Committer: &Signature{ + Name: "Unknwon", + Email: "", + When: time.Unix(1573967409, 0).In(time.FixedZone("Fixed", -8*60*60)), + }, + parents: []*SHA1{MustIDFromString("065699e51f42559ab0c3ad22c1f2c789b2def8fb")}, +} + +var commit2 = &Commit{ + ID: MustIDFromString("2c49687c5b06776a44e2a8c0635428f647909472"), + Message: "ci: migrate from Travis to GitHub Actions (#50)", + Author: &Signature{ + Name: "ᴜɴᴋɴᴡᴏɴ", + Email: "", + When: time.Unix(1585383299, 0).In(time.FixedZone("Fixed", 8*60*60)), + }, + Committer: &Signature{ + Name: "GitHub", + Email: "", + When: time.Unix(1585383299, 0).In(time.FixedZone("Fixed", 8*60*60)), + }, + parents: []*SHA1{MustIDFromString("0d17b78404b7432905a58a235d875e9d28969ee3")}, +} + +func TestOneRowBlame(t *testing.T) { + blame, _ := BlameContent([]byte(oneRowBlame)) + var expect = createBlame() + + expect.commits[3] = commit2 + + assert.Equal(t, expect, blame) +} + +func TestMultipleRowsBlame(t *testing.T) { + blame, _ := BlameContent([]byte(twoRowsBlame + oneRowBlame)) + var expect = createBlame() + + expect.commits[1] = commit1 + expect.commits[2] = commit1 + expect.commits[3] = commit2 + + assert.Equal(t, expect, blame) +} + +func TestRepository_BlameFile(t *testing.T) { + blame, _ := testrepo.BlameFile("master", "pom.xml") + assert.Greater(t, len(blame.commits), 0) +} + +func TestRepository_BlameNotExistFile(t *testing.T) { + _, err := testrepo.BlameFile("master", "0") + assert.Error(t, err) +}