Skip to content

Commit

Permalink
Merge pull request #24 from augmentable-dev/exec-blame
Browse files Browse the repository at this point in the history
implement blaming via exec of git command
  • Loading branch information
patrickdevivo authored Dec 7, 2019
2 parents bac1475 + 9e35a44 commit 672b0c4
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 160 deletions.
6 changes: 3 additions & 3 deletions cmd/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import (
func validateDir(dir string) {
if dir == "" {
cwd, err := os.Getwd()
handleError(err)
handleError(err, nil)
dir = cwd
}

abs, err := filepath.Abs(filepath.Join(dir, ".git"))
handleError(err)
handleError(err, nil)

if _, err := os.Stat(abs); os.IsNotExist(err) {
handleError(fmt.Errorf("%s is not a git repository", abs))
handleError(fmt.Errorf("%s is not a git repository", abs), nil)
}
}
11 changes: 9 additions & 2 deletions cmd/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"

"github.com/briandowns/spinner"
"github.com/spf13/cobra"
)

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

// TODO clean this up
func handleError(err error) {
func handleError(err error, spinner *spinner.Spinner) {
if err != nil {
fmt.Println(err)
if spinner != nil {
// spinner.Suffix = ""
spinner.FinalMSG = err.Error()
spinner.Stop()
} else {
fmt.Println(err)
}
os.Exit(1)
}
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/commands/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,26 @@ var statusCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cwd, err := os.Getwd()
handleError(err)
handleError(err, nil)

dir := cwd
if len(args) == 1 {
dir, err = filepath.Rel(cwd, args[0])
handleError(err)
handleError(err, nil)
}

validateDir(dir)

r, err := git.PlainOpen(dir)
handleError(err)
handleError(err, nil)

ref, err := r.Head()
handleError(err)
handleError(err, nil)

commit, err := r.CommitObject(ref.Hash())
handleError(err)
handleError(err, nil)

err = tickgit.WriteStatus(commit, os.Stdout)
handleError(err)
handleError(err, nil)
},
}
46 changes: 20 additions & 26 deletions cmd/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import (
"github.com/augmentable-dev/tickgit/pkg/todos"
"github.com/briandowns/spinner"
"github.com/spf13/cobra"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)

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

cwd, err := os.Getwd()
handleError(err)
handleError(err, s)

dir := cwd
if len(args) == 1 {
dir, err = filepath.Rel(cwd, args[0])
handleError(err)
handleError(err, s)
}

validateDir(dir)

r, err := git.PlainOpen(dir)
handleError(err)

ref, err := r.Head()
handleError(err)

commit, err := r.CommitObject(ref.Hash())
handleError(err)

comments, err := comments.SearchDir(dir)
handleError(err)

t := todos.NewToDos(comments)
foundToDos := make(todos.ToDos, 0)
err = comments.SearchDir(dir, func(comment *comments.Comment) {
todo := todos.NewToDo(*comment)
if todo != nil {
foundToDos = append(foundToDos, todo)
s.Suffix = fmt.Sprintf(" %d TODOs found", len(foundToDos))
}
})
handleError(err, s)

s.Suffix = fmt.Sprintf(" blaming %d TODOs", len(foundToDos))
ctx := context.Background()
// timeout after 30 seconds
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err = t.FindBlame(ctx, r, commit, func(commit *object.Commit, remaining int) {
total := len(t)
s.Suffix = fmt.Sprintf(" (%d/%d) %s: %s", total-remaining, total, commit.Hash, commit.Author.When)
})
sort.Sort(&t)
// ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
// defer cancel()
err = foundToDos.FindBlame(ctx, dir)
sort.Sort(&foundToDos)

handleError(err)
handleError(err, s)

s.Stop()
todos.WriteTodos(t, os.Stdout)

todos.WriteTodos(foundToDos, os.Stdout)
},
}
198 changes: 198 additions & 0 deletions pkg/blame/blame.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package blame

import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"time"
)

// Options are options to determine what and how to blame
type Options struct {
Directory string
SHA string
Lines []int
}

// Blame represents the "blame" of a particlar line or range of lines
type Blame struct {
SHA string
Author Event
Committer Event
Range [2]int
}

// Event represents the who and when of a commit event
type Event struct {
Name string
Email string
When time.Time
}

func (blame *Blame) String() string {
return fmt.Sprintf("%s: %s <%s>", blame.SHA, blame.Author.Name, blame.Author.Email)
}

