Skip to content

Add "git blame" parser #62

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

Merged
merged 3 commits into from
Oct 22, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
146 changes: 146 additions & 0 deletions blame.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions blame_test.go
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
author-time 1585383299
author-tz +0800
committer GitHub
committer-mail <[email protected]>
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 <[email protected]>
author-time 1573967409
author-tz -0800
committer Unknwon
committer-mail <[email protected]>
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: "<[email protected]>",
When: time.Unix(1573967409, 0).In(time.FixedZone("Fixed", -8*60*60)),
},
Committer: &Signature{
Name: "Unknwon",
Email: "<[email protected]>",
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: "<[email protected]>",
When: time.Unix(1585383299, 0).In(time.FixedZone("Fixed", 8*60*60)),
},
Committer: &Signature{
Name: "GitHub",
Email: "<[email protected]>",
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)
}