@@ -415,6 +415,20 @@ var CacheExclusions = []string{
415415 "user-id.txt" ,
416416}
417417
418+ // isHexHash returns true if s is a non-empty string of hex characters (0-9, a-f).
419+ func isHexHash (s string ) bool {
420+ if len (s ) == 0 {
421+ return false
422+ }
423+ for _ , c := range s {
424+ isHex := (c >= '0' && c <= '9' ) || (c >= 'a' && c <= 'f' )
425+ if ! isHex {
426+ return false
427+ }
428+ }
429+ return true
430+ }
431+
418432// DeltaExclusions are additional file/directory names excluded only from delta
419433// bundles. These files are already present in the base bundle and get rewritten
420434// every build (Gradle's embedded BTree DB flushes on close even for read-only
@@ -624,41 +638,37 @@ func WriteDeltaTar(w io.Writer, baseDir string, relPaths []string) error {
624638 return tw .Close ()
625639}
626640
627- // ImmutableWorkspaceParents maps directory names to the depth at which their
628- // immutable workspace hash directories live. Depth 1 means the hash dirs are
629- // direct children (e.g. transforms/<hash>/). Depth 2 means the hash dirs are
630- // one level deeper (e.g. kotlin-dsl/scripts/<hash>/).
641+ // AtomicCacheParents lists directory names whose descendants include
642+ // hash-keyed workspace directories that must be captured atomically.
643+ // The walker recurses through intermediate subdirectories until it finds
644+ // children whose names look like hex hashes, then treats those as atomic
645+ // units: if ANY file inside has mtime > marker, ALL files are included.
631646//
632- // Each workspace is an atomic unit: Gradle creates all files together via an
633- // atomic directory rename, and expects all files to be present when reading.
634- // However, mtime skew can occur across delta cycles: after restoring base +
635- // delta, the base provides output files (old mtime, before the marker) while
636- // the delta overwrites metadata files like metadata.bin and results.bin (new
637- // mtime, after the marker). A naive per-file mtime check would capture only
638- // the metadata files, producing a partial workspace in the next delta. When
639- // that partial delta is applied to a different base that lacks the workspace
640- // hash, Gradle crashes with "Could not read workspace metadata".
647+ // These directories contain hash-keyed subdirectories where Gradle expects
648+ // all files to be present together. Some are true immutable workspaces
649+ // (transforms, groovy-dsl, kotlin-dsl, dependencies-accessors) created via
650+ // atomic directory renames. Others are persistent caches with receipt-based
651+ // completion markers (jars-9). In both cases, a partial directory (e.g.
652+ // metadata.bin without output files, or a .receipt without the transformed
653+ // classes) causes Gradle to crash.
641654//
642- // The fix: when ANY file in a workspace is newer than the marker, include ALL
643- // files from that workspace in the delta.
644- //
645- // Workspace directory structure (relative to caches/<version>/):
646- // - transforms/<hash>/ depth 1 - artifact transforms
647- // - groovy-dsl/<hash>/ depth 1 - compiled Groovy build scripts
648- // - kotlin-dsl/scripts/<hash>/ depth 2 - compiled Kotlin build scripts
649- // - kotlin-dsl/accessors/<hash>/ depth 2 - generated type-safe accessors
650- // - dependencies-accessors/<hash>/ depth 1 - version catalog accessors
651- var ImmutableWorkspaceParents = map [string ]int {
652- "transforms" : 1 ,
653- "groovy-dsl" : 1 ,
654- "kotlin-dsl" : 2 ,
655- "dependencies-accessors" : 1 ,
655+ // Mtime skew across delta cycles can produce partial directories: after
656+ // restoring base + delta, the base provides output files (old mtime) while
657+ // the delta overwrites metadata/receipt files (new mtime). A per-file mtime
658+ // check captures only the newer files, producing a partial directory in the
659+ // next delta. When applied to a base that lacks the hash, Gradle crashes.
660+ var AtomicCacheParents = map [string ]bool {
661+ "transforms" : true ,
662+ "groovy-dsl" : true ,
663+ "kotlin-dsl" : true ,
664+ "dependencies-accessors" : true ,
665+ "jars-9" : true ,
656666}
657667
658668// CollectNewFiles walks realCaches in parallel and returns paths of regular files
659- // with mtime strictly after since. For directories listed in ImmutableWorkspaceParents ,
660- // if any file in a child workspace is newer than since, all files in that workspace
661- // are included to prevent partial restores.
669+ // with mtime strictly after since. For directories listed in AtomicCacheParents ,
670+ // if any file in a child hash directory is newer than since, all files in that
671+ // directory are included to prevent partial restores.
662672func CollectNewFiles (realCaches string , since time.Time , gradleHome string ) ([]string , error ) {
663673 workers := min (8 , runtime .GOMAXPROCS (0 ))
664674 sem := make (chan struct {}, workers )
@@ -691,12 +701,12 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
691701 }
692702
693703 // walkWorkspaceParent handles directories like transforms/ whose children
694- // are atomic workspace directories. depth indicates how many directory levels
695- // below dir the actual hash workspace directories live. When depth > 1
696- // (e.g. kotlin-dsl/scripts/<hash>/) , it recurses into intermediate directories
697- // before treating children as atomic workspaces .
698- var walkWorkspaceParent func (dir , rel string , depth int )
699- walkWorkspaceParent = func (dir , rel string , depth int ) {
704+ // are atomic workspace directories. It auto-detects whether children are
705+ // hash-keyed workspaces (hex names) or intermediate directories (like
706+ // kotlin-dsl/scripts/). For intermediates , it recurses; for hash dirs,
707+ // it checks if any file is new and captures all files atomically .
708+ var walkWorkspaceParent func (dir , rel string )
709+ walkWorkspaceParent = func (dir , rel string ) {
700710 defer wg .Done ()
701711
702712 entries , err := os .ReadDir (dir )
@@ -718,11 +728,11 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
718728 childDir := filepath .Join (dir , entry .Name ())
719729 childRel := rel + "/" + entry .Name ()
720730
721- if depth > 1 {
722- // Not yet at the hash workspace level — recurse one level deeper .
731+ if ! isHexHash ( entry . Name ()) {
732+ // Intermediate directory (e.g. kotlin-dsl/scripts/) — recurse.
723733 sem <- struct {}{}
724734 wg .Add (1 )
725- go walkWorkspaceParent (childDir , childRel , depth - 1 )
735+ go walkWorkspaceParent (childDir , childRel )
726736 continue
727737 }
728738
@@ -740,7 +750,21 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
740750
741751 if hasNew {
742752 files := collectAll (childDir , childRel )
743- if len (files ) > 0 {
753+ // Skip workspace stubs that contain only metadata files
754+ // (metadata.bin, results.bin, *.receipt) with no actual
755+ // build output. These stubs provide no caching value and
756+ // can create broken workspace dirs when applied to a base
757+ // that lacks the workspace hash.
758+ hasOutput := false
759+ for _ , f := range files {
760+ base := filepath .Base (f )
761+ if base != "metadata.bin" && base != "results.bin" &&
762+ filepath .Ext (base ) != ".receipt" {
763+ hasOutput = true
764+ break
765+ }
766+ }
767+ if hasOutput {
744768 mu .Lock ()
745769 allFiles = append (allFiles , files ... )
746770 mu .Unlock ()
@@ -776,10 +800,10 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
776800 if IsExcludedCache (name ) || IsDeltaExcluded (name ) {
777801 continue
778802 }
779- if depth , ok := ImmutableWorkspaceParents [name ]; ok {
803+ if AtomicCacheParents [name ] {
780804 sem <- struct {}{}
781805 wg .Add (1 )
782- go walkWorkspaceParent (filepath .Join (dir , name ), childRel , depth )
806+ go walkWorkspaceParent (filepath .Join (dir , name ), childRel )
783807 } else {
784808 sem <- struct {}{}
785809 wg .Add (1 )
0 commit comments