From 79910b2f1af9673ac81da571ec8305ac294dc9c1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 18:32:23 -0500 Subject: [PATCH] feat(git): support includeIf conditional config via workspace directory context Thread a workingDir parameter through GetUser(), ExtractGitConfiguration(), and addGitSSHSigningKey() so that git config commands run in the workspace directory where includeIf "gitdir:..." directives are evaluated correctly. When workingDir is non-empty the --global flag is omitted and cmd.Dir is set instead, letting git resolve the full config chain including conditional includes. When workingDir is empty the previous --global behaviour is preserved, so callers without workspace context (e.g. initial setup) are unaffected. Closes devpod#750 --- cmd/agent/container/credentials_server.go | 2 +- pkg/agent/tunnelserver/tunnelserver.go | 6 +- pkg/devcontainer/setup/setup.go | 2 +- pkg/gitcredentials/gitcredentials.go | 16 +++- pkg/gitcredentials/gitcredentials_test.go | 92 +++++++++++++++++++++++ pkg/gitsshsigning/utils.go | 13 ++-- pkg/gitsshsigning/utils_test.go | 63 ++++++++++++++++ pkg/tunnel/services.go | 10 ++- pkg/tunnel/services_test.go | 6 +- 9 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 pkg/gitcredentials/gitcredentials_test.go create mode 100644 pkg/gitsshsigning/utils_test.go diff --git a/cmd/agent/container/credentials_server.go b/cmd/agent/container/credentials_server.go index f4ed1ff9d..d3755b6e3 100644 --- a/cmd/agent/container/credentials_server.go +++ b/cmd/agent/container/credentials_server.go @@ -165,7 +165,7 @@ func configureGitUserLocally( client tunnel.TunnelClient, ) error { // get local credentials - localGitUser, err := gitcredentials.GetUser(userName) + localGitUser, err := gitcredentials.GetUser(userName, "") if err != nil { return err } else if localGitUser.Name != "" && localGitUser.Email != "" { diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 0f23511b2..db705299a 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -228,7 +228,11 @@ func (t *tunnelServer) DockerCredentials( } func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) { - gitUser, err := gitcredentials.GetUser("") + workingDir := "" + if t.workspace != nil { + workingDir = t.workspace.Source.LocalFolder + } + gitUser, err := gitcredentials.GetUser("", workingDir) if err != nil { return nil, err } diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 3807ecb90..5338969cc 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -522,7 +522,7 @@ func setupPlatformGitCredentials( // setup platform git user if platformOptions.UserCredentials.GitUser != "" && platformOptions.UserCredentials.GitEmail != "" { - gitUser, err := gitcredentials.GetUser(userName) + gitUser, err := gitcredentials.GetUser(userName, "") if err == nil && gitUser.Name == "" && gitUser.Email == "" { log.Info("Setup workspace git user and email") err := gitcredentials.SetUser(userName, &gitcredentials.GitUser{ diff --git a/pkg/gitcredentials/gitcredentials.go b/pkg/gitcredentials/gitcredentials.go index d1f6993da..c70a72792 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -185,7 +185,7 @@ func SetUser(userName string, user *GitUser) error { return nil } -func GetUser(userName string) (*GitUser, error) { +func GetUser(userName string, workingDir string) (*GitUser, error) { gitUser := &GitUser{} scopeArgs := []string{"config"} @@ -195,15 +195,23 @@ func GetUser(userName string) (*GitUser, error) { return gitUser, fmt.Errorf("get git global dir for %s: %w", userName, err) } scopeArgs = append(scopeArgs, "--file", p) - } else { + } else if workingDir == "" { scopeArgs = append(scopeArgs, "--global") } + nameCmd := exec.Command("git", append(scopeArgs, "user.name")...) // #nosec G204 + emailCmd := exec.Command("git", append(scopeArgs, "user.email")...) // #nosec G204 + + if workingDir != "" { + nameCmd.Dir = workingDir + emailCmd.Dir = workingDir + } + // we ignore the error here, because if email is empty we don't care - name, _ := exec.Command("git", append(scopeArgs[:], "user.name")...).Output() + name, _ := nameCmd.Output() gitUser.Name = strings.TrimSpace(string(name)) - email, _ := exec.Command("git", append(scopeArgs[:], "user.email")...).Output() + email, _ := emailCmd.Output() gitUser.Email = strings.TrimSpace(string(email)) return gitUser, nil diff --git a/pkg/gitcredentials/gitcredentials_test.go b/pkg/gitcredentials/gitcredentials_test.go new file mode 100644 index 000000000..8eef68fbc --- /dev/null +++ b/pkg/gitcredentials/gitcredentials_test.go @@ -0,0 +1,92 @@ +package gitcredentials + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetUser_WorkingDirResolvesIncludeIf(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + projectDir := t.TempDir() + //nolint:gosec // test-only, args are constants + require.NoError(t, exec.Command("git", "init", projectDir).Run()) + + projectConfigPath := filepath.Join(tmpHome, "project.gitconfig") + require.NoError(t, os.WriteFile(projectConfigPath, []byte(`[user] + name = Project User + email = project@example.com +`), 0o600)) + + globalConfigPath := filepath.Join(tmpHome, ".gitconfig") + require.NoError(t, os.WriteFile(globalConfigPath, fmt.Appendf(nil, `[user] + name = Global User + email = global@example.com +[includeIf "gitdir:%s/"] + path = %s +`, projectDir, projectConfigPath), 0o600)) + + user, err := GetUser("", projectDir) + require.NoError(t, err) + assert.Equal(t, "Project User", user.Name) + assert.Equal(t, "project@example.com", user.Email) +} + +func TestGetUser_EmptyWorkingDirUsesGlobal(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + globalConfigPath := filepath.Join(tmpHome, ".gitconfig") + require.NoError(t, os.WriteFile(globalConfigPath, []byte(`[user] + name = Global User + email = global@example.com +`), 0o600)) + + user, err := GetUser("", "") + require.NoError(t, err) + assert.Equal(t, "Global User", user.Name) + assert.Equal(t, "global@example.com", user.Email) +} + +func TestGetUser_WorkingDirWithNoMatchingIncludeIfUsesGlobal(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + otherDir := t.TempDir() + + projectConfigPath := filepath.Join(tmpHome, "project.gitconfig") + require.NoError(t, os.WriteFile(projectConfigPath, []byte(`[user] + name = Project User + email = project@example.com +`), 0o600)) + + globalConfigPath := filepath.Join(tmpHome, ".gitconfig") + require.NoError(t, os.WriteFile(globalConfigPath, fmt.Appendf(nil, `[user] + name = Global User + email = global@example.com +[includeIf "gitdir:%s/"] + path = %s +`, otherDir, projectConfigPath), 0o600)) + + projectDir := t.TempDir() + //nolint:gosec // test-only, args are constants + require.NoError(t, exec.Command("git", "init", projectDir).Run()) + + user, err := GetUser("", projectDir) + require.NoError(t, err) + assert.Equal(t, "Global User", user.Name) + assert.Equal(t, "global@example.com", user.Email) +} diff --git a/pkg/gitsshsigning/utils.go b/pkg/gitsshsigning/utils.go index 3d0149c22..92cc25f8e 100644 --- a/pkg/gitsshsigning/utils.go +++ b/pkg/gitsshsigning/utils.go @@ -11,15 +11,13 @@ const ( GPGFormatSSH = "ssh" ) -// ExtractGitConfiguration is used for extracting values from users local .gitconfig -// that are needed to setup devsy-ssh-signature helper inside the workspace. -func ExtractGitConfiguration() (string, string, error) { - format, err := readGitConfigValue(GPGFormatConfigKey) +func ExtractGitConfiguration(workingDir string) (string, string, error) { + format, err := readGitConfigValue(GPGFormatConfigKey, workingDir) if err != nil { return "", "", err } - signingKey, err := readGitConfigValue(UsersSigningKeyConfigKey) + signingKey, err := readGitConfigValue(UsersSigningKeyConfigKey, workingDir) if err != nil { return "", "", err } @@ -27,8 +25,11 @@ func ExtractGitConfiguration() (string, string, error) { return format, signingKey, nil } -func readGitConfigValue(key string) (string, error) { +func readGitConfigValue(key string, workingDir string) (string, error) { cmd := exec.Command("git", "config", "--get", key) + if workingDir != "" { + cmd.Dir = workingDir + } output, err := cmd.Output() if err != nil { return "", err diff --git a/pkg/gitsshsigning/utils_test.go b/pkg/gitsshsigning/utils_test.go new file mode 100644 index 000000000..c7cb6324a --- /dev/null +++ b/pkg/gitsshsigning/utils_test.go @@ -0,0 +1,63 @@ +package gitsshsigning + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractGitConfiguration_WorkingDirResolvesIncludeIf(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + projectDir := t.TempDir() + //nolint:gosec // test-only, args are constants + require.NoError(t, exec.Command("git", "init", projectDir).Run()) + + projectConfigPath := filepath.Join(tmpHome, "project.gitconfig") + require.NoError(t, os.WriteFile(projectConfigPath, []byte(`[gpg] + format = ssh +[user] + signingkey = /path/to/signing.pub +`), 0o600)) + + globalConfigPath := filepath.Join(tmpHome, ".gitconfig") + require.NoError(t, os.WriteFile(globalConfigPath, fmt.Appendf(nil, `[includeIf "gitdir:%s/"] + path = %s +`, projectDir, projectConfigPath), 0o600)) + + format, signingKey, err := ExtractGitConfiguration(projectDir) + require.NoError(t, err) + assert.Equal(t, GPGFormatSSH, format) + assert.Equal(t, "/path/to/signing.pub", signingKey) +} + +func TestExtractGitConfiguration_WorkingDirWithGlobalSigningConfig(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + projectDir := t.TempDir() + //nolint:gosec // test-only, args are constants + require.NoError(t, exec.Command("git", "init", projectDir).Run()) + + globalConfigPath := filepath.Join(tmpHome, ".gitconfig") + require.NoError(t, os.WriteFile(globalConfigPath, []byte(`[gpg] + format = ssh +[user] + signingkey = /global/key.pub +`), 0o600)) + + format, signingKey, err := ExtractGitConfiguration(projectDir) + require.NoError(t, err) + assert.Equal(t, GPGFormatSSH, format) + assert.Equal(t, "/global/key.pub", signingKey) +} diff --git a/pkg/tunnel/services.go b/pkg/tunnel/services.go index d85973959..ed554f81e 100644 --- a/pkg/tunnel/services.go +++ b/pkg/tunnel/services.go @@ -109,10 +109,10 @@ func runTunnelServer(ctx context.Context, cancel context.CancelFunc, p tunnelSer // When explicitKey is set (from --git-ssh-signing-key flag), it takes // precedence over the host's .gitconfig. This ensures signing works // even when user.signingkey is not in the host git configuration. -func addGitSSHSigningKey(command string, explicitKey string) string { +func addGitSSHSigningKey(command string, explicitKey string, workingDir string) string { userSigningKey := explicitKey if userSigningKey == "" { - format, extracted, err := gitsshsigning.ExtractGitConfiguration() + format, extracted, err := gitsshsigning.ExtractGitConfiguration(workingDir) if err != nil { log.Debugf("failed to extract git configuration: %v", err) return command @@ -138,7 +138,11 @@ func buildCredentialsCommand(opts RunServicesOptions) string { command += " --configure-git-helper" } if opts.ConfigureGitSSHSignatureHelper { - command = addGitSSHSigningKey(command, opts.GitSSHSigningKey) + workingDir := "" + if opts.Workspace != nil { + workingDir = opts.Workspace.Source.LocalFolder + } + command = addGitSSHSigningKey(command, opts.GitSSHSigningKey, workingDir) } if opts.ConfigureDockerCredentials { command += " --configure-docker-helper" diff --git a/pkg/tunnel/services_test.go b/pkg/tunnel/services_test.go index 469362f2c..4c17d3563 100644 --- a/pkg/tunnel/services_test.go +++ b/pkg/tunnel/services_test.go @@ -11,7 +11,7 @@ const testBaseCommand = "devsy agent container credentials-server --user root" func TestAddGitSSHSigningKey_ExplicitKey(t *testing.T) { command := testBaseCommand - result := addGitSSHSigningKey(command, "/path/to/key.pub") + result := addGitSSHSigningKey(command, "/path/to/key.pub", "") encoded := base64.StdEncoding.EncodeToString([]byte("/path/to/key.pub")) assert.Equal(t, command+" --git-user-signing-key "+encoded, result) @@ -22,7 +22,7 @@ func TestAddGitSSHSigningKey_ExplicitKeyTakesPrecedence(t *testing.T) { // of what ExtractGitConfiguration might return from host .gitconfig. command := testBaseCommand explicitKey := "/explicit/key.pub" - result := addGitSSHSigningKey(command, explicitKey) + result := addGitSSHSigningKey(command, explicitKey, "") encoded := base64.StdEncoding.EncodeToString([]byte(explicitKey)) assert.Equal(t, command+" --git-user-signing-key "+encoded, result) @@ -35,7 +35,7 @@ func TestAddGitSSHSigningKey_EmptyExplicitKey_FallsBackToHostConfig(t *testing.T t.Setenv("HOME", tmpHome) t.Setenv("XDG_CONFIG_HOME", tmpHome) - result := addGitSSHSigningKey(command, "") + result := addGitSSHSigningKey(command, "", "") assert.Equal(t, command, result) assert.NotContains(t, result, "--git-user-signing-key")