mac-upkeep 2.4.2__tar.gz → 2.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. mac_upkeep-2.5.1/.release-please-manifest.json +3 -0
  2. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/CHANGELOG.md +14 -0
  3. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/CLAUDE.md +13 -1
  4. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/PKG-INFO +1 -1
  5. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/pyproject.toml +1 -1
  6. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/cli.py +2 -2
  7. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/config.py +6 -0
  8. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/defaults.toml +10 -0
  9. mac_upkeep-2.5.1/src/mac_upkeep/editor_cache.py +213 -0
  10. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/tasks.py +4 -1
  11. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_cli.py +28 -0
  12. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_config.py +37 -13
  13. mac_upkeep-2.5.1/tests/test_editor_cache.py +350 -0
  14. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_tasks.py +4 -4
  15. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/uv.lock +1 -1
  16. mac_upkeep-2.4.2/.release-please-manifest.json +0 -3
  17. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/.github/workflows/release.yml +0 -0
  18. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/.github/workflows/test.yml +0 -0
  19. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/.gitignore +0 -0
  20. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/CONTRIBUTING.md +0 -0
  21. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/LICENSE +0 -0
  22. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/README.md +0 -0
  23. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/demo/demo.gif +0 -0
  24. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/demo/record.sh +0 -0
  25. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/docs/reusable-patterns.md +0 -0
  26. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/llms.txt +0 -0
  27. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/release-please-config.json +0 -0
  28. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/__init__.py +0 -0
  29. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/git_sync.py +0 -0
  30. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/notify.py +0 -0
  31. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/output.py +0 -0
  32. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/src/mac_upkeep/py.typed +0 -0
  33. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/__init__.py +0 -0
  34. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_git_sync.py +0 -0
  35. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_notify.py +0 -0
  36. {mac_upkeep-2.4.2 → mac_upkeep-2.5.1}/tests/test_output.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.5.1"
