Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions environment/integration/git_config_test.go
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>", "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 <[email protected]>")
assert.Contains(t, gitLog2, "Project User <[email protected]>")
})
})

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, "[email protected]", "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 <[email protected]>", "Should use initial project config")

// Update config in source repo
user.GitCommand("config", "user.name", "Updated Project User")
user.GitCommand("config", "user.email", "[email protected]")

// 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 <[email protected]>", "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", "[email protected]")

// 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 <[email protected]>", "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 <[email protected]>", "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")
})
})
}
81 changes: 80 additions & 1 deletion environment/integration/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "[email protected]")
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", "[email protected]")
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
Expand All @@ -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")
}

Expand Down
13 changes: 9 additions & 4 deletions repository/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
})
}
Expand Down
Loading