Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions dev-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ This creates a config file at `~/.config/dev-cache/config.yaml`.
| `--scan PATH` | Directory to scan (overrides config default) |
| `--depth N` | Max scan depth (overrides config default, 0 = use config) |
| `--languages LIST` | Comma-separated list of languages to scan (e.g., `node,python,go`) |
| `--exclude LIST` | Comma-separated paths/patterns to exclude |
| `--include-only LIST` | Comma-separated paths/patterns to include only |
| `--min-age DURATION` | Only include caches older than duration (e.g., `30d`, `48h`) |
| `--max-age DURATION` | Only include caches newer than duration (e.g., `7d`, `24h`) |
| `--clean` | Delete found cache directories |
| `--yes` | Skip confirmation prompt for cleanup |
| `--json` | Output results as JSON |
Expand All @@ -92,6 +96,10 @@ version: 1
options:
defaultScanPath: ~/src
maxDepth: 1 # How many levels deep to scan
excludePaths: []
includeOnly: []
minAge: ""
maxAge: ""

languages:
- name: node
Expand Down
213 changes: 197 additions & 16 deletions dev-cache/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ var (
flagScan = flag.String("scan", "", "Directory to scan (overrides config default)")
flagDepth = flag.Int("depth", 0, "Max scan depth (0 = use config default, overrides config)")
flagLangs = flag.String("languages", "", "Comma-separated list of languages to scan")
flagExclude = flag.String("exclude", "", "Comma-separated path patterns to exclude from scan")
flagInclude = flag.String("include-only", "", "Comma-separated path patterns to include only")
flagMinAge = flag.String("min-age", "", "Only include caches older than this (e.g. 30d, 48h)")
flagMaxAge = flag.String("max-age", "", "Only include caches newer than this (e.g. 7d, 24h)")
)

// ----- Config types -----
Expand All @@ -49,6 +53,10 @@ type Options struct {
DefaultScanPath string `yaml:"defaultScanPath"`
MaxDepth int `yaml:"maxDepth"`
DetectLanguage bool `yaml:"detectLanguage"` // If true, detect language per directory and search only relevant patterns
ExcludePaths []string `yaml:"excludePaths"`
IncludeOnly []string `yaml:"includeOnly"`
MinAge string `yaml:"minAge"`
MaxAge string `yaml:"maxAge"`
}

type Language struct {
Expand Down Expand Up @@ -85,6 +93,14 @@ type Report struct {
Warnings []string `json:"warnings"`
}

type ScanFilters struct {
ExcludePaths []string
IncludeOnly []string
MinAge time.Duration
MaxAge time.Duration
Now time.Time
}

// ----- Utilities -----

func defaultConfigPath() string {
Expand All @@ -100,6 +116,104 @@ func ensureDir(p string) error { return os.MkdirAll(filepath.Dir(p), 0o755) }
func home() string { h, _ := os.UserHomeDir(); return h }
func expand(p string) string { return os.ExpandEnv(strings.ReplaceAll(p, "~", home())) }

func splitCSV(v string) []string {
if strings.TrimSpace(v) == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
out = append(out, s)
}
}
return out
}

func expandAndCleanPatterns(patterns []string) []string {
out := make([]string, 0, len(patterns))
for _, p := range patterns {
if strings.TrimSpace(p) == "" {
continue
}
out = append(out, filepath.Clean(expand(strings.TrimSpace(p))))
}
return out
}

func matchesPathPattern(path, pattern string) bool {
path = filepath.Clean(path)
pattern = filepath.Clean(pattern)

if strings.ContainsAny(pattern, "*?[") {
ok, err := filepath.Match(pattern, path)
return err == nil && ok
}

if path == pattern {
return true
}
return strings.HasPrefix(path, pattern+string(filepath.Separator))
}

func matchesAnyPattern(path string, patterns []string) bool {
for _, p := range patterns {
if matchesPathPattern(path, p) {
return true
}
}
return false
}

func parseAgeDuration(s string) (time.Duration, error) {
if strings.TrimSpace(s) == "" {
return 0, nil
}
s = strings.TrimSpace(strings.ToLower(s))
unit := s[len(s)-1]
num := strings.TrimSpace(s[:len(s)-1])
if num == "" {
return 0, fmt.Errorf("invalid duration: %q", s)
}
var mult time.Duration
switch unit {
case 'm':
mult = time.Minute
case 'h':
mult = time.Hour
case 'd':
mult = 24 * time.Hour
case 'w':
mult = 7 * 24 * time.Hour
default:
return 0, fmt.Errorf("unsupported duration unit in %q (use m/h/d/w)", s)
}
var n int64
if _, err := fmt.Sscanf(num, "%d", &n); err != nil || n < 0 {
return 0, fmt.Errorf("invalid duration value: %q", s)
}
return time.Duration(n) * mult, nil
}

