diff --git a/cmd/gradle-cache/integration_test.go b/cmd/gradle-cache/integration_test.go index ad45213..3179eb0 100644 --- a/cmd/gradle-cache/integration_test.go +++ b/cmd/gradle-cache/integration_test.go @@ -150,6 +150,7 @@ func TestIntegrationGradleBuildCycle(t *testing.T) { "--cache-key", ctx.cacheKey, "--commit", commitSHA, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, saveArgs...) @@ -164,10 +165,15 @@ func TestIntegrationGradleBuildCycle(t *testing.T) { "--ref", commitSHA, "--git-dir", ctx.projectDir, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, restoreArgs...) t.Log("Step 4: Verifying restore...") + buildLogicBuildDir := filepath.Join(ctx.projectDir, "build-logic", "build") + if _, err := os.Stat(buildLogicBuildDir); err != nil { + t.Fatal("build-logic/build/ was NOT restored") + } verifyRestore(t, ctx) } @@ -525,6 +531,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") } "--cache-key", ctx.cacheKey, "--commit", commitSHA, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, saveArgs...) @@ -537,6 +544,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") } "--ref", commitSHA, "--git-dir", ctx.projectDir, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, restoreArgs...) @@ -574,6 +582,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") } "--branch", "test-branch", "--gradle-user-home", ctx.gradleUserHome, "--project-dir", ctx.projectDir, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, saveDeltaArgs...) @@ -590,6 +599,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") } "--branch", "test-branch", "--gradle-user-home", freshHome, "--project-dir", ctx.projectDir, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, freshRestoreDelta...) @@ -618,6 +628,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") } "--branch", "test-branch", "--gradle-user-home", ctx.gradleUserHome, "--project-dir", ctx.projectDir, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, fullRestoreDelta...) @@ -832,22 +843,42 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { } for _, tt := range []struct { - name string - fixture string - buildFile string - change string // appended to build file to invalidate CC + name string + fixture string + mutate func(t *testing.T, projectDir string) // invalidate CC }{ { - name: "groovy-dsl", - fixture: "groovy-project", - buildFile: "build.gradle", - change: "\n// force CC invalidation\n", + name: "groovy-dsl", + fixture: "groovy-project", + mutate: func(t *testing.T, projectDir string) { + appendToFile(t, filepath.Join(projectDir, "build.gradle"), "\n// force CC invalidation\n") + }, }, { - name: "kotlin-dsl", - fixture: "gradle-project", - buildFile: "build.gradle.kts", - change: "\n// force CC invalidation\n", + name: "kotlin-dsl", + fixture: "gradle-project", + mutate: func(t *testing.T, projectDir string) { + appendToFile(t, filepath.Join(projectDir, "build.gradle.kts"), "\n// force CC invalidation\n") + }, + }, + { + name: "included-build-plugin-change", + fixture: "gradle-project", + mutate: func(t *testing.T, projectDir string) { + must(t, os.WriteFile( + filepath.Join(projectDir, "build-logic", "src", "main", "java", "com", "example", "IncludedPlugin.java"), + []byte(`package com.example; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public class IncludedPlugin implements Plugin { + @Override public void apply(Project project) { + project.getLogger().lifecycle("IncludedPlugin applied (modified)"); + } +} +`), 0o644)) + }, }, } { tt := tt @@ -867,6 +898,7 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { "--cache-key", ctx.cacheKey, "--commit", commitSHA, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, saveArgs...) @@ -880,16 +912,12 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { "--ref", commitSHA, "--git-dir", ctx.projectDir, "--gradle-user-home", ctx.gradleUserHome, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, restoreArgs...) - // Modify build file to invalidate configuration cache. - buildFilePath := filepath.Join(ctx.projectDir, tt.buildFile) - f, err := os.OpenFile(buildFilePath, os.O_APPEND|os.O_WRONLY, 0o644) - must(t, err) - _, err = f.WriteString(tt.change) - must(t, err) - must(t, f.Close()) + // Mutate the project to invalidate configuration cache. + tt.mutate(t, ctx.projectDir) output := gradleRun(t, ctx.projectDir, ctx.gradlew, ctx.gradleUserHome, "build") if !strings.Contains(output, "Calculating task graph") && @@ -905,6 +933,7 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { "--branch", "cc-test-branch", "--gradle-user-home", ctx.gradleUserHome, "--project-dir", ctx.projectDir, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, saveDeltaArgs...) @@ -919,6 +948,7 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { "--branch", "cc-test-branch", "--gradle-user-home", ctx.gradleUserHome, "--project-dir", ctx.projectDir, + "--included-build", "build-logic", ) runCLI(t, binaryPath, ctx, restoreDeltaArgs...) @@ -928,7 +958,7 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { t.Fatalf("configuration-cache dir not restored: %v", err) } - // ── Step 5: Verify CC hit with the modified build file ────── + // ── Step 5: Verify CC hit after delta restore ────────────── t.Log("Step 5: Verifying configuration cache hit...") output = gradleRun(t, ctx.projectDir, ctx.gradlew, ctx.gradleUserHome, "build") @@ -945,6 +975,15 @@ func TestIntegrationDeltaConfigurationCache(t *testing.T) { } } +func appendToFile(t *testing.T, path, content string) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + must(t, err) + _, err = f.WriteString(content) + must(t, err) + must(t, f.Close()) +} + func extractLine(output, substr string) string { for _, line := range strings.Split(output, "\n") { if strings.Contains(strings.ToLower(line), strings.ToLower(substr)) { diff --git a/cmd/gradle-cache/main.go b/cmd/gradle-cache/main.go index d3c92e0..85cd03d 100644 --- a/cmd/gradle-cache/main.go +++ b/cmd/gradle-cache/main.go @@ -4,10 +4,13 @@ package main import ( "context" + "fmt" "log/slog" "os" + "path/filepath" "runtime" "runtime/pprof" + "strings" "github.com/alecthomas/errors" "github.com/alecthomas/kong" @@ -61,6 +64,25 @@ func (f *backendFlags) validate() error { return nil } +// validateIncludedBuilds checks that each --included-build value refers to an +// existing directory (or, for glob patterns like "build-logic/*", that the +// parent directory exists). baseDir is the directory paths are resolved +// against (typically the project directory). +func validateIncludedBuilds(baseDir string, entries []string) error { + for _, entry := range entries { + dir := strings.TrimSuffix(entry, "/*") + path := filepath.Join(baseDir, dir) + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("--included-build %q: %w", entry, err) + } + if !info.IsDir() { + return fmt.Errorf("--included-build %q: not a directory", entry) + } + } + return nil +} + // ── Restore ───────────────────────────────────────────────────────────────── type RestoreCmd struct { @@ -71,11 +93,17 @@ type RestoreCmd struct { Commit string `help:"Specific commit SHA to try directly, skipping history walk."` MaxBlocks int `help:"Number of distinct-author commit blocks to search." default:"20"` GradleUserHome string `help:"Path to GRADLE_USER_HOME." env:"GRADLE_USER_HOME" type:"path"` - IncludedBuilds []string `help:"Included build directories whose build/ output to restore. May be repeated." name:"included-build" type:"path"` + ProjectDir string `help:"Project directory containing included builds and .gradle/." default:"." type:"path"` + IncludedBuilds []string `help:"Included build directories whose build/ output to restore. May be repeated." name:"included-build"` Branch string `help:"Branch name to also apply a delta bundle for." optional:""` } -func (c *RestoreCmd) AfterApply() error { return c.validate() } +func (c *RestoreCmd) AfterApply() error { + if err := c.validate(); err != nil { + return err + } + return validateIncludedBuilds(c.ProjectDir, c.IncludedBuilds) +} func (c *RestoreCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) error { slog.Debug(gradleUserHomeEnv, "path", c.GradleUserHome) @@ -90,6 +118,7 @@ func (c *RestoreCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) Commit: c.Commit, MaxBlocks: c.MaxBlocks, GradleUserHome: c.GradleUserHome, + ProjectDir: c.ProjectDir, IncludedBuilds: c.IncludedBuilds, Branch: c.Branch, Metrics: metrics, @@ -104,10 +133,15 @@ type RestoreDeltaCmd struct { Branch string `help:"Branch name to look up a delta for." required:""` GradleUserHome string `help:"Path to GRADLE_USER_HOME." env:"GRADLE_USER_HOME" type:"path"` ProjectDir string `help:"Project directory for routing project-specific cache entries." type:"path"` - IncludedBuilds []string `help:"Included build directories whose build/ output to route. May be repeated." name:"included-build" type:"path"` + IncludedBuilds []string `help:"Included build directories whose build/ output to route. May be repeated." name:"included-build"` } -func (c *RestoreDeltaCmd) AfterApply() error { return c.validate() } +func (c *RestoreDeltaCmd) AfterApply() error { + if err := c.validate(); err != nil { + return err + } + return validateIncludedBuilds(c.ProjectDir, c.IncludedBuilds) +} func (c *RestoreDeltaCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) error { slog.Debug(gradleUserHomeEnv, "path", c.GradleUserHome) @@ -133,10 +167,16 @@ type SaveCmd struct { Commit string `help:"Commit SHA to tag this bundle with. Defaults to HEAD of --git-dir."` GitDir string `help:"Path to the git repository." default:"." type:"path" hidden:""` GradleUserHome string `help:"Path to GRADLE_USER_HOME." env:"GRADLE_USER_HOME" type:"path"` - IncludedBuilds []string `help:"Included build directories whose build/ output to archive. May be repeated." name:"included-build" type:"path"` + ProjectDir string `help:"Project directory containing included builds and .gradle/." default:"." type:"path"` + IncludedBuilds []string `help:"Included build directories whose build/ output to archive. May be repeated." name:"included-build"` } -func (c *SaveCmd) AfterApply() error { return c.validate() } +func (c *SaveCmd) AfterApply() error { + if err := c.validate(); err != nil { + return err + } + return validateIncludedBuilds(c.ProjectDir, c.IncludedBuilds) +} func (c *SaveCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) error { slog.Debug(gradleUserHomeEnv, "path", c.GradleUserHome) @@ -149,6 +189,7 @@ func (c *SaveCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) er Commit: c.Commit, GitDir: c.GitDir, GradleUserHome: c.GradleUserHome, + ProjectDir: c.ProjectDir, IncludedBuilds: c.IncludedBuilds, Metrics: metrics, }) @@ -162,10 +203,15 @@ type SaveDeltaCmd struct { Branch string `help:"Branch name to save the delta under." required:""` GradleUserHome string `help:"Path to GRADLE_USER_HOME." env:"GRADLE_USER_HOME" type:"path"` ProjectDir string `help:"Project directory to scan for project-specific cache changes." type:"path"` - IncludedBuilds []string `help:"Included build directories whose build/ output to include in delta. May be repeated." name:"included-build" type:"path"` + IncludedBuilds []string `help:"Included build directories whose build/ output to include in delta. May be repeated." name:"included-build"` } -func (c *SaveDeltaCmd) AfterApply() error { return c.validate() } +func (c *SaveDeltaCmd) AfterApply() error { + if err := c.validate(); err != nil { + return err + } + return validateIncludedBuilds(c.ProjectDir, c.IncludedBuilds) +} func (c *SaveDeltaCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient) error { slog.Debug(gradleUserHomeEnv, "path", c.GradleUserHome) diff --git a/cmd/gradle-cache/testdata/gradle-project/build-logic/build.gradle.kts b/cmd/gradle-cache/testdata/gradle-project/build-logic/build.gradle.kts new file mode 100644 index 0000000..117352d --- /dev/null +++ b/cmd/gradle-cache/testdata/gradle-project/build-logic/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-gradle-plugin` +} + +gradlePlugin { + plugins { + create("included") { + id = "com.example.included" + implementationClass = "com.example.IncludedPlugin" + } + } +} diff --git a/cmd/gradle-cache/testdata/gradle-project/build-logic/settings.gradle.kts b/cmd/gradle-cache/testdata/gradle-project/build-logic/settings.gradle.kts new file mode 100644 index 0000000..7fbbd44 --- /dev/null +++ b/cmd/gradle-cache/testdata/gradle-project/build-logic/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "build-logic" diff --git a/cmd/gradle-cache/testdata/gradle-project/build-logic/src/main/java/com/example/IncludedPlugin.java b/cmd/gradle-cache/testdata/gradle-project/build-logic/src/main/java/com/example/IncludedPlugin.java new file mode 100644 index 0000000..df69f7b --- /dev/null +++ b/cmd/gradle-cache/testdata/gradle-project/build-logic/src/main/java/com/example/IncludedPlugin.java @@ -0,0 +1,11 @@ +package com.example; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public class IncludedPlugin implements Plugin { + @Override + public void apply(Project project) { + // no-op plugin — exercises the included-build path + } +} diff --git a/cmd/gradle-cache/testdata/gradle-project/build.gradle.kts b/cmd/gradle-cache/testdata/gradle-project/build.gradle.kts index f812e7a..6dd4308 100644 --- a/cmd/gradle-cache/testdata/gradle-project/build.gradle.kts +++ b/cmd/gradle-cache/testdata/gradle-project/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") version "2.3.20" + id("com.example.included") } repositories { diff --git a/cmd/gradle-cache/testdata/gradle-project/settings.gradle.kts b/cmd/gradle-cache/testdata/gradle-project/settings.gradle.kts index f65f860..7b12320 100644 --- a/cmd/gradle-cache/testdata/gradle-project/settings.gradle.kts +++ b/cmd/gradle-cache/testdata/gradle-project/settings.gradle.kts @@ -1 +1,5 @@ +pluginManagement { + includeBuild("build-logic") +} + rootProject.name = "cache-test" diff --git a/cmd/gradle-cache/testdata/groovy-project/build-logic/build.gradle b/cmd/gradle-cache/testdata/groovy-project/build-logic/build.gradle new file mode 100644 index 0000000..1d5b2d6 --- /dev/null +++ b/cmd/gradle-cache/testdata/groovy-project/build-logic/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-gradle-plugin' +} + +gradlePlugin { + plugins { + included { + id = 'com.example.included' + implementationClass = 'com.example.IncludedPlugin' + } + } +} diff --git a/cmd/gradle-cache/testdata/groovy-project/build-logic/settings.gradle b/cmd/gradle-cache/testdata/groovy-project/build-logic/settings.gradle new file mode 100644 index 0000000..6ef2ba8 --- /dev/null +++ b/cmd/gradle-cache/testdata/groovy-project/build-logic/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'build-logic' diff --git a/cmd/gradle-cache/testdata/groovy-project/build-logic/src/main/java/com/example/IncludedPlugin.java b/cmd/gradle-cache/testdata/groovy-project/build-logic/src/main/java/com/example/IncludedPlugin.java new file mode 100644 index 0000000..df69f7b --- /dev/null +++ b/cmd/gradle-cache/testdata/groovy-project/build-logic/src/main/java/com/example/IncludedPlugin.java @@ -0,0 +1,11 @@ +package com.example; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public class IncludedPlugin implements Plugin { + @Override + public void apply(Project project) { + // no-op plugin — exercises the included-build path + } +} diff --git a/cmd/gradle-cache/testdata/groovy-project/build.gradle b/cmd/gradle-cache/testdata/groovy-project/build.gradle index fd4a818..3ecb94e 100644 --- a/cmd/gradle-cache/testdata/groovy-project/build.gradle +++ b/cmd/gradle-cache/testdata/groovy-project/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'com.example.included' } repositories { diff --git a/cmd/gradle-cache/testdata/groovy-project/settings.gradle b/cmd/gradle-cache/testdata/groovy-project/settings.gradle index 5408613..1161bcd 100644 --- a/cmd/gradle-cache/testdata/groovy-project/settings.gradle +++ b/cmd/gradle-cache/testdata/groovy-project/settings.gradle @@ -1 +1,5 @@ +pluginManagement { + includeBuild('build-logic') +} + rootProject.name = 'groovy-cache-test' diff --git a/gradlecache/restore.go b/gradlecache/restore.go index 70339d7..790ac09 100644 --- a/gradlecache/restore.go +++ b/gradlecache/restore.go @@ -219,6 +219,8 @@ type RestoreConfig struct { MaxBlocks int // GradleUserHome is the path to GRADLE_USER_HOME. Defaults to ~/.gradle. GradleUserHome string + // ProjectDir is the project directory; defaults to cwd. + ProjectDir string // IncludedBuilds lists included build directories whose build/ output to // restore (relative to project root). Defaults to ["buildSrc"]. IncludedBuilds []string @@ -231,6 +233,11 @@ type RestoreConfig struct { } // defaultRegion returns the AWS region from environment variables, falling back to us-west-2. +func defaultProjectDir() string { + wd, _ := os.Getwd() + return wd +} + func defaultRegion() string { if r := os.Getenv("AWS_REGION"); r != "" { return r @@ -258,6 +265,9 @@ func (c *RestoreConfig) defaults() { home, _ := os.UserHomeDir() c.GradleUserHome = filepath.Join(home, ".gradle") } + if c.ProjectDir == "" { + c.ProjectDir = defaultProjectDir() + } if len(c.IncludedBuilds) == 0 { c.IncludedBuilds = []string{"buildSrc"} } @@ -410,15 +420,10 @@ func Restore(ctx context.Context, cfg RestoreConfig) error { entries, _ := os.ReadDir(cfg.GradleUserHome) gradleUserHomeEmpty := len(entries) == 0 - projectDir, err := os.Getwd() - if err != nil { - return errors.Wrap(err, "get working directory") - } - rules := []extractRule{ {prefix: "caches/", baseDir: cfg.GradleUserHome}, {prefix: "wrapper/", baseDir: cfg.GradleUserHome}, - {prefix: "configuration-cache/", baseDir: filepath.Join(projectDir, ".gradle")}, + {prefix: "configuration-cache/", baseDir: filepath.Join(cfg.ProjectDir, ".gradle")}, } body, err := store.get(ctx, hitCommit, cfg.CacheKey, hitInfo) @@ -429,7 +434,7 @@ func Restore(ctx context.Context, cfg RestoreConfig) error { cb := &countingBody{r: body, dlStart: dlStart} netTiming := &timingReader{r: cb} - ps, err := extractBundleZstd(ctx, netTiming, rules, projectDir, !gradleUserHomeEmpty) + ps, err := extractBundleZstd(ctx, netTiming, rules, cfg.ProjectDir, !gradleUserHomeEmpty) if err != nil { return errors.Wrap(err, "extract bundle") } @@ -495,7 +500,7 @@ func Restore(ctx context.Context, cfg RestoreConfig) error { "speed_mbps", fmt.Sprintf("%.1f", float64(dr.n)/dlElapsed.Seconds()/1e6)) } applyStart := time.Now() - if err := extractDeltaTarZstdRouted(dr.tmpFile, rules, projectDir); err != nil { + if err := extractDeltaTarZstdRouted(dr.tmpFile, rules, cfg.ProjectDir); err != nil { return errors.Wrap(err, "extract delta bundle") } applyElapsed := time.Since(applyStart) diff --git a/gradlecache/save.go b/gradlecache/save.go index 925b0db..1c22140 100644 --- a/gradlecache/save.go +++ b/gradlecache/save.go @@ -46,10 +46,7 @@ func (c *RestoreDeltaConfig) defaults() { c.GradleUserHome = filepath.Join(home, ".gradle") } if c.ProjectDir == "" { - wd, err := os.Getwd() - if err == nil { - c.ProjectDir = wd - } + c.ProjectDir = defaultProjectDir() } if c.Metrics == nil { c.Metrics = NoopMetrics{} @@ -132,6 +129,7 @@ type SaveConfig struct { Commit string GitDir string GradleUserHome string + ProjectDir string // project directory; defaults to cwd IncludedBuilds []string SkipWarm bool // skip page cache warming (for benchmarking cold baseline) Metrics MetricsClient @@ -149,6 +147,9 @@ func (c *SaveConfig) defaults() { home, _ := os.UserHomeDir() c.GradleUserHome = filepath.Join(home, ".gradle") } + if c.ProjectDir == "" { + c.ProjectDir = defaultProjectDir() + } if len(c.IncludedBuilds) == 0 { c.IncludedBuilds = []string{"buildSrc"} } @@ -191,18 +192,14 @@ func Save(ctx context.Context, cfg SaveConfig) error { return nil } - projectDir, err := os.Getwd() - if err != nil { - return errors.Wrap(err, "get working directory") - } - if err := validateProjectDir(projectDir); err != nil { + if err := validateProjectDir(cfg.ProjectDir); err != nil { return err } sources := []TarSource{{BaseDir: cfg.GradleUserHome, Path: "./caches"}} if fi, err := os.Stat(filepath.Join(cfg.GradleUserHome, "wrapper")); err == nil && fi.IsDir() { sources = append(sources, TarSource{BaseDir: cfg.GradleUserHome, Path: "./wrapper"}) } - sources = append(sources, ProjectDirSources(projectDir, cfg.IncludedBuilds)...) + sources = append(sources, ProjectDirSources(cfg.ProjectDir, cfg.IncludedBuilds)...) pr, pw := io.Pipe() @@ -297,10 +294,7 @@ func (c *SaveDeltaConfig) defaults() { c.GradleUserHome = filepath.Join(home, ".gradle") } if c.ProjectDir == "" { - wd, err := os.Getwd() - if err == nil { - c.ProjectDir = wd - } + c.ProjectDir = defaultProjectDir() } if c.Metrics == nil { c.Metrics = NoopMetrics{}