From f6cb5d812de9c0725828959cae54d97ad379e91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Henrique=20Guard=C3=A3o=20Gandarez?= Date: Tue, 3 Oct 2023 14:51:11 -0300 Subject: [PATCH] Count lines changed for current file --- cmd/heartbeat/heartbeat.go | 1 + cmd/params/params.go | 18 +- cmd/params/params_test.go | 73 +++++-- cmd/root.go | 2 + pkg/git/git.go | 121 +++++++++++ pkg/git/git_test.go | 124 +++++++++++ pkg/git/testdata/main.go | 9 + pkg/heartbeat/heartbeat.go | 2 + pkg/project/git.go | 88 ++++++-- pkg/project/git_test.go | 205 ++++++++++++++---- pkg/project/project.go | 31 ++- pkg/project/project_test.go | 50 ++++- pkg/project/subversion_test.go | 5 +- pkg/project/testdata/git_real/file.go | 13 ++ pkg/project/testdata/git_real/file_changed.go | 9 + 15 files changed, 647 insertions(+), 104 deletions(-) create mode 100644 pkg/git/git.go create mode 100644 pkg/git/git_test.go create mode 100644 pkg/git/testdata/main.go create mode 100644 pkg/project/testdata/git_real/file.go create mode 100644 pkg/project/testdata/git_real/file_changed.go diff --git a/cmd/heartbeat/heartbeat.go b/cmd/heartbeat/heartbeat.go index a062fb70..5fd0ac29 100644 --- a/cmd/heartbeat/heartbeat.go +++ b/cmd/heartbeat/heartbeat.go @@ -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, diff --git a/cmd/params/params.go b/cmd/params/params.go index f4d932e1..505405dd 100644 --- a/cmd/params/params.go +++ b/cmd/params/params.go @@ -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 @@ -96,6 +99,7 @@ type ( // Heartbeat contains heartbeat command parameters. Heartbeat struct { Category heartbeat.Category + CountLinesChanged bool CursorPosition *int Entity string EntityType heartbeat.EntityType @@ -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, @@ -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 @@ -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, diff --git a/cmd/params/params_test.go b/cmd/params/params_test.go index 90b79a52..d3e1daca 100644 --- a/cmd/params/params_test.go +++ b/cmd/params/params_test.go @@ -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") @@ -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(), ) } diff --git a/cmd/root.go b/cmd/root.go index 4975cd8d..d8551fe2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "flag" "fmt" "github.com/wakatime/wakatime-cli/pkg/api" @@ -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.") diff --git a/pkg/git/git.go b/pkg/git/git.go new file mode 100644 index 00000000..2f403c94 --- /dev/null +++ b/pkg/git/git.go @@ -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\d+)\s*(?P\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) +} diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go new file mode 100644 index 00000000..1fc17f56 --- /dev/null +++ b/pkg/git/git_test.go @@ -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{"-C", "testdata", "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{"-C", "testdata", "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{"-C", "testdata", "diff", "--numstat", "testdata/main.go"}) + case 2: + assert.Equal(t, args, []string{"-C", "testdata", "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{"-C", "testdata", "diff", "--numstat", "testdata/main.go"}) + case 2: + assert.Equal(t, args, []string{"-C", "testdata", "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{"-C", "testdata", "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) +} diff --git a/pkg/git/testdata/main.go b/pkg/git/testdata/main.go new file mode 100644 index 00000000..6454500a --- /dev/null +++ b/pkg/git/testdata/main.go @@ -0,0 +1,9 @@ +package main + +import "fmt" + +func main() { + sum := 1 + 2 + + fmt.Printf("sum: %d\n", sum) +} diff --git a/pkg/heartbeat/heartbeat.go b/pkg/heartbeat/heartbeat.go index a29031e5..f2282a86 100644 --- a/pkg/heartbeat/heartbeat.go +++ b/pkg/heartbeat/heartbeat.go @@ -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"` diff --git a/pkg/project/git.go b/pkg/project/git.go index 1449bdb3..e20144a2 100644 --- a/pkg/project/git.go +++ b/pkg/project/git.go @@ -5,14 +5,19 @@ import ( "path/filepath" "strings" + "github.com/wakatime/wakatime-cli/pkg/git" "github.com/wakatime/wakatime-cli/pkg/log" "github.com/wakatime/wakatime-cli/pkg/regex" ) // Git contains git data. type Git struct { + // CountLinesChanged when enabled will count lines added and removed. + CountLinesChanged bool // Filepath contains the entity path. Filepath string + // git contains the git client. + GitClient git.Git // ProjectFromGitRemote when enabled uses the git remote as the project name instead of local git folder. ProjectFromGitRemote bool // SubmoduleDisabledPatterns will be matched against the submodule path and if matching, will skip it. @@ -37,6 +42,7 @@ func (g Git) Detect() (Result, bool, error) { return Result{}, false, fmt.Errorf("failed to find submodule: %s", err) } + // nolint:nestif if ok { project := projectOrRemote(filepath.Base(gitdirSubmodule), g.ProjectFromGitRemote, gitdirSubmodule) @@ -48,16 +54,27 @@ func (g Git) Detect() (Result, bool, error) { branch, err := findGitBranch(filepath.Join(gitdirSubmodule, "HEAD")) if err != nil { log.Errorf( - "error finding branch from %q: %s", + "failed to find branch from %q: %s", filepath.Join(filepath.Dir(gitdirSubmodule), "HEAD"), err, ) } + var added, removed *int + + if g.CountLinesChanged { + added, removed, err = g.GitClient.CountLinesChanged() + if err != nil { + log.Errorf("failed to count lines changed: %s", err) + } + } + return Result{ - Project: project, - Branch: branch, - Folder: filepath.Dir(gitdirSubmodule), + Project: project, + Branch: branch, + Folder: filepath.Dir(gitdirSubmodule), + LinesAdded: added, + LinesRemoved: removed, }, true, nil } @@ -70,14 +87,14 @@ func (g Git) Detect() (Result, bool, error) { // Find for gitdir path gitdir, err := findGitdir(dotGit) if err != nil { - return Result{}, false, fmt.Errorf("error finding gitdir: %s", err) + return Result{}, false, fmt.Errorf("failed to find gitdir: %s", err) } // Commonly .git file is present when it's a worktree // Find for commondir file commondir, ok, err := findCommondir(gitdir) if err != nil { - return Result{}, false, fmt.Errorf("error finding commondir: %s", err) + return Result{}, false, fmt.Errorf("failed to find commondir: %s", err) } // we found a commondir file so this is a worktree @@ -87,16 +104,27 @@ func (g Git) Detect() (Result, bool, error) { branch, err := findGitBranch(filepath.Join(gitdir, "HEAD")) if err != nil { log.Errorf( - "error finding branch from %q: %s", + "failed to find branch from %q: %s", filepath.Join(filepath.Dir(dotGit), "HEAD"), err, ) } + var added, removed *int + + if g.CountLinesChanged { + added, removed, err = g.GitClient.CountLinesChanged() + if err != nil { + log.Errorf("failed to count lines changed: %s", err) + } + } + return Result{ - Project: project, - Branch: branch, - Folder: filepath.Dir(commondir), + Project: project, + Branch: branch, + Folder: filepath.Dir(commondir), + LinesAdded: added, + LinesRemoved: removed, }, true, nil } @@ -107,16 +135,27 @@ func (g Git) Detect() (Result, bool, error) { branch, err := findGitBranch(filepath.Join(gitdir, "HEAD")) if err != nil { log.Errorf( - "error finding branch from %q: %s", + "failed to find branch from %q: %s", filepath.Join(filepath.Dir(gitdir), "HEAD"), err, ) } + var added, removed *int + + if g.CountLinesChanged { + added, removed, err = g.GitClient.CountLinesChanged() + if err != nil { + log.Errorf("failed to count lines changed: %s", err) + } + } + return Result{ - Project: project, - Branch: branch, - Folder: filepath.Join(gitdir, ".."), + Project: project, + Branch: branch, + Folder: filepath.Join(gitdir, ".."), + LinesAdded: added, + LinesRemoved: removed, }, true, nil } @@ -125,23 +164,34 @@ func (g Git) Detect() (Result, bool, error) { if ok { gitDir := filepath.Dir(gitConfigFile) + projectDir := filepath.Join(gitDir, "..") + project := projectOrRemote(filepath.Base(projectDir), g.ProjectFromGitRemote, gitDir) branch, err := findGitBranch(filepath.Join(gitDir, "HEAD")) if err != nil { log.Errorf( - "error finding branch from %q: %s", + "failed to find branch from %q: %s", filepath.Join(gitDir, "HEAD"), err, ) } - project := projectOrRemote(filepath.Base(projectDir), g.ProjectFromGitRemote, gitDir) + var added, removed *int + + if g.CountLinesChanged { + added, removed, err = g.GitClient.CountLinesChanged() + if err != nil { + log.Errorf("failed to count lines changed: %s", err) + } + } return Result{ - Project: project, - Branch: branch, - Folder: projectDir, + Project: project, + Branch: branch, + Folder: projectDir, + LinesAdded: added, + LinesRemoved: removed, }, true, nil } diff --git a/pkg/project/git_test.go b/pkg/project/git_test.go index b67d263d..023edd65 100644 --- a/pkg/project/git_test.go +++ b/pkg/project/git_test.go @@ -3,11 +3,14 @@ package project_test import ( "fmt" "os" + "os/exec" "path/filepath" "regexp" "runtime" "testing" + "github.com/wakatime/wakatime-cli/pkg/git" + "github.com/wakatime/wakatime-cli/pkg/heartbeat" "github.com/wakatime/wakatime-cli/pkg/project" "github.com/wakatime/wakatime-cli/pkg/regex" "github.com/wakatime/wakatime-cli/pkg/windows" @@ -18,36 +21,56 @@ import ( ) func TestGit_Detect(t *testing.T) { - fp := setupTestGitBasic(t) + dir := setupTestGitBasic(t) + fp := filepath.Join(dir, "wakatime-cli/src/pkg/file.go") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + assert.Equal(t, args, []string{"-C", filepath.Dir(fp), "diff", "--numstat", fp}) + + return "12 5 pkg/file.go", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + CountLinesChanged: true, + Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + GitClient: gc, } result, detected, err := g.Detect() require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ - Project: "wakatime-cli", - Branch: "master", - Folder: result.Folder, + Project: "wakatime-cli", + Branch: "master", + Folder: result.Folder, + LinesAdded: heartbeat.PointerTo(12), + LinesRemoved: heartbeat.PointerTo(5), }, result) } func TestGit_Detect_BranchWithSlash(t *testing.T) { - fp := setupTestGitBasicBranchWithSlash(t) + dir := setupTestGitBasicBranchWithSlash(t) + + fp := filepath.Join(dir, "wakatime-cli/src/pkg/file.go") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + Filepath: fp, + GitClient: gc, } result, detected, err := g.Detect() require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime-cli", Branch: "feature/detection", @@ -56,17 +79,25 @@ func TestGit_Detect_BranchWithSlash(t *testing.T) { } func TestGit_Detect_DetachedHead(t *testing.T) { - fp := setupTestGitBasicDetachedHead(t) + dir := setupTestGitBasicDetachedHead(t) + + fp := filepath.Join(dir, "wakatime-cli/src/pkg/file.go") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + Filepath: fp, + GitClient: gc, } result, detected, err := g.Detect() require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime-cli", Branch: "", @@ -75,37 +106,43 @@ func TestGit_Detect_DetachedHead(t *testing.T) { } func TestGit_Detect_GitConfigFile_File(t *testing.T) { - fp := setupTestGitFile(t) + dir := setupTestGitFile(t) tests := map[string]struct { Filepath string Project string }{ "main repo": { - Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + Filepath: filepath.Join(dir, "wakatime-cli/src/pkg/file.go"), Project: "wakatime-cli", }, "relative path": { - Filepath: filepath.Join(fp, "feed/src/pkg/file.go"), + Filepath: filepath.Join(dir, "feed/src/pkg/file.go"), Project: "feed", }, "absolute path": { - Filepath: filepath.Join(fp, "mobile/src/pkg/file.go"), + Filepath: filepath.Join(dir, "mobile/src/pkg/file.go"), Project: "mobile", }, } for name, test := range tests { t.Run(name, func(t *testing.T) { + gc := git.New(test.Filepath) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } + g := project.Git{ - Filepath: test.Filepath, + Filepath: test.Filepath, + GitClient: gc, } result, detected, err := g.Detect() require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: test.Project, Branch: "feature/list-elements", @@ -116,17 +153,25 @@ func TestGit_Detect_GitConfigFile_File(t *testing.T) { } func TestGit_Detect_Worktree(t *testing.T) { - fp := setupTestGitWorktree(t) + dir := setupTestGitWorktree(t) + + fp := filepath.Join(dir, "api/src/pkg/file.go") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "api/src/pkg/file.go"), + Filepath: fp, + GitClient: gc, } result, detected, err := g.Detect() require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime-cli", Branch: "feature/api", @@ -135,10 +180,18 @@ func TestGit_Detect_Worktree(t *testing.T) { } func TestGit_Detect_WorktreeGitRemote(t *testing.T) { - fp := setupTestGitWorktree(t) + dir := setupTestGitWorktree(t) + + fp := filepath.Join(dir, "api/src/pkg/file.go") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "api/src/pkg/file.go"), + Filepath: fp, + GitClient: gc, ProjectFromGitRemote: true, } @@ -146,7 +199,7 @@ func TestGit_Detect_WorktreeGitRemote(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime/wakatime-cli", Branch: "feature/api", @@ -155,10 +208,18 @@ func TestGit_Detect_WorktreeGitRemote(t *testing.T) { } func TestGit_Detect_Submodule(t *testing.T) { - fp := setupTestGitSubmodule(t) + dir := setupTestGitSubmodule(t) + + fp := filepath.Join(dir, "wakatime-cli/lib/billing/src/lib/lib.cpp") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/lib/billing/src/lib/lib.cpp"), + Filepath: fp, + GitClient: gc, SubmoduleDisabledPatterns: []regex.Regex{regexp.MustCompile("not_matching")}, } @@ -166,7 +227,7 @@ func TestGit_Detect_Submodule(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "billing", Branch: "master", @@ -175,10 +236,18 @@ func TestGit_Detect_Submodule(t *testing.T) { } func TestGit_Detect_SubmoduleDisabled(t *testing.T) { - fp := setupTestGitSubmodule(t) + dir := setupTestGitSubmodule(t) + + fp := filepath.Join(dir, "wakatime-cli/lib/billing/src/lib/lib.cpp") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/lib/billing/src/lib/lib.cpp"), + Filepath: fp, + GitClient: gc, SubmoduleDisabledPatterns: []regex.Regex{regexp.MustCompile(".*billing.*")}, } @@ -186,7 +255,7 @@ func TestGit_Detect_SubmoduleDisabled(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime-cli", Branch: "feature/billing", @@ -195,10 +264,18 @@ func TestGit_Detect_SubmoduleDisabled(t *testing.T) { } func TestGit_Detect_SubmoduleProjectMap_NotMatch(t *testing.T) { - fp := setupTestGitSubmodule(t) + dir := setupTestGitSubmodule(t) + + fp := filepath.Join(dir, "wakatime-cli/lib/billing/src/lib/lib.cpp") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/lib/billing/src/lib/lib.cpp"), + Filepath: fp, + GitClient: gc, SubmoduleProjectMapPatterns: []project.MapPattern{ { Name: "my-project-1", @@ -211,7 +288,7 @@ func TestGit_Detect_SubmoduleProjectMap_NotMatch(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "billing", Branch: "master", @@ -220,10 +297,18 @@ func TestGit_Detect_SubmoduleProjectMap_NotMatch(t *testing.T) { } func TestGit_Detect_SubmoduleProjectMap(t *testing.T) { - fp := setupTestGitSubmodule(t) + dir := setupTestGitSubmodule(t) + + fp := filepath.Join(dir, "wakatime-cli/lib/billing/src/lib/lib.cpp") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/lib/billing/src/lib/lib.cpp"), + Filepath: fp, + GitClient: gc, SubmoduleProjectMapPatterns: []project.MapPattern{ { Name: "my-project-1", @@ -236,7 +321,7 @@ func TestGit_Detect_SubmoduleProjectMap(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "my-project-1", Branch: "master", @@ -245,10 +330,18 @@ func TestGit_Detect_SubmoduleProjectMap(t *testing.T) { } func TestGit_Detect_SubmoduleGitRemote(t *testing.T) { - fp := setupTestGitSubmodule(t) + dir := setupTestGitSubmodule(t) + + fp := filepath.Join(dir, "wakatime-cli/lib/billing/src/lib/lib.cpp") + + gc := git.New(fp) + gc.GitCmd = func(args ...string) (string, error) { + return "", nil + } g := project.Git{ - Filepath: filepath.Join(fp, "wakatime-cli/lib/billing/src/lib/lib.cpp"), + Filepath: fp, + GitClient: gc, ProjectFromGitRemote: true, SubmoduleDisabledPatterns: []regex.Regex{regexp.MustCompile("not_matching")}, } @@ -257,7 +350,7 @@ func TestGit_Detect_SubmoduleGitRemote(t *testing.T) { require.NoError(t, err) assert.True(t, detected) - assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Contains(t, result.Folder, filepath.Join(dir, "wakatime-cli")) assert.Equal(t, project.Result{ Project: "wakatime/billing", Branch: "master", @@ -265,7 +358,29 @@ func TestGit_Detect_SubmoduleGitRemote(t *testing.T) { }, result) } -func setupTestGitBasic(t *testing.T) (fp string) { +func setupTestGitReal(t *testing.T) string { + tmpDir := t.TempDir() + + tmpDir, err := realpath.Realpath(tmpDir) + require.NoError(t, err) + + if runtime.GOOS == "windows" { + tmpDir = windows.FormatFilePath(tmpDir) + } + + err = os.MkdirAll(filepath.Join(tmpDir, "wakatime-cli/src/pkg"), os.FileMode(int(0700))) + require.NoError(t, err) + + copyFile(t, "testdata/git_real/file.go", filepath.Join(tmpDir, "wakatime-cli/src/pkg/file.go")) + + // setup a real git repo + err = exec.Command("git", "-C", filepath.Join(tmpDir, "wakatime-cli"), "init", "-b", "master").Run() + require.NoError(t, err) + + return tmpDir +} + +func setupTestGitBasic(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) @@ -292,7 +407,7 @@ func setupTestGitBasic(t *testing.T) (fp string) { return tmpDir } -func setupTestGitBasicBranchWithSlash(t *testing.T) (fp string) { +func setupTestGitBasicBranchWithSlash(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) @@ -319,7 +434,7 @@ func setupTestGitBasicBranchWithSlash(t *testing.T) (fp string) { return tmpDir } -func setupTestGitBasicDetachedHead(t *testing.T) (fp string) { +func setupTestGitBasicDetachedHead(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) @@ -346,7 +461,7 @@ func setupTestGitBasicDetachedHead(t *testing.T) (fp string) { return tmpDir } -func setupTestGitFile(t *testing.T) (fp string) { +func setupTestGitFile(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) @@ -406,7 +521,7 @@ func setupTestGitFile(t *testing.T) (fp string) { return tmpDir } -func setupTestGitWorktree(t *testing.T) (fp string) { +func setupTestGitWorktree(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) @@ -456,7 +571,7 @@ func setupTestGitWorktree(t *testing.T) (fp string) { return tmpDir } -func setupTestGitSubmodule(t *testing.T) (fp string) { +func setupTestGitSubmodule(t *testing.T) string { tmpDir := t.TempDir() tmpDir, err := realpath.Realpath(tmpDir) diff --git a/pkg/project/project.go b/pkg/project/project.go index 7160535b..b6e219f0 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/wakatime/wakatime-cli/pkg/git" "github.com/wakatime/wakatime-cli/pkg/heartbeat" "github.com/wakatime/wakatime-cli/pkg/log" "github.com/wakatime/wakatime-cli/pkg/regex" @@ -97,13 +98,17 @@ type ( // Result contains the result of Detect(). Result struct { - Project string - Branch string - Folder string + Branch string + Folder string + LinesAdded *int + LinesRemoved *int + Project string } // Config contains project detection configurations. Config struct { + // CountLinesChanged when enabled counts the lines added and removed. + CountLinesChanged bool // HideProjectNames determines if the project name should be obfuscated by matching its path. HideProjectNames []regex.Regex // Patterns contains the overridden project name per path. @@ -161,9 +166,10 @@ func WithDetection(config Config) heartbeat.HandleOption { // across all IDEs instead of sometimes using alternate project when file is unsaved if result.Project == "" || result.Branch == "" || result.Folder == "" { revControlResult := DetectWithRevControl( + config.CountLinesChanged, + config.ProjectFromGitRemote, config.Submodule.DisabledPatterns, config.Submodule.MapPatterns, - config.ProjectFromGitRemote, DetecterArg{Filepath: h.Entity, ShouldRun: h.EntityType == heartbeat.FileType}, DetecterArg{Filepath: h.ProjectPathOverride, ShouldRun: true}, ) @@ -171,6 +177,8 @@ func WithDetection(config Config) heartbeat.HandleOption { result.Project = firstNonEmptyString(result.Project, revControlResult.Project) result.Branch = firstNonEmptyString(result.Branch, revControlResult.Branch) result.Folder = firstNonEmptyString(result.Folder, revControlResult.Folder) + result.LinesAdded = revControlResult.LinesAdded + result.LinesRemoved = revControlResult.LinesRemoved } // fourth, use alternate project @@ -215,6 +223,8 @@ func WithDetection(config Config) heartbeat.HandleOption { hh[n].Project = &result.Project hh[n].Branch = &result.Branch hh[n].ProjectPath = result.Folder + hh[n].LinesAdded = result.LinesAdded + hh[n].LinesRemoved = result.LinesRemoved } return next(hh) @@ -259,9 +269,10 @@ func Detect(patterns []MapPattern, args ...DetecterArg) (Result, DetectorID) { // DetectWithRevControl finds the current project and branch from rev control. func DetectWithRevControl( + countLinesChanged bool, + projectFromGitRemote bool, submoduleDisabledPatterns []regex.Regex, submoduleProjectMapPatterns []MapPattern, - projectFromGitRemote bool, args ...DetecterArg) Result { for _, arg := range args { if !arg.ShouldRun || arg.Filepath == "" { @@ -270,7 +281,9 @@ func DetectWithRevControl( var revControlPlugins = []Detecter{ Git{ + CountLinesChanged: countLinesChanged, Filepath: arg.Filepath, + GitClient: git.New(arg.Filepath), ProjectFromGitRemote: projectFromGitRemote, SubmoduleDisabledPatterns: submoduleDisabledPatterns, SubmoduleProjectMapPatterns: submoduleProjectMapPatterns, @@ -297,9 +310,11 @@ func DetectWithRevControl( if detected { return Result{ - Project: result.Project, - Branch: result.Branch, - Folder: result.Folder, + Project: result.Project, + Branch: result.Branch, + Folder: result.Folder, + LinesAdded: result.LinesAdded, + LinesRemoved: result.LinesRemoved, } } } diff --git a/pkg/project/project_test.go b/pkg/project/project_test.go index 2ec442b8..8915dbe8 100644 --- a/pkg/project/project_test.go +++ b/pkg/project/project_test.go @@ -2,6 +2,7 @@ package project_test import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -536,9 +537,10 @@ func TestDetectWithRevControl_GitDetected(t *testing.T) { fp := setupTestGitBasic(t) result := project.DetectWithRevControl( + false, + false, []regex.Regex{}, []project.MapPattern{}, - false, project.DetecterArg{ Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), ShouldRun: true, @@ -557,9 +559,10 @@ func TestDetectWithRevControl_GitRemoteDetected(t *testing.T) { fp := setupTestGitBasic(t) result := project.DetectWithRevControl( + false, + true, []regex.Regex{}, []project.MapPattern{}, - true, project.DetecterArg{ Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), ShouldRun: true, @@ -574,6 +577,43 @@ func TestDetectWithRevControl_GitRemoteDetected(t *testing.T) { }, result) } +func TestDetectWithRevControl_GitDetected_CountLinesChanged(t *testing.T) { + skipIfGitNotInstalled(t) + + fp := setupTestGitReal(t) + + err := exec.Command("git", "-C", filepath.Join(fp, "wakatime-cli"), "add", ".").Run() + require.NoError(t, err) + + err = exec.Command("git", "-C", filepath.Join(fp, "wakatime-cli"), "commit", "-m", `"Add file.go"`).Run() + require.NoError(t, err) + + copyFile(t, "testdata/git_real/file_changed.go", filepath.Join(fp, "wakatime-cli/src/pkg/file.go")) + + err = exec.Command("git", "-C", filepath.Join(fp, "wakatime-cli"), "add", ".").Run() + require.NoError(t, err) + + result := project.DetectWithRevControl( + true, + false, + []regex.Regex{}, + []project.MapPattern{}, + project.DetecterArg{ + Filepath: filepath.Join(fp, "wakatime-cli/src/pkg/file.go"), + ShouldRun: true, + }, + ) + + assert.Contains(t, result.Folder, filepath.Join(fp, "wakatime-cli")) + assert.Equal(t, project.Result{ + Project: "wakatime-cli", + Folder: result.Folder, + Branch: "master", + LinesAdded: heartbeat.PointerTo(1), + LinesRemoved: heartbeat.PointerTo(5), + }, result) +} + func TestDetect_NoProjectDetected(t *testing.T) { tmpFile, err := os.CreateTemp(t.TempDir(), "wakatime") require.NoError(t, err) @@ -684,3 +724,9 @@ func (m *mockSender) SendHeartbeats(hh []heartbeat.Heartbeat) ([]heartbeat.Resul m.SendHeartbeatsFnInvoked = true return m.SendHeartbeatsFn(hh) } + +func skipIfGitNotInstalled(t *testing.T) { + if err := exec.Command("git", "--version").Run(); err != nil { + t.Skip("Skipping because git is not installed in this machine.") + } +} diff --git a/pkg/project/subversion_test.go b/pkg/project/subversion_test.go index bd96634e..7975130e 100644 --- a/pkg/project/subversion_test.go +++ b/pkg/project/subversion_test.go @@ -143,10 +143,7 @@ func findSvnBinary() (string, bool) { } for _, loc := range locations { - cmd := exec.Command(loc, "--version") - - err := cmd.Run() - if err != nil { + if err := exec.Command(loc, "--version").Run(); err != nil { continue } diff --git a/pkg/project/testdata/git_real/file.go b/pkg/project/testdata/git_real/file.go new file mode 100644 index 00000000..ce7824d9 --- /dev/null +++ b/pkg/project/testdata/git_real/file.go @@ -0,0 +1,13 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") + + fmt.Println(sum(1, 2)) +} + +func sum(a, b int) int { + return a + b +} diff --git a/pkg/project/testdata/git_real/file_changed.go b/pkg/project/testdata/git_real/file_changed.go new file mode 100644 index 00000000..1f6b2501 --- /dev/null +++ b/pkg/project/testdata/git_real/file_changed.go @@ -0,0 +1,9 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") + + fmt.Println("sum is fun") +}