diff --git a/environment/integration/git_config_test.go b/environment/integration/git_config_test.go new file mode 100644 index 0000000..40f4b1e --- /dev/null +++ b/environment/integration/git_config_test.go @@ -0,0 +1,248 @@ +package integration + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/dagger/container-use/repository" + "github.com/stretchr/testify/assert" +) + +// TestProjectSpecificGitConfiguration tests that project-specific git configurations +// are properly inherited and used within environments +func TestProjectSpecificGitConfiguration(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("Skipping integration test") + } + + t.Run("UserEmailAndName", func(t *testing.T) { + WithRepository(t, "git_config_user", SetupRepoWithGitConfig, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Git Config Test", "Testing git config inheritance") + + // Make a commit in the environment + user.FileWrite(env.ID, "test.txt", "test content", "Test commit with project config") + + // Get the worktree path to run git commands directly + worktreePath := user.WorktreePath(env.ID) + + // Check the commit author in the environment's git log + ctx := t.Context() + gitLog, err := repository.RunGitCommand(ctx, worktreePath, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err, "Should be able to get git log") + + // Should use project-specific user config, not global + assert.Contains(t, gitLog, "Project User ", "Should use project git config for commits") + }) + }) + + t.Run("GitConfigPersistsAcrossEnvironments", func(t *testing.T) { + WithRepository(t, "git_config_persist", SetupRepoWithGitConfig, func(t *testing.T, repo *repository.Repository, user *UserActions) { + // Create first environment + env1 := user.CreateEnvironment("Config Test 1", "First environment") + user.FileWrite(env1.ID, "file1.txt", "content 1", "Commit in env1") + + // Create second environment + env2 := user.CreateEnvironment("Config Test 2", "Second environment") + user.FileWrite(env2.ID, "file2.txt", "content 2", "Commit in env2") + + ctx := context.Background() + + // Both environments should use the same project config + worktree1 := user.WorktreePath(env1.ID) + gitLog1, err := repository.RunGitCommand(ctx, worktree1, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + + worktree2 := user.WorktreePath(env2.ID) + gitLog2, err := repository.RunGitCommand(ctx, worktree2, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + + // Both should use project config + assert.Contains(t, gitLog1, "Project User ") + assert.Contains(t, gitLog2, "Project User ") + }) + }) + + t.Run("GlobalVsLocalConfigPrecedence", func(t *testing.T) { + WithRepository(t, "git_config_precedence", SetupRepoWithConflictingConfig, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Precedence Test", "Testing config precedence") + user.FileWrite(env.ID, "precedence.txt", "testing precedence", "Commit to test config precedence") + + worktreePath := user.WorktreePath(env.ID) + ctx := context.Background() + + // Check that local repo config takes precedence over global + userName, err := repository.RunGitCommand(ctx, worktreePath, "config", "user.name") + assert.NoError(t, err) + assert.Contains(t, userName, "Local Project User", "Local config should override global") + + userEmail, err := repository.RunGitCommand(ctx, worktreePath, "config", "user.email") + assert.NoError(t, err) + assert.Contains(t, userEmail, "local@project.com", "Local config should override global") + }) + }) + + t.Run("DynamicConfigUpdates", func(t *testing.T) { + WithRepository(t, "git_config_dynamic", SetupRepoWithGitConfig, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Dynamic Config Test", "Testing dynamic config updates") + + // Make initial commit and verify it uses initial config + user.FileWrite(env.ID, "initial.txt", "initial content", "Initial commit") + worktreePath := user.WorktreePath(env.ID) + ctx := context.Background() + + gitLog, err := repository.RunGitCommand(ctx, worktreePath, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + assert.Contains(t, gitLog, "Project User ", "Should use initial project config") + + // Update config in source repo + user.GitCommand("config", "user.name", "Updated Project User") + user.GitCommand("config", "user.email", "updated@project.com") + + // Make another commit and verify it uses updated config automatically + user.FileWrite(env.ID, "updated.txt", "updated content", "Updated commit") + + gitLog, err = repository.RunGitCommand(ctx, worktreePath, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + assert.Contains(t, gitLog, "Updated Project User ", "Should use updated project config dynamically") + + // Verify config is readable from worktree + userName, err := repository.RunGitCommand(ctx, worktreePath, "config", "user.name") + assert.NoError(t, err) + assert.Contains(t, userName, "Updated Project User", "Config should be readable in worktree") + }) + }) + + t.Run("IncludeDirectiveWorksWithExistingRepo", func(t *testing.T) { + WithRepository(t, "git_config_existing_fork", SetupRepoWithGitConfig, func(t *testing.T, repo *repository.Repository, user *UserActions) { + // Create first environment to establish fork repo + env1 := user.CreateEnvironment("First Environment", "Create fork repo") + user.FileWrite(env1.ID, "first.txt", "first content", "First commit") + + // Change the user repo config + user.GitCommand("config", "user.name", "Modified Project User") + user.GitCommand("config", "user.email", "modified@project.com") + + // Create second environment - this should work with existing fork repo + // and pick up the modified config via the include directive + env2 := user.CreateEnvironment("Second Environment", "Should use include directive") + user.FileWrite(env2.ID, "second.txt", "second content", "Second commit") + + // Verify the commit uses the modified project config + // This proves the include directive is working for existing repos + ctx := context.Background() + worktreePath := user.WorktreePath(env2.ID) + gitLog, err := repository.RunGitCommand(ctx, worktreePath, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + assert.Contains(t, gitLog, "Modified Project User ", "Should use modified project config via include directive") + + // Also verify first environment picks up the change + worktreePath1 := user.WorktreePath(env1.ID) + user.FileWrite(env1.ID, "updated.txt", "updated content", "Updated commit") + gitLog1, err := repository.RunGitCommand(ctx, worktreePath1, "log", "--format=%an <%ae>", "-n", "1") + assert.NoError(t, err) + assert.Contains(t, gitLog1, "Modified Project User ", "Existing environment should also use modified config") + }) + }) +} + +// TestProjectSpecificGitHooks tests that git hooks are properly ignored in environments +func TestProjectSpecificGitHooks(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("Skipping integration test") + } + + t.Run("HooksAreIgnoredInEnvironment", func(t *testing.T) { + WithRepository(t, "hooks_ignored_env", SetupRepoWithGitHooks, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Hook Ignore Test", "Testing that git hooks are ignored in environments") + + // This file would normally be blocked by the pre-commit hook in the source repo + user.FileWrite(env.ID, "forbidden.txt", "This should be allowed", "Commit forbidden file") + + // Verify the file exists in the environment (commit succeeded in environment) + content := user.FileRead(env.ID, "forbidden.txt") + assert.Equal(t, "This should be allowed", content) + }) + }) + + t.Run("HooksAreIgnoredInSourceRepo", func(t *testing.T) { + WithRepository(t, "hooks_ignored_source", SetupRepoWithGitHooks, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Hook Source Test", "Testing that git hooks are ignored when updating source repo") + + // Create a file that would be blocked by pre-commit hook + user.FileWrite(env.ID, "forbidden.txt", "This should be allowed", "Commit forbidden file") + + // Now checkout the environment branch to the source repo - this should also ignore hooks + ctx := context.Background() + branch, err := repo.Checkout(ctx, env.ID, "") + assert.NoError(t, err, "Checkout should succeed even with hooks that would block it") + assert.NotEmpty(t, branch) + + // Verify the forbidden file exists in the source repo now + sourcePath := repo.SourcePath() + forbiddenPath := filepath.Join(sourcePath, "forbidden.txt") + sourceContent, err := os.ReadFile(forbiddenPath) + assert.NoError(t, err, "forbidden.txt should exist in source repo after checkout") + assert.Equal(t, "This should be allowed", string(sourceContent)) + }) + }) + + t.Run("CommitsSucceedDespiteFailingHooks", func(t *testing.T) { + WithRepository(t, "commits_despite_failing_hooks", SetupRepoWithFailingHooks, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("Failing Hook Test", "Testing commits work despite failing hooks") + + // Make commits that would be blocked by failing hooks + user.FileWrite(env.ID, "should-fail-1.txt", "content 1", "First commit that hooks would block") + user.FileWrite(env.ID, "should-fail-2.txt", "content 2", "Second commit that hooks would block") + + // Verify files exist (commits succeeded despite failing hooks) + assert.Equal(t, "content 1", user.FileRead(env.ID, "should-fail-1.txt")) + assert.Equal(t, "content 2", user.FileRead(env.ID, "should-fail-2.txt")) + + // Now test that checkout to source repo also works despite failing hooks + ctx := context.Background() + branch, err := repo.Checkout(ctx, env.ID, "") + assert.NoError(t, err, "Checkout should succeed even with failing hooks") + assert.NotEmpty(t, branch) + + // Verify files exist in source repo + sourcePath := repo.SourcePath() + file1Path := filepath.Join(sourcePath, "should-fail-1.txt") + file1Content, err := os.ReadFile(file1Path) + assert.NoError(t, err, "Files should exist in source repo after checkout") + assert.Equal(t, "content 1", string(file1Content)) + }) + }) + + t.Run("HookSideEffectsDoNotOccurAnywhere", func(t *testing.T) { + WithRepository(t, "no_hook_side_effects_anywhere", SetupRepoWithGitHooks, func(t *testing.T, repo *repository.Repository, user *UserActions) { + env := user.CreateEnvironment("No Side Effects Test", "Testing that hook side effects don't occur") + + // Make a commit that would trigger post-commit hook + user.FileWrite(env.ID, "trigger-hooks.txt", "This should trigger hooks", "Commit to trigger hooks") + + // Verify no hook evidence file in environment + user.FileReadExpectError(env.ID, ".hook-evidence") + + // Verify no hook evidence file in environment worktree + worktreePath := user.WorktreePath(env.ID) + hookEvidencePath := filepath.Join(worktreePath, ".hook-evidence") + _, err := os.Stat(hookEvidencePath) + assert.True(t, os.IsNotExist(err), "Hook evidence file should not exist in worktree") + + // Checkout to source repo + ctx := context.Background() + _, err = repo.Checkout(ctx, env.ID, "") + assert.NoError(t, err, "Checkout should succeed") + + // this is very defensive, but wanna make sure there's not some wacky uncommitted deletion in the worktree + sourcePath := repo.SourcePath() + sourceHookEvidencePath := filepath.Join(sourcePath, ".hook-evidence") + _, err = os.Stat(sourceHookEvidencePath) + assert.True(t, os.IsNotExist(err), "Hook evidence file should not exist in source repo after checkout") + }) + }) +} diff --git a/environment/integration/helpers.go b/environment/integration/helpers.go index 265f3f8..979fbfe 100644 --- a/environment/integration/helpers.go +++ b/environment/integration/helpers.go @@ -131,6 +131,85 @@ var ( writeFile(t, repoDir, "README.md", "# Test Project\n") gitCommit(t, repoDir, "Initial commit") } + + SetupRepoWithGitConfig = func(t *testing.T, repoDir string) { + // Set project-specific git config + ctx := context.Background() + _, err := repository.RunGitCommand(ctx, repoDir, "config", "user.name", "Project User") + require.NoError(t, err, "Failed to set project user.name") + _, err = repository.RunGitCommand(ctx, repoDir, "config", "user.email", "project@example.com") + require.NoError(t, err, "Failed to set project user.email") + + writeFile(t, repoDir, "README.md", "# Project with custom git config\n") + gitCommit(t, repoDir, "Initial commit with project config") + } + + SetupRepoWithConflictingConfig = func(t *testing.T, repoDir string) { + // This assumes there might be global config, and sets local config to override it + ctx := context.Background() + _, err := repository.RunGitCommand(ctx, repoDir, "config", "user.name", "Local Project User") + require.NoError(t, err, "Failed to set local user.name") + _, err = repository.RunGitCommand(ctx, repoDir, "config", "user.email", "local@project.com") + require.NoError(t, err, "Failed to set local user.email") + + writeFile(t, repoDir, "README.md", "# Project with conflicting config\n") + gitCommit(t, repoDir, "Initial commit with local config") + } + + SetupRepoWithGitHooks = func(t *testing.T, repoDir string) { + // Create git hooks directory + hooksDir := filepath.Join(repoDir, ".git/hooks") + err := os.MkdirAll(hooksDir, 0755) + require.NoError(t, err, "Failed to create hooks directory") + + // Pre-commit hook that would block "forbidden.txt" + preCommitHook := `#!/bin/sh +echo "This pre-commit hook should never run in container-use" +if [ -f "forbidden.txt" ]; then + echo "Error: forbidden.txt is not allowed" + exit 1 +fi +exit 0` + writeFile(t, hooksDir, "pre-commit", preCommitHook) + err = os.Chmod(filepath.Join(hooksDir, "pre-commit"), 0755) + require.NoError(t, err, "Failed to make pre-commit hook executable") + + // Post-commit hook that creates evidence file + postCommitHook := `#!/bin/sh +echo "Hook ran at $(date)" >> .hook-evidence` + writeFile(t, hooksDir, "post-commit", postCommitHook) + err = os.Chmod(filepath.Join(hooksDir, "post-commit"), 0755) + require.NoError(t, err, "Failed to make post-commit hook executable") + + writeFile(t, repoDir, "README.md", "# Project with git hooks\n") + gitCommit(t, repoDir, "Initial commit with hooks") + } + + SetupRepoWithFailingHooks = func(t *testing.T, repoDir string) { + // Create git hooks directory + hooksDir := filepath.Join(repoDir, ".git/hooks") + err := os.MkdirAll(hooksDir, 0755) + require.NoError(t, err, "Failed to create hooks directory") + + // Pre-commit hook that always fails + preCommitHook := `#!/bin/sh +echo "This failing pre-commit hook should never run" +exit 1` + writeFile(t, hooksDir, "pre-commit", preCommitHook) + err = os.Chmod(filepath.Join(hooksDir, "pre-commit"), 0755) + require.NoError(t, err, "Failed to make pre-commit hook executable") + + // Pre-push hook that also fails + prePushHook := `#!/bin/sh +echo "This failing pre-push hook should never run" +exit 1` + writeFile(t, hooksDir, "pre-push", prePushHook) + err = os.Chmod(filepath.Join(hooksDir, "pre-push"), 0755) + require.NoError(t, err, "Failed to make pre-push hook executable") + + writeFile(t, repoDir, "README.md", "# Project with failing hooks\n") + gitCommit(t, repoDir, "Initial commit with failing hooks") + } ) // Helper functions for repository setup @@ -147,7 +226,7 @@ func gitCommit(t *testing.T, repoDir, message string) { ctx := context.Background() _, err := repository.RunGitCommand(ctx, repoDir, "add", ".") require.NoError(t, err, "Failed to stage files") - _, err = repository.RunGitCommand(ctx, repoDir, "commit", "-m", message) + _, err = repository.RunGitCommand(ctx, repoDir, "-c", "core.hooksPath=/dev/null", "commit", "-m", message) require.NoError(t, err, "Failed to commit") } diff --git a/repository/git.go b/repository/git.go index 55dacfb..fb6e8ff 100644 --- a/repository/git.go +++ b/repository/git.go @@ -19,6 +19,11 @@ import ( "github.com/mitchellh/go-homedir" ) +var ( + // noHooks is the git config value to disable all hooks + noHooks = "core.hooksPath=/dev/null" +) + const ( maxFileSizeForTextCheck = 10 * 1024 * 1024 // 10MB ) @@ -139,10 +144,10 @@ func (r *Repository) initializeWorktree(ctx context.Context, id, gitRef string) } resolvedRef = strings.TrimSpace(resolvedRef) - _, err = RunGitCommand(ctx, r.userRepoPath, "push", containerUseRemote, fmt.Sprintf("%s:refs/heads/%s", resolvedRef, id)) + _, err = RunGitCommand(ctx, r.userRepoPath, "-c", noHooks, "push", containerUseRemote, fmt.Sprintf("%s:refs/heads/%s", resolvedRef, id)) if err != nil { // Retry once on failure - _, err = RunGitCommand(ctx, r.userRepoPath, "push", containerUseRemote, fmt.Sprintf("%s:refs/heads/%s", resolvedRef, id)) + _, err = RunGitCommand(ctx, r.userRepoPath, "-c", noHooks, "push", containerUseRemote, fmt.Sprintf("%s:refs/heads/%s", resolvedRef, id)) if err != nil { return err } @@ -226,7 +231,7 @@ func (r *Repository) getWorktree(ctx context.Context, id string) (string, error) // createInitialCommit creates an empty commit with the environment creation message - this prevents multiple environments from overwriting the container-use-state on the parent commit func (r *Repository) createInitialCommit(ctx context.Context, worktreePath, id, title string) error { commitMessage := fmt.Sprintf("Create environment %s: %s", id, title) - _, err := RunGitCommand(ctx, worktreePath, "commit", "--allow-empty", "-m", commitMessage) + _, err := RunGitCommand(ctx, worktreePath, "-c", noHooks, "commit", "--allow-empty", "-m", commitMessage) return err } @@ -515,7 +520,7 @@ func (r *Repository) commitWorktreeChanges(ctx context.Context, worktreePath, ex return err } - _, err = RunGitCommand(ctx, worktreePath, "commit", "--allow-empty", "--allow-empty-message", "-m", explanation) + _, err = RunGitCommand(ctx, worktreePath, "-c", noHooks, "commit", "--allow-empty", "--allow-empty-message", "-m", explanation) return err }) } diff --git a/repository/repository.go b/repository/repository.go index dac0cfa..1312925 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -128,25 +128,73 @@ func OpenWithBasePath(ctx context.Context, repo string, basePath string) (*Repos func (r *Repository) ensureFork(ctx context.Context) error { return r.lockManager.WithLock(ctx, LockTypeForkRepo, func() error { - if _, err := os.Stat(r.forkRepoPath); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err + // Check if fork repo already exists + forkExists := true + if _, err := os.Stat(r.forkRepoPath); err != nil { + if !os.IsNotExist(err) { + return err + } + forkExists = false } - slog.Info("Initializing local remote", "user-repo", r.userRepoPath, "fork-repo", r.forkRepoPath) - if err := os.MkdirAll(r.forkRepoPath, 0755); err != nil { - return err + // Create fork repo if it doesn't exist + if !forkExists { + slog.Info("Initializing local remote", "user-repo", r.userRepoPath, "fork-repo", r.forkRepoPath) + if err := os.MkdirAll(r.forkRepoPath, 0755); err != nil { + return err + } + _, err := RunGitCommand(ctx, r.forkRepoPath, "init", "--bare", "--template=") + if err != nil { + os.RemoveAll(r.forkRepoPath) + return err + } } - _, err := RunGitCommand(ctx, r.forkRepoPath, "init", "--bare", "--template=") - if err != nil { - os.RemoveAll(r.forkRepoPath) - return err + + // Always ensure include directive is set up correctly (for both new and existing fork repos) + if err := r.setupConfigInclude(ctx); err != nil { + slog.Warn("Failed to setup git config include in fork repo", "error", err) + // Don't fail the entire operation if config setup fails } + return nil }) } +// setupConfigInclude sets up an include directive in the fork repository config +// to dynamically inherit git configuration from the user repository +func (r *Repository) setupConfigInclude(ctx context.Context) error { + // Path to user repo's git config file + userConfigPath := filepath.Join(r.userRepoPath, ".git", "config") + + // Use absolute path to avoid any path resolution issues + absUserConfigPath, err := filepath.Abs(userConfigPath) + if err != nil { + return fmt.Errorf("failed to get absolute path for user config: %w", err) + } + + // Check if include.path already exists and points to our user config + existingPath, err := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", "include.path") + if err == nil && strings.TrimSpace(existingPath) == absUserConfigPath { + // Already correctly configured + return nil + } + + // Remove any existing include.path entries to avoid conflicts + _, err = RunGitCommand(ctx, r.forkRepoPath, "config", "--unset-all", "include.path") + if err != nil { + // Ignore error if no include.path exists + slog.Debug("No existing include.path to remove (this is fine)", "error", err) + } + + // Add our include directive + _, err = RunGitCommand(ctx, r.forkRepoPath, "config", "include.path", absUserConfigPath) + if err != nil { + return fmt.Errorf("failed to set include.path in fork repo: %w", err) + } + + return nil +} + func (r *Repository) ensureUserRemote(ctx context.Context) error { return r.lockManager.WithLock(ctx, LockTypeUserRepo, func() error { currentForkPath, err := getContainerUseRemote(ctx, r.userRepoPath) @@ -489,7 +537,7 @@ func (r *Repository) Checkout(ctx context.Context, id, branch string) (string, e aheadCount, behindCount := parts[0], parts[1] if behindCount != "0" && aheadCount == "0" { - _, err = RunGitCommand(ctx, r.userRepoPath, "merge", "--ff-only", remoteRef) + _, err = RunGitCommand(ctx, r.userRepoPath, "-c", noHooks, "merge", "--ff-only", remoteRef) if err != nil { return branch, err } @@ -554,7 +602,7 @@ func (r *Repository) Merge(ctx context.Context, id string, w io.Writer) error { return err } - return RunInteractiveGitCommand(ctx, r.userRepoPath, w, "merge", "--no-ff", "--autostash", "-m", "Merge environment "+envInfo.ID, "--", "container-use/"+envInfo.ID) + return RunInteractiveGitCommand(ctx, r.userRepoPath, w, "-c", noHooks, "merge", "--no-ff", "--autostash", "-m", "Merge environment "+envInfo.ID, "--", "container-use/"+envInfo.ID) } func (r *Repository) Apply(ctx context.Context, id string, w io.Writer) error { @@ -563,5 +611,5 @@ func (r *Repository) Apply(ctx context.Context, id string, w io.Writer) error { return err } - return RunInteractiveGitCommand(ctx, r.userRepoPath, w, "merge", "--autostash", "--squash", "--", "container-use/"+envInfo.ID) + return RunInteractiveGitCommand(ctx, r.userRepoPath, w, "-c", noHooks, "merge", "--autostash", "--squash", "--", "container-use/"+envInfo.ID) }