Skip to content

Commit

Permalink
Count lines changed for current file
Browse files Browse the repository at this point in the history
  • Loading branch information
gandarez committed Oct 24, 2023
1 parent 7c2b2e1 commit 83fa606
Show file tree
Hide file tree
Showing 8 changed files with 460 additions and 75 deletions.
13 changes: 8 additions & 5 deletions cmd/params/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ import (
"golang.org/x/net/http/httpproxy"
)

const errMsgTemplate = "invalid url %q. Must be in format" +
"'https://user:pass@host:port' or " +
"'socks5://user:pass@host:port' or " +
"'domain\\\\user:pass.'"
const (
defaultReadAPIKeyTimeoutSecs = 2
errMsgTemplate = "invalid url %q. Must be in format" +
"'https://user:pass@host:port' or " +
"'socks5://user:pass@host:port' or " +
"'domain\\\\user:pass.'"
)

var (
// nolint
Expand Down Expand Up @@ -670,7 +673,7 @@ func readAPIKeyFromCommand(cmdStr string) (string, error) {
cmdName := cmdParts[0]
cmdArgs := cmdParts[1:]

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), defaultReadAPIKeyTimeoutSecs*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, cmdName, cmdArgs...) // nolint:gosec
Expand Down
115 changes: 115 additions & 0 deletions pkg/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package git

import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"time"

"github.com/wakatime/wakatime-cli/pkg/log"
)

const defaultCountLinesChangedTimeoutSecs = 2

var gitLinesChangedRegex = regexp.MustCompile(`^(?P<added>\d+)\s*(?P<removed>\d+)\s*(?s).*$`)

type (
// Git is an interface to git.
Git interface {
CountLinesChanged() (*int, *int, error)
}

// Client is a git client.
Client struct {
filepath string
GitCmd func(args ...string) (string, error)
}
)

// New creates a new git client.
func New(filepath string) *Client {
return &Client{
filepath: filepath,
GitCmd: gitCmdFn,
}
}

// gitCmdFn runs a git command with the specified env vars and returns its output or errors.
func gitCmdFn(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCountLinesChangedTimeoutSecs*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "git", args...)
cmd.Stderr = os.Stderr

out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to execute git command: %s", err)
}

Check warning on line 51 in pkg/git/git.go

View check run for this annotation

Codecov / codecov/patch

pkg/git/git.go#L41-L51

Added lines #L41 - L51 were not covered by tests

return string(out), nil

Check warning on line 53 in pkg/git/git.go

View check run for this annotation

Codecov / codecov/patch

pkg/git/git.go#L53

Added line #L53 was not covered by tests
}

// CountLinesChanged counts the number of lines added and removed in a file.
func (c *Client) CountLinesChanged() (*int, *int, error) {
if !fileExists(c.filepath) {
return nil, nil, nil
}

out, err := c.GitCmd("diff", "--numstat", c.filepath)
if err != nil {
return nil, nil, fmt.Errorf("failed to count lines changed: %s", err)
}

if out == "" {
// Maybe it's staged, try with --cached.
out, err = c.GitCmd("diff", "--numstat", "--cached", c.filepath)
if err != nil {
return nil, nil, fmt.Errorf("failed to count lines changed: %s", err)
}

Check warning on line 72 in pkg/git/git.go

View check run for this annotation

Codecov / codecov/patch

pkg/git/git.go#L71-L72

Added lines #L71 - L72 were not covered by tests
}

if out == "" {
return nil, nil, nil
}

match := gitLinesChangedRegex.FindStringSubmatch(out)
paramsMap := make(map[string]string)

for i, name := range gitLinesChangedRegex.SubexpNames() {
if i > 0 && i <= len(match) {
paramsMap[name] = match[i]
}
}

if len(paramsMap) == 0 {
log.Debugf("failed to parse git diff output: %s", out)

return nil, nil, nil
}

var added, removed *int

if val, ok := paramsMap["added"]; ok {
if v, err := strconv.Atoi(val); err == nil {
added = &v
}
}

if val, ok := paramsMap["removed"]; ok {
if v, err := strconv.Atoi(val); err == nil {
removed = &v
}
}

return added, removed, nil
}

