From a994e9b406473a347ad3dd7f084a4ee7902bf5ee Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 09:58:43 -0500 Subject: [PATCH 01/14] fix: remove Time Machine mount check --- lib/clean/system.sh | 25 +++---------------------- tests/clean_system_maintenance.bats | 3 --- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index bb95e9c..27d6ab0 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -267,40 +267,21 @@ tm_is_running() { grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<< "$st" } -# Returns 0 if snapshot mounts exist under local snapshot paths -# Returns 1 if none found -# Returns 2 if mount state cannot be determined -tm_snapshots_mounted() { - local m - if ! m="$(run_with_timeout 3 mount 2> /dev/null)"; then - return 2 - fi - # Match modern and legacy local-snapshot browse mounts: - # - /Volumes/com.apple.TimeMachine.localsnapshots/... (APFS) - # - /.TimeMachine (APFS) - # - /Volumes/MobileBackups (HFS+, legacy) - grep -qE '[[:space:]]on[[:space:]](/\.TimeMachine(/|[[:space:]])|/Volumes/com\.apple\.TimeMachine\.localsnapshots(/|[[:space:]])|/Volumes/MobileBackups(/|[[:space:]]))' <<< "$m" -} - # Local APFS snapshots (keep the most recent). clean_local_snapshots() { if ! command -v tmutil > /dev/null 2>&1; then return 0 fi - local rc_running rc_mounted - rc_running=0 + local rc_running=0 tm_is_running || rc_running=$? - rc_mounted=0 - tm_snapshots_mounted || rc_mounted=$? - - if [[ $rc_running -eq 2 || $rc_mounted -eq 2 ]]; then + if [[ $rc_running -eq 2 ]]; then echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot cleanup" return 0 fi - if [[ $rc_running -eq 0 || $rc_mounted -eq 0 ]]; then + if [[ $rc_running -eq 0 ]]; then echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot cleanup" return 0 fi diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index a594b0d..7be620b 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -126,7 +126,6 @@ tmutil() { start_section_spinner(){ :; } stop_section_spinner(){ :; } tm_is_running(){ return 1; } -tm_snapshots_mounted(){ return 1; } DRY_RUN="false" clean_local_snapshots @@ -157,7 +156,6 @@ start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -tm_snapshots_mounted(){ return 1; } DRY_RUN="true" clean_local_snapshots @@ -193,7 +191,6 @@ start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -tm_snapshots_mounted(){ return 1; } unset -f read_key From cf4690191ed923ac03156915ef4c19571d40cdda Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 10:39:27 -0500 Subject: [PATCH 02/14] Fix semaphore acquisition order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquire semaphore before spawning goroutine in calculateDirSizeFast to prevent goroutine explosion. Previously, all subdirectory goroutines were spawned immediately and blocked on the semaphore—now the spawning itself is throttled. --- cmd/analyze/scanner.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 0d7ddca..a417ee5 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -126,10 +126,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in // ~/Library is scanned separately; reuse cache when possible. if isHomeDir && child.Name() == "Library" { + sem <- struct{}{} wg.Add(1) go func(name, path string) { defer wg.Done() - sem <- struct{}{} defer func() { <-sem }() var size int64 @@ -156,10 +156,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in // Folded dirs: fast size without expanding. if shouldFoldDirWithPath(child.Name(), fullPath) { + sem <- struct{}{} wg.Add(1) go func(name, path string) { defer wg.Done() - sem <- struct{}{} defer func() { <-sem }() size, err := getDirectorySizeFromDu(path) @@ -180,10 +180,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in continue } + sem <- struct{}{} wg.Add(1) go func(name, path string) { defer wg.Done() - sem <- struct{}{} defer func() { <-sem }() size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) @@ -311,11 +311,11 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * for _, entry := range entries { if entry.IsDir() { - wg.Add(1) subDir := filepath.Join(dirPath, entry.Name()) + sem <- struct{}{} + wg.Add(1) go func(p string) { defer wg.Done() - sem <- struct{}{} defer func() { <-sem }() walk(p) }(subDir) @@ -446,9 +446,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil if child.IsDir() { if shouldFoldDirWithPath(child.Name(), fullPath) { + sem <- struct{}{} wg.Add(1) go func(path string) { defer wg.Done() + defer func() { <-sem }() size, err := getDirectorySizeFromDu(path) if err == nil && size > 0 { atomic.AddInt64(&total, size) @@ -459,10 +461,10 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil continue } + sem <- struct{}{} wg.Add(1) go func(path string) { defer wg.Done() - sem <- struct{}{} defer func() { <-sem }() size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) From b8ab9511fd0e45212996055bdb089b79585ce50e Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 10:56:18 -0500 Subject: [PATCH 03/14] Create separate limiter for du subprocesses Introduce duSem to cap concurrent du subprocess spawns while keeping the existing sem to limit overall scan fan-out (dir recursion/task concurrency). This prevents du from monopolizing scan concurrency and reduces resource spikes during folded-dir sizing. --- cmd/analyze/scanner.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index a417ee5..a1c3c30 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -50,6 +50,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in numWorkers = 1 } sem := make(chan struct{}, numWorkers) + duSem := make(chan struct{}, min(4, runtime.NumCPU())) var wg sync.WaitGroup // Collect results via channels. @@ -138,7 +139,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in } else if cached, err := loadCacheFromDisk(path); err == nil { size = cached.TotalSize } else { - size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -162,7 +163,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size, err := getDirectorySizeFromDu(path) + size, err := func() (int64, error) { + duSem <- struct{}{} + defer func() { <-duSem }() + return getDirectorySizeFromDu(path) + }() if err != nil || size <= 0 { size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) } @@ -186,7 +191,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -416,7 +421,7 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { children, err := os.ReadDir(root) if err != nil { return 0 @@ -451,7 +456,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil go func(path string) { defer wg.Done() defer func() { <-sem }() - size, err := getDirectorySizeFromDu(path) + size, err := func() (int64, error) { + duSem <- struct{}{} + defer func() { <-duSem }() + return getDirectorySizeFromDu(path) + }() if err == nil && size > 0 { atomic.AddInt64(&total, size) atomic.AddInt64(bytesScanned, size) @@ -467,7 +476,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) }(fullPath) From 8870141923439c372520aa602d307c9a312c6dab Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 10:26:57 -0500 Subject: [PATCH 04/14] Cap entryChan buffer to prevent memory spikes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap buffer at 4096. All entries still process normally—producers just briefly block if the buffer fills, which is negligible since the collector drains it quickly. --- cmd/analyze/scanner.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 0d7ddca..af77d60 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -53,7 +53,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in var wg sync.WaitGroup // Collect results via channels. - entryChan := make(chan dirEntry, len(children)) + // Cap buffer size to prevent memory spikes with huge directories. + entryBufSize := len(children) + if entryBufSize > 4096 { + entryBufSize = 4096 + } + if entryBufSize < 1 { + entryBufSize = 1 + } + entryChan := make(chan dirEntry, entryBufSize) largeFileChan := make(chan fileEntry, maxLargeFiles*2) var collectorWg sync.WaitGroup From 08c577d24ad14e92838f2461a070fae9772d612b Mon Sep 17 00:00:00 2001 From: frozturk <33831848+frozturk@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:38:56 +0100 Subject: [PATCH 05/14] fix: refresh not working in overview mode --- cmd/analyze/analyze_test.go | 16 +++++++++++++++- cmd/analyze/scanner.go | 4 ---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 6ae8d2e..d8ddb20 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -204,7 +204,7 @@ func TestMeasureOverviewSize(t *testing.T) { if err := os.MkdirAll(target, 0o755); err != nil { t.Fatalf("create target: %v", err) } - content := []byte(strings.Repeat("x", 2048)) + content := []byte(strings.Repeat("x", 4096)) if err := os.WriteFile(filepath.Join(target, "data.bin"), content, 0o644); err != nil { t.Fatalf("write file: %v", err) } @@ -225,6 +225,20 @@ func TestMeasureOverviewSize(t *testing.T) { if cached != size { t.Fatalf("snapshot mismatch: want %d, got %d", size, cached) } + + // Ensure measureOverviewSize does not use cache + // APFS block size is 4KB, 4097 bytes should use more blocks + content = []byte(strings.Repeat("x", 4097)) + if err := os.WriteFile(filepath.Join(target, "data2.bin"), content, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + size2, err := measureOverviewSize(target) + if err != nil { + t.Fatalf("measureOverviewSize: %v", err) + } + if size2 == size { + t.Fatalf("measureOverwiewSize used cache") + } } func TestIsCleanableDir(t *testing.T) { diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 0d7ddca..c74d202 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -519,10 +519,6 @@ func measureOverviewSize(path string) (int64, error) { excludePath = filepath.Join(home, "Library") } - if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { - return cached, nil - } - if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 { _ = storeOverviewSize(path, duSize) return duSize, nil From 832722d882a9bef9b6ebab3be5955d80fe95842a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 02:27:21 +0000 Subject: [PATCH 06/14] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 525 ++++++++++++++++++++++++----------------------- 1 file changed, 268 insertions(+), 257 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index a12b041..9a17f16 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -13,6 +13,17 @@ + + + + + + + + bhadraagada + + + @@ -23,7 +34,7 @@ JackPhallen - + @@ -34,7 +45,7 @@ amanthanvi - + @@ -45,7 +56,7 @@ alexandear - + @@ -56,260 +67,7 @@ rubnogueira - - - - - - - - - bsisduck - - - - - - - - - - jimmystridh - - - - - - - - - - - fte-jjmartres - - - - - - - - - - - Else00 - - - - - - - - - - - carolyn-sun - - - - - - - - - - - Schlauer-Hax - - - - - - - - - - - MohammedEsafi - - - - - - - - - - - ndbroadbent - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bunizao - - - - - - - - - - - huyixi - - - - - - - - - - - purofle - - - - - - - - - - - anonymort - - - - - - - - - - - khipu-luke - - - - - - - - - - - LmanTW - - - - - - - - - - - kwakubiney - - - - - - - - - - - kowyo - - - - - - - - - - - jalen0x - - - - - - - - - - - Hensell - - - - - - - - - - - ClathW - - - @@ -320,7 +78,260 @@ biplavbarua + + + + + + + + + bsisduck + + + + + + + + + + + jimmystridh + + + + + + + + + + + fte-jjmartres + + + + + + + + + + + Else00 + + + + + + + + + + + carolyn-sun + + + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + bunizao + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + khipu-luke + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + jalen0x + + + + + + + + + + + Hensell + + + + + + + + + + ClathW + + + From 8aee56b7fa23a83b0177c415ee4a82a55220c705 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 11:42:07 +0800 Subject: [PATCH 07/14] fix: enhance overview scan UI and invalidate cache on refresh - Invalidate disk cache for overview entries on refresh to ensure fresh data. - Show dynamic status messages (e.g., 'Scanning Applications...') instead of static text in Overview. - Adjust spinner animation speed to 100ms for smoother visual experience. --- cmd/analyze/main.go | 7 ++++++- cmd/analyze/view.go | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index d3d34d5..269b493 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -394,7 +394,7 @@ func (m model) scanCmd(path string) tea.Cmd { } func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { return tickMsg(t) }) } @@ -683,6 +683,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.largeMultiSelected = make(map[string]bool) if m.inOverviewMode() { + // Explicitly invalidate cache for all overview entries to force re-scan + for _, entry := range m.entries { + invalidateCache(entry.Path) + } + m.overviewSizeCache = make(map[string]int64) m.overviewScanningSet = make(map[string]bool) m.hydrateOverviewEntries() // Reset sizes to pending diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index f2845a9..43263e7 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -32,7 +32,7 @@ func (m model) View() string { return b.String() } else { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) + fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } } else { hasPending := false @@ -44,7 +44,7 @@ func (m model) View() string { } if hasPending { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) + fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } else { fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset) } From 1e2cf97b479801e2e794977c6c491f8cc5e62731 Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 23:28:44 -0500 Subject: [PATCH 08/14] fix(analyze): Additional improvements to limit sem blocking --- cmd/analyze/scanner.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 91d07fd..a10f398 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -50,7 +50,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in numWorkers = 1 } sem := make(chan struct{}, numWorkers) - duSem := make(chan struct{}, min(4, runtime.NumCPU())) + duSem := make(chan struct{}, min(4, runtime.NumCPU())) // limits concurrent du processes + duQueueSem := make(chan struct{}, min(4, runtime.NumCPU())*2) // limits how many goroutines may be waiting to run du var wg sync.WaitGroup // Collect results via channels. @@ -147,7 +148,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in } else if cached, err := loadCacheFromDisk(path); err == nil { size = cached.TotalSize } else { - size = calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -165,11 +166,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in // Folded dirs: fast size without expanding. if shouldFoldDirWithPath(child.Name(), fullPath) { - sem <- struct{}{} + duQueueSem <- struct{}{} wg.Add(1) go func(name, path string) { defer wg.Done() - defer func() { <-sem }() + defer func() { <-duQueueSem }() size, err := func() (int64, error) { duSem <- struct{}{} @@ -199,7 +200,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -429,7 +430,7 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { children, err := os.ReadDir(root) if err != nil { return 0 @@ -459,11 +460,12 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS if child.IsDir() { if shouldFoldDirWithPath(child.Name(), fullPath) { - sem <- struct{}{} + duQueueSem <- struct{}{} wg.Add(1) go func(path string) { defer wg.Done() - defer func() { <-sem }() + defer func() { <-duQueueSem }() + size, err := func() (int64, error) { duSem <- struct{}{} defer func() { <-duSem }() @@ -484,7 +486,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) }(fullPath) From 47ce1cb75bb30cb173b2842af3d0eb8df8bffc9c Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Sun, 11 Jan 2026 23:44:55 -0500 Subject: [PATCH 09/14] fix(analyze): Fix race condition in currentPath --- cmd/analyze/analyze_test.go | 10 ++++++---- cmd/analyze/main.go | 13 +++++++------ cmd/analyze/scanner.go | 10 +++++----- cmd/analyze/view.go | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index d8ddb20..083b2e6 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -45,9 +45,10 @@ func TestScanPathConcurrentBasic(t *testing.T) { } var filesScanned, dirsScanned, bytesScanned int64 - current := "" + current := &atomic.Value{} + current.Store("") - result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, ¤t) + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) if err != nil { t.Fatalf("scanPathConcurrent returned error: %v", err) } @@ -361,10 +362,11 @@ func TestScanPathPermissionError(t *testing.T) { }() var files, dirs, bytes int64 - current := "" + current := &atomic.Value{} + current.Store("") // Scanning the locked dir itself should fail. - _, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, ¤t) + _, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, current) if err == nil { t.Fatalf("expected error scanning locked directory, got nil") } diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 269b493..59fe79a 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -97,7 +97,7 @@ type model struct { filesScanned *int64 dirsScanned *int64 bytesScanned *int64 - currentPath *string + currentPath *atomic.Value showLargeFiles bool isOverview bool deleteConfirm bool @@ -162,7 +162,8 @@ func main() { func newModel(path string, isOverview bool) model { var filesScanned, dirsScanned, bytesScanned int64 - currentPath := "" + currentPath := &atomic.Value{} + currentPath.Store("") var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64 overviewCurrentPath := "" @@ -174,7 +175,7 @@ func newModel(path string, isOverview bool) model { filesScanned: &filesScanned, dirsScanned: &dirsScanned, bytesScanned: &bytesScanned, - currentPath: ¤tPath, + currentPath: currentPath, showLargeFiles: false, isOverview: isOverview, cache: make(map[string]historyEntry), @@ -434,7 +435,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.bytesScanned, 0) if m.currentPath != nil { - *m.currentPath = "" + m.currentPath.Store("") } return m, tea.Batch(m.scanCmd(m.path), tickCmd()) } @@ -712,7 +713,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.bytesScanned, 0) if m.currentPath != nil { - *m.currentPath = "" + m.currentPath.Store("") } return m, tea.Batch(m.scanCmd(m.path), tickCmd()) case "t", "T": @@ -984,7 +985,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.bytesScanned, 0) if m.currentPath != nil { - *m.currentPath = "" + m.currentPath.Store("") } if cached, ok := m.cache[m.path]; ok && !cached.Dirty { diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 91d07fd..f7c938c 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -23,7 +23,7 @@ import ( var scanGroup singleflight.Group -func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) (scanResult, error) { +func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { children, err := os.ReadDir(root) if err != nil { return scanResult{}, err @@ -293,7 +293,7 @@ func shouldSkipFileForLargeTracking(path string) bool { } // calculateDirSizeFast performs concurrent dir sizing using os.ReadDir. -func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { +func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { var total int64 var wg sync.WaitGroup @@ -312,7 +312,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * } if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 { - *currentPath = dirPath + currentPath.Store(dirPath) } entries, err := os.ReadDir(dirPath) @@ -429,7 +429,7 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { children, err := os.ReadDir(root) if err != nil { return 0 @@ -507,7 +507,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS // Update current path occasionally to prevent UI jitter. if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 { - *currentPath = fullPath + currentPath.Store(fullPath) } } diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index 43263e7..b92678b 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -99,7 +99,7 @@ func (m model) View() string { colorGreen, humanizeBytes(bytesScanned), colorReset) if m.currentPath != nil { - currentPath := *m.currentPath + currentPath := m.currentPath.Load().(string) if currentPath != "" { shortPath := displayPath(currentPath) shortPath = truncateMiddle(shortPath, 50) From add3cca6ef6c49b448c989d33af4737d6fda3d6c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 14:46:15 +0800 Subject: [PATCH 10/14] fix mo update hanging by adding a timeout --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 13cbf8f..21a3c00 100755 --- a/install.sh +++ b/install.sh @@ -125,7 +125,7 @@ resolve_source_dir() { start_line_spinner "Fetching Mole source (${branch})..." if command -v curl > /dev/null 2>&1; then - if curl -fsSL -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then + if curl -fsSL --connect-timeout 10 --max-time 60 -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then stop_line_spinner From 5d5056fc9e26cbca968f02288247a458a553aa7d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 14:55:42 +0800 Subject: [PATCH 11/14] fix: address mo update hanging and imporve temporary file reliability --- bin/touchid.sh | 23 ++++++++++++----------- install.sh | 2 +- lib/core/base.sh | 9 ++++++--- lib/core/common.sh | 2 +- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/bin/touchid.sh b/bin/touchid.sh index 1f45914..94825f0 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -13,6 +13,9 @@ LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" # shellcheck source=../lib/core/common.sh source "$LIB_DIR/core/common.sh" +# Set up global cleanup trap +trap cleanup_temp_files EXIT INT TERM + readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}" readonly PAM_TID_LINE="auth sufficient pam_tid.so" @@ -66,9 +69,8 @@ show_status() { # Enable Touch ID for sudo enable_touchid() { - # Cleanup trap + # Cleanup trap handled by global EXIT trap local temp_file="" - trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT # First check if system supports Touch ID if ! supports_touchid; then @@ -88,7 +90,7 @@ enable_touchid() { # It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration) if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then # Clean up legacy config - temp_file=$(mktemp) + temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}" @@ -117,7 +119,7 @@ enable_touchid() { else # Append if not present if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then - temp_file=$(mktemp) + temp_file=$(create_temp_file) cp "$PAM_SUDO_LOCAL_FILE" "$temp_file" echo "$PAM_TID_LINE" >> "$temp_file" sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" @@ -132,7 +134,7 @@ enable_touchid() { if $write_success; then # If we migrated from legacy, clean it up now if $is_legacy_configured; then - temp_file=$(mktemp) + temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" sudo mv "$temp_file" "$PAM_SUDO_FILE" log_success "Touch ID migrated to sudo_local" @@ -163,7 +165,7 @@ enable_touchid() { fi # Create temp file - temp_file=$(mktemp) + temp_file=$(create_temp_file) # Insert pam_tid.so after the first comment block awk ' @@ -194,9 +196,8 @@ enable_touchid() { # Disable Touch ID for sudo disable_touchid() { - # Cleanup trap + # Cleanup trap handled by global EXIT trap local temp_file="" - trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT if ! is_touchid_configured; then echo -e "${YELLOW}Touch ID is not currently enabled${NC}" @@ -206,13 +207,13 @@ disable_touchid() { # Check sudo_local first if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then # Remove from sudo_local - temp_file=$(mktemp) + temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file" if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then # Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup) if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then - temp_file=$(mktemp) + temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" sudo mv "$temp_file" "$PAM_SUDO_FILE" fi @@ -236,7 +237,7 @@ disable_touchid() { fi # Remove pam_tid.so line - temp_file=$(mktemp) + temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then diff --git a/install.sh b/install.sh index 21a3c00..8a45c84 100755 --- a/install.sh +++ b/install.sh @@ -100,7 +100,7 @@ resolve_source_dir() { local tmp tmp="$(mktemp -d)" - trap 'stop_line_spinner 2>/dev/null; rm -rf "$tmp"' EXIT + trap "stop_line_spinner 2>/dev/null; rm -rf \"$tmp\"" EXIT local branch="${MOLE_VERSION:-}" if [[ -z "$branch" ]]; then diff --git a/lib/core/base.sh b/lib/core/base.sh index 5a455e9..95b3ae4 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -512,7 +512,7 @@ declare -a MOLE_TEMP_DIRS=() create_temp_file() { local temp temp=$(mktemp) || return 1 - MOLE_TEMP_FILES+=("$temp") + register_temp_file "$temp" echo "$temp" } @@ -520,7 +520,7 @@ create_temp_file() { create_temp_dir() { local temp temp=$(mktemp -d) || return 1 - MOLE_TEMP_DIRS+=("$temp") + register_temp_dir "$temp" echo "$temp" } @@ -538,9 +538,12 @@ register_temp_dir() { # Compatible with both BSD mktemp (macOS default) and GNU mktemp (coreutils) mktemp_file() { local prefix="${1:-mole}" + local temp # Use TMPDIR if set, otherwise /tmp # Add .XXXXXX suffix to work with both BSD and GNU mktemp - mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" + temp=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX") || return 1 + register_temp_file "$temp" + echo "$temp" } # Cleanup all tracked temp files and directories diff --git a/lib/core/common.sh b/lib/core/common.sh index 5437f17..923122a 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -34,7 +34,7 @@ update_via_homebrew() { temp_upgrade=$(mktemp_file "brew_upgrade") # Set up trap for interruption (Ctrl+C) with inline cleanup - trap 'stop_inline_spinner 2>/dev/null; rm -f "$temp_update" "$temp_upgrade" 2>/dev/null; echo ""; exit 130' INT TERM + trap "stop_inline_spinner 2>/dev/null; rm -f \"$temp_update\" \"$temp_upgrade\" 2>/dev/null; echo \"\"; exit 130" INT TERM # Update Homebrew if [[ -t 1 ]]; then From 93dee7b94de1def5e9c54ce6130d878f4f311a39 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 15:45:31 +0800 Subject: [PATCH 12/14] Implemented safer temp cleanup and error reporting while fixing folded-directory size fallback to prevent double counting and aligning Homebrew cleanup traps with safe removal. --- cmd/analyze/scanner.go | 8 +++++--- install.sh | 44 +++++++++++++++++++++++++++++++++++++++++- lib/core/base.sh | 7 ++++++- lib/core/common.sh | 5 +++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index ae5ab3f..2f0f797 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -471,11 +471,13 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS defer func() { <-duSem }() return getDirectorySizeFromDu(path) }() - if err == nil && size > 0 { - atomic.AddInt64(&total, size) + if err != nil || size <= 0 { + size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) + } else { atomic.AddInt64(bytesScanned, size) - atomic.AddInt64(dirsScanned, 1) } + atomic.AddInt64(&total, size) + atomic.AddInt64(dirsScanned, 1) }(fullPath) continue } diff --git a/install.sh b/install.sh index 8a45c84..b7d05be 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,39 @@ log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; } log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; } log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; } +safe_rm() { + local target="${1:-}" + local tmp_root + + if [[ -z "$target" ]]; then + log_error "safe_rm: empty path" + return 1 + fi + if [[ ! -e "$target" ]]; then + return 0 + fi + + tmp_root="${TMPDIR:-/tmp}" + case "$target" in + "$tmp_root" | /tmp) + log_error "safe_rm: refusing to remove temp root: $target" + return 1 + ;; + "$tmp_root"/* | /tmp/*) ;; + *) + log_error "safe_rm: refusing to remove non-temp path: $target" + return 1 + ;; + esac + + if [[ -d "$target" ]]; then + find "$target" -depth \( -type f -o -type l \) -exec rm -f {} + 2> /dev/null || true + find "$target" -depth -type d -exec rmdir {} + 2> /dev/null || true + else + rm -f "$target" 2> /dev/null || true + fi +} + # Install defaults INSTALL_DIR="/usr/local/bin" CONFIG_DIR="$HOME/.config/mole" @@ -100,7 +133,16 @@ resolve_source_dir() { local tmp tmp="$(mktemp -d)" - trap "stop_line_spinner 2>/dev/null; rm -rf \"$tmp\"" EXIT + + # Safe cleanup function for temporary directory + cleanup_tmp() { + stop_line_spinner 2> /dev/null || true + if [[ -z "${tmp:-}" ]]; then + return 0 + fi + safe_rm "$tmp" + } + trap cleanup_tmp EXIT local branch="${MOLE_VERSION:-}" if [[ -z "$branch" ]]; then diff --git a/lib/core/base.sh b/lib/core/base.sh index 95b3ae4..ce131ca 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -539,9 +539,14 @@ register_temp_dir() { mktemp_file() { local prefix="${1:-mole}" local temp + local error_msg # Use TMPDIR if set, otherwise /tmp # Add .XXXXXX suffix to work with both BSD and GNU mktemp - temp=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX") || return 1 + if ! error_msg=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>&1); then + echo "Error: Failed to create temporary file: $error_msg" >&2 + return 1 + fi + temp="$error_msg" register_temp_file "$temp" echo "$temp" } diff --git a/lib/core/common.sh b/lib/core/common.sh index 923122a..2408294 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -34,7 +34,7 @@ update_via_homebrew() { temp_upgrade=$(mktemp_file "brew_upgrade") # Set up trap for interruption (Ctrl+C) with inline cleanup - trap "stop_inline_spinner 2>/dev/null; rm -f \"$temp_update\" \"$temp_upgrade\" 2>/dev/null; echo \"\"; exit 130" INT TERM + trap 'stop_inline_spinner 2>/dev/null; safe_remove "$temp_update" true; safe_remove "$temp_upgrade" true; echo ""; exit 130' INT TERM # Update Homebrew if [[ -t 1 ]]; then @@ -73,7 +73,8 @@ update_via_homebrew() { trap - INT TERM # Cleanup temp files - rm -f "$temp_update" "$temp_upgrade" + safe_remove "$temp_update" true + safe_remove "$temp_upgrade" true if echo "$upgrade_output" | grep -q "already installed"; then local installed_version From ffea36e868919fb3b7614ac9302d33e3d9dbf8d8 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 16:20:37 +0800 Subject: [PATCH 13/14] fix the touchid tests --- bin/touchid.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/touchid.sh b/bin/touchid.sh index 94825f0..4dce339 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -16,8 +16,10 @@ source "$LIB_DIR/core/common.sh" # Set up global cleanup trap trap cleanup_temp_files EXIT INT TERM -readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" -readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}" +PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" +PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-$(dirname "$PAM_SUDO_FILE")/sudo_local}" +readonly PAM_SUDO_FILE +readonly PAM_SUDO_LOCAL_FILE readonly PAM_TID_LINE="auth sufficient pam_tid.so" # Check if Touch ID is already configured From 5d77001a721a795bf740d10826d3f39cbcb9de6c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 12 Jan 2026 17:49:51 +0800 Subject: [PATCH 14/14] Optimize the effect and speed of scanning --- bin/purge.sh | 104 +++++++++++++++++++++++++++++++++++-- lib/clean/project.sh | 119 +++++++++++++++++++++++++++---------------- tests/purge.bats | 21 ++++++++ 3 files changed, 195 insertions(+), 49 deletions(-) diff --git a/bin/purge.sh b/bin/purge.sh index 1574243..92ec119 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -47,21 +47,119 @@ start_purge() { printf '\033[2J\033[H' fi printf '\n' - echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" # Initialize stats file in user cache directory local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" ensure_user_dir "$stats_dir" ensure_user_file "$stats_dir/purge_stats" ensure_user_file "$stats_dir/purge_count" + ensure_user_file "$stats_dir/purge_scanning" echo "0" > "$stats_dir/purge_stats" echo "0" > "$stats_dir/purge_count" + echo "" > "$stats_dir/purge_scanning" } # Perform the purge perform_purge() { + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + local monitor_pid="" + + # Cleanup function + cleanup_monitor() { + # Remove scanning file to stop monitor + rm -f "$stats_dir/purge_scanning" 2> /dev/null || true + + if [[ -n "$monitor_pid" ]]; then + kill "$monitor_pid" 2> /dev/null || true + wait "$monitor_pid" 2> /dev/null || true + fi + if [[ -t 1 ]]; then + printf '\r\033[K\n\033[K\033[A' + fi + } + + # Set up trap for cleanup + trap cleanup_monitor INT TERM + + # Show scanning with spinner on same line as title + if [[ -t 1 ]]; then + # Print title first + printf '%s' "${PURPLE_BOLD}Purge Project Artifacts${NC} " + + # Start background monitor with ASCII spinner + ( + local spinner_chars="|/-\\" + local spinner_idx=0 + local last_path="" + + # Set up trap to exit cleanly + trap 'exit 0' INT TERM + + # Function to truncate path in the middle + truncate_path() { + local path="$1" + local max_len=80 + + if [[ ${#path} -le $max_len ]]; then + echo "$path" + return + fi + + # Calculate how much to show on each side + local side_len=$(( (max_len - 3) / 2 )) + local start="${path:0:$side_len}" + local end="${path: -$side_len}" + echo "${start}...${end}" + } + + while [[ -f "$stats_dir/purge_scanning" ]]; do + local current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") + local display_path="" + + if [[ -n "$current_path" ]]; then + display_path="${current_path/#$HOME/~}" + display_path=$(truncate_path "$display_path") + last_path="$display_path" + elif [[ -n "$last_path" ]]; then + display_path="$last_path" + fi + + # Get current spinner character + local spin_char="${spinner_chars:$spinner_idx:1}" + spinner_idx=$(( (spinner_idx + 1) % ${#spinner_chars} )) + + # Show title on first line, spinner and scanning info on second line + if [[ -n "$display_path" ]]; then + printf '\r%s\n%s %sScanning %s\033[K\033[A' \ + "${PURPLE_BOLD}Purge Project Artifacts${NC}" \ + "${BLUE}${spin_char}${NC}" \ + "${GRAY}" "$display_path" + else + printf '\r%s\n%s %sScanning...\033[K\033[A' \ + "${PURPLE_BOLD}Purge Project Artifacts${NC}" \ + "${BLUE}${spin_char}${NC}" \ + "${GRAY}" + fi + + sleep 0.05 + done + exit 0 + ) & + monitor_pid=$! + else + echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" + fi + clean_project_artifacts local exit_code=$? + + # Clean up + trap - INT TERM + cleanup_monitor + + if [[ -t 1 ]]; then + echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" + fi # Exit codes: # 0 = success, show summary @@ -79,15 +177,11 @@ perform_purge() { local total_size_cleaned=0 local total_items_cleaned=0 - # Read stats from user cache directory - local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" - if [[ -f "$stats_dir/purge_stats" ]]; then total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") rm -f "$stats_dir/purge_stats" fi - # Read count if [[ -f "$stats_dir/purge_count" ]]; then total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0") rm -f "$stats_dir/purge_count" diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 7a70ba9..15908bd 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -45,7 +45,7 @@ readonly PURGE_TARGETS=( readonly MIN_AGE_DAYS=7 # Scan depth defaults (relative to search root). readonly PURGE_MIN_DEPTH_DEFAULT=2 -readonly PURGE_MAX_DEPTH_DEFAULT=8 +readonly PURGE_MAX_DEPTH_DEFAULT=4 # Search paths (default, can be overridden via config file). readonly DEFAULT_PURGE_SEARCH_PATHS=( "$HOME/www" @@ -339,6 +339,11 @@ scan_purge_targets() { if [[ ! -d "$search_path" ]]; then return fi + + # Update current scanning path + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + echo "$search_path" > "$stats_dir/purge_scanning" 2> /dev/null || true + if command -v fd > /dev/null 2>&1; then # Escape regex special characters in target names for fd patterns local escaped_targets=() @@ -356,28 +361,39 @@ scan_purge_targets() { "--type" "d" "--min-depth" "$min_depth" "--max-depth" "$max_depth" - "--threads" "4" + "--threads" "8" "--exclude" ".git" "--exclude" "Library" "--exclude" ".Trash" "--exclude" "Applications" ) - fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null | while IFS= read -r item; do - if is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - fi - done | filter_nested_artifacts | filter_protected_artifacts > "$output_file" + # Write to temp file first, then filter - more efficient than piping + fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw" || true + + # Single pass: safe + nested + protected + if [[ -f "$output_file.raw" ]]; then + while IFS= read -r item; do + # Check if we should abort (scanning file removed by Ctrl+C) + if [[ ! -f "$stats_dir/purge_scanning" ]]; then + rm -f "$output_file.raw" + return + fi + + if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then + echo "$item" + # Update scanning path to show current project directory + local project_dir=$(dirname "$item") + echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true + fi + done < "$output_file.raw" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" + rm -f "$output_file.raw" + else + touch "$output_file" + fi else # Pruned find avoids descending into heavy directories. - local prune_args=() - local prune_dirs=(".git" "Library" ".Trash" "Applications") - for dir in "${prune_dirs[@]}"; do - prune_args+=("-name" "$dir" "-prune" "-o") - done - for target in "${PURGE_TARGETS[@]}"; do - prune_args+=("-name" "$target" "-print" "-prune" "-o") - done local find_expr=() + local prune_dirs=(".git" "Library" ".Trash" "Applications") for dir in "${prune_dirs[@]}"; do find_expr+=("-name" "$dir" "-prune" "-o") done @@ -390,28 +406,49 @@ scan_purge_targets() { ((i++)) done command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ - \( "${find_expr[@]}" \) 2> /dev/null | while IFS= read -r item; do - if is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - fi - done | filter_nested_artifacts | filter_protected_artifacts > "$output_file" + \( "${find_expr[@]}" \) 2> /dev/null > "$output_file.raw" || true + + # Single pass: safe + nested + protected + if [[ -f "$output_file.raw" ]]; then + while IFS= read -r item; do + # Check if we should abort (scanning file removed by Ctrl+C) + if [[ ! -f "$stats_dir/purge_scanning" ]]; then + rm -f "$output_file.raw" + return + fi + + if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then + echo "$item" + # Update scanning path to show current project directory + local project_dir=$(dirname "$item") + echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true + fi + done < "$output_file.raw" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" + rm -f "$output_file.raw" + else + touch "$output_file" + fi fi } -# Filter out nested artifacts (e.g. node_modules inside node_modules). +# Filter out nested artifacts (e.g. node_modules inside node_modules, .build inside build). +# Optimized: Sort paths to put parents before children, then filter in single pass. filter_nested_artifacts() { - while IFS= read -r item; do - local parent_dir=$(dirname "$item") - local is_nested=false - for target in "${PURGE_TARGETS[@]}"; do - if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then - is_nested=true - break - fi - done - if [[ "$is_nested" == "false" ]]; then - echo "$item" - fi - done + # 1. Append trailing slash to each path (to ensure /foo/bar starts with /foo/) + # 2. Sort to group parents and children (LC_COLLATE=C ensures standard sorting) + # 3. Use awk to filter out paths that start with the previous kept path + # 4. Remove trailing slash + sed 's|[^/]$|&/|' | LC_COLLATE=C sort | awk ' + BEGIN { last_kept = "" } + { + current = $0 + # If current path starts with last_kept, it is nested + # Only check if last_kept is not empty + if (last_kept == "" || index(current, last_kept) != 1) { + print current + last_kept = current + } + } + ' | sed 's|/$||' } filter_protected_artifacts() { @@ -703,17 +740,14 @@ clean_project_artifacts() { for temp in "${scan_temps[@]+"${scan_temps[@]}"}"; do rm -f "$temp" 2> /dev/null || true done - if [[ -t 1 ]]; then - stop_inline_spinner - fi + # Clean up purge scanning file + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + rm -f "$stats_dir/purge_scanning" 2> /dev/null || true echo "" exit 130 } trap cleanup_scan INT TERM - # Start parallel scanning of all paths at once - if [[ -t 1 ]]; then - start_inline_spinner "Scanning projects..." - fi + # Scanning is started from purge.sh with start_inline_spinner # Launch all scans in parallel for path in "${PURGE_SEARCH_PATHS[@]}"; do if [[ -d "$path" ]]; then @@ -730,9 +764,6 @@ clean_project_artifacts() { for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do wait "$pid" 2> /dev/null || true done - if [[ -t 1 ]]; then - stop_inline_spinner - fi # Collect all results for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do if [[ -f "$scan_output" ]]; then diff --git a/tests/purge.bats b/tests/purge.bats index 5ccab1f..7819b89 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -101,6 +101,27 @@ setup() { [[ "$result" == "2" ]] } +@test "filter_nested_artifacts: removes Xcode build subdirectories (Mac projects)" { + # Simulate Mac Xcode project with nested .build directories: + # ~/www/testapp/build + # ~/www/testapp/build/Framework.build + # ~/www/testapp/build/Package.build + mkdir -p "$HOME/www/testapp/build/Framework.build" + mkdir -p "$HOME/www/testapp/build/Package.build" + + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + printf '%s\n' \ + '$HOME/www/testapp/build' \ + '$HOME/www/testapp/build/Framework.build' \ + '$HOME/www/testapp/build/Package.build' | \ + filter_nested_artifacts | wc -l | tr -d ' ' + ") + + # Should only keep the top-level 'build' directory, filtering out nested .build dirs + [[ "$result" == "1" ]] +} + # Vendor protection unit tests @test "is_rails_project_root: detects valid Rails project" { mkdir -p "$HOME/www/test-rails/config"