diff --git a/agent/agent_configuration.go b/agent/agent_configuration.go index 5282c54ea1..74558bf11b 100644 --- a/agent/agent_configuration.go +++ b/agent/agent_configuration.go @@ -23,6 +23,10 @@ type AgentConfiguration struct { GitCloneMirrorFlags string GitCleanFlags string GitFetchFlags string + GitSparseCheckout bool + GitSparseCheckoutPaths string + GitCloneDepth string + GitCloneFilter string GitSubmodules bool AllowedRepositories []*regexp.Regexp AllowedPlugins []*regexp.Regexp diff --git a/agent/job_runner.go b/agent/job_runner.go index 48297219ed..07e73bb0da 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -554,6 +554,10 @@ func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { env["BUILDKITE_GIT_FETCH_FLAGS"] = r.conf.AgentConfiguration.GitFetchFlags env["BUILDKITE_GIT_CLONE_MIRROR_FLAGS"] = r.conf.AgentConfiguration.GitCloneMirrorFlags env["BUILDKITE_GIT_CLEAN_FLAGS"] = r.conf.AgentConfiguration.GitCleanFlags + env["BUILDKITE_GIT_SPARSE_CHECKOUT"] = fmt.Sprint(r.conf.AgentConfiguration.GitSparseCheckout) + env["BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"] = r.conf.AgentConfiguration.GitSparseCheckoutPaths + env["BUILDKITE_GIT_CLONE_DEPTH"] = r.conf.AgentConfiguration.GitCloneDepth + env["BUILDKITE_GIT_CLONE_FILTER"] = r.conf.AgentConfiguration.GitCloneFilter env["BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT"] = strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout) env["BUILDKITE_SHELL"] = r.conf.AgentConfiguration.Shell env["BUILDKITE_AGENT_EXPERIMENT"] = strings.Join(experiments.Enabled(ctx), ",") diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 196c3c8bbf..16455b0f4f 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -149,6 +149,10 @@ type AgentStartConfig struct { GitCloneMirrorFlags string `cli:"git-clone-mirror-flags"` GitCleanFlags string `cli:"git-clean-flags"` GitFetchFlags string `cli:"git-fetch-flags"` + GitSparseCheckout bool `cli:"git-sparse-checkout"` + GitSparseCheckoutPaths string `cli:"git-sparse-checkout-paths"` + GitCloneDepth string `cli:"git-clone-depth"` + GitCloneFilter string `cli:"git-clone-filter"` GitMirrorsPath string `cli:"git-mirrors-path" normalize:"filepath"` GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"` GitMirrorsSkipUpdate bool `cli:"git-mirrors-skip-update"` @@ -509,6 +513,29 @@ var AgentStartCommand = cli.Command{ Usage: "Flags to pass to \"git fetch\" command", EnvVar: "BUILDKITE_GIT_FETCH_FLAGS", }, + cli.BoolFlag{ + Name: "git-sparse-checkout", + Usage: "Enable sparse checkout for partial clones", + EnvVar: "BUILDKITE_GIT_SPARSE_CHECKOUT", + }, + cli.StringFlag{ + Name: "git-sparse-checkout-paths", + Value: "", + Usage: "Paths to include in sparse checkout (comma-separated)", + EnvVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + }, + cli.StringFlag{ + Name: "git-clone-depth", + Value: "", + Usage: "Clone depth for shallow clones (e.g., \"200\")", + EnvVar: "BUILDKITE_GIT_CLONE_DEPTH", + }, + cli.StringFlag{ + Name: "git-clone-filter", + Value: "", + Usage: "Filter specification for partial clones (e.g., \"tree:0\")", + EnvVar: "BUILDKITE_GIT_CLONE_FILTER", + }, cli.StringFlag{ Name: "git-clone-mirror-flags", Value: "-v", @@ -1019,6 +1046,10 @@ var AgentStartCommand = cli.Command{ GitCloneMirrorFlags: cfg.GitCloneMirrorFlags, GitCleanFlags: cfg.GitCleanFlags, GitFetchFlags: cfg.GitFetchFlags, + GitSparseCheckout: cfg.GitSparseCheckout, + GitSparseCheckoutPaths: cfg.GitSparseCheckoutPaths, + GitCloneDepth: cfg.GitCloneDepth, + GitCloneFilter: cfg.GitCloneFilter, GitSubmodules: !cfg.NoGitSubmodules, SSHKeyscan: !cfg.NoSSHKeyscan, CommandEval: !cfg.NoCommandEval, diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index d95c4b68de..8ea24a4689 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -69,6 +69,10 @@ type BootstrapConfig struct { GitFetchFlags string `cli:"git-fetch-flags"` GitCloneMirrorFlags string `cli:"git-clone-mirror-flags"` GitCleanFlags string `cli:"git-clean-flags"` + GitSparseCheckout bool `cli:"git-sparse-checkout"` + GitSparseCheckoutPaths string `cli:"git-sparse-checkout-paths"` + GitCloneDepth string `cli:"git-clone-depth"` + GitCloneFilter string `cli:"git-clone-filter"` GitMirrorsPath string `cli:"git-mirrors-path" normalize:"filepath"` GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"` GitMirrorsSkipUpdate bool `cli:"git-mirrors-skip-update"` @@ -244,6 +248,29 @@ var BootstrapCommand = cli.Command{ Usage: "Flags to pass to \"git fetch\" command", EnvVar: "BUILDKITE_GIT_FETCH_FLAGS", }, + cli.BoolFlag{ + Name: "git-sparse-checkout", + Usage: "Enable sparse checkout for partial clones", + EnvVar: "BUILDKITE_GIT_SPARSE_CHECKOUT", + }, + cli.StringFlag{ + Name: "git-sparse-checkout-paths", + Value: "", + Usage: "Paths to include in sparse checkout (comma-separated)", + EnvVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + }, + cli.StringFlag{ + Name: "git-clone-depth", + Value: "", + Usage: "Clone depth for shallow clones (e.g., \"200\")", + EnvVar: "BUILDKITE_GIT_CLONE_DEPTH", + }, + cli.StringFlag{ + Name: "git-clone-filter", + Value: "", + Usage: "Filter specification for partial clones (e.g., \"tree:0\")", + EnvVar: "BUILDKITE_GIT_CLONE_FILTER", + }, cli.StringSliceFlag{ Name: "git-submodule-clone-config", Value: &cli.StringSlice{}, @@ -466,6 +493,10 @@ var BootstrapCommand = cli.Command{ GitCloneFlags: cfg.GitCloneFlags, GitCloneMirrorFlags: cfg.GitCloneMirrorFlags, GitFetchFlags: cfg.GitFetchFlags, + GitSparseCheckout: cfg.GitSparseCheckout, + GitSparseCheckoutPaths: cfg.GitSparseCheckoutPaths, + GitCloneDepth: cfg.GitCloneDepth, + GitCloneFilter: cfg.GitCloneFilter, GitMirrorsLockTimeout: cfg.GitMirrorsLockTimeout, GitMirrorsPath: cfg.GitMirrorsPath, GitMirrorsSkipUpdate: cfg.GitMirrorsSkipUpdate, diff --git a/docs/partial-clone.md b/docs/partial-clone.md new file mode 100644 index 0000000000..fcb1a512b8 --- /dev/null +++ b/docs/partial-clone.md @@ -0,0 +1,112 @@ +# Partial Clone and Sparse Checkout + +The Buildkite Agent supports partial clones and sparse checkouts, which can significantly reduce clone times and disk space usage for large repositories. + +## Overview + +Partial clones allow you to clone a repository without downloading all of its history or objects, while sparse checkout allows you to check out only specific directories or files from a repository. + +## Configuration + +### Environment Variables + +The following environment variables control partial clone behavior: + +- `BUILDKITE_GIT_SPARSE_CHECKOUT` - Enable sparse checkout (boolean: `true` or `false`) +- `BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS` - Comma-separated list of paths to include in sparse checkout +- `BUILDKITE_GIT_CLONE_DEPTH` - Clone depth for shallow clones (e.g., `200`) +- `BUILDKITE_GIT_CLONE_FILTER` - Filter specification for partial clones (e.g., `tree:0`) + +### Command Line Flags + +When starting an agent, you can also use command line flags: + +```bash +buildkite-agent start \ + --git-sparse-checkout \ + --git-sparse-checkout-paths "src/frontend,src/backend" \ + --git-clone-depth 200 \ + --git-clone-filter "tree:0" +``` + +## Examples + +### Example 1: Sparse Checkout with Multiple Directories + +To check out only specific directories from a monorepo: + +```yaml +env: + BUILDKITE_GIT_SPARSE_CHECKOUT: "true" + BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS: "services/api,services/web,shared/utils" +``` + +### Example 2: Shallow Clone with Partial Objects + +For a shallow clone with limited history and filtered objects: + +```yaml +env: + BUILDKITE_GIT_CLONE_DEPTH: "100" + BUILDKITE_GIT_CLONE_FILTER: "blob:none" +``` + +### Example 3: Complete Partial Clone Setup + +Combining all features for maximum optimization: + +```yaml +env: + BUILDKITE_GIT_CLONE_DEPTH: "200" + BUILDKITE_GIT_CLONE_FILTER: "tree:0" + BUILDKITE_GIT_SPARSE_CHECKOUT: "true" + BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS: "my-service" +``` + +This configuration will: +1. Clone only the last 200 commits +2. Exclude tree objects that aren't needed (`tree:0`) +3. Only check out the `my-service` directory + +## Git Clone Filters + +The `BUILDKITE_GIT_CLONE_FILTER` supports various filter specifications: + +- `blob:none` - Omit all blob objects (file contents) +- `blob:limit=` - Omit blobs larger than `` bytes +- `tree:0` - Omit all tree objects (directory listings) +- `tree:` - Omit tree objects at specified depth + +## Sparse Checkout Paths + +The `BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS` variable accepts: +- Single directory: `"src/frontend"` +- Multiple directories: `"src/frontend,src/backend,docs"` +- Paths with wildcards are not supported in cone mode (which is the default) + +## Performance Considerations + +1. **Network Usage**: Partial clones significantly reduce network bandwidth usage +2. **Disk Space**: Sparse checkouts reduce local disk space usage +3. **Clone Time**: Both features can dramatically reduce initial clone times +4. **Fetch Time**: Subsequent fetches will only download required objects + +## Compatibility + +- Requires Git 2.25.0 or later for partial clone support +- Requires Git 2.25.0 or later for cone-mode sparse checkout +- The repository must be hosted on a server that supports partial clones + +## Job-to-Job Behavior + +When using sparse checkout, the agent automatically handles transitions between jobs: + +- If a job uses sparse checkout and the next job doesn't, sparse checkout is automatically disabled +- If a job doesn't use sparse checkout and the next job does, sparse checkout is automatically enabled +- This ensures each job gets the correct view of the repository without manual intervention + +## Troubleshooting + +1. **Missing objects**: If you encounter "object not found" errors, you may need to adjust your filter settings or disable partial clones +2. **Sparse checkout not working**: Ensure paths are comma-separated and don't contain spaces +3. **Performance issues**: Some operations may trigger on-demand object downloads; monitor your builds to ensure partial clones provide the expected benefits \ No newline at end of file diff --git a/internal/job/checkout.go b/internal/job/checkout.go index 280397dd51..35babd6a0b 100644 --- a/internal/job/checkout.go +++ b/internal/job/checkout.go @@ -567,6 +567,18 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) error { gitCloneFlags += fmt.Sprintf(" --reference %q", mirrorDir) } + // Add partial clone flags if specified + if e.GitCloneDepth != "" { + gitCloneFlags += fmt.Sprintf(" --depth=%s", e.GitCloneDepth) + } + if e.GitCloneFilter != "" { + gitCloneFlags += fmt.Sprintf(" --filter=%s", e.GitCloneFilter) + } + // For sparse checkout, we need to add --no-checkout + if e.GitSparseCheckout && e.GitSparseCheckoutPaths != "" { + gitCloneFlags += " --no-checkout" + } + // Does the git directory exist? existingGitDir := filepath.Join(e.shell.Getwd(), ".git") if osutil.FileExists(existingGitDir) { @@ -575,10 +587,60 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) error { if _, err := e.updateRemoteURL(ctx, "", e.Repository); err != nil { return fmt.Errorf("setting origin: %w", err) } + + // Handle sparse checkout for existing repos + if e.GitSparseCheckout && e.GitSparseCheckoutPaths != "" { + e.shell.Commentf("Configuring sparse checkout for existing repository") + + // Check if sparse checkout is already initialized + sparseCheckoutFile := filepath.Join(existingGitDir, "info", "sparse-checkout") + if !osutil.FileExists(sparseCheckoutFile) { + // Initialize sparse checkout + if err := gitSparseCheckoutInit(ctx, e.shell, true); err != nil { + return fmt.Errorf("initializing sparse checkout: %w", err) + } + } + + // Parse comma-separated paths + paths := strings.Split(e.GitSparseCheckoutPaths, ",") + for i := range paths { + paths[i] = strings.TrimSpace(paths[i]) + } + + e.shell.Commentf("Setting sparse checkout paths: %v", paths) + if err := gitSparseCheckoutSet(ctx, e.shell, paths); err != nil { + return fmt.Errorf("setting sparse checkout paths: %w", err) + } + } else if isSparseCheckoutEnabled(e.shell) { + // If sparse checkout is not wanted but is currently enabled, disable it + e.shell.Commentf("Disabling sparse checkout as it's not configured for this job") + if err := gitSparseCheckoutDisable(ctx, e.shell); err != nil { + return fmt.Errorf("disabling sparse checkout: %w", err) + } + } } else { if err := gitClone(ctx, e.shell, gitCloneFlags, e.Repository, "."); err != nil { return fmt.Errorf("cloning git repository: %w", err) } + + // If sparse checkout is enabled, initialize it right after cloning + if e.GitSparseCheckout && e.GitSparseCheckoutPaths != "" { + e.shell.Commentf("Initializing sparse checkout") + if err := gitSparseCheckoutInit(ctx, e.shell, true); err != nil { + return fmt.Errorf("initializing sparse checkout: %w", err) + } + + // Parse comma-separated paths + paths := strings.Split(e.GitSparseCheckoutPaths, ",") + for i := range paths { + paths[i] = strings.TrimSpace(paths[i]) + } + + e.shell.Commentf("Setting sparse checkout paths: %v", paths) + if err := gitSparseCheckoutSet(ctx, e.shell, paths); err != nil { + return fmt.Errorf("setting sparse checkout paths: %w", err) + } + } } // Git clean prior to checkout, we do this even if submodules have been @@ -594,6 +656,11 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) error { } gitFetchFlags := e.GitFetchFlags + + // Add filter flag for partial clones during fetch + if e.GitCloneFilter != "" { + gitFetchFlags += fmt.Sprintf(" --filter=%s", e.GitCloneFilter) + } switch { case e.RefSpec != "": diff --git a/internal/job/checkout_test.go b/internal/job/checkout_test.go index f7904cb206..dec95a2e8e 100644 --- a/internal/job/checkout_test.go +++ b/internal/job/checkout_test.go @@ -3,6 +3,7 @@ package job import ( "context" "os" + "strings" "testing" "time" @@ -216,3 +217,124 @@ func TestDefaultCheckoutPhase_DelayedRefCreation(t *testing.T) { err = tt.executor.defaultCheckoutPhase(ctx) assert.NoError(err) } + +func TestPartialCloneFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gitCloneFlags string + gitCloneDepth string + gitCloneFilter string + gitSparseCheckout bool + gitSparseCheckoutPaths string + expectedFlags string + }{ + { + name: "default clone flags", + gitCloneFlags: "-v", + expectedFlags: "-v", + }, + { + name: "with clone depth", + gitCloneFlags: "-v", + gitCloneDepth: "200", + expectedFlags: "-v --depth=200", + }, + { + name: "with clone filter", + gitCloneFlags: "-v", + gitCloneFilter: "tree:0", + expectedFlags: "-v --filter=tree:0", + }, + { + name: "with sparse checkout", + gitCloneFlags: "-v", + gitSparseCheckout: true, + gitSparseCheckoutPaths: "src/frontend", + expectedFlags: "-v --no-checkout", + }, + { + name: "full partial clone setup", + gitCloneFlags: "-v", + gitCloneDepth: "200", + gitCloneFilter: "tree:0", + gitSparseCheckout: true, + gitSparseCheckoutPaths: "src/frontend,src/backend", + expectedFlags: "-v --depth=200 --filter=tree:0 --no-checkout", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := &Executor{ + ExecutorConfig: ExecutorConfig{ + GitCloneFlags: tc.gitCloneFlags, + GitCloneDepth: tc.gitCloneDepth, + GitCloneFilter: tc.gitCloneFilter, + GitSparseCheckout: tc.gitSparseCheckout, + GitSparseCheckoutPaths: tc.gitSparseCheckoutPaths, + }, + } + + // Build clone flags like in defaultCheckoutPhase + gitCloneFlags := e.GitCloneFlags + if e.GitCloneDepth != "" { + gitCloneFlags += " --depth=" + e.GitCloneDepth + } + if e.GitCloneFilter != "" { + gitCloneFlags += " --filter=" + e.GitCloneFilter + } + if e.GitSparseCheckout && e.GitSparseCheckoutPaths != "" { + gitCloneFlags += " --no-checkout" + } + + // Remove any extra spaces and normalize + gitCloneFlags = strings.TrimSpace(gitCloneFlags) + gitCloneFlags = strings.Join(strings.Fields(gitCloneFlags), " ") + + require.Equal(t, tc.expectedFlags, gitCloneFlags) + }) + } +} + +func TestParseSparseCheckoutPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single path", + input: "src/frontend", + expected: []string{"src/frontend"}, + }, + { + name: "multiple paths", + input: "src/frontend,src/backend,docs", + expected: []string{"src/frontend", "src/backend", "docs"}, + }, + { + name: "paths with spaces", + input: " src/frontend , src/backend , docs ", + expected: []string{"src/frontend", "src/backend", "docs"}, + }, + { + name: "empty string", + input: "", + expected: []string{""}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + paths := strings.Split(tc.input, ",") + for i := range paths { + paths[i] = strings.TrimSpace(paths[i]) + } + require.Equal(t, tc.expected, paths) + }) + } +} diff --git a/internal/job/config.go b/internal/job/config.go index cca9ef41eb..024627dee3 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -90,6 +90,18 @@ type ExecutorConfig struct { // Config key=value pairs to pass to "git" when submodule init commands are invoked GitSubmoduleCloneConfig []string `env:"BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG" normalize:"list"` + // Enable sparse checkout for partial clones + GitSparseCheckout bool `env:"BUILDKITE_GIT_SPARSE_CHECKOUT"` + + // Paths to include in sparse checkout (comma-separated) + GitSparseCheckoutPaths string `env:"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"` + + // Clone depth for shallow clones + GitCloneDepth string `env:"BUILDKITE_GIT_CLONE_DEPTH"` + + // Filter specification for partial clones (e.g., "tree:0") + GitCloneFilter string `env:"BUILDKITE_GIT_CLONE_FILTER"` + // Whether or not to run the hooks/commands in a PTY RunInPty bool diff --git a/internal/job/git.go b/internal/job/git.go index fe0a5ad7e3..072852705f 100644 --- a/internal/job/git.go +++ b/internal/job/git.go @@ -7,12 +7,14 @@ import ( "fmt" "net" "net/url" + "os" "os/exec" "path/filepath" "regexp" "strings" "time" + "github.com/buildkite/agent/v3/internal/osutil" "github.com/buildkite/agent/v3/internal/shell" "github.com/buildkite/roko" "github.com/buildkite/shellwords" @@ -396,3 +398,52 @@ var gitCheckRefFormatDenyRegexp = regexp.MustCompile(strings.Join([]string{ func gitCheckRefFormat(ref string) bool { return !gitCheckRefFormatDenyRegexp.MatchString(ref) } + +func gitSparseCheckoutInit(ctx context.Context, sh *shell.Shell, cone bool) error { + args := []string{"sparse-checkout", "init"} + if cone { + args = append(args, "--cone") + } + + if err := sh.Command("git", args...).Run(ctx); err != nil { + return &gitError{error: err, Type: gitErrorCheckout} + } + + return nil +} + +func gitSparseCheckoutSet(ctx context.Context, sh *shell.Shell, paths []string) error { + if len(paths) == 0 { + return fmt.Errorf("no paths provided for sparse checkout") + } + + args := []string{"sparse-checkout", "set"} + args = append(args, paths...) + + if err := sh.Command("git", args...).Run(ctx); err != nil { + return &gitError{error: err, Type: gitErrorCheckout} + } + + return nil +} + +func gitSparseCheckoutDisable(ctx context.Context, sh *shell.Shell) error { + if err := sh.Command("git", "sparse-checkout", "disable").Run(ctx); err != nil { + return &gitError{error: err, Type: gitErrorCheckout} + } + + return nil +} + +func isSparseCheckoutEnabled(sh *shell.Shell) bool { + gitDir := filepath.Join(sh.Getwd(), ".git") + sparseCheckoutFile := filepath.Join(gitDir, "info", "sparse-checkout") + // Check if sparse checkout file exists and is not empty + if osutil.FileExists(sparseCheckoutFile) { + stat, err := os.Stat(sparseCheckoutFile) + if err == nil && stat.Size() > 0 { + return true + } + } + return false +} diff --git a/internal/job/git_test.go b/internal/job/git_test.go index 9e416372bd..bf39c47624 100644 --- a/internal/job/git_test.go +++ b/internal/job/git_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "path/filepath" "testing" "github.com/buildkite/agent/v3/internal/shell" @@ -311,3 +312,171 @@ func TestGitFetch(t *testing.T) { t.Errorf("executed commands diff (-got +want):\n%s", diff) } } + +func TestGitSparseCheckoutInit(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Test cone mode + { + var gotLog [][]string + sh := shell.NewTestShell(t, shell.WithDryRun(true), shell.WithCommandLog(&gotLog)) + + absoluteGit, err := sh.AbsolutePath("git") + if err != nil { + t.Fatalf("sh.AbsolutePath(git) = %v", err) + } + + err = gitSparseCheckoutInit(ctx, sh, true) + if err != nil { + t.Fatalf("gitSparseCheckoutInit(ctx, sh, true) = %v", err) + } + + wantLog := [][]string{{absoluteGit, "sparse-checkout", "init", "--cone"}} + if diff := cmp.Diff(gotLog, wantLog); diff != "" { + t.Errorf("executed commands diff (-got +want):\n%s", diff) + } + } + + // Test non-cone mode + { + var gotLog [][]string + sh := shell.NewTestShell(t, shell.WithDryRun(true), shell.WithCommandLog(&gotLog)) + + absoluteGit, err := sh.AbsolutePath("git") + if err != nil { + t.Fatalf("sh.AbsolutePath(git) = %v", err) + } + + err = gitSparseCheckoutInit(ctx, sh, false) + if err != nil { + t.Fatalf("gitSparseCheckoutInit(ctx, sh, false) = %v", err) + } + + wantLog := [][]string{{absoluteGit, "sparse-checkout", "init"}} + if diff := cmp.Diff(gotLog, wantLog); diff != "" { + t.Errorf("executed commands diff (-got +want):\n%s", diff) + } + } +} + +func TestGitSparseCheckoutSet(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Test with single path + { + var gotLog [][]string + sh := shell.NewTestShell(t, shell.WithDryRun(true), shell.WithCommandLog(&gotLog)) + + absoluteGit, err := sh.AbsolutePath("git") + if err != nil { + t.Fatalf("sh.AbsolutePath(git) = %v", err) + } + + paths := []string{"src/frontend"} + err = gitSparseCheckoutSet(ctx, sh, paths) + if err != nil { + t.Fatalf("gitSparseCheckoutSet(ctx, sh, %v) = %v", paths, err) + } + + wantLog := [][]string{{absoluteGit, "sparse-checkout", "set", "src/frontend"}} + if diff := cmp.Diff(gotLog, wantLog); diff != "" { + t.Errorf("executed commands diff (-got +want):\n%s", diff) + } + } + + // Test with multiple paths + { + var gotLog [][]string + sh := shell.NewTestShell(t, shell.WithDryRun(true), shell.WithCommandLog(&gotLog)) + + absoluteGit, err := sh.AbsolutePath("git") + if err != nil { + t.Fatalf("sh.AbsolutePath(git) = %v", err) + } + + paths := []string{"src/frontend", "src/backend", "docs"} + err = gitSparseCheckoutSet(ctx, sh, paths) + if err != nil { + t.Fatalf("gitSparseCheckoutSet(ctx, sh, %v) = %v", paths, err) + } + + wantLog := [][]string{{absoluteGit, "sparse-checkout", "set", "src/frontend", "src/backend", "docs"}} + if diff := cmp.Diff(gotLog, wantLog); diff != "" { + t.Errorf("executed commands diff (-got +want):\n%s", diff) + } + } + + // Test with empty paths + { + sh := shell.NewTestShell(t, shell.WithDryRun(true)) + err := gitSparseCheckoutSet(ctx, sh, []string{}) + if err == nil { + t.Errorf("gitSparseCheckoutSet(ctx, sh, []string{}) = nil, want error") + } + if got, want := err.Error(), "no paths provided for sparse checkout"; got != want { + t.Errorf("gitSparseCheckoutSet(ctx, sh, []string{}) error = %q, want %q", got, want) + } + } +} + +func TestGitSparseCheckoutDisable(t *testing.T) { + t.Parallel() + + ctx := context.Background() + var gotLog [][]string + sh := shell.NewTestShell(t, shell.WithDryRun(true), shell.WithCommandLog(&gotLog)) + + absoluteGit, err := sh.AbsolutePath("git") + if err != nil { + t.Fatalf("sh.AbsolutePath(git) = %v", err) + } + + err = gitSparseCheckoutDisable(ctx, sh) + if err != nil { + t.Fatalf("gitSparseCheckoutDisable(ctx, sh) = %v", err) + } + + wantLog := [][]string{{absoluteGit, "sparse-checkout", "disable"}} + if diff := cmp.Diff(gotLog, wantLog); diff != "" { + t.Errorf("executed commands diff (-got +want):\n%s", diff) + } +} + +func TestIsSparseCheckoutEnabled(t *testing.T) { + t.Parallel() + + // Create a temporary directory for testing + tmpDir := t.TempDir() + gitDir := filepath.Join(tmpDir, ".git") + infoDir := filepath.Join(gitDir, "info") + sparseCheckoutFile := filepath.Join(infoDir, "sparse-checkout") + + // Create shell with working directory set to tmpDir + sh := shell.NewTestShell(t) + sh.Chdir(tmpDir) + + // Test 1: No .git directory + if isSparseCheckoutEnabled(sh) { + t.Error("expected sparse checkout to be disabled when .git doesn't exist") + } + + // Test 2: .git exists but no sparse-checkout file + os.MkdirAll(infoDir, 0755) + if isSparseCheckoutEnabled(sh) { + t.Error("expected sparse checkout to be disabled when sparse-checkout file doesn't exist") + } + + // Test 3: sparse-checkout file exists but is empty + os.WriteFile(sparseCheckoutFile, []byte{}, 0644) + if isSparseCheckoutEnabled(sh) { + t.Error("expected sparse checkout to be disabled when sparse-checkout file is empty") + } + + // Test 4: sparse-checkout file exists with content + os.WriteFile(sparseCheckoutFile, []byte("dir1\ndir2\n"), 0644) + if !isSparseCheckoutEnabled(sh) { + t.Error("expected sparse checkout to be enabled when sparse-checkout file has content") + } +}