diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cada825..5fc1fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,9 +57,9 @@ jobs: cache: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: latest + version: v2.10.1 args: --timeout=5m build: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f7b2341..3d2a82b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -56,9 +56,9 @@ jobs: cache: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: latest + version: v2.10.1 args: --timeout=5m build: diff --git a/.golangci.yml b/.golangci.yml index 33e4d1d..81edfaf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,18 +1,13 @@ +version: "2" + run: timeout: 5m tests: true modules-download-mode: readonly linters: + default: standard enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - gofmt - - goimports - misspell - revive - gosec @@ -20,40 +15,35 @@ linters: - goconst - gocyclo - dupl - -linters-settings: - errcheck: - check-type-assertions: true - check-blank: true - - govet: - enable-all: true - disable: - - shadow - - gocyclo: - min-complexity: 15 - - dupl: - threshold: 100 - - goconst: - min-len: 3 - min-occurrences: 3 - - misspell: - locale: US - - revive: - confidence: 0.8 + settings: + errcheck: + check-type-assertions: true + check-blank: true + gocyclo: + min-complexity: 15 + dupl: + threshold: 100 + goconst: + min-len: 3 + min-occurrences: 3 + misspell: + locale: US + revive: + confidence: 0.8 + exclusions: + rules: + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec + +formatters: + enable: + - gofmt + - goimports issues: max-issues-per-linter: 0 max-same-issues: 0 - exclude-rules: - - path: _test\.go - linters: - - gocyclo - - errcheck - - dupl - - gosec diff --git a/go.mod b/go.mod index d8abdbe..eed85bd 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,15 @@ module pumu go 1.24.0 -require github.com/fatih/color v1.18.0 +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/fatih/color v1.18.0 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/go.sum b/go.sum index 28ebad9..40ea9c5 100644 --- a/go.sum +++ b/go.sum @@ -38,11 +38,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= diff --git a/internal/pkg/analyzer.go b/internal/pkg/analyzer.go index d330b16..9de4f61 100644 --- a/internal/pkg/analyzer.go +++ b/internal/pkg/analyzer.go @@ -1,3 +1,5 @@ +// Package pkg provides utilities for detecting, installing, checking, +// and analyzing dependencies across multiple package managers. package pkg import ( diff --git a/internal/pkg/checker.go b/internal/pkg/checker.go index 1afea62..3053e69 100644 --- a/internal/pkg/checker.go +++ b/internal/pkg/checker.go @@ -3,7 +3,6 @@ package pkg import ( "encoding/json" "fmt" - "os" "os/exec" "path/filepath" "strings" @@ -51,7 +50,7 @@ func checkNodeHealth(dir string, pm PackageManager, binary string) HealthResult result := HealthResult{Dir: dir, PM: pm, Healthy: true} targetPath := dir + "/node_modules" - if !fileExists(targetPath) && !dirExists(targetPath) { + if !FileExists(targetPath) && !DirExists(targetPath) { result.Healthy = false result.Issues = append(result.Issues, "node_modules not found") return result @@ -120,7 +119,7 @@ func checkCargoHealth(dir string) HealthResult { result := HealthResult{Dir: dir, PM: Cargo, Healthy: true} targetPath := dir + "/target" - if !dirExists(targetPath) { + if !DirExists(targetPath) { result.Healthy = false result.Issues = append(result.Issues, "target/ not found (never built)") return result @@ -182,7 +181,7 @@ func checkPipHealth(dir string) HealthResult { result := HealthResult{Dir: dir, PM: Pip, Healthy: true} venvPath := dir + "/.venv" - if !dirExists(venvPath) { + if !DirExists(venvPath) { result.Healthy = false result.Issues = append(result.Issues, ".venv not found") return result @@ -213,12 +212,3 @@ func checkPipHealth(dir string) HealthResult { return result } - -// dirExists checks if a directory exists at the given path. -func dirExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return info.IsDir() -} diff --git a/internal/pkg/detector.go b/internal/pkg/detector.go index cd3c557..fbf231e 100644 --- a/internal/pkg/detector.go +++ b/internal/pkg/detector.go @@ -1,3 +1,5 @@ +// Package pkg provides utilities for detecting, installing, checking, +// and analyzing dependencies across multiple package managers. package pkg import ( @@ -5,8 +7,10 @@ import ( "path/filepath" ) +// PackageManager represents the type of package manager detected in a project. type PackageManager string +// Supported package managers. const ( Npm PackageManager = "npm" Pnpm PackageManager = "pnpm" @@ -19,6 +23,7 @@ const ( Unknown PackageManager = "unknown" ) +// DetectManager identifies the package manager used in dir by checking for lock files. func DetectManager(dir string) PackageManager { managers := map[PackageManager][]string{ Bun: {"bun.lockb", "bun.lock"}, diff --git a/internal/pkg/detector_test.go b/internal/pkg/detector_test.go index 0d04160..a2b6146 100644 --- a/internal/pkg/detector_test.go +++ b/internal/pkg/detector_test.go @@ -11,7 +11,7 @@ func TestDetectManager(t *testing.T) { if err != nil { t.Fatalf("failed to create temp dir: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() tests := []struct { name string @@ -33,17 +33,19 @@ func TestDetectManager(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { caseDir := filepath.Join(tempDir, tt.name) - err := os.MkdirAll(caseDir, 0755) + err := os.MkdirAll(caseDir, 0o750) //nolint:gosec // test directory if err != nil { t.Fatalf("failed to create dir %s: %v", caseDir, err) } lockfilePath := filepath.Join(caseDir, tt.lockfileName) - file, err := os.Create(lockfilePath) + file, err := os.Create(lockfilePath) //nolint:gosec // controlled test path if err != nil { t.Fatalf("failed to create fake lock file %s: %v", lockfilePath, err) } - file.Close() + if err := file.Close(); err != nil { + t.Fatalf("failed to close file: %v", err) + } pm := DetectManager(caseDir) if pm != tt.expected { diff --git a/internal/pkg/util.go b/internal/pkg/util.go new file mode 100644 index 0000000..22de5ba --- /dev/null +++ b/internal/pkg/util.go @@ -0,0 +1,21 @@ +package pkg + +import "os" + +// FileExists reports whether a path exists and is a regular file (not a directory). +func FileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +// DirExists reports whether a path exists and is a directory. +func DirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/internal/scanner/repair.go b/internal/scanner/repair.go index db27762..3d8b04b 100644 --- a/internal/scanner/repair.go +++ b/internal/scanner/repair.go @@ -4,7 +4,7 @@ import ( "fmt" "os" "path/filepath" - "sync" + "strings" "pumu/internal/pkg" @@ -13,7 +13,7 @@ import ( // RepairDir scans for projects with broken dependencies and repairs them. func RepairDir(root string, verbose bool) error { - color.Cyan(" Scanning for projects with broken dependencies in '%s'...\n", root) + color.Cyan("šŸ”§ Scanning for projects with broken dependencies in '%s'...\n", root) projects, err := findProjects(root) if err != nil { @@ -25,7 +25,7 @@ func RepairDir(root string, verbose bool) error { return nil } - color.Yellow("šŸ‘½ Found %d projects. Checking health...\n", len(projects)) + color.Yellow("ā±ļø Found %d projects. Checking health...\n", len(projects)) var repaired, total int @@ -36,7 +36,7 @@ func RepairDir(root string, verbose bool) error { if result.Healthy { if verbose { fmt.Printf("\nšŸ“ %s (%s)\n", proj.Dir, proj.PM) - color.Green(" šŸ˜Ž Healthy, skipping.") + color.Green(" āœ… Healthy, skipping.") } continue } @@ -51,8 +51,8 @@ func RepairDir(root string, verbose bool) error { targetFolder := getTargetFolder(proj.PM) targetPath := filepath.Join(proj.Dir, targetFolder) - if dirExists(targetPath) { - fmt.Printf(" šŸ¤¢šŸ—‘ļø Removing %s...\n", targetFolder) + if pkg.DirExists(targetPath) { + fmt.Printf(" šŸ—‘ļø Removing %s...\n", targetFolder) _, err := pkg.RemoveDirectory(targetPath) if err != nil { color.Red(" āŒ Failed to remove %s: %v", targetFolder, err) @@ -61,20 +61,20 @@ func RepairDir(root string, verbose bool) error { } // Reinstall - fmt.Printf(" šŸƒšŸ¾ā€ā™‚ļøā€āž”ļø Reinstalling...\n") + fmt.Printf(" šŸ“¦ Reinstalling...\n") err := pkg.InstallDependencies(proj.Dir, proj.PM, true) if err != nil { - color.Red(" 😢 Failed to reinstall: %v", err) + color.Red(" āŒ Failed to reinstall: %v", err) continue } - color.Green(" šŸ˜Ž Repaired!") + color.Green(" āœ… Repaired!") repaired++ } fmt.Println() - fmt.Println("-----") - color.Green("šŸ‘½ Repair complete! Fixed %d/%d projects.", repaired, total) + fmt.Println(strings.Repeat("-", 40)) + color.Green("šŸ”§ Repair complete! Fixed %d/%d projects.", repaired, total) return nil } @@ -86,12 +86,9 @@ type project struct { } // findProjects recursively scans for directories containing lockfiles/manifests. +// WalkDir is sequential, so no mutex is needed. func findProjects(root string) ([]project, error) { var projects []project - var mu sync.Mutex - - // Track visited dirs to avoid duplicates - visited := make(map[string]bool) err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -103,15 +100,9 @@ func findProjects(root string) ([]project, error) { return filepath.SkipDir } - // Try to detect package manager pm := pkg.DetectManager(path) if pm != pkg.Unknown { - mu.Lock() - if !visited[path] { - visited[path] = true - projects = append(projects, project{Dir: path, PM: pm}) - } - mu.Unlock() + projects = append(projects, project{Dir: path, PM: pm}) } } @@ -120,12 +111,3 @@ func findProjects(root string) ([]project, error) { return projects, err } - -// dirExists checks if a directory exists at the given path. -func dirExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return info.IsDir() -} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index b724070..e87e6b8 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -1,4 +1,6 @@ -package scanner +// Package scanner provides core logic for scanning, processing, and cleaning +// heavy dependency folders across multiple package managers. +package scanner //nolint:revive // internal package, not stdlib conflict import ( "fmt" @@ -15,42 +17,28 @@ import ( "github.com/fatih/color" ) +// TargetFolder holds the path and calculated size of a detected heavy dependency folder. type TargetFolder struct { Path string Size int64 } -func isIgnoredPath(name string) bool { - ignored := []string{ - ".Trash", ".cache", ".npm", ".yarn", ".cargo", ".rustup", - "Library", "AppData", "Local", "Roaming", ".vscode", ".idea", - } - for _, ig := range ignored { - if name == ig { - return true - } - } - return false +// ignoredPaths contains directories that pumu should never descend into. +var ignoredPaths = map[string]bool{ + ".Trash": true, ".cache": true, ".npm": true, ".yarn": true, + ".cargo": true, ".rustup": true, "Library": true, "AppData": true, + "Local": true, "Roaming": true, ".vscode": true, ".idea": true, } -func isDeletableTarget(name string) bool { - targets := []string{ - "node_modules", - "target", - ".next", - ".svelte-kit", - ".venv", - "dist", - "build", - } - for _, t := range targets { - if name == t { - return true - } - } - return false +// deletableTargets contains known heavy dependency/build folders. +var deletableTargets = map[string]bool{ + "node_modules": true, "target": true, ".next": true, + ".svelte-kit": true, ".venv": true, "dist": true, "build": true, } +func isIgnoredPath(name string) bool { return ignoredPaths[name] } +func isDeletableTarget(name string) bool { return deletableTargets[name] } + func getTargetFolder(pm pkg.PackageManager) string { switch pm { case pkg.Bun, pkg.Pnpm, pkg.Yarn, pkg.Npm: @@ -63,6 +51,8 @@ func getTargetFolder(pm pkg.PackageManager) string { return "node_modules" } +// RefreshCurrentDir detects the package manager in the current directory, +// removes the dependency folder, and reinstalls dependencies. func RefreshCurrentDir() error { dir := "." pm := pkg.DetectManager(dir) @@ -75,7 +65,7 @@ func RefreshCurrentDir() error { targetFolder := getTargetFolder(pm) targetPath := filepath.Join(dir, targetFolder) - if fileExists(targetPath) { + if pkg.FileExists(targetPath) { fmt.Printf("šŸ—‘ļø Removing %s...\n", targetFolder) duration, err := pkg.RemoveDirectory(targetPath) if err != nil { @@ -95,6 +85,9 @@ func RefreshCurrentDir() error { return nil } +// SweepDir scans root for heavy dependency folders and deletes them. +// Pass dryRun=true for list-only mode, reinstall=true to reinstall after deletion, +// and noSelect=true to skip interactive selection. func SweepDir(root string, dryRun bool, reinstall bool, noSelect bool) error { printScanMessage(dryRun, root) @@ -217,29 +210,26 @@ func processFolders(folders []TargetFolder, dryRun bool) (int64, int64) { fmt.Printf("%-80s | %s\n", "Folder Path", "Size") color.Unset() - sem := make(chan struct{}, 20) - for _, folder := range folders { printFolderInfo(folder) totalFreed += folder.Size + } - if !dryRun { + if !dryRun { + color.Yellow("\nšŸ—‘ļø Deleting folders concurrently...") + sem := make(chan struct{}, 20) + for _, folder := range folders { deletedWg.Add(1) go func(p string, s int64) { defer deletedWg.Done() sem <- struct{}{} defer func() { <-sem }() - _, err := pkg.RemoveDirectory(p) if err == nil { atomic.AddInt64(&totalDeleted, s) } }(folder.Path, folder.Size) } - } - - if !dryRun { - color.Yellow("\nšŸ—‘ļø Deleting folders concurrently...") deletedWg.Wait() } @@ -400,14 +390,6 @@ func dirSize(path string) (int64, error) { return size, err } -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - // formatSize converts a byte count into a human-readable string (KB, MB, GB, etc.) func formatSize(bytes int64) string { const unit = 1024 diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 048984e..2b600d7 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -1,4 +1,4 @@ -package scanner +package scanner //nolint:revive // internal tests need access to unexported functions import ( "testing" diff --git a/internal/ui/multiselect.go b/internal/ui/multiselect.go index e167bcf..778ffd2 100644 --- a/internal/ui/multiselect.go +++ b/internal/ui/multiselect.go @@ -1,3 +1,4 @@ +// Package ui provides interactive TUI components for pumu using Bubble Tea. package ui import ( @@ -190,7 +191,7 @@ func (m model) View() string { detail = " " + detailStyle.Render(item.Detail) } - b.WriteString(fmt.Sprintf("%s%s %s%s\n", cursor, checkbox, label, detail)) + fmt.Fprintf(&b, "%s%s %s%s\n", cursor, checkbox, label, detail) } // Status bar @@ -222,10 +223,10 @@ func (m model) View() string { {"q/esc", "cancel"}, } for _, h := range helpItems { - b.WriteString(fmt.Sprintf(" %s %s\n", + fmt.Fprintf(&b, " %s %s\n", helpKeyStyle.Render(fmt.Sprintf("%-8s", h.key)), helpDescStyle.Render(h.desc), - )) + ) } } else { b.WriteString(helpStyle.Render(" press ? for help")) diff --git a/main.go b/main.go index f2c2741..49a6fd0 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the pumu CLI. package main import ( @@ -8,7 +9,7 @@ import ( "pumu/internal/scanner" ) -const version = "v1.1.0-beta.0" +const version = "v1.2.0-beta.0" func main() { if len(os.Args) < 2 {