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
7 changes: 4 additions & 3 deletions desktop/scripts/sync-powersched.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
#
# The Schedule tab drives the powersched CLI, which is bundled at build time from
# desktop/vendor/powersched. That vendored copy must be kept in sync with the
# canonical source in the dotai monorepo (../../../scripts/powersched). Run this
# whenever the CLI changes upstream.
# canonical source in the dotai monorepo (../../../dotai/scripts/powersched),
# a sibling repo since dscan moved out of dotai. Run this whenever the CLI
# changes upstream.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
dest="${here}/../vendor/powersched"
src="${POWERSCHED_SRC:-${here}/../../../scripts/powersched}"
src="${POWERSCHED_SRC:-${here}/../../../dotai/scripts/powersched}"

if [[ ! -f "${src}/powersched" ]]; then
echo "error: powersched source not found at ${src}" >&2
Expand Down
8 changes: 5 additions & 3 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
func ScanAll(goos, home string, system bool, onItem func(rules.Item), cancel <-chan struct{}, excludes []string) []rules.Item {
var items []rules.Item
covered := map[string]bool{}
procs := procSnapshot()
for _, e := range rules.Catalog(goos, home) {
if canceled(cancel) {
break
Expand All @@ -34,17 +35,18 @@ func ScanAll(goos, home string, system bool, onItem func(rules.Item), cancel <-c
continue
}
path := e.Expand(home)
if scan.IsExcluded(path, excludes) {
if scan.IsExcluded(path, excludes) || isDescendant(path, covered) {
continue
}
size, _ := scan.DirSizeCancel(path, cancel)
if size == 0 {
continue
}
covered[path] = true
tier, label := guardedTier(e, procs)
it := rules.Item{
Path: path, Label: e.Label, Bytes: size,
Category: e.Category, Tier: e.Tier, Method: e.Method, Source: rules.CatalogSource,
Path: path, Label: label, Bytes: size,
Category: e.Category, Tier: tier, Method: e.Method, Source: rules.CatalogSource,
}
items = append(items, it)
if onItem != nil {
Expand Down
69 changes: 69 additions & 0 deletions internal/engine/procs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package engine

import (
"os/exec"
"path/filepath"
"strings"

"github.com/gor3a/disk-scan/internal/rules"
)

// procSnapshot is a seam: tests override it to inject a fake process set.
var procSnapshot = runningProcs

// runningProcs returns a set of lowercased process basenames from one `ps`
// snapshot. On any error it returns an empty map (fail-open: the guard is a
// convenience, never blocks a scan). `ps -Ao comm=` works on macOS and Linux.
func runningProcs() map[string]bool {
out, err := exec.Command("ps", "-Ao", "comm=").Output()
if err != nil {
return map[string]bool{}
}
return parseProcs(out)
}

// parseProcs turns `ps comm=` output into a set of lowercased basenames.
func parseProcs(out []byte) map[string]bool {
procs := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
procs[strings.ToLower(filepath.Base(line))] = true
}
return procs
}

// isGuarded reports whether any running process name contains proc (lowercased
// substring, so "docker" matches "com.docker.backend").
func isGuarded(proc string, procs map[string]bool) bool {
needle := strings.ToLower(proc)
for name := range procs {
if strings.Contains(name, needle) {
return true
}
}
return false
}

// guardedTier resolves an entry's effective tier and label given a process
// snapshot: a guarded entry whose process is running is downgraded to Keep with
// a "(<proc> running — skipped)" suffix. Unguarded/idle entries are unchanged.
func guardedTier(e rules.Entry, procs map[string]bool) (rules.Tier, string) {
if e.GuardProcess != "" && isGuarded(e.GuardProcess, procs) {
return rules.Keep, e.Label + " (" + e.GuardProcess + " running — skipped)"
}
return e.Tier, e.Label
}

// isDescendant reports whether path lives under an already-covered catalog path,
// so a specific child row is never double-counted beneath a broader row.
func isDescendant(path string, covered map[string]bool) bool {
for c := range covered {
if strings.HasPrefix(path, c+"/") {
return true
}
}
return false
}
85 changes: 85 additions & 0 deletions internal/engine/procs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package engine

import (
"path/filepath"
"strings"
"testing"

"github.com/gor3a/disk-scan/internal/rules"
)

func TestParseProcs(t *testing.T) {
out := []byte("/usr/bin/Xcode\ncom.docker.backend\n/System/Library/Frameworks/bar\n")
p := parseProcs(out)
if !p["xcode"] || !p["com.docker.backend"] || !p["bar"] {
t.Fatalf("parseProcs = %v", p)
}
}

func TestIsGuarded(t *testing.T) {
procs := map[string]bool{"com.docker.backend": true, "xcode": true}
if !isGuarded("docker", procs) {
t.Error("docker should match com.docker.backend")
}
if !isGuarded("Xcode", procs) {
t.Error("Xcode should match (case-insensitive)")
}
if isGuarded("node", procs) {
t.Error("node should not match")
}
}

func TestGuardedTier(t *testing.T) {
e := rules.Entry{Label: "Xcode DerivedData", Tier: rules.Safe, GuardProcess: "Xcode"}
tier, label := guardedTier(e, map[string]bool{"xcode": true})
if tier != rules.Keep || label != "Xcode DerivedData (Xcode running — skipped)" {
t.Errorf("running: tier=%v label=%q", tier, label)
}
tier, label = guardedTier(e, map[string]bool{})
if tier != rules.Safe || label != "Xcode DerivedData" {
t.Errorf("idle: tier=%v label=%q", tier, label)
}
e2 := rules.Entry{Label: "npm cache", Tier: rules.Safe}
tier, label = guardedTier(e2, map[string]bool{"xcode": true})
if tier != rules.Safe || label != "npm cache" {
t.Errorf("unguarded changed: tier=%v label=%q", tier, label)
}
}

func TestIsDescendant(t *testing.T) {
covered := map[string]bool{"/a/b": true}
if !isDescendant("/a/b/c", covered) {
t.Error("/a/b/c is under /a/b")
}
if isDescendant("/a/bc", covered) {
t.Error("/a/bc is NOT under /a/b")
}
}

func TestScanAllGuardDowngrades(t *testing.T) {
home := t.TempDir()
dd := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData", "x")
if err := writeTree(t, dd, 4096); err != nil {
t.Fatal(err)
}
old := procSnapshot
procSnapshot = func() map[string]bool { return map[string]bool{"xcode": true} }
defer func() { procSnapshot = old }()

items := ScanAll("darwin", home, false, nil, nil, nil)
var found bool
for _, it := range items {
if strings.Contains(it.Label, "Xcode DerivedData") {
found = true
if it.Tier != rules.Keep {
t.Errorf("guarded DerivedData tier=%v want Keep", it.Tier)
}
if !strings.Contains(it.Label, "Xcode running") {
t.Errorf("label missing suffix: %q", it.Label)
}
}
}
if !found {
t.Fatal("DerivedData item not found")
}
}
88 changes: 84 additions & 4 deletions internal/rules/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Entry struct {
Tier Tier
Method CleanMethod
Command []string
GuardProcess string // if non-empty and this process is running, entry is forced to Keep
}

// Expand resolves "~" to home.
Expand All @@ -30,6 +31,12 @@ func entry(tmpl, label string, cat Category, tier Tier) Entry {
return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: tier}
}

// guarded is entry() plus a GuardProcess: when that process is running the
// engine downgrades this entry to Keep (locked) for the duration.
func guarded(tmpl, label string, cat Category, tier Tier, proc string) Entry {
return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: tier, GuardProcess: proc}
}

func cmd(tmpl, label string, cat Category, c ...string) Entry {
return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: Safe, Method: Command, Command: c}
}
Expand All @@ -44,7 +51,6 @@ func sharedEntries() []Entry {
entry("~/.m2/repository", "Maven repo", PackageStores, Safe),
entry("~/.cargo/registry", "Cargo registry", PackageStores, Safe),
entry("~/.cache/go-build", "Go build cache", Caches, Safe),
entry("~/.cache", "Generic ~/.cache", Caches, Safe),
entry("~/.bun/install/cache", "Bun cache", PackageStores, Safe),
entry("~/.ssh", "SSH keys", AppData, Keep),
}
Expand All @@ -56,9 +62,8 @@ func Catalog(goos, home string) []Entry {
switch goos {
case "darwin":
out = append(out,
entry("~/Library/Caches", "~/Library/Caches", Caches, Safe),
entry("~/Library/Logs", "~/Library/Logs", Caches, Safe),
entry("~/Library/Developer/Xcode/DerivedData", "Xcode DerivedData", BuildArtifacts, Safe),
guarded("~/Library/Developer/Xcode/DerivedData", "Xcode DerivedData", BuildArtifacts, Safe, "Xcode"),
entry("~/Library/Developer/Xcode/iOS DeviceSupport", "iOS DeviceSupport", BuildArtifacts, Safe),
entry("~/Library/Developer/CoreSimulator/Caches", "Simulator caches", Simulators, Safe),
cmd("simctl:unavailable", "Delete unavailable simulators", Simulators, "xcrun", "simctl", "delete", "unavailable"),
Expand All @@ -68,16 +73,91 @@ func Catalog(goos, home string) []Entry {
)
case "linux":
out = append(out,
entry("~/.config/google-chrome/Default/Cache", "Chrome cache", Caches, Safe),
entry("~/.cache/google-chrome/Default/Cache", "Chrome cache", Caches, Review),
cmd("brew:cleanup", "Homebrew cleanup", PackageStores, "brew", "cleanup", "-s"),
entry("~/.local/share/keyrings", "Keyrings", AppData, Keep),
)
}
out = append(out, devCaches(goos)...)
out = append(out, appCaches(goos)...)
// home is accepted for symmetry/future use; entries are home-relative via Expand.
_ = home
return out
}

// devCaches are regenerable developer tool caches (SAFE, hard-delete). Path rows
// only — no permanent command purges. High-churn caches carry a GuardProcess.
// These are specific subdirs; the coarse ~/Library/Caches and ~/.cache sweeps
// were removed so nothing here double-counts under a broad parent row.
func devCaches(goos string) []Entry {
switch goos {
case "darwin":
return []Entry{
entry("~/Library/Developer/Xcode/watchOS DeviceSupport", "watchOS DeviceSupport", BuildArtifacts, Safe),
entry("~/Library/Developer/Xcode/tvOS DeviceSupport", "tvOS DeviceSupport", BuildArtifacts, Safe),
entry("~/Library/Developer/Xcode/Archives", "Xcode Archives", BuildArtifacts, Review),
entry("~/Library/Caches/go-build", "Go build cache", Caches, Safe),
entry("~/Library/Caches/Homebrew", "Homebrew cache", PackageStores, Safe),
entry("~/Library/Caches/Yarn", "Yarn cache", PackageStores, Safe),
entry("~/Library/Caches/CocoaPods", "CocoaPods cache", PackageStores, Safe),
entry("~/Library/Caches/org.swift.swiftpm", "Swift PM cache", PackageStores, Safe),
entry("~/Library/Caches/ms-playwright", "Playwright browsers", Caches, Safe),
entry("~/Library/Caches/pip", "pip cache", PackageStores, Safe),
entry("~/Library/Caches/uv", "uv cache", PackageStores, Safe),
entry("~/.node-gyp", "node-gyp headers", Caches, Safe),
entry("~/.cargo/git", "Cargo git cache", PackageStores, Safe),
}
case "linux":
return []Entry{
entry("~/.cache/ms-playwright", "Playwright browsers", Caches, Safe),
entry("~/.cache/pip", "pip cache", PackageStores, Safe),
entry("~/.cache/uv", "uv cache", PackageStores, Safe),
entry("~/.node-gyp", "node-gyp headers", Caches, Safe),
entry("~/.cargo/git", "Cargo git cache", PackageStores, Safe),
}
}
return nil
}

// appCaches are third-party app caches (REVIEW, Trash — recoverable). Browser
// entries target cache sub-dirs only, never a profile root or cookies/history.
func appCaches(goos string) []Entry {
switch goos {
case "darwin":
c := func(id, label string) Entry {
return entry("~/Library/Caches/"+id, label, Caches, Review)
}
chrome := "~/Library/Application Support/Google/Chrome/Default/"
return []Entry{
c("com.spotify.client", "Spotify cache"),
c("com.tinyspeck.slackmacgap", "Slack cache"),
c("com.microsoft.teams2", "Teams cache"),
c("us.zoom.xos", "Zoom cache"),
c("com.hnc.Discord", "Discord cache"),
c("com.figma.Desktop", "Figma cache"),
c("md.obsidian", "Obsidian cache"),
c("notion.id", "Notion cache"),
c("com.anthropic.claudefordesktop", "Claude cache"),
c("com.openai.chat", "ChatGPT cache"),
entry("~/Library/Caches/Google/Chrome", "Chrome disk cache", Caches, Review),
entry(chrome+"Code Cache", "Chrome code cache", Caches, Review),
entry(chrome+"GPUCache", "Chrome GPU cache", Caches, Review),
entry(chrome+"Service Worker/CacheStorage", "Chrome service-worker cache", Caches, Review),
}
case "linux":
c := func(name, label string) Entry {
return entry("~/.cache/"+name, label, Caches, Review)
}
return []Entry{
c("spotify", "Spotify cache"),
c("Slack", "Slack cache"),
c("discord", "Discord cache"),
c("obsidian", "Obsidian cache"),
}
}
return nil
}

// ClassifyHeuristic classifies a path discovered by the heuristic walk that the
// catalog did not already cover. Default: user data, treated as a large file.
func ClassifyHeuristic(path string) Item {
Expand Down
Loading
Loading