Repository: lindell/multi-gitter (go)

Issue:
Changeable git backend
As of now, multi-gitter is using go-git for all git-related operations. This does make multi-gitter completely self-contained since it does not have any dependencies. But it could be useful to have the option to switch to the main git implementation by running the `git` command instead, since there seems to be some minor bugs in go-git that could be solved by making it optional.

Pull request description:
Git type
Fixes #133 

Patch:
diff --git a/cmd/print.go b/cmd/print.go
index e05871f..688e4bf 100755
--- a/cmd/print.go
+++ b/cmd/print.go
@@ -33,8 +33,8 @@ func PrintCmd() *cobra.Command {
 	}
 
 	cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
-	cmd.Flags().IntP("fetch-depth", "f", 1, "Limit fetching to the specified number of commits. Set to 0 for no limit")
 	cmd.Flags().StringP("error-output", "E", "-", `The file that the output of the script should be outputted to. "-" means stderr`)
+	configureGit(cmd)
 	configurePlatform(cmd)
 	configureLogging(cmd, "")
 	cmd.Flags().AddFlagSet(outputFlag())
@@ -46,7 +46,6 @@ func print(cmd *cobra.Command, args []string) error {
 	flag := cmd.Flags()
 
 	concurrent, _ := flag.GetInt("concurrent")
-	fetchDepth, _ := flag.GetInt("fetch-depth")
 	strOutput, _ := flag.GetString("output")
 	strErrOutput, _ := flag.GetString("error-output")
 
@@ -81,6 +80,11 @@ func print(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
+	gitCreator, err := getGitCreator(flag)
+	if err != nil {
+		return err
+	}
+
 	parsedCommand, err := parseCommandLine(command)
 	if err != nil {
 		return fmt.Errorf("could not parse command: %s", err)
@@ -116,8 +120,9 @@ func print(cmd *cobra.Command, args []string) error {
 		Stdout: output,
 		Stderr: errOutput,
 
-		FetchDepth: fetchDepth,
 		Concurrent: concurrent,
+
+		CreateGit: gitCreator,
 	}
 
 	err = printer.Print(ctx)
diff --git a/cmd/root.go b/cmd/root.go
index 4e98def..28d68e1 100755
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -9,6 +9,8 @@ import (
 	"time"
 
 	"github.com/lindell/multi-gitter/internal/domain"
+	"github.com/lindell/multi-gitter/internal/git/cmdgit"
+	"github.com/lindell/multi-gitter/internal/git/gogit"
 	"github.com/lindell/multi-gitter/internal/http"
 	"github.com/lindell/multi-gitter/internal/multigitter"
 	"github.com/lindell/multi-gitter/internal/scm/gitea"
@@ -135,6 +137,42 @@ func configurePlatform(cmd *cobra.Command) {
 	})
 }
 
+func configureGit(cmd *cobra.Command) {
+	cmd.Flags().IntP("fetch-depth", "f", 1, "Limit fetching to the specified number of commits. Set to 0 for no limit")
+	cmd.Flags().StringP("git-type", "", "go", `The type of git implementation to use.
+Available values:
+  go: Uses go-git, a Go native implementation of git. This is compiled with the multi-gitter binary, and no extra dependencies are needed.
+  cmd: Calls out to the git command. This requires git to be installed and available with by calling "git".
+`)
+	_ = cmd.RegisterFlagCompletionFunc("git-type", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+		return []string{"go", "cmd"}, cobra.ShellCompDirectiveDefault
+	})
+}
+
+func getGitCreator(flag *flag.FlagSet) (func(string) multigitter.Git, error) {
+	fetchDepth, _ := flag.GetInt("fetch-depth")
+	gitType, _ := flag.GetString("git-type")
+
+	switch gitType {
+	case "go":
+		return func(path string) multigitter.Git {
+			return &gogit.Git{
+				Directory:  path,
+				FetchDepth: fetchDepth,
+			}
+		}, nil
+	case "cmd":
+		return func(path string) multigitter.Git {
+			return &cmdgit.Git{
+				Directory:  path,
+				FetchDepth: fetchDepth,
+			}
+		}, nil
+	}
+
+	return nil, errors.Errorf(`could not parse git type "%s"`, gitType)
+}
+
 func configureLogging(cmd *cobra.Command, logFile string) {
 	flags := cmd.Flags()
 
diff --git a/cmd/run.go b/cmd/run.go
index cc692e0..f38cfb4 100755
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -42,13 +42,13 @@ func RunCmd() *cobra.Command {
 	cmd.Flags().StringSliceP("reviewers", "r", nil, "The username of the reviewers to be added on the pull request.")
 	cmd.Flags().IntP("max-reviewers", "M", 0, "If this value is set, reviewers will be randomized")
 	cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs")
-	cmd.Flags().IntP("fetch-depth", "f", 1, "Limit fetching to the specified number of commits. Set to 0 for no limit")
 	cmd.Flags().BoolP("skip-pr", "", false, "Skip pull request and directly push to the branch")
 	cmd.Flags().BoolP("dry-run", "d", false, "Run without pushing changes or creating pull requests")
 	cmd.Flags().BoolP("fork", "", false, "Fork the repository instead of creating a new branch on the same owner")
 	cmd.Flags().StringP("fork-owner", "", "", "If set, make the fork to defined one. Default behavior is for the fork to be on the logged in user.")
 	cmd.Flags().StringP("author-name", "", "", "Name of the committer. If not set, the global git config setting will be used.")
 	cmd.Flags().StringP("author-email", "", "", "Email of the committer. If not set, the global git config setting will be used.")
+	configureGit(cmd)
 	configurePlatform(cmd)
 	configureLogging(cmd, "-")
 	cmd.Flags().AddFlagSet(outputFlag())
@@ -66,7 +66,6 @@ func run(cmd *cobra.Command, args []string) error {
 	commitMessage, _ := flag.GetString("commit-message")
 	reviewers, _ := flag.GetStringSlice("reviewers")
 	maxReviewers, _ := flag.GetInt("max-reviewers")
-	fetchDepth, _ := flag.GetInt("fetch-depth")
 	concurrent, _ := flag.GetInt("concurrent")
 	skipPullRequest, _ := flag.GetBool("skip-pr")
 	dryRun, _ := flag.GetBool("dry-run")
@@ -134,6 +133,11 @@ func run(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
+	gitCreator, err := getGitCreator(flag)
+	if err != nil {
+		return err
+	}
+
 	parsedCommand, err := parseCommandLine(command)
 	if err != nil {
 		return fmt.Errorf("could not parse command: %s", err)
@@ -184,8 +188,9 @@ func run(cmd *cobra.Command, args []string) error {
 		CommitAuthor:     commitAuthor,
 		BaseBranch:       baseBranchName,
 
-		FetchDepth: fetchDepth,
 		Concurrent: concurrent,
+
+		CreateGit: gitCreator,
 	}
 
 	err = runner.Run(ctx)
diff --git a/internal/domain/git.go b/internal/domain/git.go
new file mode 100644
index 0000000..8af0b5e
--- /dev/null
+++ b/internal/domain/git.go
@@ -0,0 +1,9 @@
+package domain
+
+// GitConfig is configration for any git implementation
+type GitConfig struct {
+	// Absolute path to the directory
+	Directory string
+	// The fetch depth used when cloning, if set to 0, the entire history will be used
+	FetchDepth int
+}
diff --git a/internal/git/cmdgit/git.go b/internal/git/cmdgit/git.go
new file mode 100644
index 0000000..4b09b6b
--- /dev/null
+++ b/internal/git/cmdgit/git.go
@@ -0,0 +1,141 @@
+package cmdgit
+
+import (
+	"bytes"
+	"fmt"
+	"os/exec"
+	"regexp"
+	"strings"
+
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+
+	"github.com/lindell/multi-gitter/internal/domain"
+)
+
+// Git is an implementation of git that executes git as commands
+type Git struct {
+	Directory  string // The (temporary) directory that should be worked within
+	FetchDepth int    // Limit fetching to the specified number of commits
+}
+
+var errRe = regexp.MustCompile(`(^|\n)(error|fatal): (.+)`)
+
+func (g *Git) run(cmd *exec.Cmd) (string, error) {
+	stderr := &bytes.Buffer{}
+	stdout := &bytes.Buffer{}
+
+	cmd.Dir = g.Directory
+	cmd.Stderr = stderr
+	cmd.Stdout = stdout
+
+	err := cmd.Run()
+	if err != nil {
+		matches := errRe.FindStringSubmatch(stderr.String())
+		if matches != nil {
+			return "", errors.New(matches[3])
+		}
+
+		msg := fmt.Sprintf(`git command existed with %d`,
+			cmd.ProcessState.ExitCode(),
+		)
+
+		return "", errors.New(msg)
+	}
+	return stdout.String(), nil
+}
+
+// Clone a repository
+func (g *Git) Clone(url string, baseName string) error {
+	args := []string{"clone", url, "--branch", baseName, "--single-branch"}
+	if g.FetchDepth > 0 {
+		args = append(args, "--depth", fmt.Sprint(g.FetchDepth))
+	}
+	args = append(args, g.Directory)
+
+	cmd := exec.Command("git", args...)
+	_, err := g.run(cmd)
+	return err
+}
+
+// ChangeBranch changes the branch
+func (g *Git) ChangeBranch(branchName string) error {
+	cmd := exec.Command("git", "checkout", "-b", branchName)
+	_, err := g.run(cmd)
+	return err
+}
+
+// Changes detect if any changes has been made in the directory
+func (g *Git) Changes() (bool, error) {
+	cmd := exec.Command("git", "status", "-s")
+	stdOut, err := g.run(cmd)
+	return len(stdOut) > 0, err
+}
+
+// Commit and push all changes
+func (g *Git) Commit(commitAuthor *domain.CommitAuthor, commitMessage string) error {
+	cmd := exec.Command("git", "add", ".")
+	_, err := g.run(cmd)
+	if err != nil {
+		return err
+	}
+
+	cmd = exec.Command("git", "commit", "-m", commitMessage)
+
+	if commitAuthor != nil {
+		cmd.Env = append(cmd.Env,
+			"GIT_AUTHOR_NAME="+commitAuthor.Name,
+			"GIT_AUTHOR_EMAIL="+commitAuthor.Email,
+			"GIT_COMMITTER_NAME="+commitAuthor.Name,
+			"GIT_COMMITTER_EMAIL="+commitAuthor.Email,
+		)
+	}
+
+	_, err = g.run(cmd)
+
+	if err := g.logDiff(); err != nil {
+		return err
+	}
+
+	return err
+}
+
+func (g *Git) logDiff() error {
+	if !log.IsLevelEnabled(log.DebugLevel) {
+		return nil
+	}
+
+	cmd := exec.Command("git", "diff", "HEAD~1")
+	stdout, err := g.run(cmd)
+	if err != nil {
+		return err
+	}
+
+	log.Debug(stdout)
+
+	return nil
+}
+
+// BranchExist checks if the new branch exists
+func (g *Git) BranchExist(remoteName, branchName string) (bool, error) {
+	cmd := exec.Command("git", "ls-remote", "-q", "-h")
+	stdOut, err := g.run(cmd)
+	if err != nil {
+		return false, err
+	}
+	return strings.Contains(stdOut, fmt.Sprintf("refs/heads/%s", branchName)), nil
+}
+
+// Push the committed changes to the remote
+func (g *Git) Push(remoteName string) error {
+	cmd := exec.Command("git", "push", remoteName, "HEAD")
+	_, err := g.run(cmd)
+	return err
+}
+
+// AddRemote adds a new remote
+func (g *Git) AddRemote(name, url string) error {
+	cmd := exec.Command("git", "remote", "add", name, url)
+	_, err := g.run(cmd)
+	return err
+}
diff --git a/internal/git/git.go b/internal/git/gogit/git.go
similarity index 91%
rename from internal/git/git.go
rename to internal/git/gogit/git.go
index 50520e0..58c4f11 100755
--- a/internal/git/git.go
+++ b/internal/git/gogit/git.go
@@ -1,4 +1,4 @@
-package git
+package gogit
 
 import (
 	"bytes"
@@ -15,20 +15,18 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
-// Git is an implementation of git that executes git as a command
-// This has drawbacks, but the big benefit is that the configuration probably already present can be reused
+// Git is an implementation of git that used go-git
 type Git struct {
 	Directory  string // The (temporary) directory that should be worked within
-	Repo       string // The "url" to the repo, any format can be used as long as it's pushable
 	FetchDepth int    // Limit fetching to the specified number of commits
 
 	repo *git.Repository // The repository after the clone has been made
 }
 
 // Clone a repository
-func (g *Git) Clone(baseName, headName string) error {
+func (g *Git) Clone(url string, baseName string) error {
 	r, err := git.PlainClone(g.Directory, false, &git.CloneOptions{
-		URL:           g.Repo,
+		URL:           url,
 		RemoteName:    "origin",
 		Depth:         g.FetchDepth,
 		ReferenceName: plumbing.NewBranchReferenceName(baseName),
diff --git a/internal/multigitter/print.go b/internal/multigitter/print.go
index 5f5aa8c..f4abc73 100755
--- a/internal/multigitter/print.go
+++ b/internal/multigitter/print.go
@@ -11,7 +11,6 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	"github.com/lindell/multi-gitter/internal/domain"
-	"github.com/lindell/multi-gitter/internal/git"
 	"github.com/lindell/multi-gitter/internal/multigitter/repocounter"
 )
 
@@ -26,8 +25,9 @@ type Printer struct {
 	Stdout io.Writer
 	Stderr io.Writer
 
-	FetchDepth int // Limit fetching to the specified number of commits. Set to 0 for no limit
 	Concurrent int
+
+	CreateGit func(dir string) Git
 }
 
 // Print runs a script for multiple repositories and print the output of each run
@@ -77,13 +77,9 @@ func (r Printer) runSingleRepo(ctx context.Context, repo domain.Repository) erro
 	}
 	defer os.RemoveAll(tmpDir)
 
-	sourceController := &git.Git{
-		Directory:  tmpDir,
-		Repo:       repo.URL(r.Token),
-		FetchDepth: r.FetchDepth,
-	}
+	sourceController := r.CreateGit(tmpDir)
 
-	err = sourceController.Clone(repo.DefaultBranch(), "")
+	err = sourceController.Clone(repo.URL(r.Token), repo.DefaultBranch())
 	if err != nil {
 		return err
 	}
diff --git a/internal/multigitter/run.go b/internal/multigitter/run.go
index c43ba6c..06cf782 100755
--- a/internal/multigitter/run.go
+++ b/internal/multigitter/run.go
@@ -14,7 +14,6 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	"github.com/lindell/multi-gitter/internal/domain"
-	"github.com/lindell/multi-gitter/internal/git"
 	"github.com/lindell/multi-gitter/internal/multigitter/logger"
 	"github.com/lindell/multi-gitter/internal/multigitter/repocounter"
 )
@@ -49,12 +48,13 @@ type Runner struct {
 	CommitAuthor     *domain.CommitAuthor
 	BaseBranch       string // The base branch of the PR, use default branch if not set
 
-	FetchDepth      int // Limit fetching to the specified number of commits. Set to 0 for no limit
 	Concurrent      int
 	SkipPullRequest bool // If set, the script will run directly on the base-branch without creating any PR
 
 	Fork      bool   // If set, create a fork and make the pull request from it
 	ForkOwner string // The owner of the new fork. If empty, the fork should happen on the logged in user
+
+	CreateGit func(dir string) Git
 }
 
 var errAborted = errors.New("run was never started because of aborted execution")
@@ -169,18 +169,14 @@ func (r Runner) runSingleRepo(ctx context.Context, repo domain.Repository) (doma
 	}
 	defer os.RemoveAll(tmpDir)
 
-	sourceController := &git.Git{
-		Directory:  tmpDir,
-		Repo:       repo.URL(r.Token),
-		FetchDepth: r.FetchDepth,
-	}
+	sourceController := r.CreateGit(tmpDir)
 
 	baseBranch := r.BaseBranch
 	if baseBranch == "" {
 		baseBranch = repo.DefaultBranch()
 	}
 
-	err = sourceController.Clone(baseBranch, r.FeatureBranch)
+	err = sourceController.Clone(repo.URL(r.Token), baseBranch)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/multigitter/shared.go b/internal/multigitter/shared.go
index 9a1fd7c..8032f89 100644
--- a/internal/multigitter/shared.go
+++ b/internal/multigitter/shared.go
@@ -3,6 +3,7 @@ package multigitter
 import (
 	"syscall"
 
+	"github.com/lindell/multi-gitter/internal/domain"
 	"github.com/pkg/errors"
 )
 
@@ -19,3 +20,14 @@ func transformExecError(err error) error {
 	}
 	return err
 }
+
+// Git is a git implementation
+type Git interface {
+	Clone(url string, baseName string) error
+	ChangeBranch(branchName string) error
+	Changes() (bool, error)
+	Commit(commitAuthor *domain.CommitAuthor, commitMessage string) error
+	BranchExist(remoteName, branchName string) (bool, error)
+	Push(remoteName string) error
+	AddRemote(name, url string) error
+}
diff --git a/internal/scm/gitlab/gitlab.go b/internal/scm/gitlab/gitlab.go
index 307d746..53509fd 100644
--- a/internal/scm/gitlab/gitlab.go
+++ b/internal/scm/gitlab/gitlab.go
@@ -133,7 +133,6 @@ func (g *Gitlab) GetRepositories(ctx context.Context) ([]domain.Repository, erro
 
 	repos := make([]domain.Repository, 0, len(allProjects))
 	for _, project := range allProjects {
-
 		p, err := convertProject(project)
 		if err != nil {
 			return nil, err


Test patch:
diff --git a/tests/table_test.go b/tests/table_test.go
index 9237c7b..b7d1122 100644
--- a/tests/table_test.go
+++ b/tests/table_test.go
@@ -22,6 +22,24 @@ type runData struct {
 	took   time.Duration
 }
 
+type gitBackend string
+
+const (
+	gitBackendGo  gitBackend = "go"
+	gitBackendCmd gitBackend = "cmd"
+)
+
+var gitBackends = []gitBackend{gitBackendGo, gitBackendCmd}
+
+func containsGitBackend(gitBackends []gitBackend, gitBackend gitBackend) bool {
+	for _, gb := range gitBackends {
+		if gb == gitBackend {
+			return true
+		}
+	}
+	return false
+}
+
 func TestTable(t *testing.T) {
 	workingDir, err := os.Getwd()
 	assert.NoError(t, err)
@@ -29,9 +47,9 @@ func TestTable(t *testing.T) {
 	changerBinaryPath := filepath.ToSlash(filepath.Join(workingDir, changerBinaryPath))
 
 	tests := []struct {
-		name     string
-		vc       *vcmock.VersionController
-		vcCreate func(t *testing.T) *vcmock.VersionController // Can be used if advanced setup is needed for the vc
+		name        string
+		gitBackends []gitBackend                                 // If set, use only the specified git backends, otherwise use all
+		vcCreate    func(t *testing.T) *vcmock.VersionController // Can be used if advanced setup is needed for the vc
 
 		args   []string
 		verify func(t *testing.T, vcMock *vcmock.VersionController, runData runData)
@@ -40,10 +58,12 @@ func TestTable(t *testing.T) {
 	}{
 		{
 			name: "simple",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -71,10 +91,12 @@ func TestTable(t *testing.T) {
 
 		{
 			name: "with go run",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -100,11 +122,14 @@ func TestTable(t *testing.T) {
 		},
 
 		{
-			name: "failing base-branch",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			name:        "failing base-branch",
+			gitBackends: []gitBackend{gitBackendGo},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -121,6 +146,31 @@ func TestTable(t *testing.T) {
 			},
 		},
 
+		{
+			name:        "failing base-branch",
+			gitBackends: []gitBackend{gitBackendCmd},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
+			},
+			args: []string{
+				"run",
+				"--author-name", "Test Author",
+				"--author-email", "test@example.com",
+				"-B", "custom-branch-name",
+				"--base-branch", "custom-base-branch",
+				"-m", "custom message",
+				changerBinaryPath,
+			},
+			verify: func(t *testing.T, vcMock *vcmock.VersionController, runData runData) {
+				require.Len(t, vcMock.PullRequests, 0)
+				assert.Contains(t, runData.logOut, `msg="Remote branch custom-base-branch not found in upstream origin"`)
+			},
+		},
+
 		{
 			name: "success base-branch",
 			vcCreate: func(t *testing.T) *vcmock.VersionController {
@@ -156,10 +206,12 @@ func TestTable(t *testing.T) {
 
 		{
 			name: "reviewers",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -179,10 +231,12 @@ func TestTable(t *testing.T) {
 
 		{
 			name: "random reviewers",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -201,10 +255,12 @@ func TestTable(t *testing.T) {
 
 		{
 			name: "dry run",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -224,19 +280,21 @@ func TestTable(t *testing.T) {
 
 		{
 			name: "parallel",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change-1", "i like apples"),
-					createRepo(t, "owner", "should-change-2", "i like apples"),
-					createRepo(t, "owner", "should-change-3", "i like apples"),
-					createRepo(t, "owner", "should-change-4", "i like apples"),
-					createRepo(t, "owner", "should-change-5", "i like apples"),
-					createRepo(t, "owner", "should-change-6", "i like apples"),
-					createRepo(t, "owner", "should-change-7", "i like apples"),
-					createRepo(t, "owner", "should-change-8", "i like apples"),
-					createRepo(t, "owner", "should-change-9", "i like apples"),
-					createRepo(t, "owner", "should-change-10", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change-1", "i like apples"),
+						createRepo(t, "owner", "should-change-2", "i like apples"),
+						createRepo(t, "owner", "should-change-3", "i like apples"),
+						createRepo(t, "owner", "should-change-4", "i like apples"),
+						createRepo(t, "owner", "should-change-5", "i like apples"),
+						createRepo(t, "owner", "should-change-6", "i like apples"),
+						createRepo(t, "owner", "should-change-7", "i like apples"),
+						createRepo(t, "owner", "should-change-8", "i like apples"),
+						createRepo(t, "owner", "should-change-9", "i like apples"),
+						createRepo(t, "owner", "should-change-10", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -245,11 +303,11 @@ func TestTable(t *testing.T) {
 				"-m", "custom message",
 				"-B", "custom-branch-name",
 				"-C", "7",
-				fmt.Sprintf("%s -sleep 300ms", changerBinaryPath),
+				fmt.Sprintf("%s -sleep 500ms", changerBinaryPath),
 			},
 			verify: func(t *testing.T, vcMock *vcmock.VersionController, runData runData) {
 				require.Len(t, vcMock.PullRequests, 10)
-				require.Less(t, runData.took.Milliseconds(), int64(3000))
+				require.Less(t, runData.took.Milliseconds(), int64(5000))
 			},
 		},
 
@@ -334,7 +392,9 @@ Repositories with a successful run:
 
 		{
 			name: "autocomplete org",
-			vc:   &vcmock.VersionController{},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{}
+			},
 			args: []string{
 				"__complete", "run",
 				"--org", "dynamic-org",
@@ -346,7 +406,9 @@ Repositories with a successful run:
 
 		{
 			name: "autocomplete user",
-			vc:   &vcmock.VersionController{},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{}
+			},
 			args: []string{
 				"__complete", "run",
 				"--user", "dynamic-user",
@@ -358,7 +420,9 @@ Repositories with a successful run:
 
 		{
 			name: "autocomplete repo",
-			vc:   &vcmock.VersionController{},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{}
+			},
 			args: []string{
 				"__complete", "run",
 				"--repo", "dynamic-repo",
@@ -370,10 +434,12 @@ Repositories with a successful run:
 
 		{
 			name: "debug log",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -458,10 +524,12 @@ Repositories with a successful run:
 
 		{
 			name: "fork mode",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -495,10 +563,12 @@ Repositories with a successful run:
 
 		{
 			name: "fork mode with specified owner",
-			vc: &vcmock.VersionController{
-				Repositories: []vcmock.Repository{
-					createRepo(t, "owner", "should-change", "i like apples"),
-				},
+			vcCreate: func(t *testing.T) *vcmock.VersionController {
+				return &vcmock.VersionController{
+					Repositories: []vcmock.Repository{
+						createRepo(t, "owner", "should-change", "i like apples"),
+					},
+				}
 			},
 			args: []string{
 				"run",
@@ -532,56 +602,60 @@ Repositories with a successful run:
 		},
 	}
 
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			logFile, err := ioutil.TempFile(os.TempDir(), "multi-gitter-test-log")
-			require.NoError(t, err)
-			defer os.Remove(logFile.Name())
-
-			outFile, err := ioutil.TempFile(os.TempDir(), "multi-gitter-test-output")
-			require.NoError(t, err)
-			defer os.Remove(outFile.Name())
-
-			var vc *vcmock.VersionController
-			if test.vcCreate != nil {
-				vc = test.vcCreate(t)
-			} else {
-				vc = test.vc
+	for _, gitBackend := range gitBackends {
+		for _, test := range tests {
+			// Some tests should only be run with specific git backends
+			if test.gitBackends != nil && !containsGitBackend(test.gitBackends, gitBackend) {
+				continue
 			}
-			cmd.OverrideVersionController = vc
 
-			cobraBuf := &bytes.Buffer{}
+			t.Run(fmt.Sprintf("%s_%s", gitBackend, test.name), func(t *testing.T) {
+				logFile, err := ioutil.TempFile(os.TempDir(), "multi-gitter-test-log")
+				require.NoError(t, err)
+				defer os.Remove(logFile.Name())
 
-			staticArgs := []string{
-				"--log-file", logFile.Name(),
-				"--output", outFile.Name(),
-			}
+				outFile, err := ioutil.TempFile(os.TempDir(), "multi-gitter-test-output")
+				require.NoError(t, err)
+				// defer os.Remove(outFile.Name())
 
-			command := cmd.RootCmd()
-			command.SetOut(cobraBuf)
-			command.SetErr(cobraBuf)
-			command.SetArgs(append(staticArgs, test.args...))
-			before := time.Now()
-			err = command.Execute()
-			took := time.Since(before)
-			if test.expectErr {
-				assert.Error(t, err)
-			} else {
-				assert.NoError(t, err)
-			}
+				vc := test.vcCreate(t)
+
+				cmd.OverrideVersionController = vc
 
-			logData, err := ioutil.ReadAll(logFile)
-			assert.NoError(t, err)
+				cobraBuf := &bytes.Buffer{}
 
-			outData, err := ioutil.ReadAll(outFile)
-			assert.NoError(t, err)
+				staticArgs := []string{
+					"--log-file", logFile.Name(),
+					"--output", outFile.Name(),
+					"--git-type", string(gitBackend),
+				}
+
+				command := cmd.RootCmd()
+				command.SetOut(cobraBuf)
+				command.SetErr(cobraBuf)
+				command.SetArgs(append(staticArgs, test.args...))
+				before := time.Now()
+				err = command.Execute()
+				took := time.Since(before)
+				if test.expectErr {
+					assert.Error(t, err)
+				} else {
+					assert.NoError(t, err)
+				}
+
+				logData, err := ioutil.ReadAll(logFile)
+				assert.NoError(t, err)
+
+				outData, err := ioutil.ReadAll(outFile)
+				assert.NoError(t, err)
 
-			test.verify(t, vc, runData{
-				logOut: string(logData),
-				out:    string(outData),
-				cmdOut: cobraBuf.String(),
-				took:   took,
+				test.verify(t, vc, runData{
+					logOut: string(logData),
+					out:    string(outData),
+					cmdOut: cobraBuf.String(),
+					took:   took,
+				})
 			})
-		})
+		}
 	}
 }
diff --git a/tests/vcmock/vcmock.go b/tests/vcmock/vcmock.go
index c048233..3c599c7 100644
--- a/tests/vcmock/vcmock.go
+++ b/tests/vcmock/vcmock.go
@@ -161,7 +161,7 @@ type Repository struct {
 
 // URL return the URL (filepath) of the repository on disk
 func (r Repository) URL(token string) string {
-	return fmt.Sprintf(`file://"%s"`, filepath.ToSlash(r.Path))
+	return fmt.Sprintf(`file://%s`, filepath.ToSlash(r.Path))
 }
 
 // DefaultBranch returns "master"