mac-upkeep 2.4.2__tar.gz → 2.5.0__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.
- mac_upkeep-2.5.0/.release-please-manifest.json +3 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/CHANGELOG.md +7 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/CLAUDE.md +12 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/PKG-INFO +1 -1
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/pyproject.toml +1 -1
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/config.py +6 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/defaults.toml +10 -0
- mac_upkeep-2.5.0/src/mac_upkeep/editor_cache.py +213 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/tasks.py +4 -1
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_config.py +37 -13
- mac_upkeep-2.5.0/tests/test_editor_cache.py +350 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_tasks.py +4 -4
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/uv.lock +1 -1
- mac_upkeep-2.4.2/.release-please-manifest.json +0 -3
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/.github/workflows/release.yml +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/.github/workflows/test.yml +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/.gitignore +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/CONTRIBUTING.md +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/LICENSE +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/README.md +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/demo/demo.gif +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/demo/record.sh +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/docs/reusable-patterns.md +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/llms.txt +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/release-please-config.json +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/__init__.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/cli.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/git_sync.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/notify.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/output.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/src/mac_upkeep/py.typed +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/__init__.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_cli.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_git_sync.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_notify.py +0 -0
- {mac_upkeep-2.4.2 → mac_upkeep-2.5.0}/tests/test_output.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.5.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.4.2...v2.5.0) (2026-06-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
|
|
3
10
|
## [2.4.2](https://github.com/calvindotsg/mac-upkeep/compare/v2.4.1...v2.4.2) (2026-06-15)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -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"]) ==
|
|
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] == "
|
|
48
|
-
assert len(order) ==
|
|
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) ==
|
|
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] == "
|
|
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) ==
|
|
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] == "
|
|
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
|
|
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) ==
|
|
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) ==
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|