Skip to content

Commit f71dfb0

Browse files
committed
Fix cross-base delta contamination with base commit stamping
Delta bundles can corrupt Gradle builds when the base bundle changes between PR builds (e.g. after a rebase). The delta's cached metadata (module-artifact.bin, transforms, configuration cache) may reference files that exist in the old base but not the new one. Root cause: Gradle stores dependency jars under files-2.1/{group}/ {module}/{version}/{sha1}/ where the version directory depends on which module version wins dependency conflict resolution. When a delta is built on base A (where guava resolves to 33.5.0-jre) and applied to base B (where guava resolves to 33.5.0-android), the delta's module-artifact.bin tells Gradle the jar is already cached at a path that doesn't exist in base B, causing 'File/directory does not exist' errors during artifact transforms. Fixes: 1. Base commit stamping: record which base commit a delta was built on (both as a __base_commit__ tar entry and as S3 x-amz-meta-base-commit metadata). At restore time, discard the delta if the current base differs. This is checked in both the integrated restore (--branch) and standalone restore-delta paths. 2. Exclude module-artifact.bin and resource-at-url.bin from delta bundles. These Gradle metadata DBs are rewritten every build (BTree compaction) and a stale copy can cause Gradle to skip downloading artifacts that don't exist in the current base. Same rationale as the existing module-metadata.bin exclusion. The .cache-base-commit marker file is written to GRADLE_USER_HOME after base restore and read by save-delta to stamp outgoing deltas. Includes integration test (TestIntegrationDeltaBaseCommitStamping) that verifies delta skip on base mismatch and delta apply on base match. Test fails without the fix.
1 parent 3887671 commit f71dfb0

17 files changed

Lines changed: 789 additions & 52 deletions

File tree

cmd/gradle-cache/integration_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,25 @@ func gitInit(t *testing.T, projectDir string) {
318318
}
319319
}
320320

321+
func gitCommit(t *testing.T, projectDir, message string) {
322+
t.Helper()
323+
for _, args := range [][]string{
324+
{"add", "."},
325+
{"commit", "-m", message},
326+
} {
327+
cmd := exec.Command("git", append([]string{"-C", projectDir}, args...)...)
328+
cmd.Env = append(os.Environ(),
329+
"GIT_AUTHOR_NAME=Test",
330+
"GIT_AUTHOR_EMAIL=test@test.com",
331+
"GIT_COMMITTER_NAME=Test",
332+
"GIT_COMMITTER_EMAIL=test@test.com",
333+
)
334+
if out, err := cmd.CombinedOutput(); err != nil {
335+
t.Fatalf("git %v: %v\n%s", args, err, out)
336+
}
337+
}
338+
}
339+
321340
func gitRevParse(t *testing.T, projectDir string) string {
322341
t.Helper()
323342
cmd := exec.Command("git", "-C", projectDir, "rev-parse", "HEAD")
@@ -975,6 +994,160 @@ public class IncludedPlugin implements Plugin<Project> {
975994
}
976995
}
977996

