Skip to content

Commit 672b0c4

Browse files
Merge pull request #24 from augmentable-dev/exec-blame
implement blaming via exec of git command
2 parents bac1475 + 9e35a44 commit 672b0c4

File tree

8 files changed

+293
-160
lines changed

8 files changed

+293
-160
lines changed

cmd/commands/helpers.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import (
99
func validateDir(dir string) {
1010
if dir == "" {
1111
cwd, err := os.Getwd()
12-
handleError(err)
12+
handleError(err, nil)
1313
dir = cwd
1414
}
1515

1616
abs, err := filepath.Abs(filepath.Join(dir, ".git"))
17-
handleError(err)
17+
handleError(err, nil)
1818

1919
if _, err := os.Stat(abs); os.IsNotExist(err) {
20-
handleError(fmt.Errorf("%s is not a git repository", abs))
20+
handleError(fmt.Errorf("%s is not a git repository", abs), nil)
2121
}
2222
}

cmd/commands/root.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66

7+
"github.com/briandowns/spinner"
78
"github.com/spf13/cobra"
89
)
910

@@ -14,9 +15,15 @@ var rootCmd = &cobra.Command{
1415
}
1516

1617
// TODO clean this up
17-
func handleError(err error) {
18+
func handleError(err error, spinner *spinner.Spinner) {
1819
if err != nil {
19-
fmt.Println(err)
20+
if spinner != nil {
21+
// spinner.Suffix = ""
22+
spinner.FinalMSG = err.Error()
23+
spinner.Stop()
24+
} else {
25+
fmt.Println(err)
26+
}
2027
os.Exit(1)
2128
}
2229
}

cmd/commands/status.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,26 @@ var statusCmd = &cobra.Command{
2020
Args: cobra.MaximumNArgs(1),
2121
Run: func(cmd *cobra.Command, args []string) {
2222
cwd, err := os.Getwd()
23-
handleError(err)
23+
handleError(err, nil)
2424

2525
dir := cwd
2626
if len(args) == 1 {
2727
dir, err = filepath.Rel(cwd, args[0])
28-
handleError(err)
28+
handleError(err, nil)
2929
}
3030

3131
validateDir(dir)
3232

3333
r, err := git.PlainOpen(dir)
34-
handleError(err)
34+
handleError(err, nil)
3535

3636
ref, err := r.Head()
37-
handleError(err)
37+
handleError(err, nil)
3838

3939
commit, err := r.CommitObject(ref.Hash())
40-
handleError(err)
40+
handleError(err, nil)
4141

4242
err = tickgit.WriteStatus(commit, os.Stdout)
43-
handleError(err)
43+
handleError(err, nil)
4444
},
4545
}

cmd/commands/todos.go

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import (
1212
"github.com/augmentable-dev/tickgit/pkg/todos"
1313
"github.com/briandowns/spinner"
1414
"github.com/spf13/cobra"
15-
"gopkg.in/src-d/go-git.v4"
16-
"gopkg.in/src-d/go-git.v4/plumbing/object"
1715
)
1816

1917
func init() {
@@ -27,48 +25,44 @@ var todosCmd = &cobra.Command{
2725
Args: cobra.MaximumNArgs(1),
2826
Run: func(cmd *cobra.Command, args []string) {
2927
s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
28+
s.HideCursor = true
3029
s.Suffix = " finding TODOs"
3130
s.Writer = os.Stderr
3231
s.Start()
3332

3433
cwd, err := os.Getwd()
35-
handleError(err)
34+
handleError(err, s)
3635

3736
dir := cwd
3837
if len(args) == 1 {
3938
dir, err = filepath.Rel(cwd, args[0])
40-
handleError(err)
39+
handleError(err, s)
4140
}
4241

4342
validateDir(dir)
4443

45-
r, err := git.PlainOpen(dir)
46-
handleError(err)
47-
48-
ref, err := r.Head()
49-
handleError(err)
50-
51-
commit, err := r.CommitObject(ref.Hash())
52-
handleError(err)
53-
54-
comments, err := comments.SearchDir(dir)
55-
handleError(err)
56-
57-
t := todos.NewToDos(comments)
44+
foundToDos := make(todos.ToDos, 0)
45+
err = comments.SearchDir(dir, func(comment *comments.Comment) {
46+
todo := todos.NewToDo(*comment)
47+
if todo != nil {
48+
foundToDos = append(foundToDos, todo)
49+
s.Suffix = fmt.Sprintf(" %d TODOs found", len(foundToDos))
50+
}
51+
})
52+
handleError(err, s)
5853

54+
s.Suffix = fmt.Sprintf(" blaming %d TODOs", len(foundToDos))
5955
ctx := context.Background()
6056
// 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-
})
67-
sort.Sort(&t)
57+
// ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
58+
// defer cancel()
59+
err = foundToDos.FindBlame(ctx, dir)
60+
sort.Sort(&foundToDos)
6861

69-
handleError(err)
62+
handleError(err, s)
7063

7164
s.Stop()
72-
todos.WriteTodos(t, os.Stdout)
65+
66+
todos.WriteTodos(foundToDos, os.Stdout)
7367
},
7468
}

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+
}

0 commit comments

Comments
 (0)