From a20e504964cd1ababe4b5aabe128ed0f8484c341 Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Fri, 22 May 2026 10:22:37 +0530 Subject: [PATCH 1/3] fix:APFS clone-aware duplicate detection (opt-in) with sampling profiles Co-authored-by: Nitish Kumar Co-authored-by: Nitin Saini Co-authored-by: Sid Kr. --- .gitignore | 1 + CHANGELOG.md | 9 +++ Formula/mac-deep-cleaner.rb | 4 +- README.md | 18 ++--- checklist.md | 42 +++++----- docs/ARCHITECTURE.md | 2 +- docs/COMMAND_REFERENCE.md | 6 +- docs/PYPI_PUBLISHING.md | 2 +- pyproject.toml | 2 +- roadmap.md | 15 ++-- scripts/build.sh | 2 +- src/__init__.py | 2 +- src/cli.py | 50 ++++++++++-- src/scanners/duplicates.py | 155 +++++++++++++++++++++++++++++++++++- 14 files changed, 259 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 5b80fc4..3365278 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,4 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +docs/PYPI_PUBLISHING.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index df0074b..9d2c28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. +## v2.0.1 (2026-05-22) + +### Added +- APFS clone-aware duplicate detection (opt-in) with sampling profiles +- Duplicate export fields for logical vs physical wasted space when available + +### Changed +- Duplicates CLI output shows physical group counts and estimated wasted space + ## v2.0.0 (2026-05-14) ### Added diff --git a/Formula/mac-deep-cleaner.rb b/Formula/mac-deep-cleaner.rb index 78814f4..e1c0ccb 100644 --- a/Formula/mac-deep-cleaner.rb +++ b/Formula/mac-deep-cleaner.rb @@ -3,8 +3,8 @@ class MacDeepCleaner < Formula desc "Professional macOS cleanup CLI with safe undo, reports, and scanners" homepage "https://github.com/NK2552003/Mac-Cleaner" - url "https://files.pythonhosted.org/packages/source/m/mac-deep-cleaner/mac-deep-cleaner-1.0.0.tar.gz" - sha256 "REPLACE_WITH_PYPI_SDIST_SHA256" + url "https://files.pythonhosted.org/packages/source/m/mac-deep-cleaner/mac-deep-cleaner-2.0.1.tar.gz" + sha256 "NONE_FOR_NOW" license "MIT" depends_on "python@3.12" diff --git a/README.md b/README.md index 75e6d48..79d6230 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPI Version](https://img.shields.io/pypi/v/mac-deep-cleaner.svg)](https://pypi.org/project/mac-deep-cleaner/) [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) -[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://github.com/NK2552003/Mac-Cleaner/blob/main/LICENSE) [![macOS 10.15+](https://img.shields.io/badge/macOS-10.15+-silver.svg)](https://www.apple.com/macos/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/mac-deep-cleaner?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/mac-deep-cleaner) @@ -82,10 +82,10 @@ Comprehensive guides for every use case: | Document | Description | |----------|-------------| -| **[Features Guide](docs/FEATURES.md)** | Complete list of all 60+ features with examples | -| **[Command Reference](docs/COMMAND_REFERENCE.md)** | Detailed CLI documentation for every command | -| **[Architecture Guide](docs/ARCHITECTURE.md)** | Internal structure, modules, and extension points | -| **[PyPI Publishing](docs/PYPI_PUBLISHING.md)** | Build and publish instructions | +| **[Features Guide](https://github.com/NK2552003/Mac-Cleaner/blob/main/docs/FEATURES.md)** | Complete list of all 60+ features with examples | +| **[Command Reference](https://github.com/NK2552003/Mac-Cleaner/blob/main/docs/COMMAND_REFERENCE.md)** | Detailed CLI documentation for every command | +| **[Architecture Guide](https://github.com/NK2552003/Mac-Cleaner/blob/main/docs/ARCHITECTURE.md)** | Internal structure, modules, and extension points | +| **[PyPI Publishing](https://github.com/NK2552003/Mac-Cleaner/blob/main/docs/PYPI_PUBLISHING.md)** | Build and publish instructions | --- @@ -96,7 +96,7 @@ Comprehensive guides for every use case: - **General Junk** — Caches, logs, crash reports, `.DS_Store`, Xcode artifacts, package manager caches - **Developer Junk** — `node_modules`, `venv`, build outputs, coverage dirs (opt-in) - **Global Dev Caches** — `~/.npm`, `~/.gradle`, `~/.m2`, `~/.cargo`, `~/.nuget` (opt-in) -- **Duplicate Finder** — SHA-256 hashing with two-phase optimization +- **Duplicate Finder** — SHA-256 hashing with two-phase optimization, optional APFS clone-aware estimates - **Large File Scanner** — Find files ≥100 MB, categorized by type - **Broken Symlink Detector** — Scans Homebrew, `/usr/local`, `~/bin`, and more @@ -436,13 +436,13 @@ We welcome contributions! Please read our contributing guidelines before submitt Copyright © 2024 Mac Deep Cleaner Contributors -Licensed under the [Apache License 2.0](LICENSE). +Licensed under the [Apache License 2.0](https://github.com/NK2552003/Mac-Cleaner/blob/main/LICENSE). --- ## Support -- **Documentation:** [docs/](docs/) +- **Documentation:** [docs/](https://github.com/NK2552003/Mac-Cleaner/tree/main/docs) - **Issues:** [GitHub Issues](https://github.com/NK2552003/Mac-Cleaner/issues) --- @@ -451,7 +451,7 @@ Licensed under the [Apache License 2.0](LICENSE). **Made with ❤️ for the macOS community** [⭐ Star this repo](https://github.com/NK2552003/Mac-Cleaner) -[Read the COMMAND REFERENCE](docs/COMMAND_REFERENCE.md) +[Read the COMMAND REFERENCE](https://github.com/NK2552003/Mac-Cleaner/blob/main/docs/COMMAND_REFERENCE.md) [Report an issue](https://github.com/NK2552003/Mac-Cleaner/issues) diff --git a/checklist.md b/checklist.md index 0d2e7c0..052ce62 100644 --- a/checklist.md +++ b/checklist.md @@ -1,20 +1,26 @@ -# Mac Deep Cleaner v2.x Feature Checklist +## Checklist -Date: 2026-05-11 +### Optimization +- [ ] Profile duplicate detection and hashing hot paths +- [ ] Reduce redundant filesystem stats during scans +- [ ] Tune scan batching and progress update frequency -## Additional (not yet scheduled) -- [ ] Purgeable space reclaimer (src/scanners/purgeable.py) -- [ ] Installer and PKG file hunter (src/scanners/installer_hunter.py) -- [ ] DNS cache flush (src/core/dns_cache.py) -- [ ] Font cache rebuild (src/core/font_cache.py) -- [ ] Spotlight re-index (src/core/spotlight.py) -- [ ] Sleep and power optimizer (src/core/power_optimizer.py) -- [ ] App update checker (src/core/update_checker.py) -- [ ] PKG receipt manager (src/core/pkg_receipts.py) -- [ ] Xcode derived data cleaner (src/scanners/xcode_cleaner.py) -- [ ] Weekly digest report (src/reporting/weekly_digest.py) -- [ ] Cleaning impact score (src/reporting/impact_score.py) -- [ ] Interactive TUI app picker (src/core/tui_picker.py) -- [ ] Multi-Mac config sync (src/core/config_sync.py) -- [ ] Time Machine backup guard (src/core/time_machine_guard.py) -- [ ] Restore checksum verification (src/core/restore_checksums.py) +### CLI Visuals +- [ ] Refresh duplicate finder table layout for clarity +- [ ] Add consistent status icons and color palette +- [ ] Improve spacing and headings in Rich panels + +### Issue Fixes +- [ ] Audit recent bug reports and reproduce top 3 +- [ ] Add regression tests for resolved issues +- [ ] Validate error handling for permission-denied paths + +### CLI Gaps +- [ ] Add `dashboard` to the top-level subcommand list docstring +- [ ] Align scan `--profile` help text with supported profiles (beginner/developer/professional/designer/student/children) +- [ ] Add `--no-save-history` (and wire into scan/clean) to disable history persistence +- [ ] Add a non-interactive mode to skip first-run profile prompt (default to beginner) + +### Safety & Consistency +- [ ] Use safety validation + undo staging for duplicate deletions (avoid raw `unlink`) +- [ ] Make scan `--dry-run` either set global dry-run or remove to avoid no-op confusion diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4d2024..d6cb707 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Mac Deep Cleaner — Architecture & Implementation Guide -**Version:** 2.0.0 +**Version:** 2.0.1 This document provides a comprehensive overview of the internal architecture, module structure, and implementation details of Mac Deep Cleaner. diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index c3b4db1..6a5311f 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -1,6 +1,6 @@ # Mac Deep Cleaner — Command Reference -**Version:** 2.0.0 +**Version:** 2.0.1 **CLI Commands:** `mac-cleaner` or `mdc` This document provides comprehensive reference documentation for all available commands in Mac Deep Cleaner. @@ -150,6 +150,8 @@ mac-cleaner duplicates [OPTIONS] **Options:** - `--path PATH` — Directories to scan (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures) - `--min-size KB` — Minimum file size in KB to consider (default: 100) +- `--clone-aware/--no-clone-aware` — Estimate APFS shared extents (macOS only) +- `--clone-sampling` — Sampling profile: fast, balanced, thorough (default: balanced) - `--export PATH` — Export results to JSON - `--delete` — Interactively delete duplicates (keeps first copy) @@ -157,12 +159,14 @@ mac-cleaner duplicates [OPTIONS] - Two-phase hashing for speed and accuracy - Only scans user directories (never /System) - Groups identical files by hash +- Optional APFS clone-aware estimates to avoid overstating wasted space **Examples:** ```bash mac-cleaner duplicates mac-cleaner duplicates --path ~/Photos --min-size 1024 mac-cleaner duplicates --delete --export dupes.json +mac-cleaner duplicates --clone-aware --clone-sampling balanced ``` --- diff --git a/docs/PYPI_PUBLISHING.md b/docs/PYPI_PUBLISHING.md index 6aa4c92..ffab95f 100644 --- a/docs/PYPI_PUBLISHING.md +++ b/docs/PYPI_PUBLISHING.md @@ -1,4 +1,4 @@ -# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v2.0.0) +# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v2.0.1) ## What you’ll publish This project is configured to build with `setuptools` from `pyproject.toml` (PEP 621). The package name is: diff --git a/pyproject.toml b/pyproject.toml index 30dcf1a..642915e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mac-deep-cleaner" -version = "2.0.0" +version = "2.0.1" description = "Professional Mac cleanup tool — Smart App Orphan Detector" readme = "README.md" license = "Apache-2.0" diff --git a/roadmap.md b/roadmap.md index d25a750..1583ff7 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,9 +1,10 @@ -# Mac Deep Cleaner v2.x Roadmap +## Roadmap -Date: 2026-05-15 +### Near Term +- Performance optimization for duplicate detection and scan traversal +- CLI visual refresh (tables, panels, progress, and status cues) +- Fix reported issues with permission handling and edge-case paths -## Goals -- Ship a full v2.x feature set with professional-grade safety, logging, and undo support. -- Keep destructive actions opt-in and gated by explicit flags and confirmations. -- Keep new dependencies minimal and justified; document when optional. -- Add a feature module per roadmap item, grouped by domain. +### Mid Term +- Expand regression test coverage for scanners and CLI outputs +- Add performance benchmarks for key scan workflows diff --git a/scripts/build.sh b/scripts/build.sh index 3027cce..d07dc1c 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Mac Deep Cleaner v2.0.0 — Build & Install Script +# Mac Deep Cleaner v2.0.1 — Build & Install Script # ============================================================================= # Usage: # bash build.sh → default: build wheel + sdist diff --git a/src/__init__.py b/src/__init__.py index af1e5c1..7dbcf0c 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,5 +2,5 @@ Smart App Orphan Detector & System Cleanup Tool for macOS """ -__version__ = "2.0.0" +__version__ = "2.0.1" __author__ = "NK2552003" diff --git a/src/cli.py b/src/cli.py index 1fbfe70..fa4d1a4 100644 --- a/src/cli.py +++ b/src/cli.py @@ -182,7 +182,7 @@ def main( log_file: Optional[str], dry_run: bool, ) -> None: - """Mac Deep Cleaner v2.0.0 — Professional macOS cleanup tool.""" + """Mac Deep Cleaner v2.0.1 — Professional macOS cleanup tool.""" from core.dry_run import set_dry_run configure_logging( verbose=verbose, @@ -1156,6 +1156,12 @@ def cmd_simulators( help="Directories to scan (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures).") @click.option("--min-size", default=100, show_default=True, help="Minimum file size in KB to consider.") +@click.option("--clone-aware/--no-clone-aware", default=False, show_default=True, + help="Estimate APFS shared extents (macOS only).") +@click.option("--clone-sampling", + type=click.Choice(["fast", "balanced", "thorough"], case_sensitive=False), + default="balanced", show_default=True, + help="Sampling profile for clone detection.") @click.option("--export", "export_path", type=click.Path(), default=None, help="Export results to JSON.") @click.option("--delete", is_flag=True, default=False, @@ -1165,6 +1171,8 @@ def cmd_duplicates( ctx: click.Context, paths: Tuple[str, ...], min_size: int, + clone_aware: bool, + clone_sampling: str, export_path: Optional[str], delete: bool, ) -> None: @@ -1191,18 +1199,30 @@ def _cb(n: int) -> None: with _progress() as prog: task = prog.add_task(f"Scanning for duplicates (min {min_size} KB)…", total=None) - groups = find_duplicates(roots=roots, min_size=min_bytes, progress_callback=_cb) + groups = find_duplicates( + roots=roots, + min_size=min_bytes, + progress_callback=_cb, + clone_detect=clone_aware, + clone_sampling=clone_sampling, + ) prog.update(task, completed=100, total=100) if not groups: console.print("[green]✓ No duplicate files found![/green]") return + any_physical = any(g.physical_group_count is not None for g in groups) wasted = total_wasted(groups) + wasted_label = "estimated wasted" if any_physical else "wasting" console.print( f" Found [bold red]{len(groups)}[/bold red] duplicate groups " - f"wasting [yellow]{bytes_human(wasted)}[/yellow]\n" + f"{wasted_label} [yellow]{bytes_human(wasted)}[/yellow]\n" ) + if any_physical: + console.print( + " [dim]Wasted space uses APFS shared-extent estimates when available.[/dim]\n" + ) table = Table( title="Duplicate Groups", @@ -1211,18 +1231,28 @@ def _cb(n: int) -> None: ) table.add_column("#", style="dim", width=4, justify="right") table.add_column("Copies", justify="right", width=7) + if any_physical: + table.add_column("Phys", justify="right", width=7, style="dim") table.add_column("Wasted", justify="right", style="yellow", width=10) table.add_column("Size Each", justify="right", style="dim", width=10) table.add_column("First Path", style="dim") for i, g in enumerate(groups[:50], 1): - table.add_row( + row = [ str(i), str(len(g.paths)), + ] + if any_physical: + phys = "-" + if g.physical_group_count is not None: + phys = f"{g.physical_group_count}/{len(g.paths)}" + row.append(phys) + row.extend([ bytes_human(g.wasted_bytes), bytes_human(g.size), str(g.paths[0]), - ) + ]) + table.add_row(*row) console.print(table) @@ -1246,7 +1276,15 @@ def _cb(n: int) -> None: console.print() for g in groups: total_g = bytes_human(g.wasted_bytes) - console.print(f" [bold]Duplicates of:[/bold] {g.paths[0].name} ({total_g} wasted)") + g_label = "est. wasted" if g.physical_group_count is not None else "wasted" + shared_note = " (APFS shared extents)" if g.has_shared_extents else "" + console.print( + f" [bold]Duplicates of:[/bold] {g.paths[0].name} ({total_g} {g_label}{shared_note})" + ) + if g.has_shared_extents: + console.print( + " [dim]Shared extents detected; deleting may free little or no space.[/dim]" + ) for extra in g.paths[1:]: console.print(f" [dim]{extra}[/dim]") if Confirm.ask(f" Delete {len(g.paths)-1} extra copy/copies?", default=False): diff --git a/src/scanners/duplicates.py b/src/scanners/duplicates.py index acb69f5..5b71b49 100644 --- a/src/scanners/duplicates.py +++ b/src/scanners/duplicates.py @@ -9,16 +9,24 @@ 2. For each size-group with ≥2 files, compute a fast 4 KB head-hash. 3. For head-hash collisions, compute the full SHA-256. 4. Return groups of confirmed duplicates. +5. (macOS, optional) Estimate APFS shared extents to avoid overstating wasted space. """ from __future__ import annotations import hashlib import logging +import struct +import sys from collections import defaultdict from pathlib import Path from typing import Dict, Generator, List, Optional, Set, Tuple +try: + import fcntl # type: ignore +except ImportError: # pragma: no cover - non-Unix platforms + fcntl = None + from constants import HOME from utils import bytes_human, size_of @@ -46,6 +54,13 @@ # Head-hash sample size HEAD_BYTES = 4 * 1024 # 4 KB +# APFS shared-extents (clone) detection via F_LOG2PHYS_EXT (macOS only) +_HAS_LOG2PHYS = sys.platform == "darwin" and fcntl is not None +_F_LOG2PHYS_EXT = 65 +_L2P_QUERY_BYTES = 4096 +_L2P_STRUCT = struct.Struct("=Iqq") +_CLONE_SAMPLE_MODES = {"fast", "balanced", "thorough"} + # ── Hashing helpers ──────────────────────────────────────────────────────────── @@ -73,6 +88,85 @@ def _full_hash(path: Path) -> Optional[str]: return None +def _sample_offsets(size: int, mode: str) -> List[int]: + if size <= 0: + return [0] + mode = mode if mode in _CLONE_SAMPLE_MODES else "balanced" + offsets = [0] + if size > HEAD_BYTES: + offsets.append(size - HEAD_BYTES) + if mode in {"balanced", "thorough"} and size > 2 * HEAD_BYTES: + offsets.append(size // 2) + if mode == "thorough" and size > 4 * HEAD_BYTES: + offsets.extend([size // 4, (size * 3) // 4]) + aligned = {max(0, (o // _L2P_QUERY_BYTES) * _L2P_QUERY_BYTES) for o in offsets} + return sorted({min(o, size - 1) for o in aligned}) + + +def _log2phys_offset(fd: int, offset: int) -> Optional[int]: + if not _HAS_LOG2PHYS: + return None + try: + buf = _L2P_STRUCT.pack(0, _L2P_QUERY_BYTES, offset) + out = fcntl.fcntl(fd, _F_LOG2PHYS_EXT, buf) + _, _, devoffset = _L2P_STRUCT.unpack(out) + if devoffset < 0: + return None + return int(devoffset) + except OSError as exc: + logger.debug("log2phys failed for fd %s offset %s: %s", fd, offset, exc) + return None + + +def _physical_signature( + path: Path, + size: int, + sample_mode: str, +) -> Optional[Tuple[int, Tuple[int, ...]]]: + if not _HAS_LOG2PHYS: + return None + try: + st = path.stat() + except OSError as exc: + logger.debug("stat failed for %s: %s", path, exc) + return None + offsets = _sample_offsets(size, sample_mode) + try: + with open(path, "rb") as f: + fd = f.fileno() + dev_offsets: List[int] = [] + for offset in offsets: + dev_offset = _log2phys_offset(fd, offset) + if dev_offset is None: + return None + dev_offsets.append(dev_offset) + except OSError as exc: + logger.debug("log2phys open failed for %s: %s", path, exc) + return None + return (st.st_dev, tuple(dev_offsets)) + + +def _group_by_physical_extents( + paths: List[Path], + size: int, + clone_detect: bool, + clone_sampling: str, +) -> Tuple[Optional[List[List[Path]]], int]: + if not clone_detect or not _HAS_LOG2PHYS: + return None, 0 + groups: Dict[Tuple[int, Tuple[int, ...]], List[Path]] = defaultdict(list) + unknown = 0 + for p in paths: + sig = _physical_signature(p, size, clone_sampling) + if sig is None: + unknown += 1 + else: + groups[sig].append(p) + if not groups and unknown == len(paths): + return None, 0 + return list(groups.values()), unknown + + # ── Directory walker ─────────────────────────────────────────────────────────── def _walk(root: Path) -> Generator[Path, None, None]: @@ -98,19 +192,54 @@ def _walk(root: Path) -> Generator[Path, None, None]: class DuplicateGroup: """One set of files that are byte-for-byte identical.""" - __slots__ = ("hash", "size", "paths") + __slots__ = ("hash", "size", "paths", "physical_groups", "physical_unknown") - def __init__(self, file_hash: str, size: int, paths: List[Path]) -> None: + def __init__( + self, + file_hash: str, + size: int, + paths: List[Path], + physical_groups: Optional[List[List[Path]]] = None, + physical_unknown: int = 0, + ) -> None: self.hash = file_hash self.size = size # per-file size self.paths = paths # ≥2 confirmed duplicates + self.physical_groups = physical_groups + self.physical_unknown = physical_unknown @property - def wasted_bytes(self) -> int: + def logical_wasted_bytes(self) -> int: """Space wasted by keeping (n-1) extra copies.""" return self.size * (len(self.paths) - 1) + @property + def physical_group_count(self) -> Optional[int]: + if self.physical_groups is None: + return None + return len(self.physical_groups) + self.physical_unknown + + @property + def physical_wasted_bytes(self) -> Optional[int]: + group_count = self.physical_group_count + if group_count is None: + return None + return self.size * max(0, group_count - 1) + + @property + def has_shared_extents(self) -> bool: + if not self.physical_groups: + return False + return any(len(g) > 1 for g in self.physical_groups) + + @property + def wasted_bytes(self) -> int: + """Best-effort wasted bytes (APFS shared extents when available).""" + physical = self.physical_wasted_bytes + return physical if physical is not None else self.logical_wasted_bytes + def to_dict(self) -> dict: + physical_wasted = self.physical_wasted_bytes return { "hash": self.hash, "size": self.size, @@ -118,6 +247,14 @@ def to_dict(self) -> dict: "copies": len(self.paths), "wasted_bytes": self.wasted_bytes, "wasted_human": bytes_human(self.wasted_bytes), + "logical_wasted_bytes": self.logical_wasted_bytes, + "logical_wasted_human": bytes_human(self.logical_wasted_bytes), + "physical_wasted_bytes": physical_wasted, + "physical_wasted_human": ( + bytes_human(physical_wasted) if physical_wasted is not None else None + ), + "physical_group_count": self.physical_group_count, + "has_shared_extents": self.has_shared_extents, "paths": [str(p) for p in self.paths], } @@ -132,6 +269,8 @@ def find_duplicates( roots: Optional[List[Path]] = None, min_size: int = MIN_FILE_BYTES, progress_callback=None, # callable(scanned_files: int) → None + clone_detect: bool = False, + clone_sampling: str = "balanced", ) -> List[DuplicateGroup]: """ Scan *roots* (default: DEFAULT_SCAN_ROOTS) and return a list of @@ -141,6 +280,8 @@ def find_duplicates( roots: Directories to scan. Defaults to DEFAULT_SCAN_ROOTS. min_size: Ignore files smaller than this many bytes. progress_callback: Optional hook called periodically with file count. + clone_detect: Enable APFS clone-aware wasted space estimates (macOS only). + clone_sampling: Sampling profile: fast, balanced, thorough. Returns: List[DuplicateGroup], largest wasted space first. @@ -208,10 +349,18 @@ def find_duplicates( continue sz_str, file_hash = key.split(":", 1) + physical_groups, physical_unknown = _group_by_physical_extents( + unique, + int(sz_str), + clone_detect, + clone_sampling, + ) groups.append(DuplicateGroup( file_hash=file_hash, size=int(sz_str), paths=unique, + physical_groups=physical_groups, + physical_unknown=physical_unknown, )) groups.sort(key=lambda g: g.wasted_bytes, reverse=True) From 46984cb9eaeacaa13e2643f3cc79319ff5f9512f Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Fri, 22 May 2026 10:29:54 +0530 Subject: [PATCH 2/3] Docs: update architecture project structure --- docs/ARCHITECTURE.md | 171 ++++++++++++++++++++----------------------- 1 file changed, 81 insertions(+), 90 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6cb707..a6b5035 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,100 +22,91 @@ This document provides a comprehensive overview of the internal architecture, mo ## Project Structure ``` -mac-deep-cleaner/ -├── src/ -│ ├── __init__.py # Package initialization, version -│ ├── cli.py # Main CLI entry point (Click-based) -│ ├── constants.py # Global constants, paths, defaults -│ ├── utils.py # Utility functions (logging, bytes formatting) -│ │ -│ ├── config/ -│ │ ├── __init__.py -│ │ ├── config.py # Configuration loading, profiles -│ │ ├── history.py # Scan history management -│ │ └── models.py # Data models (JunkEntry, etc.) -│ │ -│ ├── core/ -│ │ ├── __init__.py -│ │ ├── scanner.py # Orphan and junk scanning logic -│ │ ├── cleaner.py # Cleanup execution engine -│ │ ├── safety.py # Safety checks, path validation -│ │ ├── dry_run.py # Dry-run mode handling -│ │ ├── undo.py # Undo/restore session management -│ │ ├── uninstaller.py # App uninstallation logic -│ │ ├── system_inspector.py # Launch items, SIP checks -│ │ ├── memory_pressure.py # Memory stats, purge -│ │ ├── brew_manager.py # Homebrew integration -│ │ ├── completions.py # Shell completion generation -│ │ ├── scheduler.py # LaunchAgent scheduling -│ │ ├── updater.py # PyPI update checking -│ │ ├── menubar.py # SwiftBar/xbar plugin -│ │ ├── config_sync.py # Multi-Mac config sync -│ │ ├── permissions_auditor.py # TCC database audit -│ │ ├── apfs_snapshots.py # APFS snapshot management -│ │ ├── breach_monitor.py # HIBP API integration -│ │ ├── dns_cache.py # DNS flush operations -│ │ ├── font_cache.py # Font cache rebuild -│ │ ├── spotlight.py # Spotlight index management -│ │ ├── power_optimizer.py # Power settings management -│ │ ├── update_checker.py # App update detection -│ │ ├── pkg_receipts.py # PKG receipt management -│ │ └── time_machine_guard.py # Time Machine status -│ │ -│ ├── scanners/ -│ │ ├── __init__.py -│ │ ├── discovery.py # App discovery (bundle IDs) -│ │ ├── matching.py # Bundle ID matching logic -│ │ ├── dev_junk.py # Developer junk detection -│ │ ├── duplicates.py # Duplicate file finder -│ │ ├── large_files.py # Large file scanner -│ │ ├── symlinks.py # Broken symlink detector -│ │ ├── space_map.py # Disk usage tree builder -│ │ ├── photos_analyzer.py # Photos library analyzer -│ │ ├── simulators.py # iOS simulator data scanner -│ │ ├── extras.py # iOS backups, language packs -│ │ ├── binary_thinner.py # Fat binary detector -│ │ ├── browser_data.py # Browser cache scanner -│ │ ├── cloud_junk.py # Cloud storage caches -│ │ ├── installer_hunter.py # Installer file finder -│ │ ├── xcode_cleaner.py # Xcode derived data -│ │ ├── purgeable.py # Purgeable space analysis -│ │ └── recent_activity.py # Recent items scanner -│ │ -│ └── reporting/ -│ ├── __init__.py -│ ├── reporter.py # Console report formatting -│ ├── exporter.py # JSON/YAML export -│ ├── html_report.py # HTML report generation -│ ├── weekly_digest.py # Weekly digest reports -│ ├── impact_score.py # Impact score calculation -│ └── storage_trend.py # Storage trend tracking -│ -├── tests/ -│ ├── test_scanner.py -│ ├── test_features_p0_p1.py -│ └── test_features_p2_p3.py -│ -├── docs/ +├── CHANGELOG.md +├── Formula +│ └── mac-deep-cleaner.rb +├── LICENSE +├── README.md +├── SECURITY.md +├── checklist.md +├── docs +│ ├── ARCHITECTURE.md │ ├── COMMAND_REFERENCE.md -│ ├── FEATURES.md -│ ├── ARCHITECTURE.md (this file) -│ └── PYPI_PUBLISHING.md -│ -├── scripts/ +├── pyproject.toml +├── requirements.txt +├── roadmap.md +├── scripts │ ├── build.sh │ └── build_pkg.sh -│ -├── Formula/ -│ └── mac-deep-cleaner.rb -│ -├── pyproject.toml ├── setup.py -├── requirements.txt -├── README.md -├── CHANGELOG.md -├── LICENSE -└── SECURITY.md +├── src +│ ├── __init__.py +│ ├── cli.py +│ ├── config +│ │ ├── config.py +│ │ ├── history.py +│ │ └── models.py +│ ├── constants.py +│ ├── core +│ │ ├── apfs_snapshots.py +│ │ ├── breach_monitor.py +│ │ ├── brew_manager.py +│ │ ├── cleaner.py +│ │ ├── completions.py +│ │ ├── config_sync.py +│ │ ├── dns_cache.py +│ │ ├── dry_run.py +│ │ ├── font_cache.py +│ │ ├── memory_pressure.py +│ │ ├── menubar.py +│ │ ├── permissions_auditor.py +│ │ ├── pkg_receipts.py +│ │ ├── power_optimizer.py +│ │ ├── restore_checksums.py +│ │ ├── safety.py +│ │ ├── scanner.py +│ │ ├── scheduler.py +│ │ ├── spotlight.py +│ │ ├── system_inspector.py +│ │ ├── time_machine_guard.py +│ │ ├── tui_picker.py +│ │ ├── undo.py +│ │ ├── uninstaller.py +│ │ ├── update_checker.py +│ │ └── updater.py +│ ├── reporting +│ │ ├── exporter.py +│ │ ├── html_report.py +│ │ ├── impact_score.py +│ │ ├── reporter.py +│ │ ├── reporter_v5.py +│ │ ├── storage_trend.py +│ │ └── weekly_digest.py +│ ├── scanners +│ │ ├── binary_thinner.py +│ │ ├── browser_data.py +│ │ ├── cloud_junk.py +│ │ ├── dev_junk.py +│ │ ├── discovery.py +│ │ ├── duplicates.py +│ │ ├── extras.py +│ │ ├── installer_hunter.py +│ │ ├── large_files.py +│ │ ├── matching.py +│ │ ├── photos_analyzer.py +│ │ ├── purgeable.py +│ │ ├── recent_activity.py +│ │ ├── simulators.py +│ │ ├── space_map.py +│ │ ├── symlinks.py +│ │ └── xcode_cleaner.py +│ └── utils.py +└── tests + ├── test_features_p0_p1.py + ├── test_features_p2_p3.py + └── test_scanner.py + +10 directories, 74 files ``` --- From e5e1aad29a57d116478ee6e4e3e7180dbdc9baff Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Fri, 22 May 2026 10:39:48 +0530 Subject: [PATCH 3/3] ci: fix release artifacts and develop auto-bump (#14 and #16) --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0802e..1118caa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,7 @@ jobs: with: name: dist-packages path: dist/ + if-no-files-found: error retention-days: 7 version-check: @@ -242,6 +243,19 @@ jobs: python-version: "3.11" cache: pip + - name: Download dist-packages artifact + uses: actions/download-artifact@v4 + with: + name: dist-packages + path: dist + + - name: Verify release artifacts exist + run: | + if ! ls -1 dist/* >/dev/null 2>&1; then + echo "dist artifacts missing after download" >&2 + exit 1 + fi + - name: Extract changelog section for this version id: changelog run: | @@ -303,6 +317,7 @@ jobs: tag_name: ${{ steps.changelog.outputs.tag }} name: "mac-deep-cleaner ${{ steps.changelog.outputs.tag }}" body_path: release_notes.md + files: dist/* draft: false prerelease: false env: @@ -322,20 +337,37 @@ jobs: permissions: contents: write # needed to push the commit steps: - - name: Checkout develop + - name: Checkout repository uses: actions/checkout@v4 with: - ref: develop fetch-depth: 1 persist-credentials: true + - name: Check develop branch exists + id: develop_check + run: | + if git ls-remote --heads origin develop | grep -q develop; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Develop branch not found; skipping auto-bump." + fi + + - name: Checkout develop + if: steps.develop_check.outputs.exists == 'true' + run: | + git fetch --depth=1 origin develop + git checkout -B develop FETCH_HEAD + - name: Set up Python 3.11 + if: steps.develop_check.outputs.exists == 'true' uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Bump patch version in pyproject.toml + if: steps.develop_check.outputs.exists == 'true' id: bump run: | python - <<'EOF' @@ -368,6 +400,7 @@ jobs: EOF - name: Commit and push version bump + if: steps.develop_check.outputs.exists == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: |