Repository: coder/envbuilder (go)

Issue:
how can I clone repos to `/home/coder`?
By default, envbuilder will clone workspaces to `/workspaces/<repo-name>`. However, when I configure `ENVBUILDER_WORKSPACE_FOLDER` to `/home/coder`, it clones it directly in that folder, not `/home/coder/<repo-name>`.

What can I do here?

Pull request:
feat: allow changing default workspaces folder
Fixes #384


Patch:
diff --git a/docs/env-variables.md b/docs/env-variables.md
index faca028..861f31b 100644
--- a/docs/env-variables.md
+++ b/docs/env-variables.md
@@ -32,7 +32,8 @@
 | `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` |  | Path to an SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. |
 | `--git-ssh-private-key-base64` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64` |  | Base64 encoded SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set. |
 | `--git-http-proxy-url` | `ENVBUILDER_GIT_HTTP_PROXY_URL` |  | The URL for the HTTP proxy. This is optional. |
-| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` |  | The path to the workspace folder that will be built. This is optional. |
+| `--workspace-base-dir` | `ENVBUILDER_WORKSPACE_BASE_DIR` | `/workspaces` | The path under which workspaces will be placed when workspace folder option is not given. |
+| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` |  | The path to the workspace folder that will be built. This is optional. Defaults to `[workspace base dir]/[name]` where name is the name of the repository or `empty`. |
 | `--ssl-cert-base64` | `ENVBUILDER_SSL_CERT_BASE64` |  | The content of an SSL cert file. This is useful for self-signed certificates. |
 | `--export-env-file` | `ENVBUILDER_EXPORT_ENV_FILE` |  | Optional file path to a .env file where envbuilder will dump environment variables from devcontainer.json and the built container image. |
 | `--post-start-script-path` | `ENVBUILDER_POST_START_SCRIPT_PATH` |  | The path to a script that will be created by envbuilder based on the postStartCommand in devcontainer.json, if any is specified (otherwise the script is not created). If this is set, the specified InitCommand should check for the presence of this script and execute it after successful startup. |
diff --git a/options/defaults.go b/options/defaults.go
index 4b153c8..ee2543f 100644
--- a/options/defaults.go
+++ b/options/defaults.go
@@ -12,29 +12,29 @@ import (
 	"github.com/coder/envbuilder/internal/workingdir"
 )
 
-// EmptyWorkspaceDir is the path to a workspace that has
-// nothing going on... it's empty!
-var EmptyWorkspaceDir = "/workspaces/empty"
-
 // DefaultWorkspaceFolder returns the default workspace folder
 // for a given repository URL.
-func DefaultWorkspaceFolder(repoURL string) string {
+func DefaultWorkspaceFolder(workspacesFolder, repoURL string) string {
+	// emptyWorkspaceDir is the path to a workspace that has
+	// nothing going on... it's empty!
+	emptyWorkspaceDir := workspacesFolder + "/empty"
+
 	if repoURL == "" {
-		return EmptyWorkspaceDir
+		return emptyWorkspaceDir
 	}
 	parsed, err := giturls.Parse(repoURL)
 	if err != nil {
-		return EmptyWorkspaceDir
+		return emptyWorkspaceDir
 	}
 	repo := path.Base(parsed.Path)
 	// Giturls parsing never actually fails since ParseLocal never
 	// errors and places the entire URL in the Path field. This check
 	// ensures it's at least a Unix path containing forwardslash.
 	if repo == repoURL || repo == "/" || repo == "." || repo == "" {
-		return EmptyWorkspaceDir
+		return emptyWorkspaceDir
 	}
 	repo = strings.TrimSuffix(repo, ".git")
-	return fmt.Sprintf("/workspaces/%s", repo)
+	return fmt.Sprintf("%s/%s", workspacesFolder, repo)
 }
 
 func (o *Options) SetDefaults() {
@@ -59,8 +59,11 @@ func (o *Options) SetDefaults() {
 	if o.Filesystem == nil {
 		o.Filesystem = chmodfs.New(osfs.New("/"))
 	}
+	if o.WorkspaceBaseDir == "" {
+		o.WorkspaceBaseDir = "/workspaces"
+	}
 	if o.WorkspaceFolder == "" {
-		o.WorkspaceFolder = DefaultWorkspaceFolder(o.GitURL)
+		o.WorkspaceFolder = DefaultWorkspaceFolder(o.WorkspaceBaseDir, o.GitURL)
 	}
 	if o.BinaryPath == "" {
 		o.BinaryPath = "/.envbuilder/bin/envbuilder"
diff --git a/options/options.go b/options/options.go
index ba26d1d..c2b6efe 100644
--- a/options/options.go
+++ b/options/options.go
@@ -120,8 +120,12 @@ type Options struct {
 	GitSSHPrivateKeyBase64 string
 	// GitHTTPProxyURL is the URL for the HTTP proxy. This is optional.
 	GitHTTPProxyURL string
+	// WorkspaceBaseDir is the path under which workspaces will be placed when
+	// workspace folder option is not given.
+	WorkspaceBaseDir string
 	// WorkspaceFolder is the path to the workspace folder that will be built.
-	// This is optional.
+	// This is optional. Defaults to `[workspace base dir]/[name]` where name is
+	// the name of the repository or "empty".
 	WorkspaceFolder string
 	// SSLCertBase64 is the content of an SSL cert file. This is useful for
 	// self-signed certificates.
@@ -403,12 +407,21 @@ func (o *Options) CLI() serpent.OptionSet {
 			Value:       serpent.StringOf(&o.GitHTTPProxyURL),
 			Description: "The URL for the HTTP proxy. This is optional.",
 		},
+		{
+			Flag:    "workspace-base-dir",
+			Env:     WithEnvPrefix("WORKSPACE_BASE_DIR"),
+			Value:   serpent.StringOf(&o.WorkspaceBaseDir),
+			Default: "/workspaces",
+			Description: "The path under which workspaces will be placed when " +
+				"workspace folder option is not given.",
+		},
 		{
 			Flag:  "workspace-folder",
 			Env:   WithEnvPrefix("WORKSPACE_FOLDER"),
 			Value: serpent.StringOf(&o.WorkspaceFolder),
-			Description: "The path to the workspace folder that will " +
-				"be built. This is optional.",
+			Description: "The path to the workspace folder that will be built. " +
+				"This is optional. Defaults to `[workspace base dir]/[name]` where " +
+				"name is the name of the repository or `empty`.",
 		},
 		{
 			Flag:  "ssl-cert-base64",


Test patch:
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 75972ae..aaa25d1 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -834,6 +834,28 @@ func TestBuildFromDevcontainerInCustomPath(t *testing.T) {
 	require.Equal(t, "hello", strings.TrimSpace(output))
 }
 
+func TestBuildFromCustomWorkspaceBaseDir(t *testing.T) {
+	t.Parallel()
+
+	// Ensures that a Git repository with a devcontainer.json is cloned and built.
+	srv := gittest.CreateGitServer(t, gittest.Options{
+		Files: map[string]string{
+			"Dockerfile": "FROM " + testImageUbuntu,
+		},
+	})
+	ctr, err := runEnvbuilder(t, runOpts{
+		env: []string{
+			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
+			envbuilderEnv("WORKSPACE_BASE_DIR", "/foo"),
+			envbuilderEnv("GIT_URL", srv.URL),
+		},
+	})
+	require.NoError(t, err)
+
+	output := execContainer(t, ctr, "readlink /proc/1/cwd")
+	require.Contains(t, output, "/foo/")
+}
+
 func TestBuildFromDevcontainerInSubfolder(t *testing.T) {
 	t.Parallel()
 
diff --git a/options/defaults_test.go b/options/defaults_test.go
index 20aacc6..91dac3b 100644
--- a/options/defaults_test.go
+++ b/options/defaults_test.go
@@ -17,83 +17,110 @@ func TestDefaultWorkspaceFolder(t *testing.T) {
 
 	successTests := []struct {
 		name     string
+		baseDir  string
 		gitURL   string
 		expected string
 	}{
 		{
 			name:     "HTTP",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder.git",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "SSH",
+			baseDir:  "/workspaces",
 			gitURL:   "git@github.com:coder/envbuilder.git",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "username and password",
+			baseDir:  "/workspaces",
 			gitURL:   "https://username:password@github.com/coder/envbuilder.git",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "trailing",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder.git/",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "trailing-x2",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder.git//",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "no .git",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "trailing no .git",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder/",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "fragment",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder.git#feature-branch",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "fragment-trailing",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder.git/#refs/heads/feature-branch",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "fragment-trailing no .git",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/envbuilder/#refs/heads/feature-branch",
 			expected: "/workspaces/envbuilder",
 		},
 		{
 			name:     "space",
+			baseDir:  "/workspaces",
 			gitURL:   "https://github.com/coder/env%20builder.git",
 			expected: "/workspaces/env builder",
 		},
 		{
 			name:     "Unix path",
+			baseDir:  "/workspaces",
 			gitURL:   "/repo",
 			expected: "/workspaces/repo",
 		},
 		{
 			name:     "Unix subpath",
+			baseDir:  "/workspaces",
 			gitURL:   "/path/to/repo",
 			expected: "/workspaces/repo",
 		},
 		{
 			name:     "empty",
+			baseDir:  "/workspaces",
 			gitURL:   "",
-			expected: options.EmptyWorkspaceDir,
+			expected: "/workspaces/empty",
+		},
+		{
+			name:     "non default workspaces folder",
+			baseDir:  "/foo",
+			gitURL:   "https://github.com/coder/envbuilder.git",
+			expected: "/foo/envbuilder",
+		},
+		{
+			name:     "non default workspaces folder empty git URL",
+			baseDir:  "/foo",
+			gitURL:   "",
+			expected: "/foo/empty",
 		},
 	}
 	for _, tt := range successTests {
 		t.Run(tt.name, func(t *testing.T) {
-			dir := options.DefaultWorkspaceFolder(tt.gitURL)
+			dir := options.DefaultWorkspaceFolder(tt.baseDir, tt.gitURL)
 			require.Equal(t, tt.expected, dir)
 		})
 	}
@@ -125,8 +152,8 @@ func TestDefaultWorkspaceFolder(t *testing.T) {
 	}
 	for _, tt := range invalidTests {
 		t.Run(tt.name, func(t *testing.T) {
-			dir := options.DefaultWorkspaceFolder(tt.invalidURL)
-			require.Equal(t, options.EmptyWorkspaceDir, dir)
+			dir := options.DefaultWorkspaceFolder("/workspaces", tt.invalidURL)
+			require.Equal(t, "/workspaces/empty", dir)
 		})
 	}
 }
@@ -135,14 +162,15 @@ func TestOptions_SetDefaults(t *testing.T) {
 	t.Parallel()
 
 	expected := options.Options{
-		InitScript:      "sleep infinity",
-		InitCommand:     "/bin/sh",
-		IgnorePaths:     []string{"/var/run", "/product_uuid", "/product_name"},
-		Filesystem:      chmodfs.New(osfs.New("/")),
-		GitURL:          "",
-		WorkspaceFolder: options.EmptyWorkspaceDir,
-		WorkingDirBase:  "/.envbuilder",
-		BinaryPath:      "/.envbuilder/bin/envbuilder",
+		InitScript:       "sleep infinity",
+		InitCommand:      "/bin/sh",
+		IgnorePaths:      []string{"/var/run", "/product_uuid", "/product_name"},
+		Filesystem:       chmodfs.New(osfs.New("/")),
+		GitURL:           "",
+		WorkspaceBaseDir: "/workspaces",
+		WorkspaceFolder:  "/workspaces/empty",
+		WorkingDirBase:   "/.envbuilder",
+		BinaryPath:       "/.envbuilder/bin/envbuilder",
 	}
 
 	var actual options.Options
diff --git a/options/testdata/options.golden b/options/testdata/options.golden
index 211305f..6a8145a 100644
--- a/options/testdata/options.golden
+++ b/options/testdata/options.golden
@@ -181,6 +181,12 @@ OPTIONS:
       --verbose bool, $ENVBUILDER_VERBOSE
           Enable verbose logging.
 
+      --workspace-base-dir string, $ENVBUILDER_WORKSPACE_BASE_DIR (default: /workspaces)
+          The path under which workspaces will be placed when workspace folder
+          option is not given.
+
       --workspace-folder string, $ENVBUILDER_WORKSPACE_FOLDER
           The path to the workspace folder that will be built. This is optional.
+          Defaults to `[workspace base dir]/[name]` where name is the name of
+          the repository or `empty`.