Repository: lindell/multi-gitter (go)

Issue:
Add ability to configure repository clone directory (currently system temp)
I'm running multi-gitter inside a Docker / VS Code devcontainer environment.
It would be nice to configure the directory where multi-gitter clones the repositories.
Currently this is the system temporary directory:
https://github.com/lindell/multi-gitter/blob/c12fb13b4889e174c684f0fc1ef46b52b5462a67/internal/multigitter/run.go#L202

This would allow mounting a Docker volume and examine the GIT changes in interactive mode with a GIT GUI of choice on the Docker host.
Currently I have to map the system temporary directory to the Docker host which is not nice.

Pull request description:
feat: Add ability to configure repository clone directory
# What does this change
Adds the ability to configure repository clone directory. 

# What issue does it fix
Closes #294 

# Notes for the reviewer
Hi! I'm a newcomer to Go, so the code probably isn't really idiomatic, any comments and reviews are more than welcome!

# Checklist
- [x] Made sure the PR follows the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
- [x] Tests if something new is added


Patch:
diff --git a/cmd/cmd-print.go b/cmd/cmd-print.go
index 1314e2a..6a78230 100755
--- a/cmd/cmd-print.go
+++ b/cmd/cmd-print.go
@@ -34,6 +34,7 @@ func PrintCmd() *cobra.Command {
 
 	cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs.")
 	cmd.Flags().StringP("error-output", "E", "-", `The file that the output of the script should be outputted to. "-" means stderr.`)
+	cmd.Flags().StringP("clone-dir", "", "", "The temporary directory where the repositories will be cloned. If not set, the default os temporary directory will be used.")
 	configureGit(cmd)
 	configurePlatform(cmd)
 	configureLogging(cmd, "")
@@ -49,6 +50,7 @@ func printCMD(cmd *cobra.Command, _ []string) error {
 	concurrent, _ := flag.GetInt("concurrent")
 	strOutput, _ := flag.GetString("output")
 	strErrOutput, _ := flag.GetString("error-output")
+	cloneDir, _ := flag.GetString("clone-dir")
 
 	if concurrent < 1 {
 		return errors.New("concurrent runs can't be less than one")
@@ -101,6 +103,7 @@ func printCMD(cmd *cobra.Command, _ []string) error {
 		Stderr: errOutput,
 
 		Concurrent: concurrent,
+		CloneDir:   cloneDir,
 
 		CreateGit: gitCreator,
 	}
diff --git a/cmd/cmd-run.go b/cmd/cmd-run.go
index 6b6c62d..4bbaecc 100755
--- a/cmd/cmd-run.go
+++ b/cmd/cmd-run.go
@@ -64,6 +64,7 @@ Available values:
 	cmd.Flags().StringSliceP("labels", "", nil, "Labels to be added to any created pull request.")
 	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.")
+	cmd.Flags().StringP("clone-dir", "", "", "The temporary directory where the repositories will be cloned. If not set, the default os temporary directory will be used.")
 	cmd.Flags().StringP("repo-include", "", "", "Include repositories that match with a given Regular Expression")
 	cmd.Flags().StringP("repo-exclude", "", "", "Exclude repositories that match with a given Regular Expression")
 	configureGit(cmd)
@@ -102,6 +103,7 @@ func run(cmd *cobra.Command, _ []string) error {
 	strOutput, _ := flag.GetString("output")
 	assignees, _ := stringSlice(flag, "assignees")
 	draft, _ := flag.GetBool("draft")
+	cloneDir, _ := flag.GetString("clone-dir")
 	labels, _ := stringSlice(flag, "labels")
 	repoInclude, _ := flag.GetString("repo-include")
 	repoExclude, _ := flag.GetString("repo-exclude")
@@ -246,6 +248,7 @@ func run(cmd *cobra.Command, _ []string) error {
 		ConflictStrategy:       conflictStrategy,
 		Draft:                  draft,
 		Labels:                 labels,
+		CloneDir:               cloneDir,
 
 		Concurrent: concurrent,
 
diff --git a/internal/multigitter/print.go b/internal/multigitter/print.go
index 3aac194..cacb864 100755
--- a/internal/multigitter/print.go
+++ b/internal/multigitter/print.go
@@ -22,6 +22,7 @@ type Printer struct {
 	Stderr io.Writer
 
 	Concurrent int
+	CloneDir   string
 
 	CreateGit func(dir string) Git
 }
@@ -66,12 +67,12 @@ func (r Printer) runSingleRepo(ctx context.Context, repo scm.Repository) error {
 
 	log := log.WithField("repo", repo.FullName())
 	log.Info("Cloning and running script")
+	tmpDir, err := createTempDir(r.CloneDir)
 
-	tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-changer-")
+	defer os.RemoveAll(tmpDir)
 	if err != nil {
 		return err
 	}
-	defer os.RemoveAll(tmpDir)
 
 	sourceController := r.CreateGit(tmpDir)
 
diff --git a/internal/multigitter/run.go b/internal/multigitter/run.go
index 8ce5568..2d73466 100755
--- a/internal/multigitter/run.go
+++ b/internal/multigitter/run.go
@@ -70,7 +70,8 @@ type Runner struct {
 
 	Draft bool // If set, creates Pull Requests as draft
 
-	Labels []string // Labels to be added to the pull request
+	Labels   []string // Labels to be added to the pull request
+	CloneDir string   // Directory to clone repositories to
 
 	Interactive bool // If set, interactive mode is activated and the user will be asked to verify every change
 
@@ -229,12 +230,12 @@ func (r *Runner) runSingleRepo(ctx context.Context, repo scm.Repository) (scm.Pu
 
 	log := log.WithField("repo", repo.FullName())
 	log.Info("Cloning and running script")
+	tmpDir, err := createTempDir(r.CloneDir)
 
-	tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-changer-")
+	defer os.RemoveAll(tmpDir)
 	if err != nil {
 		return nil, err
 	}
-	defer os.RemoveAll(tmpDir)
 
 	sourceController := r.CreateGit(tmpDir)
 
diff --git a/internal/multigitter/shared.go b/internal/multigitter/shared.go
index d05b5f7..fde49cf 100644
--- a/internal/multigitter/shared.go
+++ b/internal/multigitter/shared.go
@@ -3,6 +3,8 @@ package multigitter
 import (
 	"context"
 	"fmt"
+	"os"
+	"path/filepath"
 	"syscall"
 
 	"github.com/lindell/multi-gitter/internal/git"
@@ -70,3 +72,57 @@ func ParseConflictStrategy(str string) (ConflictStrategy, error) {
 		return ConflictStrategyReplace, nil
 	}
 }
+
+// createTempDir creates a temporary directory in the given directory.
+// If the given directory is an empty string, it will use the os.TempDir()
+func createTempDir(cloneDir string) (string, error) {
+	if cloneDir == "" {
+		cloneDir = os.TempDir()
+	}
+
+	absDir, err := makeAbsolutePath(cloneDir)
+	if err != nil {
+		return "", err
+	}
+
+	err = createDirectoryIfDoesntExist(absDir)
+	if err != nil {
+		return "", err
+	}
+
+	tmpDir, err := os.MkdirTemp(absDir, "multi-git-changer-")
+	if err != nil {
+		return "", err
+	}
+
+	return tmpDir, nil
+}
+
+func createDirectoryIfDoesntExist(directoryPath string) error {
+	// Check if the directory exists
+	if _, err := os.Stat(directoryPath); !os.IsNotExist(err) {
+		return nil
+	}
+
+	// Create the directory
+	err := os.MkdirAll(directoryPath, 0700)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// makeAbsolutePath creates an absolute path from a relative path
+func makeAbsolutePath(path string) (string, error) {
+	workingDir, err := os.Getwd()
+	if err != nil {
+		return "", errors.Wrap(err, "could not get the working directory")
+	}
+
+	if !filepath.IsAbs(path) {
+		return filepath.Join(workingDir, path), nil
+	}
+
+	return path, nil
+}


Test patch:
diff --git a/tests/print_test.go b/tests/print_test.go
index 109d52c..d1e546a 100644
--- a/tests/print_test.go
+++ b/tests/print_test.go
@@ -35,7 +35,8 @@ func TestPrint(t *testing.T) {
 	errOutFile := filepath.Join(tmpDir, "err-out.txt")
 
 	command := cmd.RootCmd()
-	command.SetArgs([]string{"print",
+	command.SetArgs([]string{
+		"print",
 		"--log-file", filepath.ToSlash(runLogFile),
 		"--output", filepath.ToSlash(outFile),
 		"--error-output", filepath.ToSlash(errOutFile),
diff --git a/tests/repo_helper_test.go b/tests/repo_helper_test.go
index 8968d3f..64e13d1 100644
--- a/tests/repo_helper_test.go
+++ b/tests/repo_helper_test.go
@@ -19,7 +19,7 @@ import (
 const fileName = "test.txt"
 
 func createRepo(t *testing.T, ownerName string, repoName string, dataInFile string) vcmock.Repository {
-	tmpDir, err := createDummyRepo(dataInFile)
+	tmpDir, err := createDummyRepo(dataInFile, os.TempDir())
 	require.NoError(t, err)
 
 	return vcmock.Repository{
@@ -29,8 +29,8 @@ func createRepo(t *testing.T, ownerName string, repoName string, dataInFile stri
 	}
 }
 
-func createDummyRepo(dataInFile string) (string, error) {
-	tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-test-*.git")
+func createDummyRepo(dataInFile string, dir string) (string, error) {
+	tmpDir, err := os.MkdirTemp(dir, "multi-git-test-*.git")
 	if err != nil {
 		return "", err
 	}
diff --git a/tests/scripts/pwd/main.go b/tests/scripts/pwd/main.go
new file mode 100644
index 0000000..020d1f2
--- /dev/null
+++ b/tests/scripts/pwd/main.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func main() {
+	path, _ := os.Getwd()
+	fmt.Println("Current path:", path)
+	err := os.WriteFile("pwd.txt", []byte(path), 0600)
+	if err != nil {
+		fmt.Println("Could not write to pwd.txt:", err)
+		return
+	}
+	fmt.Println("Wrote to pwd.txt")
+}
diff --git a/tests/story_test.go b/tests/story_test.go
index 818f244..0cfb97b 100644
--- a/tests/story_test.go
+++ b/tests/story_test.go
@@ -37,7 +37,8 @@ func TestStory(t *testing.T) {
 	runOutFile := filepath.Join(tmpDir, "run-log.txt")
 
 	command := cmd.RootCmd()
-	command.SetArgs([]string{"run",
+	command.SetArgs([]string{
+		"run",
 		"--output", runOutFile,
 		"--author-name", "Test Author",
 		"--author-email", "test@example.com",
@@ -75,7 +76,8 @@ Repositories with a successful run:
 	statusOutFile := filepath.Join(tmpDir, "status-log.txt")
 
 	command = cmd.RootCmd()
-	command.SetArgs([]string{"status",
+	command.SetArgs([]string{
+		"status",
 		"--output", statusOutFile,
 		"-B", "custom-branch-name",
 	})
@@ -96,7 +98,8 @@ Repositories with a successful run:
 	mergeLogFile := filepath.Join(tmpDir, "merge-log.txt")
 
 	command = cmd.RootCmd()
-	command.SetArgs([]string{"merge",
+	command.SetArgs([]string{
+		"merge",
 		"--log-file", mergeLogFile,
 		"-B", "custom-branch-name",
 	})
@@ -115,7 +118,8 @@ Repositories with a successful run:
 	afterMergeStatusOutFile := filepath.Join(tmpDir, "after-merge-status-log.txt")
 
 	command = cmd.RootCmd()
-	command.SetArgs([]string{"status",
+	command.SetArgs([]string{
+		"status",
 		"--output", afterMergeStatusOutFile,
 		"-B", "custom-branch-name",
 	})
@@ -133,7 +137,8 @@ Repositories with a successful run:
 	closeLogFile := filepath.Join(tmpDir, "close-log.txt")
 
 	command = cmd.RootCmd()
-	command.SetArgs([]string{"close",
+	command.SetArgs([]string{
+		"close",
 		"--log-file", closeLogFile,
 		"-B", "custom-branch-name",
 	})
@@ -152,7 +157,8 @@ Repositories with a successful run:
 	afterCloseStatusOutFile := filepath.Join(tmpDir, "after-close-status-log.txt")
 
 	command = cmd.RootCmd()
-	command.SetArgs([]string{"status",
+	command.SetArgs([]string{
+		"status",
 		"--output", afterCloseStatusOutFile,
 		"-B", "custom-branch-name",
 	})
diff --git a/tests/table_test.go b/tests/table_test.go
index 7926ee4..9e70278 100644
--- a/tests/table_test.go
+++ b/tests/table_test.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"testing"
 	"time"
@@ -1187,6 +1188,74 @@ Repositories with a successful run:
 			},
 		},
 
+		{
+			name: "custom clone dir with relative path",
+			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", "clone-dir-branch-name",
+				"-m", "clone dir message",
+				"--clone-dir", "./tmp-test",
+				fmt.Sprintf("go run %s", normalizePath(filepath.Join(workingDir, "scripts/pwd/main.go"))),
+			},
+			verify: func(t *testing.T, vcMock *vcmock.VersionController, runData runData) {
+				require.Len(t, vcMock.PullRequests, 1)
+				expectedPath := filepath.Join(workingDir, "tmp-test")
+
+				// Check the path in the logs
+				assert.Contains(t, runData.logOut, "Current path: "+strings.ReplaceAll(expectedPath, `\`, `\\`))
+
+				changeBranch(t, vcMock.Repositories[0].Path, "clone-dir-branch-name", false)
+				pathInFile := readFile(t, vcMock.Repositories[0].Path, "pwd.txt")
+				assert.True(t, strings.HasPrefix(pathInFile, expectedPath))
+			},
+		},
+
+		{
+			name: "custom clone dir with absolute path",
+			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", "clone-dir-branch-name",
+				"-m", "clone dir message",
+				"--clone-dir", filepath.Join(os.TempDir(), "tmp-test"),
+				fmt.Sprintf("go run %s", normalizePath(filepath.Join(workingDir, "scripts/pwd/main.go"))),
+			},
+			verify: func(t *testing.T, vcMock *vcmock.VersionController, runData runData) {
+				require.Len(t, vcMock.PullRequests, 1)
+
+				tmpDir := os.TempDir()
+				// Fix for MacOS (darwin) where the tmp directory is aliased under two different directories
+				if runtime.GOOS == "darwin" {
+					tmpDir = filepath.Join("/private", tmpDir)
+				}
+
+				expectedPath := filepath.Join(tmpDir, "tmp-test")
+
+				assert.Contains(t, runData.logOut, "Current path: "+strings.ReplaceAll(expectedPath, `\`, `\\`))
+
+				changeBranch(t, vcMock.Repositories[0].Path, "clone-dir-branch-name", false)
+				pathInFile := readFile(t, vcMock.Repositories[0].Path, "pwd.txt")
+				assert.True(t, strings.HasPrefix(pathInFile, expectedPath))
+			},
+		},
+
 		{
 			name: "fork conflicts with pushOnly",
 			vcCreate: func(t *testing.T) *vcmock.VersionController {
@@ -1277,9 +1346,10 @@ Repositories with a successful run:
 
 				outFile, err := os.CreateTemp(os.TempDir(), "multi-gitter-test-output")
 				require.NoError(t, err)
-				// defer os.Remove(outFile.Name())
+				defer os.Remove(outFile.Name())
 
 				vc := test.vcCreate(t)
+
 				defer vc.Clean()
 
 				cmd.OverrideVersionController = vc