997+
// TestIntegrationDeltaBaseCommitStamping verifies that deltas built on one base
998+
// commit are skipped when restored against a different base commit.
999+
//
1000+
// The scenario:
1001+
// 1. Build on commit A → save base bundle
1002+
// 2. Restore base A, build (simulated PR build) → save delta (stamped with commit A)
1003+
// 3. Create commit B → save base bundle
1004+
// 4. Restore base B + delta from step 2 → delta MUST be skipped (base mismatch)
1005+
// 5. Verify delta from same base IS applied (base match)
1006+
//
1007+
// This prevents cross-base contamination where stale delta metadata (e.g.
1008+
// module-artifact.bin, transforms, configuration cache) references files that
1009+
// exist in the old base but not the new one.
1010+
func TestIntegrationDeltaBaseCommitStamping(t *testing.T) {
1011+
if testing.Short() {
1012+
t.Skip("skipping integration test in short mode")
1013+
}
1014+
for _, tool := range []string{"java", "tar"} {
1015+
if _, err := exec.LookPath(tool); err != nil {
1016+
t.Skipf("%s not available", tool)
1017+
}
1018+
}
1019+
1020+
binaryPath := filepath.Join(t.TempDir(), "gradle-cache")
1021+
buildCmd := exec.Command("go", "build", "-o", binaryPath, ".")
1022+
buildCmd.Dir = "."
1023+
if out, err := buildCmd.CombinedOutput(); err != nil {
1024+
t.Fatalf("go build failed: %v\n%s", err, out)
1025+
}
1026+
1027+
backend, cleanupBackend := backendArgs(t)
1028+
defer cleanupBackend()
1029+
1030+
ctx := integrationContextFrom(t, "gradle-project")
1031+
1032+
// ── Step 1: Build and save base A ───────────────────────────────────
1033+
t.Log("Step 1: Building and saving base A...")
1034+
gradleRun(t, ctx.projectDir, ctx.gradlew, ctx.gradleUserHome, "build")
1035+
commitA := gitRevParse(t, ctx.projectDir)
1036+
1037+
saveArgs := append([]string{"--log-level", "debug", "save"}, backend...)
1038+
saveArgs = append(saveArgs,
1039+
"--cache-key", ctx.cacheKey,
1040+
"--commit", commitA,
1041+
"--gradle-user-home", ctx.gradleUserHome,
1042+
"--included-build", "build-logic",
1043+
)
1044+
runCLI(t, binaryPath, ctx, saveArgs...)
1045+
1046+
// ── Step 2: Restore base A, mutate, build, save delta ───────────────
1047+
t.Log("Step 2: Restoring base A, building, saving delta...")
1048+
clearGradleState(t, ctx)
1049+
1050+
restoreArgs := append([]string{"--log-level", "debug", "restore"}, backend...)
1051+
restoreArgs = append(restoreArgs,
1052+
"--cache-key", ctx.cacheKey,
1053+
"--ref", commitA,
1054+
"--git-dir", ctx.projectDir,
1055+
"--gradle-user-home", ctx.gradleUserHome,
1056+
"--included-build", "build-logic",
1057+
)
1058+
runCLI(t, binaryPath, ctx, restoreArgs...)
1059+
1060+
// Verify .cache-base-commit was written by restore.
1061+
baseCommitFile := filepath.Join(ctx.gradleUserHome, ".cache-base-commit")
1062+
if data, err := os.ReadFile(baseCommitFile); err != nil {
1063+
t.Fatalf("expected .cache-base-commit after restore: %v", err)
1064+
} else if got := strings.TrimSpace(string(data)); got != commitA {
1065+
t.Fatalf(".cache-base-commit = %q, want %q", got, commitA)
1066+
}
1067+
1068+
// Mutate build to create new cache entries for the delta.
1069+
appendToFile(t, filepath.Join(ctx.projectDir, "build.gradle.kts"), "\n// delta change\n")
1070+
gradleRun(t, ctx.projectDir, ctx.gradlew, ctx.gradleUserHome, "build")
1071+
1072+
saveDeltaArgs := append([]string{"--log-level", "debug", "save-delta"}, backend...)
1073+
saveDeltaArgs = append(saveDeltaArgs,
1074+
"--cache-key", ctx.cacheKey,
1075+
"--branch", "stamp-test-branch",
1076+
"--gradle-user-home", ctx.gradleUserHome,
1077+
"--project-dir", ctx.projectDir,
1078+
"--included-build", "build-logic",
1079+
)
1080+
saveDeltaOut := runCLI(t, binaryPath, ctx, saveDeltaArgs...)
1081+
if !strings.Contains(saveDeltaOut, "stamped with base commit") {
1082+
t.Log(" Warning: delta save did not log base commit stamp")
1083+
}
1084+
1085+
// ── Step 3: Create commit B, save base B ────────────────────────────
1086+
t.Log("Step 3: Creating commit B, building and saving base B...")
1087+
ctxB := integrationContextFrom(t, "gradle-project")
1088+
// Make commit B different from commit A.
1089+
appendToFile(t, filepath.Join(ctxB.projectDir, "build.gradle.kts"), "\n// base B\n")
1090+
gitCommit(t, ctxB.projectDir, "base B change")
1091+
gradleRun(t, ctxB.projectDir, ctxB.gradlew, ctxB.gradleUserHome, "build")
1092+
commitB := gitRevParse(t, ctxB.projectDir)
1093+
1094+
if commitA == commitB {
1095+
t.Fatal("commitA and commitB must be different")
1096+
}
1097+
1098+
saveBArgs := append([]string{"--log-level", "debug", "save"}, backend...)
1099+
saveBArgs = append(saveBArgs,
1100+
"--cache-key", ctx.cacheKey,
1101+
"--commit", commitB,
1102+
"--gradle-user-home", ctxB.gradleUserHome,
1103+
"--included-build", "build-logic",
1104+
)
1105+
runCLI(t, binaryPath, ctxB, saveBArgs...)
1106+
1107+
// ── Step 4: Restore base B + delta from step 2 → delta SKIPPED ──────
1108+
t.Log("Step 4: Restoring base B + delta (should be skipped)...")
1109+
clearGradleState(t, ctx)
1110+
1111+
restoreBArgs := append([]string{"--log-level", "debug", "restore"}, backend...)
1112+
restoreBArgs = append(restoreBArgs,
1113+
"--cache-key", ctx.cacheKey,
1114+
"--ref", commitB,
1115+
"--git-dir", ctxB.projectDir,
1116+
"--gradle-user-home", ctx.gradleUserHome,
1117+
"--included-build", "build-logic",
1118+
)
1119+
runCLI(t, binaryPath, ctx, restoreBArgs...)
1120+
1121+
restoreDeltaArgs := append([]string{"--log-level", "debug", "restore-delta"}, backend...)
1122+
restoreDeltaArgs = append(restoreDeltaArgs,
1123+
"--cache-key", ctx.cacheKey,
1124+
"--branch", "stamp-test-branch",
1125+
"--gradle-user-home", ctx.gradleUserHome,
1126+
"--project-dir", ctx.projectDir,
1127+
"--included-build", "build-logic",
1128+
)
1129+
restoreDeltaOut := runCLI(t, binaryPath, ctx, restoreDeltaArgs...)
1130+
1131+
if !strings.Contains(restoreDeltaOut, "skipping delta") {
1132+
t.Fatal("expected delta to be skipped when base commit differs")
1133+
}
1134+
t.Log(" Delta correctly skipped on base mismatch ✓")
1135+
1136+
// ── Step 5: Restore base A + delta → delta APPLIED ──────────────────
1137+
t.Log("Step 5: Restoring base A + delta (should be applied)...")
1138+
clearGradleState(t, ctx)
1139+
runCLI(t, binaryPath, ctx, restoreArgs...)
1140+
1141+
restoreDeltaOut = runCLI(t, binaryPath, ctx, restoreDeltaArgs...)
1142+
if strings.Contains(restoreDeltaOut, "skipping delta") {
1143+
t.Fatal("expected delta to be applied when base commit matches")
1144+
}
1145+
if !strings.Contains(restoreDeltaOut, "applied delta") {
1146+
t.Fatal("expected 'applied delta' in output when base matches")
1147+
}
1148+
t.Log(" Delta correctly applied on base match ✓")
1149+
}
1150+
9781151
func appendToFile(t *testing.T, path, content string) {
9791152
t.Helper()
9801153
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
plugins {
2+
id 'com.android.application'
3+
}
4+
5+
android {
6+
namespace 'com.example.cachetest'
7+
compileSdk 36
8+
9+
defaultConfig {
10+
applicationId 'com.example.cachetest'
11+
minSdk 21
12+
targetSdk 36
13+
}
14+
}
15+
16+
dependencies {
17+
implementation 'androidx.core:core:1.16.0'
18+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example;
2+
3+
public class App {
4+
public String greet() {
5+
return "Hello";
6+
}
7+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<string name="app_name">CacheTest</string>
4+
</resources>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
plugins {
2+
id 'com.android.application' version '8.9.3' apply false
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
android.useAndroidX=true
2+
org.gradle.caching=true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
6+
zipStoreBase=GRADLE_USER_HOME
7+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)