From ebb87c874edc2fe13aff8b597dafbaf7a2af557d Mon Sep 17 00:00:00 2001 From: Richard North Date: Fri, 16 Apr 2021 12:05:29 +0100 Subject: [PATCH] Implement `commit` --- cmd/clone/clone_test.go | 18 ++--- cmd/commit/commit.go | 83 +++++++++++++++++++++ cmd/commit/commit_test.go | 116 +++++++++++++++++++++++++++++ cmd/root.go | 2 + internal/executor/executor.go | 18 +++++ internal/executor/fake_executor.go | 22 ++++-- internal/git/fake_git.go | 33 +++++--- internal/git/git.go | 28 +++++++ 8 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 cmd/commit/commit.go create mode 100644 cmd/commit/commit_test.go diff --git a/cmd/clone/clone_test.go b/cmd/clone/clone_test.go index 1e779b2..5727fce 100644 --- a/cmd/clone/clone_test.go +++ b/cmd/clone/clone_test.go @@ -72,8 +72,8 @@ func TestItLogsCheckoutErrorsButContinuesToTryAll(t *testing.T) { {"work/org", "org/repo2"}, }) fakeGit.AssertCalledWith(t, [][]string{ - {"work/org/repo1", testsupport.Pwd()}, - {"work/org/repo2", testsupport.Pwd()}, + {"checkout", "work/org/repo1", testsupport.Pwd()}, + {"checkout", "work/org/repo2", testsupport.Pwd()}, }) } @@ -96,8 +96,8 @@ func TestItClonesReposFoundInReposFile(t *testing.T) { {"work/org", "org/repo2"}, }) fakeGit.AssertCalledWith(t, [][]string{ - {"work/org/repo1", testsupport.Pwd()}, - {"work/org/repo2", testsupport.Pwd()}, + {"checkout", "work/org/repo1", testsupport.Pwd()}, + {"checkout", "work/org/repo2", testsupport.Pwd()}, }) } @@ -117,8 +117,8 @@ func TestItClonesReposInMultipleOrgs(t *testing.T) { {"work/orgB", "orgB/repo2"}, }) fakeGit.AssertCalledWith(t, [][]string{ - {"work/orgA/repo1", testsupport.Pwd()}, - {"work/orgB/repo2", testsupport.Pwd()}, + {"checkout", "work/orgA/repo1", testsupport.Pwd()}, + {"checkout", "work/orgB/repo2", testsupport.Pwd()}, }) } @@ -138,8 +138,8 @@ func TestItClonesReposFromOtherHosts(t *testing.T) { {"work/orgB", "orgB/repo2"}, }) fakeGit.AssertCalledWith(t, [][]string{ - {"work/orgA/repo1", testsupport.Pwd()}, - {"work/orgB/repo2", testsupport.Pwd()}, + {"checkout", "work/orgA/repo1", testsupport.Pwd()}, + {"checkout", "work/orgB/repo2", testsupport.Pwd()}, }) } @@ -160,7 +160,7 @@ func TestItSkipsCloningIfAWorkingCopyAlreadyExists(t *testing.T) { {"work/org", "org/repo2"}, }) fakeGit.AssertCalledWith(t, [][]string{ - {"work/org/repo2", testsupport.Pwd()}, + {"checkout", "work/org/repo2", testsupport.Pwd()}, }) } diff --git a/cmd/commit/commit.go b/cmd/commit/commit.go new file mode 100644 index 0000000..4aaf2ef --- /dev/null +++ b/cmd/commit/commit.go @@ -0,0 +1,83 @@ +package commit + +import ( + "github.com/skyscanner/turbolift/internal/campaign" + "github.com/skyscanner/turbolift/internal/colors" + "github.com/skyscanner/turbolift/internal/git" + "github.com/spf13/cobra" + "os" + "path" +) + +var g git.Git = git.NewRealGit() + +var message string + +func NewCommitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "commit", + Short: "Applies git commit -a -m '...' to all working copies, if they have changes", + Run: run, + } + + cmd.Flags().StringVarP(&message, "message", "m", "", "Commit message to apply") + err := cmd.MarkFlagRequired("message") + if err != nil { + panic(err) + } + + return cmd +} + +func run(c *cobra.Command, _ []string) { + dir, err := campaign.OpenCampaign() + if err != nil { + c.Printf(colors.Red("Error when reading campaign directory: %s\n"), err) + return + } + + doneCount := 0 + skippedCount := 0 + errorCount := 0 + for _, repo := range dir.Repos { + repoDirPath := path.Join("work", repo.OrgName, repo.RepoName) // i.e. work/org/repo + + // skip if the working copy does not exist + if _, err = os.Stat(repoDirPath); os.IsNotExist(err) { + c.Printf(colors.Yellow("Not running against %s as the directory %s does not exist - has it been cloned?\n"), repo.FullRepoName, repoDirPath) + skippedCount++ + continue + } + + c.Println(repo.FullRepoName) + + isChanged, err := g.IsRepoChanged(c.OutOrStdout(), repoDirPath) + if err != nil { + c.Printf(colors.Red("Error when checking for changes in %s: %s\n"), repo.FullRepoName, err) + errorCount++ + continue + } + + if !isChanged { + c.Printf(colors.Yellow("No changes in %s - skipping commit\n"), repo.FullRepoName) + skippedCount++ + continue + } + + c.Printf("Committing changes in %s\n", repo.FullRepoName) + + err = g.Commit(c.OutOrStdout(), repoDirPath, message) + if err != nil { + c.Printf(colors.Red("Error when committing changes in %s: %s\n"), repo.FullRepoName, err) + errorCount++ + } else { + doneCount++ + } + } + + if errorCount == 0 { + c.Printf(colors.Green("✅ turbolift commit completed (%d OK, %d skipped)\n"), doneCount, skippedCount) + } else { + c.Printf(colors.Yellow("⚠️ turbolift commit completed with errors (%d OK, %d skipped, %d errored)\n"), doneCount, skippedCount, errorCount) + } +} diff --git a/cmd/commit/commit_test.go b/cmd/commit/commit_test.go new file mode 100644 index 0000000..bdc1a63 --- /dev/null +++ b/cmd/commit/commit_test.go @@ -0,0 +1,116 @@ +package commit + +import ( + "bytes" + "errors" + "github.com/skyscanner/turbolift/internal/git" + "github.com/skyscanner/turbolift/internal/testsupport" + "github.com/stretchr/testify/assert" + "io" + "os" + "testing" +) + +func TestItCommitsAllWithChanges(t *testing.T) { + fakeGit := git.NewAlwaysSucceedsFakeGit() + g = fakeGit + + testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCommand("some test message", []string{}...) + assert.NoError(t, err) + assert.Contains(t, out, "2 OK") + + fakeGit.AssertCalledWith(t, [][]string{ + {"isRepoChanged", "work/org/repo1"}, + {"commit", "work/org/repo1", "some test message"}, + {"isRepoChanged", "work/org/repo2"}, + {"commit", "work/org/repo2", "some test message"}, + }) +} + +func TestItSkipsReposWithoutChanges(t *testing.T) { + fakeGit := git.NewFakeGit(func(output io.Writer, call []string) (bool, error) { + if call[0] == "isRepoChanged" && call[1] == "work/org/repo1" { + return false, nil + } else { + return true, nil + } + }) + g = fakeGit + + testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCommand("some test message", []string{}...) + assert.NoError(t, err) + assert.Contains(t, out, "No changes in org/repo1 - skipping commit") + assert.Contains(t, out, "1 OK, 1 skipped") + + fakeGit.AssertCalledWith(t, [][]string{ + {"isRepoChanged", "work/org/repo1"}, + {"isRepoChanged", "work/org/repo2"}, + {"commit", "work/org/repo2", "some test message"}, + }) +} + +func TestItSkipsReposWhichErrorOnStatusChekc(t *testing.T) { + fakeGit := git.NewFakeGit(func(output io.Writer, call []string) (bool, error) { + if call[0] == "isRepoChanged" && call[1] == "work/org/repo1" { + return false, errors.New("synthetic error") + } else { + return true, nil + } + }) + g = fakeGit + + testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCommand("some test message", []string{}...) + assert.NoError(t, err) + assert.Contains(t, out, "Error when checking for changes in org/repo1") + assert.Contains(t, out, "1 OK, 0 skipped, 1 errored") + + fakeGit.AssertCalledWith(t, [][]string{ + {"isRepoChanged", "work/org/repo1"}, + {"isRepoChanged", "work/org/repo2"}, + {"commit", "work/org/repo2", "some test message"}, + }) +} + +func TestItSkipsMissingRepos(t *testing.T) { + fakeGit := git.NewAlwaysSucceedsFakeGit() + g = fakeGit + + testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + err := os.RemoveAll("work/org/repo1") + if err != nil { + panic(err) + } + + out, err := runCommand("some test message", []string{}...) + assert.NoError(t, err) + assert.Contains(t, out, "1 OK, 1 skipped") + + fakeGit.AssertCalledWith(t, [][]string{ + {"isRepoChanged", "work/org/repo2"}, + {"commit", "work/org/repo2", "some test message"}, + }) +} + +func runCommand(m string, args ...string) (string, error) { + cmd := NewCommitCmd() + outBuffer := bytes.NewBufferString("") + cmd.SetOut(outBuffer) + cmd.SetArgs(args) + err := cmd.Flags().Set("message", m) + if err != nil { + panic(err) + } + + err = cmd.Execute() + + if err != nil { + return outBuffer.String(), err + } + return outBuffer.String(), nil +} diff --git a/cmd/root.go b/cmd/root.go index 8a8a993..60c6ddf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( cloneCmd "github.com/skyscanner/turbolift/cmd/clone" + commitCmd "github.com/skyscanner/turbolift/cmd/commit" foreachCmd "github.com/skyscanner/turbolift/cmd/foreach" initCmd "github.com/skyscanner/turbolift/cmd/init" "github.com/spf13/cobra" @@ -15,6 +16,7 @@ var rootCmd = &cobra.Command{ } func init() { + rootCmd.AddCommand(commitCmd.NewCommitCmd()) rootCmd.AddCommand(cloneCmd.NewCloneCmd()) rootCmd.AddCommand(initCmd.NewInitCmd()) rootCmd.AddCommand(foreachCmd.NewForeachCmd()) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 86b7d79..7fa3e85 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -10,6 +10,7 @@ import ( type Executor interface { Execute(output io.Writer, workingDir string, name string, args ...string) error + ExecuteAndCapture(output io.Writer, workingDir string, name string, args ...string) (string, error) } type RealExecutor struct { @@ -38,6 +39,23 @@ func (e *RealExecutor) Execute(output io.Writer, workingDir string, name string, return nil } +func (e *RealExecutor) ExecuteAndCapture(output io.Writer, workingDir string, name string, args ...string) (string, error) { + command := exec.Command(name, args...) + command.Dir = workingDir + + _, err := fmt.Fprintln(output, "Executing:", name, args) + if err != nil { + return "", err + } + + commandOutput, err := command.Output() + if err != nil { + return string(commandOutput), err + } + + return string(commandOutput), nil +} + func NewRealExecutor() *RealExecutor { return &RealExecutor{} } diff --git a/internal/executor/fake_executor.go b/internal/executor/fake_executor.go index 7b47fef..0ee2f69 100644 --- a/internal/executor/fake_executor.go +++ b/internal/executor/fake_executor.go @@ -8,8 +8,9 @@ import ( ) type FakeExecutor struct { - Handler func(workingDir string, name string, args ...string) error - calls [][]string + Handler func(workingDir string, name string, args ...string) error + ReturningHandler func(workingDir string, name string, args ...string) (string, error) + calls [][]string } func (e *FakeExecutor) Execute(_ io.Writer, workingDir string, name string, args ...string) error { @@ -18,25 +19,36 @@ func (e *FakeExecutor) Execute(_ io.Writer, workingDir string, name string, args return e.Handler(workingDir, name, args...) } +func (e *FakeExecutor) ExecuteAndCapture(_ io.Writer, workingDir string, name string, args ...string) (string, error) { + allArgs := append([]string{workingDir, name}, args...) + e.calls = append(e.calls, allArgs) + return e.ReturningHandler(workingDir, name, args...) +} + func (e *FakeExecutor) AssertCalledWith(t *testing.T, expected [][]string) { assert.Equal(t, expected, e.calls) } -func NewFakeExecutor(h func(string, string, ...string) error) *FakeExecutor { +func NewFakeExecutor(handler func(string, string, ...string) error, returningHandler func(string, string, ...string) (string, error)) *FakeExecutor { return &FakeExecutor{ - Handler: h, - calls: [][]string{}, + Handler: handler, + ReturningHandler: returningHandler, + calls: [][]string{}, } } func NewAlwaysSucceedsFakeExecutor() *FakeExecutor { return NewFakeExecutor(func(s string, s2 string, s3 ...string) error { return nil + }, func(s string, s2 string, s3 ...string) (string, error) { + return "", nil }) } func NewAlwaysFailsFakeExecutor() *FakeExecutor { return NewFakeExecutor(func(s string, s2 string, s3 ...string) error { return errors.New("synthetic error") + }, func(s string, s2 string, s3 ...string) (string, error) { + return "", errors.New("synthetic error") }) } diff --git a/internal/git/fake_git.go b/internal/git/fake_git.go index bfb6162..f548671 100644 --- a/internal/git/fake_git.go +++ b/internal/git/fake_git.go @@ -8,25 +8,36 @@ import ( ) type FakeGit struct { - handler func(output io.Writer, workingDir string, branchName string) error + handler func(output io.Writer, call []string) (bool, error) calls [][]string } func (f *FakeGit) Checkout(output io.Writer, workingDir string, branch string) error { - f.calls = append(f.calls, []string{workingDir, branch}) - return f.handler(output, workingDir, branch) + call := []string{"checkout", workingDir, branch} + f.calls = append(f.calls, call) + _, err := f.handler(output, call) + return err } -func (f *FakeGit) ForkAndClone(output io.Writer, workingDir string, branchName string) error { - f.calls = append(f.calls, []string{workingDir, branchName}) - return f.handler(output, workingDir, branchName) +func (f *FakeGit) Commit(output io.Writer, workingDir string, message string) error { + call := []string{"commit", workingDir, message} + f.calls = append(f.calls, call) + _, err := f.handler(output, call) + return err +} + +func (f *FakeGit) IsRepoChanged(output io.Writer, workingDir string) (bool, error) { + call := []string{"isRepoChanged", workingDir} + f.calls = append(f.calls, call) + result, err := f.handler(output, call) + return result, err } func (f *FakeGit) AssertCalledWith(t *testing.T, expected [][]string) { assert.Equal(t, expected, f.calls) } -func NewFakeGit(h func(output io.Writer, workingDir string, branchName string) error) *FakeGit { +func NewFakeGit(h func(io.Writer, []string) (bool, error)) *FakeGit { return &FakeGit{ handler: h, calls: [][]string{}, @@ -34,13 +45,13 @@ func NewFakeGit(h func(output io.Writer, workingDir string, branchName string) e } func NewAlwaysSucceedsFakeGit() *FakeGit { - return NewFakeGit(func(output io.Writer, workingDir string, branchName string) error { - return nil + return NewFakeGit(func(io.Writer, []string) (bool, error) { + return true, nil }) } func NewAlwaysFailsFakeGit() *FakeGit { - return NewFakeGit(func(output io.Writer, workingDir string, branchName string) error { - return errors.New("synthetic error") + return NewFakeGit(func(io.Writer, []string) (bool, error) { + return false, errors.New("synthetic error") }) } diff --git a/internal/git/git.go b/internal/git/git.go index 372c401..165fc8f 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,12 +3,16 @@ package git import ( "github.com/skyscanner/turbolift/internal/executor" "io" + "os" + "strconv" ) var execInstance executor.Executor = executor.NewRealExecutor() type Git interface { Checkout(output io.Writer, workingDir string, branch string) error + Commit(output io.Writer, workingDir string, message string) error + IsRepoChanged(output io.Writer, workingDir string) (bool, error) } type RealGit struct { @@ -18,6 +22,30 @@ func (r *RealGit) Checkout(output io.Writer, workingDir string, branchName strin return execInstance.Execute(output, workingDir, "git", "checkout", "-b", branchName) } +func (r *RealGit) Commit(output io.Writer, workingDir string, message string) error { + return execInstance.Execute(output, workingDir, "git", "commit", "--all", "--message", message) +} + +func (r *RealGit) IsRepoChanged(output io.Writer, workingDir string) (bool, error) { + shellCommand := os.Getenv("SHELL") + if shellCommand == "" { + shellCommand = "sh" + } + shellArgs := []string{"-c", "git status --porcelain=v1 | wc -l | tr -d '[:space:]'"} + commandOutput, err := execInstance.ExecuteAndCapture(output, workingDir, shellCommand, shellArgs...) + + if err != nil { + return false, err + } + + diffSize, err := strconv.Atoi(commandOutput) + if err != nil { + return false, err + } + + return diffSize > 0, nil +} + func NewRealGit() *RealGit { return &RealGit{} }