Skip to content

Commit e9a1db2

Browse files
committed
implement blaming via exec of git command
1 parent bac1475 commit e9a1db2

File tree

4 files changed

+235
-94
lines changed

4 files changed

+235
-94
lines changed

cmd/commands/todos.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package commands
22

33
import (
44
"context"
5-
"fmt"
65
"os"
76
"path/filepath"
87
"sort"
@@ -13,7 +12,6 @@ import (
1312
"github.com/briandowns/spinner"
1413
"github.com/spf13/cobra"
1514
"gopkg.in/src-d/go-git.v4"
16-
"gopkg.in/src-d/go-git.v4/plumbing/object"
1715
)
1816

1917
func init() {
@@ -58,12 +56,9 @@ var todosCmd = &cobra.Command{
5856

5957
ctx := context.Background()
6058
// timeout after 30 seconds
61-
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
62-
defer cancel()
63-
err = t.FindBlame(ctx, r, commit, func(commit *object.Commit, remaining int) {
64-
total := len(t)
65-
s.Suffix = fmt.Sprintf(" (%d/%d) %s: %s", total-remaining, total, commit.Hash, commit.Author.When)
66-
})
59+
// ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
60+
// defer cancel()
61+
err = t.FindBlame(ctx, dir, commit.Hash.String())
6762
sort.Sort(&t)
6863

6964
handleError(err)

pkg/blame/blame.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package blame
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
"time"
12+
)
13+
14+
// Options are options to determine what and how to blame
15+
type Options struct {
16+
Directory string
17+
SHA string
18+
Lines []int
19+
}
20+
21+
// Blame represents the "blame" of a particlar line or range of lines
22+
type Blame struct {
23+
SHA string
24+
Author Event
25+
Committer Event
26+
Range [2]int
27+
}
28+
29+
// Event represents the who and when of a commit event
30+
type Event struct {
31+
Name string
32+
Email string
33+
When time.Time
34+
}
35+
36+
func (blame *Blame) String() string {
37+
return fmt.Sprintf("%s: %s <%s>", blame.SHA, blame.Author.Name, blame.Author.Email)
38+
}
39+
40+
func (event *Event) String() string {
41+
return fmt.Sprintf("%s <%s>", event.Name, event.Email)
42+
}
43+
44+
// Result is a mapping of line numbers to blames for a given file
45+
type Result map[int]Blame
46+
47+
func (options *Options) argsFromOptions(filePath string) []string {
48+
args := []string{"blame"}
49+
if options.SHA != "" {
50+
args = append(args, options.SHA)
51+
}
52+
53+
for _, line := range options.Lines {
54+
args = append(args, fmt.Sprintf("-L %d,%d", line, line))
55+
}
56+
57+
args = append(args, "--porcelain", "--incremental")
58+
59+
args = append(args, filePath)
60+
return args
61+
}
62+
63+
func parsePorcelain(reader io.Reader) (Result, error) {
64+
scanner := bufio.NewScanner(reader)
65+
res := make(Result)
66+
67+
const (
68+
author = "author "
69+
authorMail = "author-mail "
70+
authorTime = "author-time "
71+
authorTZ = "author-tz "
72+
73+
committer = "committer "
74+
committerMail = "committer-mail "
75+
committerTime = "committer-time "
76+
committerTZ = "committer-tz "
77+
)
78+
79+
seenCommits := make(map[string]Blame)
80+
var currentCommit Blame
81+
for scanner.Scan() {
82+
line := scanner.Text()
83+
switch {
84+
case strings.HasPrefix(line, author):
85+
currentCommit.Author.Name = strings.TrimPrefix(line, author)
86+
case strings.HasPrefix(line, authorMail):
87+
s := strings.TrimPrefix(line, authorMail)
88+
currentCommit.Author.Email = strings.Trim(s, "<>")
89+
case strings.HasPrefix(line, authorTime):
90+
timeString := strings.TrimPrefix(line, authorTime)
91+
i, err := strconv.ParseInt(timeString, 10, 64)
92+
if err != nil {
93+
return nil, err
94+
}
95+
currentCommit.Author.When = time.Unix(i, 0)
96+
case strings.HasPrefix(line, authorTZ):
97+
tzString := strings.TrimPrefix(line, authorTZ)
98+
parsed, err := time.Parse("-0700", tzString)
99+
if err != nil {
100+
return nil, err
101+
}
102+
loc := parsed.Location()
103+
currentCommit.Author.When = currentCommit.Author.When.In(loc)
104+
case strings.HasPrefix(line, committer):
105+
currentCommit.Committer.Name = strings.TrimPrefix(line, committer)
106+
case strings.HasPrefix(line, committerMail):
107+
s := strings.TrimPrefix(line, committer)
108+
currentCommit.Committer.Email = strings.Trim(s, "<>")
109+
case strings.HasPrefix(line, committerTime):
110+
timeString := strings.TrimPrefix(line, committerTime)
111+
i, err := strconv.ParseInt(timeString, 10, 64)
112+
if err != nil {
113+
return nil, err
114+
}
115+
currentCommit.Committer.When = time.Unix(i, 0)
116+
case strings.HasPrefix(line, committerTZ):
117+
tzString := strings.TrimPrefix(line, committerTZ)
118+
parsed, err := time.Parse("-0700", tzString)
119+
if err != nil {
120+
return nil, err
121+
}
122+
loc := parsed.Location()
123+
currentCommit.Committer.When = currentCommit.Committer.When.In(loc)
124+
case len(strings.Split(line, " ")[0]) == 40: // if the first string sep by a space is 40 chars long, it's probably the commit header
125+
split := strings.Split(line, " ")
126+
sha := split[0]
127+
128+
// if we haven't seen this commit before, create an entry in the seen commits map that will get filled out in subsequent lines
129+
if _, ok := seenCommits[sha]; !ok {
130+
seenCommits[sha] = Blame{SHA: sha}
131+
}
132+
133+
// update the current commit to be this new one we've just encountered
134+
currentCommit.SHA = sha
135+
136+
// pull out the line information
137+
line := split[2]
138+
l, err := strconv.ParseInt(line, 10, 64) // the starting line of the range
139+
if err != nil {
140+
return nil, err
141+
}
142+
143+
var c int64
144+
if len(split) > 3 {
145+
c, err = strconv.ParseInt(split[3], 10, 64) // the number of lines in the range
146+
if err != nil {
147+
return nil, err
148+
}
149+
}
150+
for i := l; i < l+c; i++ {
151+
res[int(i)] = Blame{SHA: sha}
152+
}
153+
}
154+
// after every line, make sure the current commit in the seen commits map is updated
155+
seenCommits[currentCommit.SHA] = currentCommit
156+
}
157+
for line, blame := range res {
158+
res[line] = seenCommits[blame.SHA]
159+
}
160+
if err := scanner.Err(); err != nil {
161+
return nil, err
162+
}
163+
164+
return res, nil
165+
}
166+
167+
// Exec uses git to lookup the blame of a file, given the supplied options
168+
func Exec(ctx context.Context, filePath string, options *Options) (Result, error) {
169+
gitPath, err := exec.LookPath("git")
170+
if err != nil {
171+
return nil, fmt.Errorf("could not find git: %w", err)
172+
}
173+
174+
args := options.argsFromOptions(filePath)
175+
176+
cmd := exec.CommandContext(ctx, gitPath, args...)
177+
cmd.Dir = options.Directory
178+
179+
stdout, err := cmd.StdoutPipe()
180+
if err != nil {
181+
return nil, err
182+
}
183+
184+
if err := cmd.Start(); err != nil {
185+
return nil, err
186+
}
187+
188+
res, err := parsePorcelain(stdout)
189+
if err != nil {
190+
return nil, err
191+
}
192+
193+
if err := cmd.Wait(); err != nil {
194+
return nil, err
195+
}
196+
197+
return res, nil
198+
}

pkg/todos/report.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const DefaultTemplate = `
1010
{{- range $index, $todo := . }}
1111
{{ print "\u001b[33m" }}TODO{{ print "\u001b[0m" }}: {{ .String }}
1212
=> {{ .Comment.FilePath }}:{{ .Comment.StartLocation.Line }}:{{ .Comment.StartLocation.Pos }}
13-
{{- if .Commit }}
14-
=> added {{ .TimeAgo }} by {{ .Commit.Author }} in {{ .Commit.Hash }}
13+
{{- if .Blame }}
14+
=> added {{ .TimeAgo }} by {{ .Blame.Author }} in {{ .Blame.SHA }}
1515
{{- end }}
1616
{{ else }}
1717
no todos 🎉

0 commit comments

Comments
 (0)