//go:build mage package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" ) // BuildCache manages incremental build artifacts and dependency tracking type BuildCache struct { cacheDir string metadataDir string } // BuildMetadata tracks build dependencies and outputs type BuildMetadata struct { Target string `json:"target"` Sources []SourceFile `json:"sources"` Dependencies []string `json:"dependencies"` Outputs []string `json:"outputs"` Environment map[string]string `json:"environment"` Timestamp time.Time `json:"timestamp"` Checksum string `json:"checksum"` } // SourceFile represents a source file with its modification time and hash type SourceFile struct { Path string `json:"path"` ModTime time.Time `json:"mod_time"` Size int64 `json:"size"` Checksum string `json:"checksum"` } const ( buildCacheDir = ".build-cache" metadataExt = ".meta.json" ) // NewBuildCache creates a new build cache instance func NewBuildCache() *BuildCache { cacheDir := filepath.Join(buildCacheDir, "artifacts") metadataDir := filepath.Join(buildCacheDir, "metadata") // Ensure cache directories exist os.MkdirAll(cacheDir, 0755) os.MkdirAll(metadataDir, 0755) return &BuildCache{ cacheDir: cacheDir, metadataDir: metadataDir, } } // NeedsBuild checks if a target needs to be rebuilt based on source changes func (bc *BuildCache) NeedsBuild(target string, sources []string, dependencies []string, outputs []string) (bool, error) { if mg.Verbose() { fmt.Printf("Cache: Checking if %s needs build\n", target) } // Check if any output files are missing for _, output := range outputs { if _, err := os.Stat(output); os.IsNotExist(err) { if mg.Verbose() { fmt.Printf("Cache: Output %s missing, needs build\n", output) } return true, nil } } // Load existing metadata metadata, err := bc.loadMetadata(target) if err != nil { if mg.Verbose() { fmt.Printf("Cache: No metadata for %s, needs build\n", target) } return true, nil // No cached data, needs build } // Check if dependencies have changed if !stringSlicesEqual(metadata.Dependencies, dependencies) { if mg.Verbose() { fmt.Printf("Cache: Dependencies changed for %s, needs build\n", target) } return true, nil } // Check if any source files have changed currentSources, err := bc.analyzeSourceFiles(sources) if err != nil { return true, err } if bc.sourcesChanged(metadata.Sources, currentSources) { if mg.Verbose() { fmt.Printf("Cache: Sources changed for %s, needs build\n", target) } return true, nil } // Check if environment has changed for critical variables criticalEnvVars := []string{"CGO_ENABLED", "GOOS", "GOARCH", "LDFLAGS"} for _, envVar := range criticalEnvVars { currentValue := os.Getenv(envVar) cachedValue, exists := metadata.Environment[envVar] if !exists || cachedValue != currentValue { if mg.Verbose() { fmt.Printf("Cache: Environment variable %s changed for %s, needs build\n", envVar, target) } return true, nil } } if mg.Verbose() { fmt.Printf("Cache: %s is up to date\n", target) } return false, nil } // RecordBuild records successful build metadata func (bc *BuildCache) RecordBuild(target string, sources []string, dependencies []string, outputs []string) error { if mg.Verbose() { fmt.Printf("Cache: Recording build metadata for %s\n", target) } currentSources, err := bc.analyzeSourceFiles(sources) if err != nil { return err } // Create environment snapshot environment := make(map[string]string) criticalEnvVars := []string{"CGO_ENABLED", "GOOS", "GOARCH", "LDFLAGS"} for _, envVar := range criticalEnvVars { environment[envVar] = os.Getenv(envVar) } // Calculate overall checksum checksum := bc.calculateBuildChecksum(currentSources, dependencies, environment) metadata := BuildMetadata{ Target: target, Sources: currentSources, Dependencies: dependencies, Outputs: outputs, Environment: environment, Timestamp: time.Now(), Checksum: checksum, } return bc.saveMetadata(target, &metadata) } // CopyToCache copies build artifacts to cache func (bc *BuildCache) CopyToCache(target string, files []string) error { if mg.Verbose() { fmt.Printf("Cache: Copying artifacts for %s to cache\n", target) } targetDir := filepath.Join(bc.cacheDir, target) if err := os.MkdirAll(targetDir, 0755); err != nil { return err } for _, file := range files { if _, err := os.Stat(file); os.IsNotExist(err) { continue // Skip missing files } dest := filepath.Join(targetDir, filepath.Base(file)) if err := copyFile(file, dest); err != nil { return fmt.Errorf("failed to copy %s to cache: %w", file, err) } } return nil } // RestoreFromCache restores build artifacts from cache func (bc *BuildCache) RestoreFromCache(target string, files []string) error { if mg.Verbose() { fmt.Printf("Cache: Restoring artifacts for %s from cache\n", target) } targetDir := filepath.Join(bc.cacheDir, target) for _, file := range files { source := filepath.Join(targetDir, filepath.Base(file)) if _, err := os.Stat(source); os.IsNotExist(err) { continue // Skip missing cached files } if err := copyFile(source, file); err != nil { return fmt.Errorf("failed to restore %s from cache: %w", file, err) } } return nil } // CleanCache removes all cached artifacts and metadata func (bc *BuildCache) CleanCache() error { fmt.Println("Cache: Cleaning build cache") return sh.Rm(buildCacheDir) } // CacheStats returns statistics about the build cache func (bc *BuildCache) CacheStats() (map[string]interface{}, error) { stats := make(map[string]interface{}) // Count cached targets metadataFiles, err := filepath.Glob(filepath.Join(bc.metadataDir, "*"+metadataExt)) if err != nil { return nil, err } stats["targets_cached"] = len(metadataFiles) // Calculate cache size var totalSize int64 err = filepath.Walk(buildCacheDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { totalSize += info.Size() } return nil }) if err != nil { return nil, err } stats["cache_size_bytes"] = totalSize stats["cache_size_mb"] = totalSize / (1024 * 1024) return stats, nil } // analyzeSourceFiles calculates checksums and metadata for source files func (bc *BuildCache) analyzeSourceFiles(sources []string) ([]SourceFile, error) { var result []SourceFile for _, source := range sources { // Handle glob patterns matches, err := filepath.Glob(source) if err != nil { return nil, err } if len(matches) == 0 { // Not a glob, treat as literal path matches = []string{source} } for _, match := range matches { info, err := os.Stat(match) if os.IsNotExist(err) { continue // Skip missing files } else if err != nil { return nil, err } if info.IsDir() { // For directories, walk and include all relevant files err = filepath.Walk(match, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } // Only include source code files if bc.isSourceFile(path) { checksum, err := bc.calculateFileChecksum(path) if err != nil { return err } result = append(result, SourceFile{ Path: path, ModTime: info.ModTime(), Size: info.Size(), Checksum: checksum, }) } return nil }) if err != nil { return nil, err } } else { checksum, err := bc.calculateFileChecksum(match) if err != nil { return nil, err } result = append(result, SourceFile{ Path: match, ModTime: info.ModTime(), Size: info.Size(), Checksum: checksum, }) } } } return result, nil } // isSourceFile determines if a file is a source code file we should track func (bc *BuildCache) isSourceFile(path string) bool { ext := strings.ToLower(filepath.Ext(path)) return ext == ".go" || ext == ".js" || ext == ".ts" || ext == ".vue" || ext == ".py" || ext == ".yaml" || ext == ".yml" || filepath.Base(path) == "go.mod" || filepath.Base(path) == "go.sum" || filepath.Base(path) == "package.json" || filepath.Base(path) == "yarn.lock" } // calculateFileChecksum calculates SHA256 checksum of a file func (bc *BuildCache) calculateFileChecksum(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } // calculateBuildChecksum creates a composite checksum for the entire build func (bc *BuildCache) calculateBuildChecksum(sources []SourceFile, dependencies []string, environment map[string]string) string { hash := sha256.New() // Add source file checksums for _, source := range sources { hash.Write([]byte(source.Path + source.Checksum)) } // Add dependencies for _, dep := range dependencies { hash.Write([]byte(dep)) } // Add environment variables for key, value := range environment { hash.Write([]byte(key + "=" + value)) } return hex.EncodeToString(hash.Sum(nil)) } // sourcesChanged checks if source files have changed compared to cached data func (bc *BuildCache) sourcesChanged(cached []SourceFile, current []SourceFile) bool { if len(cached) != len(current) { return true } // Create lookup maps cachedMap := make(map[string]SourceFile) for _, file := range cached { cachedMap[file.Path] = file } for _, currentFile := range current { cachedFile, exists := cachedMap[currentFile.Path] if !exists { return true // New file } if cachedFile.Checksum != currentFile.Checksum { return true // File changed } } return false } // loadMetadata loads build metadata from disk func (bc *BuildCache) loadMetadata(target string) (*BuildMetadata, error) { metaPath := filepath.Join(bc.metadataDir, target+metadataExt) data, err := os.ReadFile(metaPath) if err != nil { return nil, err } var metadata BuildMetadata if err := json.Unmarshal(data, &metadata); err != nil { return nil, err } return &metadata, nil } // saveMetadata saves build metadata to disk func (bc *BuildCache) saveMetadata(target string, metadata *BuildMetadata) error { metaPath := filepath.Join(bc.metadataDir, target+metadataExt) data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } return os.WriteFile(metaPath, data, 0644) } // copyFile copies a file from src to dst func copyFile(src, dst string) error { source, err := os.Open(src) if err != nil { return err } defer source.Close() // Ensure destination directory exists if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } destination, err := os.Create(dst) if err != nil { return err } defer destination.Close() _, err = io.Copy(destination, source) return err } // stringSlicesEqual compares two string slices for equality func stringSlicesEqual(a, b []string) bool { if len(a) != len(b) { return false } for i, v := range a { if v != b[i] { return false } } return true }