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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The walk engine now reads each directory once and reuses those entries for both project-marker detection and recursion, instead of reading every directory twice (a `filepath.WalkDir` read plus a second `os.ReadDir`). Directory traversal — the dominant cost of scanning a large tree — is roughly 1.8× faster (~29% faster end-to-end on a workspace with tens of thousands of directories); scan results are unchanged.
- Git classification now forks `git` across a bounded worker pool (`min(NumCPU, 8)`) instead of one repo at a time — root resolution, `status`/`log`, and `check-ignore` all run concurrently across repos. On a workspace spanning dozens of repos this cut the classify phase roughly in half (~1.24s → ~0.54s warm cache, ~19% faster end-to-end); protection and activity results are unchanged.
- The status-badge, safety-icon, and relative-time display formatters, previously duplicated between the table output and the interactive tree selector, are now single shared functions in `internal/ui` (`StatusBadge`, `SafetyIcon`, `RelativeTime`). Table and selector rendering are unchanged; the formatters gained direct unit tests.
- Test coverage: the interactive tree selector's toggle, parent/child selection propagation, cursor movement, quick-select keys, and `Update` key routing are now tested (`internal/ui` 35% → 57%); the scan→classify integration workspace gained Python and Ruby project fixtures plus a Global-scanner (fixed-home) pipeline test; and `DirSize` now has a test that pins the sizing arithmetic (the golden test zeroes sizes out for portability).

### Fixed

Expand Down
74 changes: 67 additions & 7 deletions internal/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ func setupTestWorkspace(t *testing.T) string {
mustWriteFile(t, filepath.Join(rustProject, "target", "debug", "cli-tool"), make([]byte, 8192))
mustWriteFile(t, filepath.Join(rustProject, "Cargo.toml"), []byte("[package]"))

// Python project with __pycache__ and .pytest_cache
pyProject := filepath.Join(root, "py-svc")
mustMkdir(t, filepath.Join(pyProject, "__pycache__"))
mustWriteFile(t, filepath.Join(pyProject, "__pycache__", "mod.cpython-312.pyc"), make([]byte, 1024))
mustMkdir(t, filepath.Join(pyProject, ".pytest_cache"))
mustWriteFile(t, filepath.Join(pyProject, ".pytest_cache", "lastfailed"), make([]byte, 512))
mustWriteFile(t, filepath.Join(pyProject, "pyproject.toml"), []byte("[project]"))

// Ruby project with vendor/bundle and .bundle
rbProject := filepath.Join(root, "rb-app")
mustMkdir(t, filepath.Join(rbProject, "vendor", "bundle", "ruby"))
mustWriteFile(t, filepath.Join(rbProject, "vendor", "bundle", "ruby", "gem.rb"), make([]byte, 2048))
mustMkdir(t, filepath.Join(rbProject, ".bundle"))
mustWriteFile(t, filepath.Join(rbProject, ".bundle", "config"), make([]byte, 256))
mustWriteFile(t, filepath.Join(rbProject, "Gemfile"), []byte("source 'https://rubygems.org'"))

return root
}