func passesAgeFilter(f Finding, filters ScanFilters) bool {
if !isCacheDirectory(f) || f.ModMax.IsZero() {
return true
}
Comment on lines +200 to +202
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Age filter currently fails open for cache findings with ModMax.IsZero() (returns true). Since inspectPath only updates ModMax based on file mtimes, cache dirs that contain only subdirectories (or unreadable files) may bypass --min-age/--max-age and still be reported/cleaned. Consider initializing ModMax from the directory's own modtime and/or including directory mtimes in inspectPath, or make ModMax.IsZero() fail the age filter when MinAge/MaxAge are set.

Suggested change
if !isCacheDirectory(f) || f.ModMax.IsZero() {
return true
}
// Only apply age filters to cache directories.
if !isCacheDirectory(f) {
return true
}
// If we don't have a recorded max mtime, decide based on whether
// age filters were requested. When MinAge/MaxAge are set, fail
// closed so that entries with unknown age do not bypass the filter.
if f.ModMax.IsZero() {
if filters.MinAge > 0 || filters.MaxAge > 0 {
return false
}
return true
}

Copilot uses AI. Check for mistakes.
now := filters.Now
if now.IsZero() {
now = time.Now()
}
age := now.Sub(f.ModMax)
if filters.MinAge > 0 && age < filters.MinAge {
return false
}
if filters.MaxAge > 0 && age > filters.MaxAge {
return false
}
return true
}

