Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e80d2c8
Inject in agent partial clone filters on sparse checkout
lizrabuya Jun 30, 2026
134ea4d
Fix failing tests comparing git version
lizrabuya Jul 1, 2026
c3fe43a
Fix git version bump on the sparse checkout tests
lizrabuya Jul 2, 2026
e056363
Fix stale check on git version and some lin errors
lizrabuya Jul 3, 2026
8de3967
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
mcncl Jul 3, 2026
d189e67
Add --sparse to clone flags and --filter=blob:none to fetch flags whe…
lizrabuya Jul 6, 2026
8849268
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
lizrabuya Jul 6, 2026
e99144d
Update test comment to remove stale fallback plan
lizrabuya Jul 6, 2026
82db707
Fix issue on fetch path filter possibly overwriting user-supplied clo…
lizrabuya Jul 6, 2026
1612962
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
isaacsu Jul 7, 2026
6c853d9
Suggestions for fix/set-clone-flags-on-sparsecheckout
isaacsu Jul 7, 2026
bf7c3cf
Update internal/job/git.go
lizrabuya Jul 8, 2026
8c2949d
Merging changes from https://github.com/buildkite/agent/pull/4067
lizrabuya Jul 12, 2026
5197e6e
Remove unused hasSparseCheckoutFlag
lizrabuya Jul 12, 2026
b1f2dd8
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
lizrabuya Jul 12, 2026
9515dae
Renamed hasPartialCloneFilter to hasPartialFilterFlags for generic use
lizrabuya Jul 14, 2026
9ef7dbb
Fix failed tests by flagging only user supplied clone filters before …
lizrabuya Jul 14, 2026
85d4b9a
Fix test sequence and provide clear function descriptions
lizrabuya Jul 14, 2026
794dd29
Fix test sequence for TestCheckingOutLocalGitProjectWithSparseCheckou…
lizrabuya Jul 14, 2026
31f00c7
Provide more verbose error when checking for git version and update t…
lizrabuya Jul 14, 2026
a8e9ab5
Fix failing checkout integration test TestCheckingOutLocalGitProjectW…
lizrabuya Jul 14, 2026
e72f36c
Fix lint error
lizrabuya Jul 14, 2026
5f0e5bd
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
lizrabuya Jul 14, 2026
d0f77f3
Fixed git operation sequence to one test
lizrabuya Jul 15, 2026
88e19a4
Merge branch 'main' into fix/set-clone-flags-on-sparsecheckout
lizrabuya Jul 15, 2026
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
17 changes: 16 additions & 1 deletion internal/job/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,9 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) (retErr error) {
return fmt.Errorf("creating checkout dir: %w", err)
}

// If sparse checkout paths are present, plan the sparse checkout.
sparsePlan := e.planSparseCheckout(ctx)

// On mirrors and dissociation:
//
// --reference makes the clone reuse objects from the mirror, using the
Expand Down Expand Up @@ -964,6 +967,18 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) (retErr error) {
}
}

// If sparse checkout will actually apply, use a partial clone so blobs
// outside the sparse set aren't downloaded up front. Only when the user
// hasn't already supplied a --filter — git takes the last --filter on the
// command line and would silently override theirs.
if sparsePlan.supported {
if hasPartialCloneFilter(gitCloneFlags) {
e.shell.Commentf("Sparse checkout is configured and BUILDKITE_GIT_CLONE_FLAGS already contains a --filter (preserving user-supplied filter).")
} else {
gitCloneFlags = append(gitCloneFlags, "--filter=blob:none")
Comment thread
buildsworth-bk-app[bot] marked this conversation as resolved.
}
}

// Do the clone.
if err := gitClone(ctx, e.shell, gitCloneFlags, e.Repository, "."); err != nil {
return fmt.Errorf("cloning git repository: %w", err)
Expand Down Expand Up @@ -999,7 +1014,7 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) (retErr error) {
return err
}

sparseCheckoutActive, err := e.setupSparseCheckout(ctx)
sparseCheckoutActive, err := e.setupSparseCheckout(ctx, sparsePlan)
if err != nil {
return err
}
Expand Down
66 changes: 45 additions & 21 deletions internal/job/checkout_sparse.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,42 @@ import (
"github.com/buildkite/agent/v3/internal/shell"
)