3
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.1](https://github.com/calvindotsg/mac-upkeep/compare/v2.5.0...v2.5.1) (2026-06-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * show handler tasks as ready in tasks dashboard, not "not found" ([#43](https://github.com/calvindotsg/mac-upkeep/issues/43)) ([788646a](https://github.com/calvindotsg/mac-upkeep/commit/788646aff95bb05f7589227691fd43b736f47ddc))
9
+
10
+ ## [2.5.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.4.2...v2.5.0) (2026-06-15)
11
+
12
+
13
+ ### Features
14
+
15
+ * add editor_cache task for Notion/Zed caches mole misses ([#41](https://github.com/calvindotsg/mac-upkeep/issues/41)) ([fd4d3e6](https://github.com/calvindotsg/mac-upkeep/commit/fd4d3e63968f88528095e7700f53dbf02f3d1fc8))
16
+
3
17
  ## [2.4.2](https://github.com/calvindotsg/mac-upkeep/compare/v2.4.1...v2.4.2) (2026-06-15)
4
18
 
5
19
 
@@ -65,7 +65,7 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
65
65
  - **Safety net**: prevents redundant runs from RunAtLoad boot triggers, launchd coalescing, and manual `mac-upkeep run`. `run_at_load true` is intentional — `StartCalendarInterval` does NOT coalesce from power-off (only sleep), so RunAtLoad is essential for laptops that reboot frequently.
66
66
  - Timestamps only update on successful non-dry-run execution. Corrupt/missing state file silently triggers re-run.
67
67
  - **`FREQUENCY_THRESHOLDS` is dual-purpose**: used for gating in `_should_run()` and for display in `format_next_run()`. `format_next_run()` accepts an optional `state` dict parameter to avoid redundant `_load_state()` calls — the `tasks` command pre-loads state once; `_run()` skip path omits it (one-off read is fine).
68
- - **Status column priority in `tasks` command**: `disabled → not found → ready` mirrors the check order in `run_task()` (disabled check then detection check) but is computed independently in `cli.py` using `td.enabled` and `shutil.which(td.detect)`. `td.detect` is already variable-resolved and auto-inferred by `Config.load()`, so `shutil.which(td.detect)` works directly — no raw TOML variable resolution needed.
68
+ - **Status column priority in `tasks` command**: `disabled → not found → ready` mirrors the check order in `run_task()` (disabled check then detection check) but is computed independently in `cli.py` using `td.enabled` and `shutil.which(td.detect)`. `td.detect` is already variable-resolved and auto-inferred by `Config.load()`, so `shutil.which(td.detect)` works directly — no raw TOML variable resolution needed. The detection check is guarded by `td.detect and ...`: handler tasks (e.g. `editor_cache`) carry an empty `detect`, so they short-circuit to `ready`/`disabled` instead of a misleading `not found` (`shutil.which("")` is `None`). This mirrors the execution gate in `_run_handler` (`if td.detect and not shutil.which(td.detect)`).
69
69
 
70
70
  ### Output and notifications
71
71
 
@@ -81,6 +81,18 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
81
81
 
82
82
  A TaskDef with `handler="<name>"` and empty `command` bypasses subprocess building: `run_all_tasks` routes it through `_run_handler`, which applies filter + frequency + `detect` gates then calls `HANDLERS[name](config, output, dry_run)`. Handler modules register themselves in `tasks._register_handlers()` (called at import). `KNOWN_HANDLERS` drives config validation — unknown handler names are rejected early. Handlers emit per-step output via `output.task_debug()` and return one aggregate `TaskResult`.
83
83
 
84
+ **Adding a handler is a 3-edit change** (see `editor_cache` as the second exemplar after `git_sync`): the module + `_register_handlers()`/`KNOWN_HANDLERS` line + a `[tasks.<name>]` block (with `handler=`, no `command`) in `defaults.toml` plus its `run.order` entry. **Gotcha:** every new `defaults.toml` task breaks fixtures that hardcode the task count and patched `KNOWN_HANDLERS` sets — grep `tests/` for the old count and `KNOWN_HANDLERS` monkeypatches (memory: "grep test fixtures when adding tasks to defaults.toml").
85
+
86
+ ### editor_cache handler
87
+
88
+ Reclaims Electron/editor caches mole's classifier structurally misses (`Service Worker`, `node/cache` don't match its `[Cc]ache|[Ll]og|...` regex). Source-validated against `tw93/mole`:
89
+
90
+ - **Surgical targeting**: clear `Service Worker/CacheStorage` (the bloat), never the parent `Service Worker/` — its `Database/` holds the SW registrations (mole never deletes it).
91
+ - **Running-app guard via `pgrep -x`** (mole's technique; robust under launchd, no TCC/GUI dependency unlike osascript) — deleting a running Electron app's cache can corrupt state, so the app must be closed.
92
+ - **Zed `node/cache` is npm's `--cache`** (`zed-industries/zed` `node_runtime.rs:603`): clearing only re-downloads tarballs on the next LSP install; installed servers keep working. Size-gated (`min_size_mb`, default 2048) to skip pointless re-downloads.
93
+ - **`_is_safe_target`** refuses anything not ≥2 segments below `~/Library/Application Support`, and refuses symlinks — mirrors mole's `validate_path_for_deletion`.
94
+ - Ships **`enabled = false`** (opt-in: it's `rm -rf` and reaches all users, like `pnpm`). Targets default to Notion + Zed; override with `[[editor_cache.apps]]` (`name`/`process`/`min_size_mb`/`targets`) in user config.
95
+
84
96
  ### sudo + HOME
85
97
 
86
98
  `sudo -n` with full path `$BREW_PREFIX/bin/mo`. Sudoers `env_keep += "HOME"` preserves user's home directory (otherwise `HOME=/var/root` and mole misses user caches). The `sudo` field in `TaskDef` exists instead of embedding `sudo` in the command so that: (a) dry-run can skip sudo, (b) `detect` infers the correct binary, (c) `mac-upkeep setup` can generate sudoers rules.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mac-upkeep
3
- Version: 2.4.2
3
+ Version: 2.5.1
4
4
  Summary: Automated macOS maintenance CLI
5
5
  Project-URL: Homepage, https://github.com/calvindotsg/mac-upkeep
6
6
  Project-URL: Repository, https://github.com/calvindotsg/mac-upkeep
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mac-upkeep"
3
- version = "2.4.2"
3
+ version = "2.5.1"
4
4
  description = "Automated macOS maintenance CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -196,7 +196,7 @@ def tasks() -> None:
196
196
  for name, td in task_list:
197
197
  if not td.enabled:
198
198
  status = "[dim]disabled[/dim]"
199
- elif shutil.which(td.detect) is None:
199
+ elif td.detect and shutil.which(td.detect) is None:
200
200
  status = "[yellow]not found[/yellow]"
201
201
  else:
202
202
  status = "[green]ready[/green]"
@@ -209,7 +209,7 @@ def tasks() -> None:
209
209
  for name, td in task_list:
210
210
  if not td.enabled:
211
211
  status = "disabled"
212
- elif shutil.which(td.detect) is None:
212
+ elif td.detect and shutil.which(td.detect) is None:
213
213
  status = "not found"
214
214
  else:
215
215
  status = "ready"
@@ -209,6 +209,7 @@ class Config:
209
209
  notify_sound: str = "Submarine"
210
210
  git_sync_repos: list[str] = field(default_factory=list)
211
211
  git_sync_skip_dirty: bool = True
212
+ editor_cache_apps: list[dict] = field(default_factory=list)
212
213
 
213
214
  @classmethod
214
215
  def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> Config:
@@ -235,6 +236,11 @@ class Config:
235
236
  config.git_sync_repos = list(gs.get("repos", []))
236
237
  config.git_sync_skip_dirty = bool(gs.get("skip_dirty", True))
237
238
 
239
+ # Extract editor_cache app overrides from user config (else handler uses
240
+ # its built-in DEFAULT_APPS).
241
+ if user_data and "editor_cache" in user_data:
242
+ config.editor_cache_apps = list(user_data["editor_cache"].get("apps", []))
243
+
238
244
  # Extract brewfile from user config
239
245
  if user_data and "paths" in user_data and "brewfile" in user_data["paths"]:
240
246
  config.brewfile = user_data["paths"]["brewfile"]
@@ -75,6 +75,15 @@ handler = "git_sync"
75
75
  detect = "git"
76
76
  frequency = "daily"
77
77
 
78
+ [tasks.editor_cache]
79
+ description = "Clear Electron/editor caches when their apps are closed"
80
+ handler = "editor_cache"
81
+ frequency = "monthly"
82
+ # Opt-in: deletes app caches (rm -rf) and ships to all users. Enable per-machine
83
+ # via `[tasks.editor_cache]\nenabled = true`. Targets Notion + Zed by default;
84
+ # override with [[editor_cache.apps]] in your config.
85
+ enabled = false
86
+
78
87
  [run]
79
88
  order = [
80
89
  "brew_update",
@@ -89,4 +98,5 @@ order = [
89
98
  "brew_cleanup",
90
99
  "brew_bundle",
91
100
  "git_sync",
101
+ "editor_cache",
92
102
  ]
@@ -0,0 +1,213 @@
1
+ """Built-in editor_cache handler: reclaim Electron/editor caches mole can't see.
2
+
3
+ Apps like Notion (Electron) and Zed leave multi-GB caches under names that
4
+ mole's risk classifier ignores ("Service Worker", "node/cache"), so they are
5
+ never cleaned by `mo clean`. This handler targets those paths directly, but
6
+ only when the owning app is closed (deleting a running Electron app's cache can
7
+ corrupt its state) and, optionally, only above a size threshold.
8
+
9
+ Safety mirrors tw93/mole: surgical sub-folder targeting (e.g. the
10
+ `Service Worker/CacheStorage` bloat, never the sibling `Database` that holds the
11
+ service-worker registrations), a `pgrep -x` running-app guard (robust under
12
+ launchd, unlike osascript), and a path guard that refuses anything not strictly
13
+ under ~/Library/Application Support.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import stat
21
+ import subprocess
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from mac_upkeep.output import TaskResult
26
+
27
+ if TYPE_CHECKING:
28
+ from mac_upkeep.config import Config
29
+ from mac_upkeep.output import Output
30
+
31
+ # Built-in targets. Each app is cleaned only when its `process` is not running.
32
+ # `min_size_mb` gates a target: 0 = always clean when closed; >0 = only when the
33
+ # target exceeds the threshold (Zed re-downloads LSP tarballs, so small caches
34
+ # aren't worth the re-fetch). Paths use ~ and are expanded at run time.
35
+ DEFAULT_APPS: list[dict] = [
36
+ {
37
+ "name": "Notion",
38
+ "process": "Notion",
39
+ "min_size_mb": 0,
40
+ "targets": [
41
+ "~/Library/Application Support/Notion/Partitions/notion/Service Worker/CacheStorage",
42
+ "~/Library/Application Support/Notion/Partitions/notion/Cache",
43
+ "~/Library/Application Support/Notion/Partitions/notion/Code Cache",
44
+ "~/Library/Application Support/Notion/Partitions/notion/GPUCache",
45
+ ],
46
+ },
47
+ {
48
+ "name": "Zed",
49
+ "process": "zed",
50
+ "min_size_mb": 2048,
51
+ "targets": [
52
+ "~/Library/Application Support/Zed/node/cache",
53
+ ],
54
+ },
55
+ ]
56
+
57
+ # Deletion is refused for anything not strictly below this directory.
58
+ _SAFE_ROOT = Path.home() / "Library" / "Application Support"
59
+
60
+
61
+ def _dir_size(path: Path) -> int:
62
+ """Total bytes under path, following no symlinks. 0 if unreadable."""
63
+ total = 0
64
+ for root, _dirs, files in os.walk(path, followlinks=False):
65
+ for name in files:
66
+ fp = Path(root) / name
67
+ try:
68
+ st = fp.lstat()
69
+ except OSError:
70
+ continue
71
+ # Skip symlinks: count only real bytes that deletion reclaims. Test
72
+ # the mode bits from the single lstat above rather than a second
73
+ # os.path.islink syscall (avoids a stat-vs-stat TOCTOU).
74
+ if not stat.S_ISLNK(st.st_mode):
75
+ total += st.st_size
76
+ return total
77
+
78
+
79
+ def _pgrep_running(process: str) -> bool:
80
+ """True if a process named exactly `process` appears to be running (pgrep -x).
81
+
82
+ Fail-closed: any uncertainty returns True so the caller skips deletion. An
83
+ empty/missing process name, a pgrep that errors (exit >=2 = bad args /
84
+ internal error, distinct from exit 1 = no match), or an OS/timeout error all
85
+ count as "assume running, don't delete".
86
+ """
87
+ if not process:
88
+ return True
89
+ try:
90
+ result = subprocess.run(
91
+ ["pgrep", "-x", process],
92
+ capture_output=True,
93
+ stdin=subprocess.DEVNULL,
94
+ timeout=10,
95
+ )
96
+ except (OSError, subprocess.TimeoutExpired):
97
+ return True
98
+ # pgrep exit codes: 0 = match (running), 1 = no match (not running),
99
+ # >=2 = pgrep itself errored → fail-closed (treat as running).
100
+ return result.returncode != 1
101
+
102
+
103
+ def _is_safe_target(path: Path) -> bool:
104
+ """Guard before any rmtree: refuse anything not safely deep under _SAFE_ROOT.
105
+
106
+ Mirrors mole's validate_path_for_deletion: the resolved path must live
107
+ strictly inside ~/Library/Application Support, be at least two segments below
108
+ it (so an app's whole support dir can never be the target), and not be a
109
+ symlink (which could redirect deletion elsewhere).
110
+ """
111
+ if path.is_symlink():
112
+ return False
113
+ try:
114
+ resolved = path.resolve()
115
+ root = _SAFE_ROOT.resolve()
116
+ except OSError:
117
+ return False
118
+ if not resolved.is_relative_to(root):
119
+ return False
120
+ return len(resolved.relative_to(root).parts) >= 2
121
+
122
+
123
+ def _clean_target(target: Path, min_size_mb: int, output: Output, dry_run: bool) -> int:
124
+ """Clean one target dir if eligible. Returns bytes freed (0 if skipped)."""
125
+ if not target.is_dir():
126
+ # A path that exists but isn't a directory is almost certainly a
127
+ # misconfigured target — surface it (the handler's debug output is the
128
+ # only feedback channel under launchd). A missing path is normal (most
129
+ # machines lack most default targets), so stay silent there.
130
+ if target.exists():
131
+ output.task_debug(f" skipped (not a directory): {target}")
132
+ return 0
133
+ if not _is_safe_target(target):
134
+ output.task_debug(f" refused unsafe path: {target}")
135
+ return 0
136
+ # Operate on the canonical resolved path so the validated path and the
137
+ # deleted path are identical — closes any symlink-swap TOCTOU between the
138
+ # _is_safe_target check and the rmtree. Re-validate the resolved path.
139
+ try:
140
+ safe = target.resolve(strict=True)
141
+ except OSError:
142
+ return 0
143
+ if not _is_safe_target(safe):
144
+ output.task_debug(f" refused unsafe path: {target}")
145
+ return 0
146
+
147
+ size = _dir_size(safe)
148
+ size_mb = size // (1024 * 1024)
149
+ if size_mb < min_size_mb:
150
+ output.task_debug(f" {target.name}: {size_mb}MB below {min_size_mb}MB threshold, skipped")
151
+ return 0
152
+
153
+ if dry_run:
154
+ output.task_debug(f" would clean {target.name}: {size_mb}MB")
155
+ return size
156
+
157
+ try:
158
+ shutil.rmtree(safe)
159
+ except OSError as exc:
160
+ output.task_debug(f" failed to clean {target.name}: {exc}")
161
+ raise
162
+ output.task_debug(f" cleaned {target.name}: {size_mb}MB")
163
+ return size
164
+
165
+
166
+ def run_editor_cache(config: Config, output: Output, dry_run: bool) -> TaskResult:
167
+ """Handler entry point. Clear configured editor caches for closed apps."""
168
+ apps = config.editor_cache_apps or DEFAULT_APPS
169
+
170
+ total_freed = 0
171
+ n_cleaned = 0
172
+ n_running = 0
173
+ failures: list[str] = []
174
+
175
+ for app in apps:
176
+ name = app.get("name", "?")
177
+ process = app.get("process", "")
178
+ min_size_mb = int(app.get("min_size_mb", 0))
179
+ targets = [Path(os.path.expanduser(t)) for t in app.get("targets", [])]
180
+
181
+ if _pgrep_running(process):
182
+ output.task_debug(f"{name}: skipped, app is running")
183
+ n_running += 1
184
+ continue
185
+
186
+ app_freed = 0
187
+ try:
188
+ for target in targets:
189
+ app_freed += _clean_target(target, min_size_mb, output, dry_run)
190
+ except OSError:
191
+ failures.append(name)
192
+ continue
193
+
194
+ if app_freed:
195
+ n_cleaned += 1
196
+ total_freed += app_freed
197
+
198
+ if failures:
199
+ return TaskResult(
200
+ "editor_cache", "failed", reason=f"{len(failures)} failed: {', '.join(failures)}"
201
+ )
202
+
203
+ freed_mb = total_freed // (1024 * 1024)
204
+ if dry_run:
205
+ return TaskResult("editor_cache", "ok", reason=f"dry-run: would free {freed_mb}MB")
206
+
207
+ parts: list[str] = []
208
+ if n_cleaned:
209
+ parts.append(f"{freed_mb}MB freed")
210
+ if n_running:
211
+ parts.append(f"{n_running} running")
212
+ reason = ", ".join(parts) if parts else "nothing to clean"
213
+ return TaskResult("editor_cache", "ok", reason=reason)
@@ -48,11 +48,14 @@ KNOWN_HANDLERS: set[str] = set() # kept in sync with HANDLERS; read by config v
48
48
 
49
49
  def _register_handlers() -> None:
50
50
  """Register built-in handlers. Local import avoids any import-cycle risk."""
51
- from mac_upkeep import git_sync
51
+ from mac_upkeep import editor_cache, git_sync
52
52
 
53
53
  HANDLERS["git_sync"] = git_sync.run_git_sync
54
54
  KNOWN_HANDLERS.add("git_sync")
55
55
 
56
+ HANDLERS["editor_cache"] = editor_cache.run_editor_cache
57
+ KNOWN_HANDLERS.add("editor_cache")
58
+
56
59
 
57
60
  _register_handlers()
58
61
 
@@ -65,6 +65,34 @@ def test_tasks_shows_not_found_status(tmp_path):
65
65
  assert "not found" in result.output
66
66
 
67
67
 
68
+ def test_tasks_handler_without_detect_shows_ready():
69
+ # A handler task has no `detect` binary; even when shutil.which() returns None
70
+ # for everything, it must show "ready" (or "disabled") — never "not found".
71
+ from mac_upkeep.config import Config, TaskDef
72
+
73
+ cfg = Config()
74
+ cfg.task_defs = {
75
+ "editor_cache": TaskDef(
76
+ name="editor_cache",
77
+ description="handler task",
78
+ command="",
79
+ handler="editor_cache",
80
+ detect="",
81
+ enabled=True,
82
+ )
83
+ }
84
+ cfg.run_order = ["editor_cache"]
85
+ with (
86
+ patch("mac_upkeep.cli.Config.load", return_value=cfg),
87
+ patch("mac_upkeep.cli.shutil.which", return_value=None),
88
+ ):
89
+ result = runner.invoke(app, ["tasks"])
90
+ assert result.exit_code == 0
91
+ line = next(line for line in result.output.splitlines() if line.startswith("editor_cache"))
92
+ assert "ready" in line
93
+ assert "not found" not in line
94
+
95
+
68
96
  def test_force_invalid_shows_valid_tasks():
69
97
  result = runner.invoke(app, ["run", "--force", "nonexistent"])
70
98
  assert result.exit_code == 1
@@ -34,7 +34,7 @@ def test_task_def_defaults():
34
34
  def test_load_defaults_returns_all_tasks():
35
35
  data = _load_defaults()
36
36
  assert "tasks" in data
37
- assert len(data["tasks"]) == 12
37
+ assert len(data["tasks"]) == 13
38
38
  assert "brew_update" in data["tasks"]
39
39
  assert "brew_bundle" in data["tasks"]
40
40
 
@@ -44,8 +44,8 @@ def test_load_defaults_has_run_order():
44
44
  assert "run" in data
45
45
  order = data["run"]["order"]
46
46
  assert order[0] == "brew_update"
47
- assert order[-1] == "git_sync"
48
- assert len(order) == 12
47
+ assert order[-1] == "editor_cache"
48
+ assert len(order) == 13
49
49
 
50
50
 
51
51
  # --- load_default_task_names ---
@@ -53,10 +53,10 @@ def test_load_defaults_has_run_order():
53
53
 
54
54
  def test_load_default_task_names():
55
55
  tasks, order = load_default_task_names()
56
- assert len(tasks) == 12
56
+ assert len(tasks) == 13
57
57
  assert tasks["brew_update"] == "Update Homebrew package database"
58
58
  assert order[0] == "brew_update"
59
- assert order[-1] == "git_sync"
59
+ assert order[-1] == "editor_cache"
60
60
 
61
61
 
62
62
  # --- resolve_variables ---
@@ -93,7 +93,7 @@ def test_resolve_variables_no_vars():
93
93
  def test_load_task_defs_defaults_only():
94
94
  variables = {"BREW_PREFIX": "/opt/homebrew", "BREWFILE": "", "HOME": "/users/me"}
95
95
  task_defs, run_order = load_task_defs(None, variables)
96
- assert len(task_defs) == 12
96
+ assert len(task_defs) == 13
97
97
  assert "brew_update" in task_defs
98
98
  assert task_defs["brew_update"].command == "brew update"
99
99
  assert task_defs["brew_update"].detect == "brew"
@@ -103,7 +103,7 @@ def test_load_task_defs_defaults_only():
103
103
  assert task_defs["mo_clean"].command == "/opt/homebrew/bin/mo clean"
104
104
  assert task_defs["fisher"].shell == "fish --interactive -c"
105
105
  assert run_order[0] == "brew_update"
106
- assert run_order[-1] == "git_sync"
106
+ assert run_order[-1] == "editor_cache"
107
107
 
108
108
 
109
109
  def test_load_task_defs_user_override():
@@ -235,7 +235,7 @@ def test_validation_empty_command():
235
235
  def test_validation_command_and_handler_conflict(monkeypatch):
236
236
  import pytest
237
237
 
238
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
238
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
239
239
  variables = {"BREW_PREFIX": "/opt/homebrew", "BREWFILE": "", "HOME": "/users/me"}
240
240
  user_data = {
241
241
  "tasks": {
@@ -253,8 +253,8 @@ def test_validation_command_and_handler_conflict(monkeypatch):
253
253
  def test_validation_unknown_handler(monkeypatch):
254
254
  import pytest
255
255
 
256
- # Keep git_sync registered so default task validates; only "nope" is unknown
257
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"git_sync"})
256
+ # Keep default handlers registered so default tasks validate; only "nope" is unknown
257
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"git_sync", "editor_cache"})
258
258
  variables = {"BREW_PREFIX": "/opt/homebrew", "BREWFILE": "", "HOME": "/users/me"}
259
259
  user_data = {
260
260
  "tasks": {
@@ -270,7 +270,7 @@ def test_validation_unknown_handler(monkeypatch):
270
270
 
271
271
 
272
272
  def test_validation_accepts_handler_with_empty_command(monkeypatch):
273
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
273
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
274
274
  variables = {"BREW_PREFIX": "/opt/homebrew", "BREWFILE": "", "HOME": "/users/me"}
275
275
  user_data = {
276
276
  "tasks": {
@@ -346,7 +346,7 @@ def test_custom_task_appended_to_default_order():
346
346
  # Custom task appended after all default tasks
347
347
  assert "my_task" in run_order
348
348
  assert run_order[-1] == "my_task"
349
- assert len(run_order) == 13
349
+ assert len(run_order) == 14
350
350
 
351
351
 
352
352
  def test_custom_task_not_duplicated_with_explicit_order():
@@ -371,12 +371,36 @@ def test_custom_task_not_duplicated_with_explicit_order():
371
371
 
372
372
  def test_load_nonexistent_config_returns_defaults():
373
373
  config = Config.load(Path("/nonexistent/config.toml"))
374
- assert len(config.task_defs) == 12
374
+ assert len(config.task_defs) == 13
375
375
  assert config.run_order[0] == "brew_update"
376
376
  assert config.is_enabled("brew_update") is True
377
377
  assert config.get_frequency("gcloud") == "monthly"
378
378
 
379
379
 
380
+ def test_load_editor_cache_apps(tmp_path):
381
+ cfg_path = tmp_path / "config.toml"
382
+ cfg_path.write_text(
383
+ "[[editor_cache.apps]]\n"
384
+ 'name = "Notion"\n'
385
+ 'process = "Notion"\n'
386
+ "min_size_mb = 0\n"
387
+ 'targets = ["~/Library/Application Support/Notion/X"]\n'
388
+ )
389
+ config = Config.load(cfg_path)
390
+ assert len(config.editor_cache_apps) == 1
391
+ app = config.editor_cache_apps[0]
392
+ assert app["name"] == "Notion"
393
+ assert app["process"] == "Notion"
394
+ assert app["targets"] == ["~/Library/Application Support/Notion/X"]
395
+
396
+
397
+ def test_load_editor_cache_apps_absent_defaults_empty(tmp_path):
398
+ cfg_path = tmp_path / "config.toml"
399
+ cfg_path.write_text('[tasks.brew_update]\nfrequency = "monthly"\n')
400
+ config = Config.load(cfg_path)
401
+ assert config.editor_cache_apps == []
402
+
403
+
380
404
  def test_config_is_enabled():
381
405
  config = Config.load(Path("/nonexistent/config.toml"))
382
406
  assert config.is_enabled("brew_update") is True
@@ -0,0 +1,350 @@
1
+ """Tests for the editor_cache handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from unittest.mock import MagicMock
8
+
9
+ from mac_upkeep import editor_cache
10
+ from mac_upkeep.config import Config
11
+ from mac_upkeep.editor_cache import (
12
+ DEFAULT_APPS,
13
+ _dir_size,
14
+ _is_safe_target,
15
+ _pgrep_running,
16
+ run_editor_cache,
17
+ )
18
+
19
+
20
+ def _config(apps: list[dict] | None = None) -> Config:
21
+ config = Config.load()
22
+ config.editor_cache_apps = apps if apps is not None else []
23
+ return config
24
+
25
+
26
+ def _app(name: str, targets: list[str], *, process: str = "FakeApp", min_size_mb: int = 0) -> dict:
27
+ return {"name": name, "process": process, "min_size_mb": min_size_mb, "targets": targets}
28
+
29
+
30
+ def _make_cache(root, *parts, size: int = 1024) -> str:
31
+ """Create a cache dir at least two levels under root with `size` bytes inside."""
32
+ d = root.joinpath(*parts)
33
+ d.mkdir(parents=True, exist_ok=True)
34
+ (d / "blob").write_bytes(b"x" * size)
35
+ return str(d)
36
+
37
+
38
+ def _use_tmp_root(monkeypatch, tmp_path, *, running: bool = False) -> None:
39
+ """Point the safety root at tmp_path and control the pgrep guard."""
40
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path)
41
+ monkeypatch.setattr(editor_cache, "_pgrep_running", lambda _proc: running)
42
+
43
+
44
+ # --- _dir_size ---
45
+
46
+
47
+ def test_dir_size_sums_files(tmp_path):
48
+ (tmp_path / "a").write_bytes(b"x" * 100)
49
+ sub = tmp_path / "sub"
50
+ sub.mkdir()
51
+ (sub / "b").write_bytes(b"y" * 250)
52
+ assert _dir_size(tmp_path) == 350
53
+
54
+
55
+ def test_dir_size_ignores_symlinks(tmp_path):
56
+ real = tmp_path / "real"
57
+ real.write_bytes(b"x" * 500)
58
+ (tmp_path / "link").symlink_to(real)
59
+ # Only the real 500 bytes count; the symlink adds nothing.
60
+ assert _dir_size(tmp_path) == 500
61
+
62
+
63
+ # --- _pgrep_running ---
64
+
65
+
66
+ def test_pgrep_running_true(monkeypatch):
67
+ monkeypatch.setattr(
68
+ editor_cache.subprocess,
69
+ "run",
70
+ lambda *a, **k: subprocess.CompletedProcess(a, returncode=0),
71
+ )
72
+ assert _pgrep_running("Notion") is True
73
+
74
+
75
+ def test_pgrep_running_false(monkeypatch):
76
+ monkeypatch.setattr(
77
+ editor_cache.subprocess,
78
+ "run",
79
+ lambda *a, **k: subprocess.CompletedProcess(a, returncode=1),
80
+ )
81
+ assert _pgrep_running("Notion") is False
82
+
83
+
84
+ def test_pgrep_running_assumes_running_on_error(monkeypatch):
85
+ def _boom(*a, **k):
86
+ raise OSError("pgrep missing")
87
+
88
+ monkeypatch.setattr(editor_cache.subprocess, "run", _boom)
89
+ # Fail closed: if we can't tell, never delete.
90
+ assert _pgrep_running("Notion") is True
91
+
92
+
93
+ def test_pgrep_running_error_code_assumes_running(monkeypatch):
94
+ # pgrep exit >=2 = pgrep itself errored (bad args/internal), NOT "no match".
95
+ monkeypatch.setattr(
96
+ editor_cache.subprocess,
97
+ "run",
98
+ lambda *a, **k: subprocess.CompletedProcess(a, returncode=2),
99
+ )
100
+ assert _pgrep_running("--weird") is True
101
+
102
+
103
+ def test_pgrep_running_timeout_assumes_running(monkeypatch):
104
+ def _timeout(*a, **k):
105
+ raise subprocess.TimeoutExpired(cmd=["pgrep"], timeout=10)
106
+
107
+ monkeypatch.setattr(editor_cache.subprocess, "run", _timeout)
108
+ assert _pgrep_running("Notion") is True
109
+
110
+
111
+ def test_pgrep_running_empty_process_assumes_running():
112
+ # A missing `process` must not silently bypass the running-app guard.
113
+ assert _pgrep_running("") is True
114
+
115
+
116
+ # --- _is_safe_target ---
117
+
118
+
119
+ def test_is_safe_target_accepts_deep_path(monkeypatch, tmp_path):
120
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path)
121
+ target = tmp_path / "Notion" / "CacheStorage"
122
+ target.mkdir(parents=True)
123
+ assert _is_safe_target(target) is True
124
+
125
+
126
+ def test_is_safe_target_rejects_root_and_app_dir(monkeypatch, tmp_path):
127
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path)
128
+ assert _is_safe_target(tmp_path) is False # the root itself
129
+ app = tmp_path / "Notion"
130
+ app.mkdir()
131
+ assert _is_safe_target(app) is False # one segment below root
132
+
133
+
134
+ def test_is_safe_target_rejects_outside_root(monkeypatch, tmp_path):
135
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path / "inside")
136
+ (tmp_path / "inside").mkdir()
137
+ outside = tmp_path / "elsewhere" / "cache"
138
+ outside.mkdir(parents=True)
139
+ assert _is_safe_target(outside) is False
140
+
141
+
142
+ def test_is_safe_target_rejects_symlink(monkeypatch, tmp_path):
143
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path)
144
+ real = tmp_path / "Notion" / "CacheStorage"
145
+ real.mkdir(parents=True)
146
+ link = tmp_path / "Notion" / "link"
147
+ link.symlink_to(real)
148
+ assert _is_safe_target(link) is False
149
+
150
+
151
+ # --- run_editor_cache ---
152
+
153
+
154
+ def test_skips_when_app_running(monkeypatch, tmp_path):
155
+ _use_tmp_root(monkeypatch, tmp_path, running=True)
156
+ target = _make_cache(tmp_path, "Notion", "CacheStorage", size=4096)
157
+ config = _config([_app("Notion", [target])])
158
+ output = MagicMock()
159
+
160
+ result = run_editor_cache(config, output, dry_run=False)
161
+
162
+ assert os.path.isdir(target) # not deleted
163
+ assert result.status == "ok"
164
+ assert "running" in result.reason
165
+
166
+
167
+ def test_cleans_when_app_closed(monkeypatch, tmp_path):
168
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
169
+ target = _make_cache(tmp_path, "Notion", "CacheStorage", size=1024)
170
+ config = _config([_app("Notion", [target])])
171
+ output = MagicMock()
172
+
173
+ result = run_editor_cache(config, output, dry_run=False)
174
+
175
+ assert not os.path.exists(target) # deleted
176
+ assert result.status == "ok"
177
+ assert "MB freed" in result.reason
178
+
179
+
180
+ def test_reason_combines_freed_and_running(monkeypatch, tmp_path):
181
+ # Two apps: one running (skipped), one closed with a large cache (cleaned).
182
+ # Exercises the ", ".join(parts) path with both segments populated.
183
+ monkeypatch.setattr(editor_cache, "_SAFE_ROOT", tmp_path)
184
+ monkeypatch.setattr(editor_cache, "_pgrep_running", lambda proc: proc == "Zed")
185
+ closed = _make_cache(tmp_path, "Notion", "CacheStorage", size=1024)
186
+ config = _config(
187
+ [
188
+ _app("Notion", [closed], process="Notion"),
189
+ _app("Zed", [str(tmp_path / "Zed" / "cache")], process="Zed"),
190
+ ]
191
+ )
192
+ output = MagicMock()
193
+
194
+ result = run_editor_cache(config, output, dry_run=False)
195
+
196
+ assert result.status == "ok"
197
+ assert "MB freed" in result.reason
198
+ assert "running" in result.reason
199
+
200
+
201
+ def test_size_gate_skips_small_cache(monkeypatch, tmp_path):
202
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
203
+ target = _make_cache(tmp_path, "Zed", "node", "cache", size=1024)
204
+ config = _config([_app("Zed", [target], min_size_mb=2048)])
205
+ output = MagicMock()
206
+ # Report a sub-threshold size without writing 2 GB.
207
+ monkeypatch.setattr(editor_cache, "_dir_size", lambda _p: 100 * 1024 * 1024)
208
+
209
+ result = run_editor_cache(config, output, dry_run=False)
210
+
211
+ assert os.path.isdir(target) # below threshold → kept
212
+ assert result.reason == "nothing to clean"
213
+
214
+
215
+ def test_size_gate_cleans_large_cache(monkeypatch, tmp_path):
216
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
217
+ target = _make_cache(tmp_path, "Zed", "node", "cache", size=1024)
218
+ config = _config([_app("Zed", [target], min_size_mb=2048)])
219
+ output = MagicMock()
220
+ # Report an over-threshold size without writing 3 GB.
221
+ monkeypatch.setattr(editor_cache, "_dir_size", lambda _p: 3000 * 1024 * 1024)
222
+
223
+ result = run_editor_cache(config, output, dry_run=False)
224
+
225
+ assert not os.path.exists(target) # above threshold → cleaned
226
+ assert "3000MB freed" in result.reason
227
+
228
+
229
+ def test_dry_run_deletes_nothing(monkeypatch, tmp_path):
230
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
231
+ target = _make_cache(tmp_path, "Notion", "CacheStorage", size=1024)
232
+ config = _config([_app("Notion", [target])])
233
+ output = MagicMock()
234
+
235
+ result = run_editor_cache(config, output, dry_run=True)
236
+
237
+ assert os.path.isdir(target) # preserved
238
+ assert result.status == "ok"
239
+ assert "would free" in result.reason
240
+ assert any("would clean" in c[0][0] for c in output.task_debug.call_args_list)
241
+
242
+
243
+ def test_dry_run_with_running_app(monkeypatch, tmp_path):
244
+ # dry-run + every app running: exercises the dry-run early return when
245
+ # nothing would be freed (n_running > 0).
246
+ _use_tmp_root(monkeypatch, tmp_path, running=True)
247
+ target = _make_cache(tmp_path, "Notion", "CacheStorage", size=1024)
248
+ config = _config([_app("Notion", [target])])
249
+ output = MagicMock()
250
+
251
+ result = run_editor_cache(config, output, dry_run=True)
252
+
253
+ assert os.path.isdir(target) # nothing deleted
254
+ assert result.status == "ok"
255
+ assert result.reason == "dry-run: would free 0MB"
256
+
257
+
258
+ def test_missing_target_is_skipped(monkeypatch, tmp_path):
259
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
260
+ missing = str(tmp_path / "Notion" / "CacheStorage") # never created
261
+ config = _config([_app("Notion", [missing])])
262
+ output = MagicMock()
263
+
264
+ result = run_editor_cache(config, output, dry_run=False)
265
+
266
+ assert result.status == "ok"
267
+ assert result.reason == "nothing to clean"
268
+
269
+
270
+ def test_empty_config_falls_back_to_default_apps(monkeypatch):
271
+ # No app override → DEFAULT_APPS used. Force every app "running" so nothing
272
+ # is deleted; the running count proves all defaults were iterated.
273
+ monkeypatch.setattr(editor_cache, "_pgrep_running", lambda _proc: True)
274
+ config = _config([])
275
+ output = MagicMock()
276
+
277
+ result = run_editor_cache(config, output, dry_run=False)
278
+
279
+ assert result.status == "ok"
280
+ assert result.reason == f"{len(DEFAULT_APPS)} running"
281
+
282
+
283
+ def test_custom_apps_replace_default_apps(monkeypatch):
284
+ # A non-empty editor_cache_apps must REPLACE DEFAULT_APPS, not merge with it.
285
+ checked: list[str] = []
286
+ monkeypatch.setattr(editor_cache, "_pgrep_running", lambda proc: checked.append(proc) or True)
287
+ config = _config([_app("Custom", ["~/x"], process="CustomProc")])
288
+
289
+ run_editor_cache(config, MagicMock(), dry_run=False)
290
+
291
+ # Only the custom app's process is consulted — no Notion/zed from DEFAULT_APPS.
292
+ assert checked == ["CustomProc"]
293
+
294
+
295
+ def test_file_target_skipped_with_feedback(monkeypatch, tmp_path):
296
+ # A target that exists but is a file (misconfig) is skipped WITH a debug line.
297
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
298
+ appdir = tmp_path / "App"
299
+ appdir.mkdir()
300
+ file_target = appdir / "cache"
301
+ file_target.write_text("x")
302
+ config = _config([_app("App", [str(file_target)])])
303
+ output = MagicMock()
304
+
305
+ result = run_editor_cache(config, output, dry_run=False)
306
+
307
+ assert os.path.isfile(file_target) # not deleted
308
+ assert result.status == "ok"
309
+ assert any("not a directory" in c[0][0] for c in output.task_debug.call_args_list)
310
+
311
+
312
+ def test_rmtree_failure_reports_failed(monkeypatch, tmp_path):
313
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
314
+ target = _make_cache(tmp_path, "Notion", "CacheStorage", size=1024)
315
+ config = _config([_app("Notion", [target])])
316
+ output = MagicMock()
317
+
318
+ def _boom(_path):
319
+ raise OSError("permission denied")
320
+
321
+ monkeypatch.setattr(editor_cache.shutil, "rmtree", _boom)
322
+
323
+ result = run_editor_cache(config, output, dry_run=False)
324
+
325
+ assert result.status == "failed"
326
+ assert "Notion" in result.reason
327
+
328
+
329
+ def test_multi_target_first_failure_aborts_remaining(monkeypatch, tmp_path):
330
+ # An app with multiple targets: a failure on target 1 marks the app failed
331
+ # and abandons its remaining targets (Notion ships 4 default targets).
332
+ _use_tmp_root(monkeypatch, tmp_path, running=False)
333
+ t1 = _make_cache(tmp_path, "App", "cache1", size=1024)
334
+ t2 = _make_cache(tmp_path, "App", "cache2", size=1024)
335
+ config = _config([_app("App", [t1, t2])])
336
+ output = MagicMock()
337
+ calls = {"n": 0}
338
+
339
+ def _boom(_path):
340
+ calls["n"] += 1
341
+ raise OSError("permission denied")
342
+
343
+ monkeypatch.setattr(editor_cache.shutil, "rmtree", _boom)
344
+
345
+ result = run_editor_cache(config, output, dry_run=False)
346
+
347
+ assert result.status == "failed"
348
+ assert "App" in result.reason
349
+ assert calls["n"] == 1 # bailed after target 1; target 2 never attempted
350
+ assert os.path.isdir(t2) # remaining target untouched
@@ -527,7 +527,7 @@ def test_handler_dispatch_runs_and_records_state(tmp_path, monkeypatch):
527
527
 
528
528
  handler, calls = _stub_handler()
529
529
  monkeypatch.setitem(HANDLERS, "stub", handler)
530
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
530
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
531
531
 
532
532
  config = Config.load()
533
533
  config.task_defs["stub_task"] = TaskDef(
@@ -558,7 +558,7 @@ def test_handler_dispatch_frequency_gate_skips(tmp_path, monkeypatch):
558
558
 
559
559
  handler, calls = _stub_handler()
560
560
  monkeypatch.setitem(HANDLERS, "stub", handler)
561
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
561
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
562
562
 
563
563
  config = Config.load()
564
564
  config.task_defs["stub_task"] = TaskDef(
@@ -585,7 +585,7 @@ def test_handler_dispatch_force_filter(tmp_path, monkeypatch):
585
585
 
586
586
  handler, calls = _stub_handler()
587
587
  monkeypatch.setitem(HANDLERS, "stub", handler)
588
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
588
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
589
589
 
590
590
  config = Config.load()
591
591
  config.task_defs["stub_task"] = TaskDef(
@@ -611,7 +611,7 @@ def test_handler_dispatch_detect_miss(tmp_path, monkeypatch):
611
611
 
612
612
  handler, calls = _stub_handler()
613
613
  monkeypatch.setitem(HANDLERS, "stub", handler)
614
- monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync"})
614
+ monkeypatch.setattr("mac_upkeep.tasks.KNOWN_HANDLERS", {"stub", "git_sync", "editor_cache"})
615
615
  monkeypatch.setattr("mac_upkeep.tasks.shutil.which", lambda _: None)
616
616
 
617
617
  config = Config.load()
@@ -43,7 +43,7 @@ wheels = [
43
43
 
44
44
  [[package]]
45
45
  name = "mac-upkeep"
46
- version = "2.4.2"
46
+ version = "2.5.1"
47
47
  source = { editable = "." }
48
48
  dependencies = [
49
49
  { name = "typer" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "2.4.2"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes