Skip to content
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

Count lines changed for current file #946

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions cmd/heartbeat/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ func initHandleOptions(params paramscmd.Params) []heartbeat.HandleOption {
FilePatterns: params.Heartbeat.Sanitize.HideFileNames,
}),
project.WithDetection(project.Config{
CountLinesChanged: params.Heartbeat.CountLinesChanged,
HideProjectNames: params.Heartbeat.Sanitize.HideProjectNames,
MapPatterns: params.Heartbeat.Project.MapPatterns,
ProjectFromGitRemote: params.Heartbeat.Project.ProjectFromGitRemote,
Expand Down
18 changes: 12 additions & 6 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 @@ -96,6 +99,7 @@ type (
// Heartbeat contains heartbeat command parameters.
Heartbeat struct {
Category heartbeat.Category
CountLinesChanged bool
CursorPosition *int
Entity string
EntityType heartbeat.EntityType
Expand Down Expand Up @@ -407,6 +411,7 @@ func LoadHeartbeatParams(v *viper.Viper) (Heartbeat, error) {

return Heartbeat{
Category: category,
CountLinesChanged: vipertools.FirstNonEmptyBool(v, "count-lines-changed", "settings.count_lines_changed"),
CursorPosition: cursorPosition,
Entity: entityExpanded,
ExtraHeartbeats: extraHeartbeats,
Expand Down Expand Up @@ -670,7 +675,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 Expand Up @@ -967,11 +972,12 @@ func (p Heartbeat) String() string {
}

return fmt.Sprintf(
"category: '%s', cursor position: '%s', entity: '%s', entity type: '%s',"+
"category: '%s', count lines changed: %t, cursor position: '%s', entity: '%s', entity type: '%s',"+
" num extra heartbeats: %d, guess language: %t, is unsaved entity: %t,"+
" is write: %t, language: '%s', line number: '%s', lines in file: '%s',"+
" time: %.5f, filter params: (%s), project params: (%s), sanitize params: (%s)",
p.Category,
p.CountLinesChanged,
cursorPosition,
p.Entity,
p.EntityType,
Expand Down
73 changes: 53 additions & 20 deletions cmd/params/params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ func TestLoadParams_CursorPosition_Unset(t *testing.T) {
assert.Nil(t, params.CursorPosition)
}

func TestLoadHeartbeat_CountLinesChanged_FlagTakesPrecedence(t *testing.T) {
v := viper.New()
v.Set("entity", "/path/to/file")
v.Set("count-lines-changed", true)
v.Set("settings.count_lines_changed", false)

params, err := paramscmd.LoadHeartbeatParams(v)
require.NoError(t, err)

assert.True(t, params.CountLinesChanged)
}

func TestLoadHeartbeat_CountLinesChanged_FromConfig(t *testing.T) {
v := viper.New()
v.Set("entity", "/path/to/file")
v.Set("settings.count_lines_changed", true)

params, err := paramscmd.LoadHeartbeatParams(v)
require.NoError(t, err)

assert.True(t, params.CountLinesChanged)
}

func TestLoadHeartbeat_CountLinesChanged_Default(t *testing.T) {
v := viper.New()
v.Set("entity", "/path/to/file")

params, err := paramscmd.LoadHeartbeatParams(v)
require.NoError(t, err)

assert.False(t, params.CountLinesChanged)
}

func TestLoadParams_Entity_EntityFlagTakesPrecedence(t *testing.T) {
v := viper.New()
v.Set("entity", "/path/to/file")
Expand Down Expand Up @@ -2327,30 +2360,30 @@ func TestFilterParams_String(t *testing.T) {

func TestHeartbeat_String(t *testing.T) {
heartbeat := paramscmd.Heartbeat{
Category: heartbeat.CodingCategory,
CursorPosition: heartbeat.PointerTo(15),
Entity: "path/to/entity.go",
EntityType: heartbeat.FileType,
ExtraHeartbeats: make([]heartbeat.Heartbeat, 3),
GuessLanguage: true,
IsUnsavedEntity: true,
IsWrite: heartbeat.PointerTo(true),
Language: heartbeat.PointerTo("Golang"),
LineNumber: heartbeat.PointerTo(4),
LinesInFile: heartbeat.PointerTo(56),
Time: 1585598059,
Category: heartbeat.CodingCategory,
CountLinesChanged: true,
CursorPosition: heartbeat.PointerTo(15),
Entity: "path/to/entity.go",
EntityType: heartbeat.FileType,
ExtraHeartbeats: make([]heartbeat.Heartbeat, 3),
GuessLanguage: true,
IsUnsavedEntity: true,
IsWrite: heartbeat.PointerTo(true),
Language: heartbeat.PointerTo("Golang"),
LineNumber: heartbeat.PointerTo(4),
LinesInFile: heartbeat.PointerTo(56),
Time: 1585598059,
}

assert.Equal(
t,
"category: 'coding', cursor position: '15', entity: 'path/to/entity.go', entity type: 'file',"+
" num extra heartbeats: 3, guess language: true, is unsaved entity: true, is write: true,"+
" language: 'Golang', line number: '4', lines in file: '56', time: 1585598059.00000, filter"+
" params: (exclude: '[]', exclude unknown project: false, include: '[]', include only with"+
" project file: false), project params: (alternate: '', branch alternate: '', map patterns:"+
" '[]', override: '', git submodules disabled: '[]', git submodule project map: '[]'), sanitize"+
" params: (hide branch names: '[]', hide project folder: false, hide file names: '[]',"+
" hide project names: '[]', project path override: '')",
"category: 'coding', count lines changed: true, cursor position: '15', entity: 'path/to/entity.go',"+
" entity type: 'file', num extra heartbeats: 3, guess language: true, is unsaved entity: true, is write: true,"+
" language: 'Golang', line number: '4', lines in file: '56', time: 1585598059.00000, filter params:"+
" (exclude: '[]', exclude unknown project: false, include: '[]', include only with project file: false),"+
" project params: (alternate: '', branch alternate: '', map patterns: '[]', override: '',"+
" git submodules disabled: '[]', git submodule project map: '[]'), sanitize params: (hide branch names: '[]',"+
" hide project folder: false, hide file names: '[]', hide project names: '[]', project path override: '')",
heartbeat.String(),
)
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"flag"
"fmt"

"github.com/wakatime/wakatime-cli/pkg/api"
Expand Down Expand Up @@ -74,6 +75,7 @@ func setFlags(cmd *cobra.Command, v *viper.Viper) {
nil,
"Writes value to a config key, then exits. Expects two arguments, key and value.",
)
flag.Bool("count-lines-changed", false, "When set, counts lines added and removed in the current entity.")
flags.Int("cursorpos", 0, "Optional cursor position in the current file.")
flags.Bool("disable-offline", false, "Disables offline time logging instead of queuing logged time.")
flags.Bool("disableoffline", false, "(deprecated) Disables offline time logging instead of queuing logged time.")
Expand Down
121 changes: 121 additions & 0 deletions pkg/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package git

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"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...)

stdout := bytes.Buffer{}
stderr := bytes.Buffer{}

cmd.Stdout = &stdout
cmd.Stderr = &stderr

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

return stdout.String(), nil
}

// 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("-C", filepath.Dir(c.filepath), "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("-C", filepath.Dir(c.filepath), "diff", "--numstat", "--cached", c.filepath)
if err != nil {
return nil, nil, fmt.Errorf("failed to count lines changed: %s", err)
}
}

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)
}
Loading
Loading