Skip to content
Merged
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
19 changes: 18 additions & 1 deletion docs/ecosystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| `android` | Android | implemented |
| `global` | Global Caches | implemented |
| `llm` | LLM Model Stores | implemented |
| `docker` | Docker | planned |
| `docker` | Docker | implemented (scan only; prune deferred) |

**Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter, android) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go → flutter → android), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`).

Expand Down Expand Up @@ -184,6 +184,23 @@
- `Archives` is `caution` because losing an archive means losing the ability to symbolicate crash reports for that release.
- `CoreSimulator/Devices` is `caution` because it contains app installs, settings, and user data inside simulators currently in use.

## Docker

**Detection**: a fixed path — the default Docker Desktop VM disk image `~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw`. A stat-based scanner (like Xcode / Global Caches), not a project-tree walk. A non-default Docker data root is not tracked; only the default location is scanned.

**Scope rule**: reported only when the path is the same as, or a descendant of, the scan root (default `~`). Narrowing the root excludes it.

**Artifacts**:

| Path (relative to home) | Category | Safety | Description |
|-------------------------|----------|--------|-------------|
| `Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw` | runtime | protected | Docker Desktop VM disk image — holds every image, container and volume |

**Notes**:
- **Protected, never path-deleted.** The image is a single sparse file holding all Docker state; deleting it destroys every image, container and volume. It is reported as `protected` so the cleaner refuses to remove it (`Protected: true`).
- **Sparse-aware sizing.** `Size` is the real on-disk usage (allocated blocks, `st_blocks×512`); `ApparentSize` is the image's declared size. A 460G-declared image reads as its real ~8G on disk — the shared sparse-aware `Measure` handles this with no Docker-specific code.
- **Reclaiming space is deferred.** Space inside the image is reclaimed by Docker's own prune (`docker system prune`), which is destructive (removes images/containers/volumes/build cache). That vendor cleanup is gated behind a future `--include-destructive` flag and is **not** wired up yet — this scanner reports the image and its real footprint only.

## Global Caches

Shared, home-rooted developer caches that are not tied to any single project. Unlike the per-project scanners, paths are fixed (home-relative) and span package managers and dev tools across ecosystems. Paths owned by a dedicated scanner (Xcode's DerivedData, DeviceSupport, Archives, CoreSimulator) are intentionally excluded to avoid double-counting.
Expand Down
103 changes: 103 additions & 0 deletions internal/scanner/docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package scanner

import (
"context"
"os"
"path/filepath"

"github.com/ohing504/devclean/internal/model"
)

// dockerArtifact is one fixed, home-relative Docker path plus how to classify
// it. Docker keeps every image, container and volume inside a single sparse
// disk image, so the file itself is never a deletion target — reclaiming space
// means pruning from inside Docker, not removing the file.
type dockerArtifact struct {
relPath string
category model.Category
safety model.SafetyLevel
label string
reason string
rec string
}

// dockerArtifacts are the Docker Desktop disk images scanned on this machine.
// The path is the default Docker Desktop location; a non-default DOCKER data
// root is not tracked (only the default is scanned), mirroring how the Flutter
// scanner tracks only the default ~/.pub-cache.
var dockerArtifacts = []dockerArtifact{
{
relPath: "Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw",
category: model.CatRuntime,
safety: model.SafetyProtected,
label: "Docker Desktop VM disk image",
reason: "holds every Docker image, container and volume in one sparse file — deleting it destroys all Docker state",
rec: "reclaim from inside Docker (`docker system prune`), not by deleting this file; disk shows real usage, apparent shows the sparse image's declared size",
},
}

// DockerScanner reports Docker Desktop disk images. It is a stat-based scanner
// (fixed paths, no project tree walk) like xcode and global. The disk image is
// reported as protected: it must never be path-deleted, and its space is
// reclaimed by Docker's own prune commands (a future --include-destructive
// vendor cleanup), not by this tool removing the file.
type DockerScanner struct{}

// NewDockerScanner constructs a Docker scanner.
func NewDockerScanner() *DockerScanner { return &DockerScanner{} }

func (s *DockerScanner) Name() string { return "docker" }
func (s *DockerScanner) Ecosystem() model.Ecosystem { return model.EcoDocker }

// Scan reports each Docker disk image that exists and falls under the scan
// root. Unlike the global scanner these targets are files, not directories, so
// there is no IsDir gate — presence alone qualifies. Sizing is deferred to
// sizePending, which fills sparse-aware Size (allocated blocks) and ApparentSize
// (the image's declared size) so a 460G-declared image reads as its real ~8G.
func (s *DockerScanner) Scan(ctx context.Context, root string) ([]model.ScanResult, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil
}

absRoot, err := filepath.Abs(root)
if err != nil {
absRoot = root
}

var results []model.ScanResult
for _, a := range dockerArtifacts {
select {
case <-ctx.Done():
return results, ctx.Err()
default:
}

full := filepath.Join(home, a.relPath)
if !isUnderRoot(full, absRoot) {
continue
}
if _, err := os.Stat(full); err != nil {
continue // Docker not installed, or a non-default data root
}

results = append(results, model.ScanResult{
Path: full,
Ecosystem: model.EcoDocker,
Category: a.category,
LastMod: ModTime(full),
Safety: a.safety,
Protected: a.safety == model.SafetyProtected,
Reason: a.reason,
ProjectRoot: filepath.Dir(full),
Label: a.label,
Recommendation: a.rec,
})
ReportProgress(ctx, len(results))
}

if err := sizePending(ctx, results); err != nil {
return results, err
}
return results, nil
}
141 changes: 141 additions & 0 deletions internal/scanner/docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package scanner_test

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/ohing504/devclean/internal/model"
"github.com/ohing504/devclean/internal/scanner"
)

const dockerRawRel = "Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw"

func TestDockerScanner_ReportsRawImageAsProtected(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

raw := filepath.Join(home, dockerRawRel)
mustMkdir(t, filepath.Dir(raw))
mustWriteFile(t, raw, make([]byte, 4096))

results, err := scanner.NewDockerScanner().Scan(context.Background(), home)
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result (Docker.raw), got %d", len(results))
}

r := results[0]
if r.Ecosystem != model.EcoDocker {
t.Errorf("expected ecosystem=docker, got %s", r.Ecosystem)
}
if r.Category != model.CatRuntime {
t.Errorf("expected category=runtime, got %s", r.Category)
}
if r.Safety != model.SafetyProtected {
t.Errorf("expected safety=protected, got %s", r.Safety)
}
if !r.Protected {
t.Error("Docker.raw must be marked Protected so the cleaner refuses to delete it")
}
if r.Reason == "" {
t.Error("protected result should carry a Reason")
}
}

func TestDockerScanner_IgnoresMissingImage(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

results, err := scanner.NewDockerScanner().Scan(context.Background(), home)
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results without Docker installed, got %d", len(results))
}
}

// TestDockerScanner_ScopedRootExcludesImage pins the scope rule: a --path scan
// of a home subdirectory that does not cover the Docker image must not surface
// it (isUnderRoot), matching the global scanner's home-cache behavior.
func TestDockerScanner_ScopedRootExcludesImage(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

raw := filepath.Join(home, dockerRawRel)
mustMkdir(t, filepath.Dir(raw))
mustWriteFile(t, raw, make([]byte, 4096))

// Scan an unrelated subdirectory of home.
scoped := filepath.Join(home, "workspace")
mustMkdir(t, scoped)

results, err := scanner.NewDockerScanner().Scan(context.Background(), scoped)
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for a scoped root excluding the image, got %d", len(results))
}
}

// TestDockerScanner_SparseAwareSize pins that the scanner routes sizing through
// the sparse-aware measurer: a sparse image's on-disk Size stays below its
// declared ApparentSize. This is the whole reason Docker.raw reads as ~8G, not
// its 460G declared size.
func TestDockerScanner_SparseAwareSize(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

raw := filepath.Join(home, dockerRawRel)
mustMkdir(t, filepath.Dir(raw))

// Create a sparse file: a large declared size with almost no allocated
// blocks (truncate writes no data). If the host filesystem does not support
// sparse files, disk and apparent may match — assert non-strict so the test
// is portable, but require apparent to reflect the declared size.
const declared = 512 * 1024 * 1024 // 512 MiB
f, err := os.Create(raw)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := f.Truncate(declared); err != nil {
t.Fatalf("truncate: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close: %v", err)
}

results, err := scanner.NewDockerScanner().Scan(context.Background(), home)
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}

r := results[0]
if r.ApparentSize != declared {
t.Errorf("expected apparent size %d (declared), got %d", declared, r.ApparentSize)
}
if r.Size > r.ApparentSize {
t.Errorf("disk size %d must not exceed apparent size %d", r.Size, r.ApparentSize)
}
}

func TestDockerScanner_NameAndEcosystem(t *testing.T) {
for _, s := range scanner.DefaultRegistry().All() {
if s.Name() != "docker" {
continue
}
if s.Ecosystem() != model.EcoDocker {
t.Errorf("expected ecosystem=docker, got %s", s.Ecosystem())
}
return
}
t.Error(`expected a registered scanner named "docker"`)
}
1 change: 1 addition & 0 deletions internal/scanner/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func DefaultRegistry() *Registry {
reg.Register(newWalkScanner(flutterWalkEcosystem))
reg.Register(newWalkScanner(androidWalkEcosystem))
reg.Register(NewXcodeScanner())
reg.Register(NewDockerScanner())
reg.Register(NewGlobalScanner())
reg.Register(NewLLMScanner())
return reg
Expand Down