func (event *Event) String() string {
return fmt.Sprintf("%s <%s>", event.Name, event.Email)
}

// Result is a mapping of line numbers to blames for a given file
type Result map[int]Blame

func (options *Options) argsFromOptions(filePath string) []string {
args := []string{"blame"}
if options.SHA != "" {
args = append(args, options.SHA)
}

for _, line := range options.Lines {
args = append(args, fmt.Sprintf("-L %d,%d", line, line))
}

args = append(args, "--porcelain", "--incremental")

args = append(args, filePath)
return args
}

func parsePorcelain(reader io.Reader) (Result, error) {
scanner := bufio.NewScanner(reader)
res := make(Result)

const (
author = "author "
authorMail = "author-mail "
authorTime = "author-time "
authorTZ = "author-tz "

committer = "committer "
committerMail = "committer-mail "
committerTime = "committer-time "
committerTZ = "committer-tz "
)

seenCommits := make(map[string]Blame)
var currentCommit Blame
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, author):
currentCommit.Author.Name = strings.TrimPrefix(line, author)
case strings.HasPrefix(line, authorMail):
s := strings.TrimPrefix(line, authorMail)
currentCommit.Author.Email = strings.Trim(s, "<>")
case strings.HasPrefix(line, authorTime):
timeString := strings.TrimPrefix(line, authorTime)
i, err := strconv.ParseInt(timeString, 10, 64)
if err != nil {
return nil, err
}
currentCommit.Author.When = time.Unix(i, 0)
case strings.HasPrefix(line, authorTZ):
tzString := strings.TrimPrefix(line, authorTZ)
parsed, err := time.Parse("-0700", tzString)
if err != nil {
return nil, err
}
loc := parsed.Location()
currentCommit.Author.When = currentCommit.Author.When.In(loc)
case strings.HasPrefix(line, committer):
currentCommit.Committer.Name = strings.TrimPrefix(line, committer)
case strings.HasPrefix(line, committerMail):
s := strings.TrimPrefix(line, committer)
currentCommit.Committer.Email = strings.Trim(s, "<>")
case strings.HasPrefix(line, committerTime):
timeString := strings.TrimPrefix(line, committerTime)
i, err := strconv.ParseInt(timeString, 10, 64)
if err != nil {
return nil, err
}
currentCommit.Committer.When = time.Unix(i, 0)
case strings.HasPrefix(line, committerTZ):
tzString := strings.TrimPrefix(line, committerTZ)
parsed, err := time.Parse("-0700", tzString)
if err != nil {
return nil, err
}
loc := parsed.Location()
currentCommit.Committer.When = currentCommit.Committer.When.In(loc)
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
split := strings.Split(line, " ")
sha := split[0]

// if we haven't seen this commit before, create an entry in the seen commits map that will get filled out in subsequent lines
if _, ok := seenCommits[sha]; !ok {
seenCommits[sha] = Blame{SHA: sha}
}

// update the current commit to be this new one we've just encountered
currentCommit.SHA = sha

// pull out the line information
line := split[2]
l, err := strconv.ParseInt(line, 10, 64) // the starting line of the range
if err != nil {
return nil, err
}

var c int64
if len(split) > 3 {
c, err = strconv.ParseInt(split[3], 10, 64) // the number of lines in the range
if err != nil {
return nil, err
}
}
for i := l; i < l+c; i++ {
res[int(i)] = Blame{SHA: sha}
}
}
// after every line, make sure the current commit in the seen commits map is updated
seenCommits[currentCommit.SHA] = currentCommit
}
for line, blame := range res {
res[line] = seenCommits[blame.SHA]
}
if err := scanner.Err(); err != nil {
return nil, err
}

return res, nil
}

// Exec uses git to lookup the blame of a file, given the supplied options
func Exec(ctx context.Context, filePath string, options *Options) (Result, error) {
gitPath, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("could not find git: %w", err)
}

args := options.argsFromOptions(filePath)

cmd := exec.CommandContext(ctx, gitPath, args...)
cmd.Dir = options.Directory

stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}

if err := cmd.Start(); err != nil {
return nil, err
}

res, err := parsePorcelain(stdout)
if err != nil {
return nil, err
}

if err := cmd.Wait(); err != nil {
return nil, err
}

return res, nil
}
Loading

0 comments on commit 672b0c4

Please sign in to comment.