// sparseCheckoutPlan is the decision, made once at the top of the checkout,
// about whether sparse checkout applies to this build. Data only — all state
// mutation happens in setupSparseCheckout.
type sparseCheckoutPlan struct {
Comment thread
isaacsu marked this conversation as resolved.
Outdated
paths []string // cleaned; empty when sparse checkout is not requested
supported bool // requested AND `sparse-checkout set --cone` is available (git >= 2.27)
}

// planSparseCheckout resolves whether sparse checkout can be applied to this
// build. Called once, before the clone, so the pre-clone --filter decision
// and the post-fetch `sparse-checkout set --cone` call share the same answer
// without running `git --version` twice. Runs `git --version` only when
// sparse checkout was actually requested.
func (e *Executor) planSparseCheckout(ctx context.Context) sparseCheckoutPlan {
paths := cleanGitSparseCheckoutPaths(e.GitSparseCheckoutPaths)
if len(paths) == 0 {
return sparseCheckoutPlan{}
}

// 2.27 is the floor because setupSparseCheckout calls
// `git sparse-checkout set --cone <paths>` in a single call. Cone mode
// shipped in 2.26 but `--cone` was only accepted on the `init` subcommand
// then; `set --cone` landed in 2.27. On 2.26.x that call fails, so we
// fall back to a full checkout rather than carrying the older two-step.
ok, err := gitVersionAtLeast(ctx, e.shell, 2, 27)
if err != nil {
e.shell.Warningf("Sparse checkout requires git >= 2.27; falling back to full checkout (%v)", err)
return sparseCheckoutPlan{paths: paths}
}
if !ok {
e.shell.Warningf("Sparse checkout requires git >= 2.27; falling back to full checkout")
Comment thread
lizrabuya marked this conversation as resolved.
Outdated
return sparseCheckoutPlan{paths: paths}
}
return sparseCheckoutPlan{paths: paths, supported: true}
}

func cleanGitSparseCheckoutPaths(paths []string) []string {
cleaned := make([]string, 0, len(paths))
for _, path := range paths {
Expand Down Expand Up @@ -91,31 +127,19 @@ func (e *Executor) disableSparseCheckoutIfConfigured(ctx context.Context) {
}
}

// setupSparseCheckout configures (or disables) git sparse checkout for the
// current working tree. It returns true if sparse checkout was successfully
// applied for this build, so callers can adjust later behaviour (e.g. skip
// submodule init, which requires the full tree).
func (e *Executor) setupSparseCheckout(ctx context.Context) (bool, error) {
paths := cleanGitSparseCheckoutPaths(e.GitSparseCheckoutPaths)
if len(paths) == 0 {
e.disableSparseCheckoutIfConfigured(ctx)
return false, nil
}

ok, err := gitVersionAtLeast(ctx, e.shell, 2, 26)
if err != nil {
e.shell.Warningf("Sparse checkout requires git >= 2.26; falling back to full checkout (%v)", err)
e.disableSparseCheckoutIfConfigured(ctx)
return false, nil
}
if !ok {
e.shell.Warningf("Sparse checkout requires git >= 2.26; falling back to full checkout")
// setupSparseCheckout applies a resolved plan to configure (or disable)
// git sparse checkout for the current working tree. It returns true if
// sparse checkout was successfully applied for this build, so callers can
// adjust later behaviour (e.g. skip submodule init, which requires the full
// tree).
func (e *Executor) setupSparseCheckout(ctx context.Context, plan sparseCheckoutPlan) (bool, error) {
if len(plan.paths) == 0 || !plan.supported {
e.disableSparseCheckoutIfConfigured(ctx)
return false, nil
}

e.shell.Commentf("Setting up sparse checkout for paths: %s", strings.Join(paths, ","))
args := append([]string{"sparse-checkout", "set", "--cone"}, paths...)
e.shell.Commentf("Setting up sparse checkout for paths: %s", strings.Join(plan.paths, ","))
args := append([]string{"sparse-checkout", "set", "--cone"}, plan.paths...)
if err := e.shell.Command("git", args...).Run(ctx); err != nil {
return false, fmt.Errorf("setting sparse checkout paths: %w", err)
}
Expand Down
48 changes: 26 additions & 22 deletions internal/job/checkout_sparse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ func TestSetupSparseCheckout_Enable(t *testing.T) {
defer git.Close() //nolint:errcheck // Best-effort cleanup.
executor.GitSparseCheckoutPaths = []string{".buildkite/", "src/"}

git.Expect("--version").AndWriteToStdout("git version 2.39.0").AndExitWith(0)
git.Expect("sparse-checkout", "set", "--cone", ".buildkite/", "src/").AndExitWith(0)

active, err := executor.setupSparseCheckout(t.Context())
plan := sparseCheckoutPlan{paths: []string{".buildkite/", "src/"}, supported: true}
active, err := executor.setupSparseCheckout(t.Context(), plan)
if err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
t.Fatalf("executor.setupSparseCheckout(ctx, plan) error = %v, want nil", err)
}
if !active {
t.Fatalf("executor.setupSparseCheckout(ctx) active = false, want true")
t.Fatalf("executor.setupSparseCheckout(ctx, plan) active = false, want true")
}
if got, want := out.String(), "Setting up sparse checkout for paths: .buildkite/,src/"; !strings.Contains(got, want) {
t.Fatalf("shell output = %q, want to contain %q", got, want)
Expand All @@ -83,12 +83,13 @@ func TestSetupSparseCheckout_DisableWithPriorSparseConfig(t *testing.T) {
git.Expect("config", "--worktree", "--list").AndWriteToStdout("").AndExitWith(0)
git.Expect("config", "--unset", "extensions.worktreeConfig").AndExitWith(0)

active, err := executor.setupSparseCheckout(t.Context())
plan := sparseCheckoutPlan{paths: []string{"src/"}, supported: false}
active, err := executor.setupSparseCheckout(t.Context(), plan)
if err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
t.Fatalf("executor.setupSparseCheckout(ctx, plan) error = %v, want nil", err)
}
if active {
t.Fatalf("executor.setupSparseCheckout(ctx) active = true, want false")
t.Fatalf("executor.setupSparseCheckout(ctx, plan) active = true, want false")
}
if got, want := out.String(), "Disabling sparse checkout from previous build"; !strings.Contains(got, want) {
t.Fatalf("shell output = %q, want to contain %q", got, want)
Expand All @@ -107,8 +108,9 @@ func TestSetupSparseCheckout_DisablePreservesOtherWorktreeConfig(t *testing.T) {
git.Expect("config", "--worktree", "--list").AndWriteToStdout("user.something=value\n").AndExitWith(0)
git.Expect("config", "--unset", "extensions.worktreeConfig").NotCalled()

if _, err := executor.setupSparseCheckout(t.Context()); err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
plan := sparseCheckoutPlan{paths: []string{"src/"}, supported: false}
if _, err := executor.setupSparseCheckout(t.Context(), plan); err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx, plan) error = %v, want nil", err)
}

git.Check(t)
Expand All @@ -121,8 +123,9 @@ func TestSetupSparseCheckout_DisableWithoutPriorSparseConfig(t *testing.T) {
git.Expect("config").WithAnyArguments().NotCalled()
git.Expect("sparse-checkout").WithAnyArguments().NotCalled()

if _, err := executor.setupSparseCheckout(t.Context()); err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
plan := sparseCheckoutPlan{paths: []string{"src/"}, supported: false}
if _, err := executor.setupSparseCheckout(t.Context(), plan); err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx, plan) error = %v, want nil", err)
}

git.Check(t)
Expand All @@ -136,14 +139,14 @@ func TestSetupSparseCheckout_VersionFallback(t *testing.T) {
git.Expect("--version").AndWriteToStdout("git version 2.25.4").AndExitWith(0)
git.Expect("sparse-checkout").WithAnyArguments().NotCalled()

active, err := executor.setupSparseCheckout(t.Context())
if err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
plan := executor.planSparseCheckout(t.Context())
if plan.supported {
t.Fatalf("planSparseCheckout(ctx).supported = true, want false")
}
if active {
t.Fatalf("executor.setupSparseCheckout(ctx) active = true, want false")
if len(plan.paths) != 1 || plan.paths[0] != "src/" {
t.Fatalf("planSparseCheckout(ctx).paths = %#v, want [\"src/\"]", plan.paths)
}
if got, want := out.String(), "Sparse checkout requires git >= 2.26; falling back to full checkout"; !strings.Contains(got, want) {
if got, want := out.String(), "Sparse checkout requires git >= 2.27; falling back to full checkout"; !strings.Contains(got, want) {
t.Fatalf("shell output = %q, want to contain %q", got, want)
}

Expand All @@ -156,17 +159,18 @@ func TestSetupSparseCheckout_VersionFallbackDisablesPriorSparseConfig(t *testing
executor.GitSparseCheckoutPaths = []string{"src/"}
createSparseCheckoutFile(t, executor.shell.Getwd())

git.Expect("--version").AndWriteToStdout("git version 2.25.4").AndExitWith(0)
git.Expect("config", "--get", "core.sparseCheckout").AndWriteToStdout("true\n").AndExitWith(0)
git.Expect("sparse-checkout", "disable").AndExitWith(0)
git.Expect("config", "--worktree", "--list").AndWriteToStdout("").AndExitWith(0)
git.Expect("config", "--unset", "extensions.worktreeConfig").AndExitWith(0)

if _, err := executor.setupSparseCheckout(t.Context()); err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx) error = %v, want nil", err)
plan := sparseCheckoutPlan{paths: []string{"src/"}, supported: false}
active, err := executor.setupSparseCheckout(t.Context(), plan)
if err != nil {
t.Fatalf("executor.setupSparseCheckout(ctx, plan) error = %v, want nil", err)
}
if got, want := out.String(), "Sparse checkout requires git >= 2.26; falling back to full checkout"; !strings.Contains(got, want) {
t.Fatalf("shell output = %q, want to contain %q", got, want)
if active {
t.Fatalf("executor.setupSparseCheckout(ctx, plan) active = true, want false")
}
if got, want := out.String(), "Disabling sparse checkout from previous build"; !strings.Contains(got, want) {
t.Fatalf("shell output = %q, want to contain %q", got, want)
Expand Down
17 changes: 17 additions & 0 deletions internal/job/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ func gitCheckout(ctx context.Context, sh *shell.Shell, gitCheckoutFlags, referen
return nil
}

// hasPartialCloneFilter reports whether the given clone flags already include
// any --filter=<spec> or --filter <spec> option. The caller (sparse checkout
// setup) uses this to avoid stacking its own --filter=blob:none on top of a
// user-supplied filter, since git takes the last --filter on the command line
// and would silently override the user's choice.
func hasPartialCloneFilter(flags []string) bool {
for i, f := range flags {
if strings.HasPrefix(f, "--filter=") {
return true
}
if f == "--filter" && i+1 < len(flags) {
return true
}
}
return false
}

func gitClone(ctx context.Context, sh *shell.Shell, gitCloneFlags []string, repository, dir string) error {
commandArgs := []string{"clone"}
commandArgs = append(commandArgs, gitCloneFlags...)
Expand Down
30 changes: 30 additions & 0 deletions internal/job/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,36 @@ func TestGitClone(t *testing.T) {
}
}

func TestHasPartialCloneFilter(t *testing.T) {
t.Parallel()

tests := []struct {
name string
flags []string
want bool
}{
{name: "no filter", flags: []string{"-v", "--reference", "mirror"}, want: false},
{name: "filter blob:none with equals", flags: []string{"-v", "--filter=blob:none"}, want: true},
{name: "filter blob:none separate arg", flags: []string{"-v", "--filter", "blob:none"}, want: true},
{name: "filter tree:0 with equals", flags: []string{"-v", "--filter=tree:0"}, want: true},
{name: "filter tree:0 separate arg", flags: []string{"-v", "--filter", "tree:0"}, want: true},
{name: "multiple filters with blob:none", flags: []string{"--filter=blob:none", "--filter=tree:0"}, want: true},
{name: "multiple filters separate args", flags: []string{"--filter", "blob:none", "--filter", "tree:0"}, want: true},
{name: "filter prefix lookalike", flags: []string{"-v", "--filtered"}, want: false},
{name: "filter without value", flags: []string{"--filter"}, want: false},
{name: "empty", flags: nil, want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := hasPartialCloneFilter(tt.flags); got != tt.want {
t.Errorf("hasPartialCloneFilter(%v) = %t, want %t", tt.flags, got, tt.want)
}
})
}
}

func TestGitClean(t *testing.T) {
t.Parallel()
ctx := t.Context()
Expand Down
Loading