// fileExists checks if a file or directory exist.
func fileExists(fp string) bool {
_, err := os.Stat(fp)
return err == nil || os.IsExist(err)
}
124 changes: 124 additions & 0 deletions pkg/git/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package git_test

import (
"errors"
"testing"

"github.com/wakatime/wakatime-cli/pkg/git"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCountLinesChanged(t *testing.T) {
gc := git.New("testdata/main.go")
gc.GitCmd = func(args ...string) (string, error) {
assert.Equal(t, args, []string{"diff", "--numstat", "testdata/main.go"})

return "4 1 testdata/main.go", nil
}

added, removed, err := gc.CountLinesChanged()
require.NoError(t, err)

require.NotNil(t, added)
require.NotNil(t, removed)

assert.Equal(t, 4, *added)
assert.Equal(t, 1, *removed)
}

func TestCountLinesChanged_Err(t *testing.T) {
gc := git.New("testdata/main.go")
gc.GitCmd = func(args ...string) (string, error) {
assert.Equal(t, args, []string{"diff", "--numstat", "testdata/main.go"})

return "", errors.New("some error")
}

added, removed, err := gc.CountLinesChanged()
assert.EqualError(t, err, "failed to count lines changed: some error")

assert.Nil(t, added)
assert.Nil(t, removed)
}

func TestCountLinesChanged_Staged(t *testing.T) {
gc := git.New("testdata/main.go")

var numCalls int

gc.GitCmd = func(args ...string) (string, error) {
numCalls++

switch numCalls {
case 1:
assert.Equal(t, args, []string{"diff", "--numstat", "testdata/main.go"})
case 2:
assert.Equal(t, args, []string{"diff", "--numstat", "--cached", "testdata/main.go"})

return "4 1 testdata/main.go", nil
}

return "", nil
}

added, removed, err := gc.CountLinesChanged()
assert.NoError(t, err)

require.NotNil(t, added)
require.NotNil(t, removed)

assert.Equal(t, 4, *added)
assert.Equal(t, 1, *removed)
}

func TestCountLinesChanged_MissingFile(t *testing.T) {
gc := git.New("/tmp/missing-file")

added, removed, err := gc.CountLinesChanged()
assert.NoError(t, err)

assert.Nil(t, added)
assert.Nil(t, removed)
}

func TestCountLinesChanged_NoOutput(t *testing.T) {
gc := git.New("testdata/main.go")

var numCalls int

gc.GitCmd = func(args ...string) (string, error) {
numCalls++

switch numCalls {
case 1:
assert.Equal(t, args, []string{"diff", "--numstat", "testdata/main.go"})
case 2:
assert.Equal(t, args, []string{"diff", "--numstat", "--cached", "testdata/main.go"})
}

return "", nil
}

added, removed, err := gc.CountLinesChanged()
assert.NoError(t, err)

assert.Nil(t, added)
assert.Nil(t, removed)
}

func TestCountLinesChanged_MalformedOutput(t *testing.T) {
gc := git.New("testdata/main.go")
gc.GitCmd = func(args ...string) (string, error) {
assert.Equal(t, args, []string{"diff", "--numstat", "testdata/main.go"})

return "malformed output", nil
}

added, removed, err := gc.CountLinesChanged()
assert.NoError(t, err)

assert.Nil(t, added)
assert.Nil(t, removed)
}
9 changes: 9 additions & 0 deletions pkg/git/testdata/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package main

import "fmt"

func main() {
sum := 1 + 2

fmt.Printf("sum: %d\n", sum)
}
2 changes: 2 additions & 0 deletions pkg/heartbeat/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ type Heartbeat struct {
LanguageAlternate string `json:"-"`
LineNumber *int `json:"lineno,omitempty"`
Lines *int `json:"lines,omitempty"`
LinesAdded *int `json:"lines_added,omitempty"`
LinesRemoved *int `json:"lines_removed,omitempty"`
LocalFile string `json:"-"`
LocalFileNeedsCleanup bool `json:"-"`
Project *string `json:"project,omitempty"`
Expand Down
Loading

0 comments on commit 83fa606

Please sign in to comment.