Expand Down Expand Up @@ -85,17 +101,16 @@ func TestIntegration_ScanClassifyPipeline(t *testing.T) {
for _, r := range results {
ecoFound[r.Ecosystem] = true
}
if !ecoFound[model.EcoNode] {
t.Error("expected Node.js ecosystem in results")
}
if !ecoFound[model.EcoRust] {
t.Error("expected Rust ecosystem in results")
for _, eco := range []model.Ecosystem{model.EcoNode, model.EcoRust, model.EcoPython, model.EcoRuby} {
if !ecoFound[eco] {
t.Errorf("expected %s ecosystem in results", eco)
}
}

// 4. Group by project
projects := model.GroupByProject(results)
if len(projects) < 2 {
t.Errorf("expected at least 2 projects (my-app, cli-tool), got %d", len(projects))
if len(projects) < 4 {
t.Errorf("expected at least 4 projects (my-app, cli-tool, py-svc, rb-app), got %d", len(projects))
}
}

Expand Down Expand Up @@ -155,6 +170,50 @@ func TestIntegration_FilterByEcosystem(t *testing.T) {
}
}

// TestIntegration_GlobalScannerPipeline drives the Global stat scanner with an
// injected HOME, then classifies — exercising the full scan→classify path for a
// fixed-home scanner (the walk fixtures above only cover the tree-walking
// scanners). The scanner is constructed directly with an isolated TmpRoot and a
// stubbed process check so it never touches real machine temp state.
func TestIntegration_GlobalScannerPipeline(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

npm := filepath.Join(home, ".npm")
mustMkdir(t, npm)
mustWriteFile(t, filepath.Join(npm, "cache.json"), make([]byte, 2048))

s := &scanner.GlobalScanner{
TmpRoot: t.TempDir(), // isolate from real browser code-sign clones
ProcessRunning: func(string) bool { return false },
}
results, err := s.Scan(context.Background(), home)
if err != nil {
t.Fatalf("scan error: %v", err)
}

var npmResult *model.ScanResult
for i := range results {
if results[i].Ecosystem != model.EcoGlobal {
t.Errorf("expected only global ecosystem, got %s for %s", results[i].Ecosystem, results[i].Path)
}
if results[i].Path == npm {
npmResult = &results[i]
}
}
if npmResult == nil {
t.Fatalf("expected ~/.npm to be detected under injected HOME")
}
if npmResult.Safety != model.SafetySafe {
t.Errorf("~/.npm: expected safety=safe, got %s", npmResult.Safety)
}

classifier.ClassifyResults(results, classifier.DefaultThresholds())
if npmResult.Activity == "" {
t.Error("classify should assign an activity status to the global cache")
}
}

func TestIntegration_FilterResults(t *testing.T) {
root := setupTestWorkspace(t)

Expand Down Expand Up @@ -201,6 +260,7 @@ func TestGolden_JSONOutput(t *testing.T) {
results[i].Path = filepath.Base(results[i].Path)
results[i].Size = 0 // du returns block-aligned sizes
results[i].LastMod = time.Time{} // zero out to make golden file stable
results[i].ProjectRoot = "" // holds the absolute temp path — machine-specific
}

var buf bytes.Buffer
Expand Down
38 changes: 38 additions & 0 deletions internal/scanner/sizing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,44 @@ func TestSizePendingEmpty(t *testing.T) {
}
}

// TestDirSizeReflectsContents checks DirSize actually measures bytes, not just
// returns a positive number: the reported size must cover the logical content
// (du rounds up to block boundaries, so it is a lower bound) and must grow when
// more data is added. The golden test zeroes Size out for portability, so this
// is where the sizing arithmetic itself is pinned.
func TestDirSizeReflectsContents(t *testing.T) {
dir := t.TempDir()

empty := DirSize(dir)

const fileSize = 100_000
for _, name := range []string{"a", "b", "c"} {
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, fileSize), 0o644); err != nil {
t.Fatal(err)
}
}
withThree := DirSize(dir)
if withThree < 3*fileSize {
t.Errorf("DirSize with 300KB of files = %d, want >= %d", withThree, 3*fileSize)
}
if withThree <= empty {
t.Errorf("DirSize did not grow after adding files: empty=%d, withThree=%d", empty, withThree)
}

// Adding a nested file must increase the measured size further.
sub := filepath.Join(dir, "nested")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "d"), make([]byte, fileSize), 0o644); err != nil {
t.Fatal(err)
}
withFour := DirSize(dir)
if withFour <= withThree {
t.Errorf("DirSize did not grow after adding a nested file: withThree=%d, withFour=%d", withThree, withFour)
}
}

// TestDirSizeMissingPath locks the deferred-sizing TOCTOU contract: an artifact
// deleted between walk discovery and sizing must yield 0, not a panic. The
// walk→size gap widened when sizing moved to a post-walk phase.
Expand Down
42 changes: 41 additions & 1 deletion internal/testdata/scan_output.golden.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"total_size": 0,
"total_count": 3,
"total_count": 7,
"results": [
{
"path": ".next",
Expand Down Expand Up @@ -31,6 +31,46 @@
"activity": "active",
"safety": "safe",
"protected": false
},
{
"path": ".bundle",
"ecosystem": "ruby",
"category": "cache",
"size": 0,
"last_modified": "0001-01-01T00:00:00Z",
"activity": "active",
"safety": "safe",
"protected": false
},
{
"path": "bundle",
"ecosystem": "ruby",
"category": "deps",
"size": 0,
"last_modified": "0001-01-01T00:00:00Z",
"activity": "active",
"safety": "safe",
"protected": false
},
{
"path": ".pytest_cache",
"ecosystem": "python",
"category": "cache",
"size": 0,
"last_modified": "0001-01-01T00:00:00Z",
"activity": "active",
"safety": "safe",
"protected": false
},
{
"path": "__pycache__",
"ecosystem": "python",
"category": "build",
"size": 0,
"last_modified": "0001-01-01T00:00:00Z",
"activity": "active",
"safety": "safe",
"protected": false
}
]
}
Loading