func checkVersionFlag() bool {
for _, arg := range os.Args[1:] {
if arg == "-version" || arg == "--version" {
Expand Down Expand Up @@ -201,6 +315,10 @@ func writeStarterConfig(path string, force bool) error {
DefaultScanPath: "~/src",
MaxDepth: 1,
DetectLanguage: true,
ExcludePaths: []string{},
IncludeOnly: []string{},
MinAge: "",
MaxAge: "",
},
Languages: []Language{
{Name: "node", Enabled: true, Priority: 10, Patterns: []string{"node_modules", ".npm", ".yarn", ".pnpm-store"}, Signatures: []string{"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"}},
Expand Down Expand Up @@ -278,6 +396,41 @@ func main() {
maxDepth = *flagDepth
}

minAgeStr := cfg.Options.MinAge
if *flagMinAge != "" {
minAgeStr = *flagMinAge
}
maxAgeStr := cfg.Options.MaxAge
if *flagMaxAge != "" {
maxAgeStr = *flagMaxAge
}
minAge, err := parseAgeDuration(minAgeStr)
if err != nil {
fmt.Println("invalid --min-age:", err)
os.Exit(1)
}
maxAge, err := parseAgeDuration(maxAgeStr)
if err != nil {
fmt.Println("invalid --max-age:", err)
os.Exit(1)
}

excludePatterns := cfg.Options.ExcludePaths
if *flagExclude != "" {
excludePatterns = splitCSV(*flagExclude)
}
includePatterns := cfg.Options.IncludeOnly
if *flagInclude != "" {
includePatterns = splitCSV(*flagInclude)
}
filters := ScanFilters{
ExcludePaths: expandAndCleanPatterns(excludePatterns),
IncludeOnly: expandAndCleanPatterns(includePatterns),
MinAge: minAge,
MaxAge: maxAge,
Now: time.Now(),
}

// Filter languages
selectedLangs := map[string]bool{}
if *flagLangs != "" {
Expand Down Expand Up @@ -343,7 +496,7 @@ func main() {
if cfg.Options.DetectLanguage {
fmt.Printf("Language detection enabled - scanning with language-specific patterns\n")
}
findings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns)
findings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters)
rep.Findings = findings

var total int64
Expand Down Expand Up @@ -424,7 +577,7 @@ func main() {

// Re-scan to verify
fmt.Println("Re-scanning after cleanup...")
afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns)
afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is inconsistent indentation on this line (extra leading tab), suggesting gofmt wasn’t run after the changes. Running gofmt on this file will normalize indentation and avoid noisy diffs / potential CI formatting checks.

Suggested change
afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters)
afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters)

Copilot uses AI. Check for mistakes.
afterTotal := totalCacheBytes(afterFindings)
freed := bytesFreed(beforeTotal, afterTotal)
fmt.Printf("\nDeleted %d directories", deletedCount)
Expand Down Expand Up @@ -542,7 +695,7 @@ func detectLanguage(dirPath string, langSignatures map[string][]string, langPrio
}

// scanDirectory walks through the directory tree up to maxDepth and matches directory names against patterns
func scanDirectory(root string, maxDepth int, patterns []string, patternToLang map[string]string, detectLang bool, langSignatures map[string][]string, langPriorities map[string]int, langToPatterns map[string][]string) []Finding {
func scanDirectory(root string, maxDepth int, patterns []string, patternToLang map[string]string, detectLang bool, langSignatures map[string][]string, langPriorities map[string]int, langToPatterns map[string][]string, filters ScanFilters) []Finding {
var findings []Finding

// Directories that should not have language detection performed
Expand All @@ -568,6 +721,8 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m
findingsByPath := make(map[string]bool)
// Track excluded directories (will be reported with empty language)
excludedDirs := make(map[string]bool)
// Track whether depth-0 roots are included/excluded by path filters.
allowedDepth0 := make(map[string]bool)

if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
Expand Down Expand Up @@ -607,10 +762,33 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m
return filepath.SkipDir
}

// Determine which patterns to check for this directory
patternsToCheck := patterns
var detectedLang string
var projectRoot string
parts := strings.Split(relPath, string(filepath.Separator))
projectRootPath := cleanPath
if len(parts) > 0 && parts[0] != "." && parts[0] != "" {
projectRootPath = filepath.Join(root, parts[0])
}

// Apply include/exclude path filters at project-root granularity.
if depth == 0 {
allowed := true
if len(filters.IncludeOnly) > 0 {
allowed = matchesAnyPattern(projectRootPath, filters.IncludeOnly)
}
if allowed && len(filters.ExcludePaths) > 0 && matchesAnyPattern(projectRootPath, filters.ExcludePaths) {
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The include/exclude precedence currently makes ExcludePaths win even when IncludeOnly is set (allowed=true from IncludeOnly, then possibly flipped to false by ExcludePaths). Issue #46's proposed behavior says includeOnly should override exclude (whitelist semantics). Either adjust the logic so IncludeOnly takes precedence over ExcludePaths, or document the intended precedence clearly in README/config docs.

Suggested change
if allowed && len(filters.ExcludePaths) > 0 && matchesAnyPattern(projectRootPath, filters.ExcludePaths) {
// When IncludeOnly is configured, it has whitelist semantics and
// should not be overridden by ExcludePaths. Only apply ExcludePaths
// when no IncludeOnly patterns are set.
if len(filters.IncludeOnly) == 0 && allowed && len(filters.ExcludePaths) > 0 && matchesAnyPattern(projectRootPath, filters.ExcludePaths) {

Copilot uses AI. Check for mistakes.
allowed = false
}
allowedDepth0[projectRootPath] = allowed
if !allowed {
return filepath.SkipDir
}
} else if ok, seen := allowedDepth0[projectRootPath]; seen && !ok {
return filepath.SkipDir
}

// Determine which patterns to check for this directory
patternsToCheck := patterns
var detectedLang string
var projectRoot string

// First, check if this directory matches a cache pattern
// Cache directories should not be treated as project roots
Expand Down Expand Up @@ -646,7 +824,7 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m
} else {
// We're deeper in the tree - walk up to find the project root
// Find the project root (first directory at depth 0 from root)
parts := strings.Split(relPath, string(filepath.Separator))
parts := strings.Split(relPath, string(filepath.Separator))
if len(parts) > 0 {
projectRootAbs, err := filepath.Abs(filepath.Join(root, parts[0]))
if err == nil {
Expand Down Expand Up @@ -783,14 +961,17 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m
} else {
f.Language = patternToLang[pattern]
}
// Set project root to where language was detected, or parent if no language detection
if detectLang && projectRoot != "" {
f.ProjectRoot = projectRoot
} else {
// If no language detection, use parent directory as project root
f.ProjectRoot = filepath.Dir(cleanPath)
}
findings = append(findings, f)
// Set project root to where language was detected, or parent if no language detection
if detectLang && projectRoot != "" {
f.ProjectRoot = projectRoot
} else {
// If no language detection, use parent directory as project root
f.ProjectRoot = filepath.Dir(cleanPath)
}
if !passesAgeFilter(f, filters) {
continue
}
findings = append(findings, f)
Comment on lines +964 to +974
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a directory matches a cache pattern but is excluded by passesAgeFilter, the code uses continue, which means WalkDir will still descend into that cache directory. This can cause a big performance hit (walking huge cache trees) and may produce unexpected nested cache findings under a filtered-out cache dir. Consider returning filepath.SkipDir for matched cache dirs even when they're filtered out by age (and avoid further pattern checks for that directory).

Suggested change
// Set project root to where language was detected, or parent if no language detection
if detectLang && projectRoot != "" {
f.ProjectRoot = projectRoot
} else {
// If no language detection, use parent directory as project root
f.ProjectRoot = filepath.Dir(cleanPath)
}
if !passesAgeFilter(f, filters) {
continue
}
findings = append(findings, f)
// Set project root to where language was detected, or parent if no language detection
if detectLang && projectRoot != "" {
f.ProjectRoot = projectRoot
} else {
// If no language detection, use parent directory as project root
f.ProjectRoot = filepath.Dir(cleanPath)
}
if !passesAgeFilter(f, filters) {
// Directory matches a cache pattern but is excluded by age;
// skip walking this directory's subtree entirely.
return filepath.SkipDir
}
findings = append(findings, f)

Copilot uses AI. Check for mistakes.
// Track this path in the map for O(1) lookups
findingsByPath[cleanPath] = true

Expand Down
Loading
Loading