diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b6b707d..a38877f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,21 @@ Add new entries to the top of the "Unreleased" section. ## Unreleased +### Added + +- `devpod up --git-user-name` and `--git-user-email` set the git identity that the workspace's dev container + gets, and save it with the workspace. An empty value removes the saved one. + ### Fixed +- The git identity that DevPod gives a dev container is the one git uses in the repository the workspace comes + from (its local folder, or the directory `devpod up` runs in), including the repository's config and matching + `include` and `includeIf` directives. DevPod used to read only `~/.gitconfig` without its includes, an issue + inherited from upstream, so a work identity kept in an `includeIf` block was ignored and workspaces committed + with the global (often personal) email. `devpod up` saves the repository's identity with the workspace, so + later connections from anywhere use the same one; outside a repository it saves nothing, and the container + gets the global identity (now with its includes) when it connects. A dev container that already has an + identity keeps it, including those of existing workspaces: use `devpod up --recreate` to give it the new one. - `devpod up` of a Kubernetes workspace recreates the workspace's pod when it is dead (its phase is `Failed`, `Succeeded` or `Unknown`, its dev container has terminated, or it is being deleted), keeping the workspace's persistent volume claim. It used to reuse such a pod, for example after the node restarted, and then time out diff --git a/cmd/build.go b/cmd/build.go index 855f366ae..9c9dace35 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -89,6 +89,7 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { cmd.DevContainerPath, sshConfigPath, nil, + nil, cmd.UID, false, log.Default, diff --git a/cmd/up.go b/cmd/up.go index 3c37aefbd..3f0a3695c 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -58,6 +58,10 @@ type UpCmd struct { SSHConfigPath string + GitUserName string + GitUserEmail string + GitUser workspace2.GitUserOptions + DotfilesSource string DotfilesScript string DotfilesScriptEnv []string // Key=Value to pass to install script @@ -85,6 +89,15 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { ctx, cancel := WithSignals(cobraCmd.Context()) defer cancel() + // an empty directory makes DevPod use the global git identity + cmd.GitUser.Dir, _ = os.Getwd() + if cobraCmd.Flags().Changed("git-user-name") { + cmd.GitUser.Name = &cmd.GitUserName + } + if cobraCmd.Flags().Changed("git-user-email") { + cmd.GitUser.Email = &cmd.GitUserEmail + } + client, logger, err := cmd.prepareClient(ctx, devPodConfig, args) if err != nil { return fmt.Errorf("prepare workspace client: %w", err) @@ -123,6 +136,8 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { upCmd.Flags().Var(&cmd.GitCloneStrategy, "git-clone-strategy", "The git clone strategy DevPod uses to checkout git based workspaces. Can be full (default), blobless, treeless or shallow") upCmd.Flags().BoolVar(&cmd.GitCloneRecursiveSubmodules, "git-clone-recursive-submodules", false, "If true will clone git submodule repositories recursively") upCmd.Flags().StringVar(&cmd.GitSSHSigningKey, "git-ssh-signing-key", "", "The ssh key to use when signing git commits. Used to explicitly setup DevPod's ssh signature forwarding with given key. Should be same format as value of `git config user.signingkey`") + upCmd.Flags().StringVar(&cmd.GitUserName, "git-user-name", "", "The git user.name to give the workspace's container, saved with the workspace. An empty value removes the saved one. Without one, DevPod saves the one git uses in the workspace's repository: its local folder, or the directory DevPod runs in") + upCmd.Flags().StringVar(&cmd.GitUserEmail, "git-user-email", "", "The git user.email to give the workspace's container, saved with the workspace. An empty value removes the saved one. Without one, DevPod saves the one git uses in the workspace's repository: its local folder, or the directory DevPod runs in") upCmd.Flags().StringVar(&cmd.FallbackImage, "fallback-image", "", "The fallback image to use if no devcontainer configuration has been detected") upCmd.Flags().BoolVar(&cmd.DisableDaemon, "disable-daemon", false, "If enabled, will not install a daemon into the target machine to track activity") upCmd.Flags().StringVar(&cmd.Source, "source", "", "Optional source for the workspace. E.g. git:https://github.com/my-org/my-repo") @@ -1095,6 +1110,7 @@ func (cmd *UpCmd) prepareClient(ctx context.Context, devPodConfig *config.Config cmd.DevContainerPath, cmd.SSHConfigPath, source, + &cmd.GitUser, cmd.UID, true, logger, diff --git a/docs/pages/developing-in-workspaces/credentials.mdx b/docs/pages/developing-in-workspaces/credentials.mdx index 0cfea03c9..443a32b67 100644 --- a/docs/pages/developing-in-workspaces/credentials.mdx +++ b/docs/pages/developing-in-workspaces/credentials.mdx @@ -20,6 +20,32 @@ Both only work while you are connected to the workspace, because the credentials workspaces that must keep working without you, such as ones running AI agents, see [Run Workspaces Unattended](./unattended-workspaces.mdx). +## Git identity + +DevPod gives the dev container your git `user.name` and `user.email`, unless the container user's `~/.gitconfig` +already has them (for example from the container's own start command). It uses the identity git uses in the +repository the workspace comes from: the workspace's local folder or, for other sources, the directory you run +`devpod up` from. That includes the repository's config and the `include` and `includeIf` directives that match +it, so running `devpod up` from inside a work repository gives the workspace your work identity. + +`devpod up` saves that identity with the workspace, and running it again, from anywhere, doesn't change it. If +`devpod up` doesn't run in a repository (for example from the desktop app, for a git source), it saves nothing, +and the container gets your global identity, following the global config's includes, when it connects. + +Git ignores a repository it doesn't trust, for example one on a network share or owned by another user, and then +DevPod can't use its identity. Add it to git's +[`safe.directory`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory) to fix that. + +To set the identity explicitly, pass it to `devpod up`. An empty value removes the saved one, so that DevPod looks +it up again: + +``` +devpod up --git-user-email me+devpod@company.com --git-user-name "My Name" my-workspace +``` + +A dev container that already has an identity keeps it, and so do the dev containers of workspaces created before +DevPod saved the identity. Use `devpod up --recreate` to give the container the new one. + ## Docker credentials DevPod will make docker registry credentials available inside the dev container through a [docker credentials helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers). This allows you to pull and push images from and to private registries from within the dev container. diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 3e7939397..274d08838 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -186,12 +186,7 @@ func (t *tunnelServer) DockerCredentials(ctx context.Context, message *tunnel.Me } func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) { - gitUser, err := gitcredentials.GetUser("") - if err != nil { - return nil, err - } - - out, err := json.Marshal(gitUser) + out, err := json.Marshal(t.gitUser()) if err != nil { return nil, err } @@ -201,6 +196,27 @@ func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunne }, nil } +// gitUser returns the identity `devpod up` saved with the workspace. A value it didn't save, +// because it wasn't given and the workspace doesn't come from a repository, is the global one. +func (t *tunnelServer) gitUser() *gitcredentials.GitUser { + gitUser := &gitcredentials.GitUser{} + if t.workspace != nil { + gitUser.Name = t.workspace.GitUser.Name + gitUser.Email = t.workspace.GitUser.Email + } + if gitUser.Name == "" || gitUser.Email == "" { + globalUser := gitcredentials.GetGlobalUser() + if gitUser.Name == "" { + gitUser.Name = globalUser.Name + } + if gitUser.Email == "" { + gitUser.Email = globalUser.Email + } + } + + return gitUser +} + func (t *tunnelServer) GitCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { if !t.allowGitCredentials { return nil, fmt.Errorf("git credentials forbidden") diff --git a/pkg/agent/tunnelserver/tunnelserver_test.go b/pkg/agent/tunnelserver/tunnelserver_test.go index 52f308ed7..5e63a491d 100644 --- a/pkg/agent/tunnelserver/tunnelserver_test.go +++ b/pkg/agent/tunnelserver/tunnelserver_test.go @@ -2,10 +2,14 @@ package tunnelserver import ( "context" + "os" + "path/filepath" "testing" "github.com/loft-sh/devpod/pkg/agent/tunnel" "github.com/loft-sh/devpod/pkg/log" + provider2 "github.com/loft-sh/devpod/pkg/provider" + "gotest.tools/assert" ) // TestGitCredentials_NoWorkspace checks that a git credentials request to a @@ -30,3 +34,49 @@ func TestGitCredentials_NoWorkspace(t *testing.T) { // a panic is not. _, _ = s.GitCredentials(context.Background(), request) } + +// TestGitUser checks that the container gets the identity saved with the +// workspace, and the global one for what the workspace doesn't have. +func TestGitUser(t *testing.T) { + home := t.TempDir() + globalConfig := filepath.Join(home, ".gitconfig") + err := os.WriteFile(globalConfig, []byte("[user]\n\tname = Global\n\temail = global@personal.com\n"), 0600) + assert.NilError(t, err) + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + testCases := []struct { + name string + workspace *provider2.Workspace + expected string + }{ + { + name: "saved identity", + workspace: &provider2.Workspace{GitUser: provider2.WorkspaceGitUser{Name: "Work", Email: "me@company.com"}}, + expected: `{"name":"Work","email":"me@company.com"}`, + }, + { + name: "partly saved identity", + workspace: &provider2.Workspace{GitUser: provider2.WorkspaceGitUser{Email: "me@company.com"}}, + expected: `{"name":"Global","email":"me@company.com"}`, + }, + { + name: "workspace without a saved identity", + workspace: &provider2.Workspace{}, + expected: `{"name":"Global","email":"global@personal.com"}`, + }, + { + name: "no workspace", + expected: `{"name":"Global","email":"global@personal.com"}`, + }, + } + + for _, testCase := range testCases { + s := New(log.Discard, WithWorkspace(testCase.workspace)) + response, err := s.GitUser(context.Background(), &tunnel.Empty{}) + assert.NilError(t, err, testCase.name) + assert.Equal(t, response.Message, testCase.expected, testCase.name) + } +} diff --git a/pkg/gitcredentials/gitcredentials.go b/pkg/gitcredentials/gitcredentials.go index 5424a3f7c..e4daf3539 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -145,59 +145,78 @@ func ToString(credentials *GitCredentials) string { } func SetUser(userName string, user *GitUser) error { - if user.Name != "" { - shellCommand := fmt.Sprintf(`git config --global user.name "%s"`, user.Name) - args := []string{} - if userName != "" { - args = append(args, "su", userName, "-c", shellCommand) - } else { - args = append(args, "sh", "-c", shellCommand) - } + if user.Name == "" && user.Email == "" { + return nil + } - out, err := exec.Command(args[0], args[1:]...).CombinedOutput() - if err != nil { - return fmt.Errorf("set user.name '%s': %w", strings.Join(args, " "), command.WrapCommandError(out, err)) - } + gitConfigPath, err := getGlobalGitConfigPath(userName) + if err != nil { + return err + } + + err = file.MkdirAll(userName, filepath.Dir(gitConfigPath), 0755) + if err != nil { + return err } - if user.Email != "" { - shellCommand := fmt.Sprintf(`git config --global user.email "%s"`, user.Email) - args := []string{} - if userName != "" { - args = append(args, "su", userName, "-c", shellCommand) - } else { - args = append(args, "sh", "-c", shellCommand) + + // run git without a shell, so that the values need no quoting + for _, setting := range [][2]string{{"user.name", user.Name}, {"user.email", user.Email}} { + if setting[1] == "" { + continue } - out, err := exec.Command(args[0], args[1:]...).CombinedOutput() + out, err := exec.Command("git", "config", "--file", gitConfigPath, setting[0], setting[1]).CombinedOutput() if err != nil { - return fmt.Errorf("set user.email '%s': %w", strings.Join(args, " "), command.WrapCommandError(out, err)) + return fmt.Errorf("set %s: %w", setting[0], command.WrapCommandError(out, err)) } } - return nil + + return file.Chown(userName, gitConfigPath) } func GetUser(userName string) (*GitUser, error) { - gitUser := &GitUser{} + gitConfigPath, err := getGlobalGitConfigPath(userName) + if err != nil { + return &GitUser{}, fmt.Errorf("get git global dir for %s: %w", userName, err) + } - scopeArgs := []string{"config"} - if userName != "" { - p, err := getGlobalGitConfigPath(userName) - if err != nil { - return gitUser, fmt.Errorf("get git global dir for %s: %w", userName, err) - } - scopeArgs = append(scopeArgs, "--file", p) - } else { - scopeArgs = append(scopeArgs, "--global") + // --file names a single file, so git only follows its includes with --includes + return readUser([]string{"config", "--file", gitConfigPath, "--includes"}), nil +} + +// GetRepositoryUser returns the git identity that git uses in the repository that dir is in. That +// includes the repository's config and the include and includeIf directives that match it. It +// fails if dir isn't in a repository that git can use, for example one that git doesn't trust +// because another user owns it (see git's safe.directory). +func GetRepositoryUser(dir string) (*GitUser, error) { + if dir == "" { + return nil, fmt.Errorf("no directory") + } + + out, err := exec.Command("git", "-C", dir, "rev-parse", "--git-dir").CombinedOutput() + if err != nil { + return nil, command.WrapCommandError(out, err) } - // we ignore the error here, because if email is empty we don't care - name, _ := exec.Command("git", append(scopeArgs[:], "user.name")...).Output() + return readUser([]string{"-C", dir, "config"}), nil +} + +// GetGlobalUser returns the git identity of the global git config, following its includes. +func GetGlobalUser() *GitUser { + return readUser([]string{"config", "--global", "--includes"}) +} + +func readUser(configArgs []string) *GitUser { + gitUser := &GitUser{} + + // we ignore the error here, because git fails if the value isn't set + name, _ := exec.Command("git", append(configArgs, "user.name")...).Output() gitUser.Name = strings.TrimSpace(string(name)) - email, _ := exec.Command("git", append(scopeArgs[:], "user.email")...).Output() + email, _ := exec.Command("git", append(configArgs, "user.email")...).Output() gitUser.Email = strings.TrimSpace(string(email)) - return gitUser, nil + return gitUser } func GetCredentials(requestObj *GitCredentials) (*GitCredentials, error) { diff --git a/pkg/gitcredentials/gituser_test.go b/pkg/gitcredentials/gituser_test.go new file mode 100644 index 000000000..c816f55bd --- /dev/null +++ b/pkg/gitcredentials/gituser_test.go @@ -0,0 +1,129 @@ +package gitcredentials + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "gotest.tools/assert" +) + +// setupIncludeIfConfig gives git a global config with a personal identity and an includeIf that +// gives repositories under the returned work directory a work email, as in the bug report. +func setupIncludeIfConfig(t *testing.T) (workDir string) { + t.Helper() + + // resolve symlinks (such as macOS's /var) so the gitdir pattern matches + root, err := filepath.EvalSymlinks(t.TempDir()) + assert.NilError(t, err) + home := filepath.Join(root, "home") + workDir = filepath.Join(home, "work") + assert.NilError(t, os.MkdirAll(workDir, 0755)) + + // gitdir/i because Windows paths are case insensitive + globalConfig := filepath.Join(home, ".gitconfig") + writeFile(t, globalConfig, `[user] + name = Me + email = me@personal.com +[includeIf "gitdir/i:`+configPath(workDir)+`/"] + path = `+configPath(filepath.Join(workDir, ".gitconfig"))+"\n") + writeFile(t, filepath.Join(workDir, ".gitconfig"), "[user]\n\temail = me@company.com\n") + + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + // keep git from finding a repository the temporary directory is in + t.Setenv("GIT_CEILING_DIRECTORIES", root) + + return workDir +} + +// configPath writes path for a git config file: git takes a backslash as an escape character, and +// gitdir patterns use forward slashes on Windows too. +func configPath(path string) string { + return filepath.ToSlash(path) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + assert.NilError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func gitInit(t *testing.T, dir string) { + t.Helper() + out, err := exec.Command("git", "init", "--quiet", dir).CombinedOutput() + assert.NilError(t, err, string(out)) +} + +func TestGetRepositoryUser_FollowsIncludeIf(t *testing.T) { + repo := filepath.Join(setupIncludeIfConfig(t), "repo") + gitInit(t, repo) + + gitUser, err := GetRepositoryUser(repo) + assert.NilError(t, err) + assert.DeepEqual(t, gitUser, &GitUser{Name: "Me", Email: "me@company.com"}) + + // a subdirectory of the repository is in it too + subDir := filepath.Join(repo, "sub") + assert.NilError(t, os.Mkdir(subDir, 0755)) + gitUser, err = GetRepositoryUser(subDir) + assert.NilError(t, err) + assert.DeepEqual(t, gitUser, &GitUser{Name: "Me", Email: "me@company.com"}) +} + +func TestGetRepositoryUser_UsesRepositoryConfig(t *testing.T) { + repo := filepath.Join(setupIncludeIfConfig(t), "..", "other") + gitInit(t, repo) + out, err := exec.Command("git", "-C", repo, "config", "user.email", "me@repo.com").CombinedOutput() + assert.NilError(t, err, string(out)) + + gitUser, err := GetRepositoryUser(repo) + assert.NilError(t, err) + assert.DeepEqual(t, gitUser, &GitUser{Name: "Me", Email: "me@repo.com"}) +} + +func TestGetRepositoryUser_FailsOutsideRepository(t *testing.T) { + workDir := setupIncludeIfConfig(t) + + for _, dir := range []string{workDir, filepath.Join(workDir, "missing"), ""} { + _, err := GetRepositoryUser(dir) + assert.Assert(t, err != nil, dir) + } +} + +func TestGetRepositoryUser_FailsForUntrustedRepository(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Git for Windows checks the owner of a repository differently") + } + + repo := filepath.Join(setupIncludeIfConfig(t), "repo") + gitInit(t, repo) + // git then takes the repository to belong to another user + t.Setenv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1") + + _, err := GetRepositoryUser(repo) + assert.ErrorContains(t, err, "dubious ownership") +} + +func TestGetGlobalUser(t *testing.T) { + workDir := setupIncludeIfConfig(t) + assert.DeepEqual(t, GetGlobalUser(), &GitUser{Name: "Me", Email: "me@personal.com"}) + + writeFile(t, os.Getenv("GIT_CONFIG_GLOBAL"), "[include]\n\tpath = "+configPath(filepath.Join(workDir, ".gitconfig"))+"\n") + assert.DeepEqual(t, GetGlobalUser(), &GitUser{Email: "me@company.com"}) +} + +func TestSetUser_TakesValuesLiterally(t *testing.T) { + setupIncludeIfConfig(t) + + // SetUser and GetUser use $XDG_CONFIG_HOME/git/config, whose directory doesn't exist yet + gitUser := &GitUser{Name: `Ann "Nan' $HOME`, Email: "`me`@company.com"} + assert.NilError(t, SetUser("", gitUser)) + + saved, err := GetUser("") + assert.NilError(t, err) + assert.DeepEqual(t, saved, gitUser) +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 3356d48f5..ccefdb03a 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -49,6 +49,9 @@ type Workspace struct { // DevContainerConfig holds the config for the devcontainer.json. DevContainerConfig *devcontainerconfig.DevContainerConfig `json:"devContainerConfig,omitempty"` + // GitUser is the git identity to give the dev container, if it doesn't have one yet + GitUser WorkspaceGitUser `json:"gitUser,omitempty"` + // CreationTimestamp is the timestamp when this workspace was created CreationTimestamp types.Time `json:"creationTimestamp,omitempty"` @@ -73,6 +76,14 @@ type WorkspaceIDEConfig struct { Options map[string]config.OptionValue `json:"options,omitempty"` } +type WorkspaceGitUser struct { + // Name is the git user.name + Name string `json:"name,omitempty"` + + // Email is the git user.email + Email string `json:"email,omitempty"` +} + type WorkspaceMachineConfig struct { // ID is the machine ID to use for this workspace ID string `json:"machineId,omitempty"` diff --git a/pkg/workspace/gituser.go b/pkg/workspace/gituser.go new file mode 100644 index 000000000..527f021cb --- /dev/null +++ b/pkg/workspace/gituser.go @@ -0,0 +1,74 @@ +package workspace + +import ( + "fmt" + + "github.com/loft-sh/devpod/pkg/gitcredentials" + "github.com/loft-sh/devpod/pkg/log" + providerpkg "github.com/loft-sh/devpod/pkg/provider" +) + +// GitUserOptions are the `devpod up` options for the git identity of the workspace's container. +type GitUserOptions struct { + // Name and Email replace the saved values unless they are nil. An empty value removes the + // saved one, so that DevPod looks it up again. + Name *string + Email *string + + // Dir is the directory `devpod up` runs in + Dir string +} + +// ConfigureGitUser saves the git identity that the workspace's container gets if it doesn't have +// one yet. A name or email in options replaces the saved one. One that is neither given nor saved +// is the one git uses in the repository the workspace comes from: its local folder, or else the +// directory `devpod up` runs in. That way the repository's config and its includeIf matches +// count, and running `devpod up` again from somewhere else doesn't change the identity. Outside a +// repository nothing is saved, so the container gets the global identity when it connects. +func ConfigureGitUser(workspace *providerpkg.Workspace, options GitUserOptions, log log.Logger) error { + dir := options.Dir + if workspace.Source.LocalFolder != "" { + dir = workspace.Source.LocalFolder + } + + newGitUser := resolveGitUser(workspace.GitUser, options, dir, log) + if newGitUser == workspace.GitUser { + return nil + } + + log.Debugf("Use git identity '%s <%s>' for workspace %s", newGitUser.Name, newGitUser.Email, workspace.ID) + workspace.GitUser = newGitUser + err := providerpkg.SaveWorkspaceConfig(workspace) + if err != nil { + return fmt.Errorf("save workspace: %w", err) + } + + return nil +} + +func resolveGitUser(saved providerpkg.WorkspaceGitUser, options GitUserOptions, dir string, log log.Logger) providerpkg.WorkspaceGitUser { + gitUser := saved + if options.Name != nil { + gitUser.Name = *options.Name + } + if options.Email != nil { + gitUser.Email = *options.Email + } + + if gitUser.Name == "" || gitUser.Email == "" { + repositoryUser, err := gitcredentials.GetRepositoryUser(dir) + if err != nil { + log.Debugf("Not using the git identity of %q: %v", dir, err) + return gitUser + } + + if gitUser.Name == "" { + gitUser.Name = repositoryUser.Name + } + if gitUser.Email == "" { + gitUser.Email = repositoryUser.Email + } + } + + return gitUser +} diff --git a/pkg/workspace/gituser_test.go b/pkg/workspace/gituser_test.go new file mode 100644 index 000000000..eb9322c2d --- /dev/null +++ b/pkg/workspace/gituser_test.go @@ -0,0 +1,127 @@ +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/loft-sh/devpod/pkg/config" + "github.com/loft-sh/devpod/pkg/log" + providerpkg "github.com/loft-sh/devpod/pkg/provider" + "gotest.tools/assert" +) + +// setupGitConfig gives git a global identity and returns a repository with its own email. +func setupGitConfig(t *testing.T) (repo string) { + t.Helper() + + root, err := filepath.EvalSymlinks(t.TempDir()) + assert.NilError(t, err) + globalConfig := filepath.Join(root, ".gitconfig") + assert.NilError(t, os.WriteFile(globalConfig, []byte("[user]\n\tname = Me\n\temail = me@personal.com\n"), 0600)) + t.Setenv("HOME", root) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, ".config")) + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + // keep git from finding a repository the temporary directory is in + t.Setenv("GIT_CEILING_DIRECTORIES", root) + t.Setenv(config.DEVPOD_HOME, filepath.Join(root, ".devpod")) + + repo = filepath.Join(root, "repo") + out, err := exec.Command("git", "init", "--quiet", repo).CombinedOutput() + assert.NilError(t, err, string(out)) + out, err = exec.Command("git", "-C", repo, "config", "user.email", "me@company.com").CombinedOutput() + assert.NilError(t, err, string(out)) + + return repo +} + +func TestConfigureGitUser(t *testing.T) { + repo := setupGitConfig(t) + // git's ceiling only stops it above the temporary directory, so not the directory itself + notRepo := filepath.Join(filepath.Dir(repo), "other") + assert.NilError(t, os.Mkdir(notRepo, 0755)) + saved := providerpkg.WorkspaceGitUser{Name: "Saved", Email: "saved@company.com"} + empty := "" + devpodEmail := "me+devpod@company.com" + givenName := "Given" + + testCases := []struct { + name string + workspace providerpkg.Workspace + options GitUserOptions + expected providerpkg.WorkspaceGitUser + }{ + { + name: "new workspace gets the identity of the repository devpod runs in", + options: GitUserOptions{Dir: repo}, + expected: providerpkg.WorkspaceGitUser{Name: "Me", Email: "me@company.com"}, + }, + { + name: "a local folder wins over the directory devpod runs in", + workspace: providerpkg.Workspace{Source: providerpkg.WorkspaceSource{LocalFolder: repo}}, + options: GitUserOptions{Dir: notRepo}, + expected: providerpkg.WorkspaceGitUser{Name: "Me", Email: "me@company.com"}, + }, + { + name: "outside a repository nothing is saved", + options: GitUserOptions{Dir: notRepo}, + }, + { + name: "without a directory nothing is saved", + options: GitUserOptions{}, + }, + { + name: "the saved identity is kept", + workspace: providerpkg.Workspace{GitUser: saved}, + options: GitUserOptions{Dir: repo}, + expected: saved, + }, + { + name: "a given value replaces the saved one", + workspace: providerpkg.Workspace{GitUser: saved}, + options: GitUserOptions{Email: &devpodEmail, Dir: notRepo}, + expected: providerpkg.WorkspaceGitUser{Name: "Saved", Email: devpodEmail}, + }, + { + name: "a value that isn't given is looked up", + options: GitUserOptions{Name: &givenName, Dir: repo}, + expected: providerpkg.WorkspaceGitUser{Name: givenName, Email: "me@company.com"}, + }, + { + name: "a given value is saved outside a repository", + options: GitUserOptions{Email: &devpodEmail, Dir: notRepo}, + expected: providerpkg.WorkspaceGitUser{Email: devpodEmail}, + }, + { + name: "an empty value removes the saved one, which is looked up again", + workspace: providerpkg.Workspace{GitUser: saved}, + options: GitUserOptions{Email: &empty, Dir: repo}, + expected: providerpkg.WorkspaceGitUser{Name: "Saved", Email: "me@company.com"}, + }, + { + name: "an empty value outside a repository removes the saved one", + workspace: providerpkg.Workspace{GitUser: saved}, + options: GitUserOptions{Name: &empty, Email: &empty, Dir: notRepo}, + }, + } + + for i, testCase := range testCases { + workspace := testCase.workspace + workspace.ID = fmt.Sprintf("test-%d", i) + workspace.Context = "default" + assert.NilError(t, ConfigureGitUser(&workspace, testCase.options, log.Discard), testCase.name) + assert.Equal(t, workspace.GitUser, testCase.expected, testCase.name) + + saved, err := providerpkg.LoadWorkspaceConfig(workspace.Context, workspace.ID) + if testCase.expected == testCase.workspace.GitUser { + // nothing changed, so nothing was saved + assert.Assert(t, err != nil, testCase.name) + } else { + assert.NilError(t, err, testCase.name) + assert.Equal(t, saved.GitUser, testCase.expected, testCase.name) + } + } +} diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index 8f540d350..73c971b3c 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -36,6 +36,7 @@ func Resolve( devContainerPath string, sshConfigPath string, source *providerpkg.WorkspaceSource, + gitUser *GitUserOptions, uid string, changeLastUsed bool, log log.Logger, @@ -101,6 +102,14 @@ func Resolve( } } + // configure the git identity of the container, unless the caller doesn't connect to it + if gitUser != nil { + err = ConfigureGitUser(workspace, *gitUser, log) + if err != nil { + return nil, fmt.Errorf("configure git user: %w", err) + } + } + // create workspace client client, err := getWorkspaceClient(devPodConfig, provider, workspace, machine, log) if err != nil {