diff --git a/cmd/git-lzc/main.go b/cmd/git-lzc/main.go index a5ba525..d7997dd 100644 --- a/cmd/git-lzc/main.go +++ b/cmd/git-lzc/main.go @@ -3,14 +3,12 @@ package main import ( "fmt" "os" - "strings" lazycommit "github.com/spenserblack/git-lazy-commit" ) func main() { - repo, err := lazycommit.OpenRepo(".") - onError(err) + repo := lazycommit.Repo(".") noStaged, err := repo.NoStaged() onError(err) @@ -19,12 +17,10 @@ func main() { onError(repo.StageAll()) } - hash, msg, err := repo.Commit() + out, err := repo.Commit() onError(err) - msgLines := strings.Split(msg, "\n") - - fmt.Printf("[%s] %s\n", hash, msgLines[0]) + fmt.Printf("%s", out) } func onError(err error) { diff --git a/commit.go b/commit.go index 31182bf..3d76822 100644 --- a/commit.go +++ b/commit.go @@ -5,74 +5,57 @@ import ( "fmt" "strings" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/spenserblack/git-lazy-commit/pkg/fileutils" ) // Commit commits all changes in the repository. // -// It returns the commit hash and the commit message. -func (r *LazyRepo) Commit() (hash plumbing.Hash, msg string, err error) { - msg, err = r.CommitMsg() +// It returns the output of the commit command. +func (repo Repo) Commit() ([]byte, error) { + msg, err := repo.CommitMsg() if err != nil { - return + return nil, err } - - hash, err = r.wt.Commit(msg, &git.CommitOptions{}) - return + cmd, err := repo.cmd("commit", "-m", msg) + if err != nil { + return nil, err + } + return cmd.Output() } // CommitMsg builds a commit message using the tracked files in the repository. -func (r *LazyRepo) CommitMsg() (string, error) { - status, err := r.status() +func (repo Repo) CommitMsg() (string, error) { + statuses, err := repo.Status() if err != nil { return "", err } - for filename, fileStatus := range status { - if fileStatus.Staging == git.Unmodified || fileStatus.Staging == git.Untracked { - delete(status, filename) + + // NOTE: Filtering to only statuses that are staged and can be used for the commit message. + commitableStatuses := make([]StatusRecord, 0, len(statuses)) + for _, status := range statuses { + if _, ok := statusMap[status.Staged]; ok { + commitableStatuses = append(commitableStatuses, status) } } - if len(status) == 0 { + if len(commitableStatuses) == 0 { return "", errors.New("no tracked files") } - if len(status) == 1 { - for filename, fileStatus := range status { - return singleFileMsg(filename, fileStatus), nil - } - } - return multiFileMsg(status), nil -} -func singleFileMsg(filename string, fileStatus *git.FileStatus) string { - statusString := "" - switch fileStatus.Staging { - case git.Added: - statusString = "Create" - case git.Deleted: - statusString = "Delete" - case git.Modified: - statusString = "Update" - case git.Renamed: - statusString = "Rename to" - case git.Copied: - statusString = "Copy to" - default: - statusString = "Do something to" + if len(commitableStatuses) == 1 { + status := commitableStatuses[0] + return status.Message(), nil } - return fmt.Sprintf("%s %s", statusString, filename) + return multiFileMsg(commitableStatuses), nil } -func multiFileMsg(status git.Status) string { +// MultiFileMsg builds a commit message from multiple files. +func multiFileMsg(statuses []StatusRecord) string { var builder strings.Builder - - filenames := make([]string, 0, len(status)) - for name := range status { - filenames = append(filenames, name) + filenames := make([]string, 0, len(statuses)) + for _, status := range statuses { + filenames = append(filenames, status.Path) } sharedDir := fileutils.SharedDirectory(filenames) @@ -84,9 +67,8 @@ func multiFileMsg(status git.Status) string { } builder.WriteRune('\n') - for filename, fileStatus := range status { - msgItem := singleFileMsg(filename, fileStatus) - builder.WriteString(fmt.Sprintf("- %s\n", msgItem)) + for _, status := range statuses { + builder.WriteString(fmt.Sprintf("- %s\n", status.Message())) } return builder.String() diff --git a/commit_test.go b/commit_test.go index b39ba70..33cf391 100644 --- a/commit_test.go +++ b/commit_test.go @@ -3,53 +3,73 @@ package lazycommit import ( "strings" "testing" - - gitconfig "github.com/go-git/go-git/v5/config" ) // Tests that a commit message can't be built when there are no staged changes. -func TestBuildCommitMessageNoStaged(t *testing.T) { +func TestBuildCommitMessage(t *testing.T) { + t.Log("Creating a new repo.") dir := tempRepo(t) - repo, err := OpenRepo(dir) + repo := Repo(dir) + + _, err := repo.CommitMsg() + if err == nil && err.Error() != "no tracked files" { + t.Errorf(`Expected "no tracked files", got %v`, err) + } + + f := commitFile(t, dir, "test.txt", "test") + defer f.Close() + + t.Log(`Modifying test.txt`) + commitFile(t, dir, "test.txt", "") + addFile(t, dir, "test.txt", "different text") + + msg, err := repo.CommitMsg() if err != nil { t.Fatal(err) } - _, err = repo.CommitMsg() - if err == nil { - t.Fatal("expected error") + if msg != "Update test.txt" { + t.Errorf(`Expected "Update test.txt", got %v`, msg) } -} -// Tests that commit commits all files in the worktree. -func TestCommit(t *testing.T) { - dir := tempRepo(t) - updateConfig(t, dir, func(config *gitconfig.Config) { - config.User.Name = "Test User" - config.User.Email = "test@example.com" - }) - addFile(t, dir, "test.txt", "test") + t.Log(`Adding a new file`) addFile(t, dir, "test2.txt", "test") - repo, err := OpenRepo(dir) + msg, err = repo.CommitMsg() if err != nil { t.Fatal(err) } - - _, msg, err := repo.Commit() - if err != nil { - t.Fatal(err) + lines := strings.Split(msg, "\n") + if lines[0] != "Update files" { + t.Errorf(`Expected "Update files" in the header, got %v`, lines[0]) } + if lines[1] != "" { + t.Errorf(`Expected an empty line after the header, got %v`, lines[1]) + } + body := strings.Join(lines[2:], "\n") + t.Logf("Body:\n %v", body) + for _, want := range []string{"- Update test.txt", "- Create test2.txt"} { + if !strings.Contains(body, want) { + t.Errorf(`Expected %v in the body`, want) + } + } +} - wantHeader := "Update files" - wantBodyLines := []string{"- Create test.txt", "- Create test2.txt"} +// TestBuildCommitMessageWithRename tests that a commit message can be built when a file is renamed. +func TestBuildCommitMessageWithRename(t *testing.T) { + dir := tempRepo(t) + repo := Repo(dir) - if !strings.HasPrefix(msg, wantHeader) { - t.Errorf("expected commit message to start with %q, got %q", wantHeader, msg) - } + f := commitFile(t, dir, "foo.txt", "test") + defer f.Close() - for _, line := range wantBodyLines { - if !strings.Contains(msg, line) { - t.Errorf("expected commit message to contain %q, got %q", line, msg) - } + t.Log(`Renaming test.txt to test2.txt`) + moveFile(t, dir, "foo.txt", "bar.txt") + + msg, err := repo.CommitMsg() + if err != nil { + t.Fatal(err) + } + if msg != "Rename foo.txt to bar.txt" { + t.Errorf(`Expected "Rename foo.txt to bar.txt", got %v`, msg) } } diff --git a/go.mod b/go.mod index 2715f50..50dadb9 100644 --- a/go.mod +++ b/go.mod @@ -2,27 +2,4 @@ module github.com/spenserblack/git-lazy-commit go 1.20 -require ( - github.com/go-git/go-billy/v5 v5.4.1 - github.com/go-git/go-git/v5 v5.5.2 -) - -require ( - github.com/Microsoft/go-winio v0.5.2 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect - github.com/acomagu/bufpipe v1.0.3 // indirect - github.com/cloudflare/circl v1.1.0 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/go-git/gcfg v1.5.0 // indirect - github.com/imdario/mergo v0.3.13 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/pjbgf/sha1cd v0.2.3 // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/skeema/knownhosts v1.1.0 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.3.0 // indirect - golang.org/x/net v0.2.0 // indirect - golang.org/x/sys v0.3.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect -) +require github.com/cli/safeexec v1.0.1 diff --git a/go.sum b/go.sum index 09513a9..95bde4d 100644 --- a/go.sum +++ b/go.sum @@ -1,130 +1,2 @@ -github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 h1:ra2OtmuW0AE5csawV4YXMNGNQQXvLRps3z2Z59OPO+I= -github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8= -github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= -github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= -github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= -github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= -github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= -github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-billy/v5 v5.4.0/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= -github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= -github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= -github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ= -github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= -github.com/go-git/go-git/v5 v5.5.2 h1:v8lgZa5k9ylUw+OR/roJHTxR4QItsNFI5nKtAXFuynw= -github.com/go-git/go-git/v5 v5.5.2/go.mod h1:BE5hUJ5yaV2YMxhmaP4l6RBQ08kMxKSPD4BlxtH7OjI= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= -github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pjbgf/sha1cd v0.2.3 h1:uKQP/7QOzNtKYH7UTohZLcjF5/55EnTw0jO/Ru4jZwI= -github.com/pjbgf/sha1cd v0.2.3/go.mod h1:HOK9QrgzdHpbc2Kzip0Q1yi3M2MFGPADtR6HjG65m5M= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0= -github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= diff --git a/helpers_test.go b/helpers_test.go index ff63ebf..5445b46 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,12 +1,10 @@ package lazycommit import ( + "os" + "os/exec" + "path" "testing" - - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-git/v5" - gitconfig "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing/object" ) // Helper function to create a new repository in a temporary directory. @@ -14,111 +12,79 @@ import ( func tempRepo(t *testing.T) string { t.Helper() dir := t.TempDir() - _, err := git.PlainInit(dir, false) - if err != nil { - t.Fatal(err) - } - return dir -} - -// Helper function that writes a file, but does not stage or commit it. -func writeFile(t *testing.T, dir, filename, contents string) (*git.Worktree, billy.File) { - t.Helper() - rawRepo, err := git.PlainOpen(dir) - if err != nil { - t.Fatal(err) - } - wt, err := rawRepo.Worktree() + cmd := exec.Command("git", "init") + cmd.Dir = dir + err := cmd.Run() if err != nil { t.Fatal(err) } - f, err := wt.Filesystem.Create(filename) + cmd = exec.Command("git", "config", "--local", "user.name", "Test User") + cmd.Dir = dir + err = cmd.Run() if err != nil { t.Fatal(err) } - _, err = f.Write([]byte(contents)) + cmd = exec.Command("git", "config", "--local", "user.email", "test@example.com") + cmd.Dir = dir + err = cmd.Run() if err != nil { t.Fatal(err) } - - return wt, f -} - -// Helper function that writes a file and stages it (but doesn't commit it). -func addFile(t *testing.T, dir, filename, contents string) billy.File { - t.Helper() - wt, f := writeFile(t, dir, filename, contents) - _, err := wt.Add(filename) + cmd = exec.Command("git", "config", "--local", "commit.gpgsign", "false") + cmd.Dir = dir + err = cmd.Run() if err != nil { t.Fatal(err) } - - return f + return dir } -// Helper function that commits a file to the repository. -func commitFile(t *testing.T, dir, filename, contents string) billy.File { +// Helper function that writes a file, but does not stage or commit it. +func writeFile(t *testing.T, dir, filename, contents string) *os.File { t.Helper() - rawRepo, err := git.PlainOpen(dir) - if err != nil { - t.Fatal(err) - } - wt, err := rawRepo.Worktree() + f, err := os.Create(path.Join(dir, filename)) if err != nil { t.Fatal(err) } - f := addFile(t, dir, filename, contents) - _, err = wt.Commit("test commit", &git.CommitOptions{ - AllowEmptyCommits: true, - Author: &object.Signature{ - Name: "Test", - Email: "test@example.com", - }, - }) + _, err = f.WriteString(contents) if err != nil { t.Fatal(err) } return f } -// Helper function that gets the working tree of a repository. -func getWorktree(t *testing.T, dir string) *git.Worktree { +// Helper function that writes a file and stages it (but doesn't commit it). +func addFile(t *testing.T, dir, filename, contents string) *os.File { t.Helper() - rawRepo, err := git.PlainOpen(dir) - if err != nil { - t.Fatal(err) - } - wt, err := rawRepo.Worktree() + f := writeFile(t, dir, filename, contents) + cmd := exec.Command("git", "add", filename) + cmd.Dir = dir + err := cmd.Run() if err != nil { t.Fatal(err) } - return wt + return f } -// Helper function that gets the status of a repository. -func getStatus(t *testing.T, dir string) git.Status { +// Helper function that commits a file to the repository. +func commitFile(t *testing.T, dir, filename, contents string) *os.File { t.Helper() - wt := getWorktree(t, dir) - status, err := wt.Status() + f := addFile(t, dir, filename, contents) + cmd := exec.Command("git", "commit", "-m", "test") + cmd.Dir = dir + err := cmd.Run() if err != nil { t.Fatal(err) } - return status + return f } -// Helper function that updates a repo's config. -func updateConfig(t *testing.T, dir string, f func(*gitconfig.Config)) { +// Helper function that moves a file. +func moveFile(t *testing.T, dir, oldName, newName string) { t.Helper() - rawRepo, err := git.PlainOpen(dir) - if err != nil { - t.Fatal(err) - } - config, err := rawRepo.Config() - if err != nil { - t.Fatal(err) - } - f(config) - err = rawRepo.Storer.SetConfig(config) + cmd := exec.Command("git", "mv", oldName, newName) + cmd.Dir = dir + err := cmd.Run() if err != nil { t.Fatal(err) } diff --git a/lazycommit.go b/lazycommit.go index 3019632..8caa26d 100644 --- a/lazycommit.go +++ b/lazycommit.go @@ -1,54 +1,3 @@ // Package lazycommit mostly provides wrappers around go-git to make it easier for // "lazy" usage. package lazycommit - -import "github.com/go-git/go-git/v5" - -// LazyRepo is a wrapper around go-git's Repository for simpler usage. -type LazyRepo struct { - *git.Repository - wt *git.Worktree -} - -// OpenRepo opens a repository at the given path. -func OpenRepo(path string) (*LazyRepo, error) { - repo, err := git.PlainOpen(path) - if err != nil { - return nil, err - } - wt, err := repo.Worktree() - if err != nil { - return nil, err - } - return &LazyRepo{ - Repository: repo, - wt: wt, - }, nil -} - -// NoStaged checks if there are no staged changes (added files, changed files, removed files) -// in the repository. -func (r *LazyRepo) NoStaged() (bool, error) { - status, err := r.status() - if err != nil { - return false, err - } - - for _, file := range status { - if file.Staging != git.Unmodified && file.Staging != git.Untracked { - return false, nil - } - } - - return true, nil -} - -// StageAll stages all changes in the repository. -func (r *LazyRepo) StageAll() error { - return r.wt.AddWithOptions(&git.AddOptions{All: true}) -} - -// Status gets the repo's status. -func (r *LazyRepo) status() (git.Status, error) { - return r.wt.Status() -} diff --git a/lazycommit_test.go b/lazycommit_test.go deleted file mode 100644 index a42c7c7..0000000 --- a/lazycommit_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package lazycommit - -import ( - "testing" - - "github.com/go-git/go-git/v5" -) - -// Tests that OpenRepo returns a LazyRepo if the repository can be opened. -func TestOpenRepo(t *testing.T) { - dir := tempRepo(t) - var ( - repo *LazyRepo - err error - ) - repo, err = OpenRepo(dir) - if err != nil { - t.Fatal(err) - } - if repo == nil { - t.Fatal("repo is nil") - } -} - -// Tests that, if a repo cannot be opened, OpenRepo returns an error. -func TestOpenRepoError(t *testing.T) { - repo, err := OpenRepo(t.TempDir()) - if err == nil { - t.Fatal("expected error") - } - if repo != nil { - t.Fatal("expected repo to be nil") - } -} - -// Tests that NoStaged returns true if there are no staged changes. -func TestNoStagedChanges(t *testing.T) { - dir := tempRepo(t) - // NOTE: Committing a file so that there's something in the worktree. - f := commitFile(t, dir, "test.txt", "test") - // NOTE: Adding some unstaged contents to the file - _, err := f.Write([]byte("changes")) - if err != nil { - t.Fatal(err) - } - - repo, err := OpenRepo(dir) - if err != nil { - t.Fatal(err) - } - noStaged, err := repo.NoStaged() - if err != nil { - t.Fatal(err) - } - if !noStaged { - t.Fatal("expected no staged changes") - } -} - -// Tests that NoStaged returns true if there new files are not staged. -func TestNoStagedNewFiles(t *testing.T) { - dir := tempRepo(t) - // NOTE: Committing a file so that there's something in the worktree. - commitFile(t, dir, "test.txt", "test") - writeFile(t, dir, "test2.txt", "test") - - repo, err := OpenRepo(dir) - if err != nil { - t.Fatal(err) - } - noStaged, err := repo.NoStaged() - if err != nil { - t.Fatal(err) - } - if !noStaged { - t.Logf("status: %v", getStatus(t, dir)) - t.Fatal("expected no staged changes") - } -} - -// Tests that NoStaged returns false if there are staged changes. -func TestNoStagedStaged(t *testing.T) { - dir := tempRepo(t) - // NOTE: Committing a file so that there's something in the worktree. - commitFile(t, dir, "test.txt", "test") - - repo, err := OpenRepo(dir) - if err != nil { - t.Fatal(err) - } - addFile(t, dir, "test2.txt", "test") - - noStaged, err := repo.NoStaged() - if err != nil { - t.Fatal(err) - } - if noStaged { - t.Fatal("expected staged changes") - } -} - -// Tests that StageAll stages all changes in the repository. -func TestStageAll(t *testing.T) { - dir := tempRepo(t) - // NOTE: Committing a file so that there's something in the worktree. - commitFile(t, dir, "test.txt", "test") - writeFile(t, dir, "test2.txt", "test") - - repo, err := OpenRepo(dir) - if err != nil { - t.Fatal(err) - } - err = repo.StageAll() - if err != nil { - t.Fatal(err) - } - - status := getStatus(t, dir) - - if fileStatus := status.File("test2.txt"); fileStatus.Staging != git.Added { - t.Errorf("expected test2.txt to be staged, got %v", fileStatus.Staging) - } -} diff --git a/repo.go b/repo.go new file mode 100644 index 0000000..98ac07d --- /dev/null +++ b/repo.go @@ -0,0 +1,23 @@ +package lazycommit + +import ( + "os/exec" + + "github.com/cli/safeexec" +) + +// Repo is a path to a git repository. Used to call the "git" command on the +// repository. +type Repo string + +// Cmd runs a git command on the repository. +func (repo Repo) cmd(args ...string) (*exec.Cmd, error) { + gitBin, err := safeexec.LookPath("git") + if err != nil { + return nil, err + } + + cmd := exec.Command(gitBin, args...) + cmd.Dir = string(repo) + return cmd, nil +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..0575404 --- /dev/null +++ b/status.go @@ -0,0 +1,100 @@ +package lazycommit + +import ( + "strings" +) + +// StatusRecord represents a single status record from "git status". +type StatusRecord struct { + // Staged is the staged status of the file. + Staged rune + // Unstaged is the unstaged status of the file. + Unstaged rune + // Path is the path to the file. + Path string + // Src is the original path for a rename or copy. + Src string +} + +// Message returns a human-readable message usable for a commit message. +func (s StatusRecord) Message() string { + var builder strings.Builder + builder.WriteString(statusMap[s.Staged]) + if s.Src != "" { + builder.WriteRune(' ') + builder.WriteString(s.Src) + builder.WriteString(" to") + } + builder.WriteRune(' ') + builder.WriteString(s.Path) + return builder.String() +} + +// StatusMap maps status codes from "git status --porcelain" to human-readable, imperative +// verbs. +// +// NOTE: See https://git-scm.com/docs/git-status#_short_format +var statusMap = map[rune]string{ + 'M': "Update", + 'A': "Create", + 'D': "Delete", + // NOTE: With -z, the *new* filename is followed by the old filename, separated by a NUL. + 'R': "Rename", + 'C': "Copy", + 'T': "Change type of", + // NOTE: '?' is untracked, ' ' is unmodified + // NOTE: '!' is ignored, 'U' is unmerged +} + +// NoStaged checks if there are no staged changes (added files, changed files, removed files) +// in the repository. +func (repo Repo) NoStaged() (bool, error) { + statuses, err := repo.Status() + if err != nil { + return false, err + } + for _, status := range statuses { + if status.Staged != ' ' && status.Staged != '?' { + return false, nil + } + } + return true, nil +} + +// Status gets and parses the repo's status. +func (repo Repo) Status() ([]StatusRecord, error) { + // TODO: Test this method with a variety of added, moved, deleted, and modified files. + cmd, err := repo.cmd("status", "--porcelain", "-z") + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, err + } + statuses := strings.Split(string(out), "\x00") + records := make([]StatusRecord, 0, len(statuses)) + + for i := 0; i < len(statuses); i++ { + status := []rune(statuses[i]) + if len(status) == 0 { + continue + } + stagedStatus := status[0] + unstagedStatus := status[1] + path := string(status[3:]) + src := "" + if stagedStatus == 'R' || stagedStatus == 'C' { + i++ + src = statuses[i] + } + records = append(records, StatusRecord{ + Staged: stagedStatus, + Unstaged: unstagedStatus, + Path: path, + Src: src, + }) + } + + return records, nil +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..e2a078c --- /dev/null +++ b/status_test.go @@ -0,0 +1,41 @@ +package lazycommit + +import "testing" + +// TestNoStagedChanges tests that NoStaged returns true if there are no staged changes, and false otherwise. +func TestNoStagedChanges(t *testing.T) { + t.Log("Creating a new repo.") + dir := tempRepo(t) + repo := Repo(dir) + noStaged, err := repo.NoStaged() + if err != nil { + t.Fatal(err) + } + if !noStaged { + t.Error("expected no staged changes") + } + + t.Log("Committing a file so that there's something in the worktree.") + f := commitFile(t, dir, "test.txt", "test") + defer f.Close() + + noStaged, err = repo.NoStaged() + if err != nil { + t.Fatal(err) + } + if !noStaged { + t.Error("expected no staged changes") + } + + t.Log("Adding a staged file.") + addFile(t, dir, "test2.txt", "test") + + noStaged, err = repo.NoStaged() + if err != nil { + t.Fatal(err) + } + + if noStaged { + t.Error("expected staged changes") + } +} diff --git a/worktree.go b/worktree.go new file mode 100644 index 0000000..dc419da --- /dev/null +++ b/worktree.go @@ -0,0 +1,10 @@ +package lazycommit + +// StageAll stages all changes in the repository. +func (repo Repo) StageAll() error { + cmd, err := repo.cmd("add", "--all") + if err != nil { + return err + } + return cmd.Run() +} diff --git a/worktree_test.go b/worktree_test.go new file mode 100644 index 0000000..7693a34 --- /dev/null +++ b/worktree_test.go @@ -0,0 +1,40 @@ +package lazycommit + +import ( + "os/exec" + "testing" +) + +// TestStageAll tests that all changes are staged. +func TestStageAll(t *testing.T) { + dir := tempRepo(t) + repo := Repo(dir) + + writeFile(t, dir, "test.txt", "test") + writeFile(t, dir, "test2.txt", "test") + + err := repo.StageAll() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + filename string + want string + }{ + {"test.txt", "A test.txt\x00"}, + {"test2.txt", "A test2.txt\x00"}, + } + + for _, tt := range tests { + cmd := exec.Command("git", "status", "--porcelain", "-z", "--", tt.filename) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + if string(out) != tt.want { + t.Errorf("expected %s status to be %q, got %q", tt.filename, tt.want, string(out)) + } + } +}