mac-upkeep 2.3.0__tar.gz → 2.4.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.
Files changed (34) hide show
  1. mac_upkeep-2.4.0/.release-please-manifest.json +3 -0
  2. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/CHANGELOG.md +7 -0
  3. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/CLAUDE.md +8 -2
  4. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/PKG-INFO +28 -2
  5. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/README.md +27 -1
  6. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/docs/reusable-patterns.md +1 -0
  7. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/llms.txt +3 -3
  8. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/pyproject.toml +1 -1
  9. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/cli.py +9 -1
  10. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/config.py +22 -4
  11. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/defaults.toml +7 -0
  12. mac_upkeep-2.4.0/src/mac_upkeep/git_sync.py +138 -0
  13. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/tasks.py +117 -14
  14. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/test_cli.py +2 -1
  15. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/test_config.py +83 -13
  16. mac_upkeep-2.4.0/tests/test_git_sync.py +279 -0
  17. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/test_tasks.py +206 -3
  18. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/uv.lock +1 -1
  19. mac_upkeep-2.3.0/.release-please-manifest.json +0 -3
  20. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/.github/workflows/release.yml +0 -0
  21. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/.github/workflows/test.yml +0 -0
  22. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/.gitignore +0 -0
  23. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/CONTRIBUTING.md +0 -0
  24. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/LICENSE +0 -0
  25. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/demo/demo.gif +0 -0
  26. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/demo/record.sh +0 -0
  27. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/release-please-config.json +0 -0
  28. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/__init__.py +0 -0
  29. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/notify.py +0 -0
  30. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/output.py +0 -0
  31. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/src/mac_upkeep/py.typed +0 -0
  32. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/__init__.py +0 -0
  33. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/test_notify.py +0 -0
  34. {mac_upkeep-2.3.0 → mac_upkeep-2.4.0}/tests/test_output.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.3.0...v2.4.0) (2026-04-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * add git_sync task with daily frequency and handler dispatch ([#35](https://github.com/calvindotsg/mac-upkeep/issues/35)) ([5f4628f](https://github.com/calvindotsg/mac-upkeep/commit/5f4628f5eaae9ddc6941fff72c2eb4cd841390ee))
9
+
3
10
  ## [2.3.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.1...v2.3.0) (2026-04-13)
4
11
 
5
12
 
@@ -19,7 +19,7 @@
19
19
  ## Architecture
20
20
 
21
21
  ```
22
- defaults.toml → bundled task definitions (11 tasks), loaded via importlib.resources
22
+ defaults.toml → bundled task definitions, loaded via importlib.resources
23
23
  config.py → TaskDef dataclass, load_task_defs(), resolve_variables(), get_brew_prefix(),
24
24
  Config.load() (3-layer merge: defaults.toml → user config → env vars)
25
25
  tasks.py → _build_cmd(), run_task(), _run(), run_all_tasks() data-driven loop,
@@ -61,7 +61,7 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
61
61
 
62
62
  ### Frequency scheduling
63
63
 
64
- - Thresholds are 6 days for weekly and 27 days for monthly (not 7/30 — buffer for launchd schedule drift after sleep/reboot). State tracked in `~/.local/state/mac-upkeep/last-run.json`.
64
+ - Thresholds are 20 hours for daily, 6 days for weekly, 27 days for monthly (not 24h/7d/30d — buffer for launchd schedule drift after sleep/reboot). State tracked in `~/.local/state/mac-upkeep/last-run.json`.
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).
@@ -77,6 +77,10 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
77
77
  - **Rich is a transitive dependency**: `typer>=0.12` requires `rich>=12.3.0`. Using Rich adds zero new runtime dependencies.
78
78
  - **`status` dashboard graceful degradation**: if `_get_service_info()` returns None (brew not installed, service not registered, or JSON parse failure), the service header is skipped and only the task scheduling summary is shown. Reuses `format_last_run()` and `format_next_run()` from tasks.py. Test by patching `mac_upkeep.cli._get_service_info` directly rather than mocking subprocess.run (avoids interfering with Config.load() → get_brew_prefix() subprocess call).
79
79
 
80
+ ### Handler-dispatched tasks
81
+
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
+
80
84
  ### sudo + HOME
81
85
 
82
86
  `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.
@@ -98,6 +102,8 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
98
102
  - **`terminal-notifier` is optional** — installed via `brew install terminal-notifier`.
99
103
  - **Do not open terminal windows from launchd** — fragile (focus stealing, macOS 13+ permission escalation). Use headless + notification + click-to-act.
100
104
  - **Testing**: `Config.load()` calls `get_brew_prefix()` which runs `subprocess.run(["brew", "--prefix"])`. Tests that mock `subprocess.run` or `shutil.which` will capture this call too (shared module objects). Mock `mac_upkeep.config.get_brew_prefix` directly in `init` command tests.
105
+ - **`git_sync` SSH auth under launchd** relies on `~/.ssh/config` `IdentityAgent` (path-based, e.g. 1Password socket). `SSH_AUTH_SOCK` env vars are NOT inherited by LaunchAgents, so env-based agent forwarding won't work here — the `IdentityAgent` directive is the supported path.
106
+ - **`git_sync` forces `GIT_TERMINAL_PROMPT=0` and defaults `GIT_ASKPASS=/usr/bin/true`** (user-set `GIT_ASKPASS` is respected) — fail-fast on auth misconfiguration instead of stalling to the 60 s subprocess timeout.
101
107
 
102
108
  ## Release Process
103
109
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mac-upkeep
3
- Version: 2.3.0
3
+ Version: 2.4.0
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
@@ -66,6 +66,7 @@ uvx mac-upkeep run # one-off without installing
66
66
  | `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
67
67
  | `brew_cleanup` | Remove old versions and cache files | Monthly |
68
68
  | `brew_bundle` | Remove packages not in Brewfile | Weekly |
69
+ | `git_sync` | Pull configured git repositories | Daily |
69
70
 
70
71
  Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
71
72
 
@@ -117,7 +118,7 @@ mac-upkeep show-config --default
117
118
  [tasks.gcloud]
118
119
  enabled = false
119
120
 
120
- # Change frequency (weekly or monthly)
121
+ # Change frequency (daily, weekly, or monthly)
121
122
  [tasks.brew_update]
122
123
  frequency = "monthly"
123
124
 
@@ -142,6 +143,31 @@ frequency = "monthly"
142
143
  order = ["brew_update", "brew_upgrade", "docker_prune", "brew_cleanup", "brew_bundle"]
143
144
  ```
144
145
 
146
+ ### git_sync
147
+
148
+ Pull configured git repositories daily with `git pull --ff-only`. Opt-in — list your repos explicitly:
149
+
150
+ ```toml
151
+ [git_sync]
152
+ repos = [
153
+ "~/code/my-project",
154
+ "~/work/max-*", # glob patterns supported
155
+ ]
156
+ skip_dirty = true # skip repos with uncommitted changes
157
+ ```
158
+
159
+ Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
160
+
161
+ #### Authentication
162
+
163
+ Any of the following work under launchd without mac-upkeep-side configuration:
164
+
165
+ - **SSH + `IdentityAgent` (recommended under launchd):** a path-based entry in `~/.ssh/config` pointing at any SSH agent's UNIX socket. Works because the directive is a file path, not the `SSH_AUTH_SOCK` env var that launchd would strip.
166
+ - **HTTPS + credential helper:** `gh auth setup-git` or `git config --global credential.helper osxkeychain`. Requires the helper binary on the launchd `PATH`.
167
+ - **`[url].insteadOf` rewrite:** force SSH regardless of remote protocol by rewriting `https://<host>/` in `~/.gitconfig` to a matching SSH `Host` alias. Bypasses HTTPS auth entirely.
168
+
169
+ git_sync sets `GIT_TERMINAL_PROMPT=0` and a no-op `GIT_ASKPASS` default (user-set `GIT_ASKPASS` is respected) so misconfigured auth fails in milliseconds instead of stalling to the 60 s subprocess timeout.
170
+
145
171
  ### Environment variables
146
172
 
147
173
  ```bash
@@ -39,6 +39,7 @@ uvx mac-upkeep run # one-off without installing
39
39
  | `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
40
40
  | `brew_cleanup` | Remove old versions and cache files | Monthly |
41
41
  | `brew_bundle` | Remove packages not in Brewfile | Weekly |
42
+ | `git_sync` | Pull configured git repositories | Daily |
42
43
 
43
44
  Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
44
45
 
@@ -90,7 +91,7 @@ mac-upkeep show-config --default
90
91
  [tasks.gcloud]
91
92
  enabled = false
92
93
 
93
- # Change frequency (weekly or monthly)
94
+ # Change frequency (daily, weekly, or monthly)
94
95
  [tasks.brew_update]
95
96
  frequency = "monthly"
96
97
 
@@ -115,6 +116,31 @@ frequency = "monthly"
115
116
  order = ["brew_update", "brew_upgrade", "docker_prune", "brew_cleanup", "brew_bundle"]
116
117
  ```
117
118
 
119
+ ### git_sync
120
+
121
+ Pull configured git repositories daily with `git pull --ff-only`. Opt-in — list your repos explicitly:
122
+
123
+ ```toml
124
+ [git_sync]
125
+ repos = [
126
+ "~/code/my-project",
127
+ "~/work/max-*", # glob patterns supported
128
+ ]
129
+ skip_dirty = true # skip repos with uncommitted changes
130
+ ```
131
+
132
+ Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
133
+
134
+ #### Authentication
135
+
136
+ Any of the following work under launchd without mac-upkeep-side configuration:
137
+
138
+ - **SSH + `IdentityAgent` (recommended under launchd):** a path-based entry in `~/.ssh/config` pointing at any SSH agent's UNIX socket. Works because the directive is a file path, not the `SSH_AUTH_SOCK` env var that launchd would strip.
139
+ - **HTTPS + credential helper:** `gh auth setup-git` or `git config --global credential.helper osxkeychain`. Requires the helper binary on the launchd `PATH`.
140
+ - **`[url].insteadOf` rewrite:** force SSH regardless of remote protocol by rewriting `https://<host>/` in `~/.gitconfig` to a matching SSH `Host` alias. Bypasses HTTPS auth entirely.
141
+
142
+ git_sync sets `GIT_TERMINAL_PROMPT=0` and a no-op `GIT_ASKPASS` default (user-set `GIT_ASKPASS` is respected) so misconfigured auth fails in milliseconds instead of stalling to the 60 s subprocess timeout.
143
+
118
144
  ### Environment variables
119
145
 
120
146
  ```bash
@@ -15,6 +15,7 @@ Adjust versions/paths:
15
15
  ## Adapt
16
16
 
17
17
  - TOML-driven task definitions with `importlib.resources` bundling — for any CLI needing an extensible command registry where adding a task shouldn't require code changes
18
+ - Handler registry dispatch (`HANDLERS: dict[str, Callable]` + `_run_handler`) — for any TOML-driven CLI that needs some tasks to run as Python rather than subprocess commands, without coupling the framework to specific handler implementations
18
19
  - `init` with system detection via `shutil.which()` — for any CLI replacing static example config files with generated, system-aware configs
19
20
  - 3-layer config merge (bundled `defaults.toml` → user `config.toml` → env vars) with field-level override — for any CLI needing layered configuration
20
21
  - `${VAR}` variable resolution in TOML fields — for portable paths across architectures (`${BREW_PREFIX}` resolves differently on Apple Silicon vs Intel)
@@ -4,9 +4,9 @@
4
4
 
5
5
  ## What it does
6
6
 
7
- Runs 11 maintenance tasks on boot + weekly/monthly schedule via brew services:
7
+ Runs maintenance tasks on boot + recurring schedule via brew services:
8
8
  Homebrew update/upgrade/cleanup, gcloud/pnpm/uv cache pruning, Fish plugin
9
- updates, mole system optimization, and Brewfile enforcement.
9
+ updates, mole system optimization, Brewfile enforcement, and git repository sync.
10
10
 
11
11
  ## Install
12
12
 
@@ -20,7 +20,7 @@ updates, mole system optimization, and Brewfile enforcement.
20
20
  - TOML-driven task registry — add tasks without code changes
21
21
  - Rich Live TUI for interactive use, plain logging for launchd
22
22
  - macOS notifications via terminal-notifier with osascript fallback
23
- - Per-task frequency scheduling (weekly/monthly) with schedule drift buffers and next-run visibility
23
+ - Per-task frequency scheduling (daily/weekly/monthly) with schedule drift buffers and next-run visibility
24
24
  - Custom tasks in user config.toml
25
25
 
26
26
  ## Links
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mac-upkeep"
3
- version = "2.3.0"
3
+ version = "2.4.0"
4
4
  description = "Automated macOS maintenance CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -127,7 +127,7 @@ def run(
127
127
  Disable specific tasks via config file or MAC_UPKEEP_<TASK>=false environment variables.
128
128
 
129
129
  Task order: brew_update, brew_upgrade, gcloud, pnpm, uv, fisher,
130
- mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle.
130
+ mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle, git_sync.
131
131
  brew_cleanup runs after mo_clean (which runs brew autoremove).
132
132
  brew_bundle runs last (homebrew/brew#21350).
133
133
 
@@ -319,6 +319,14 @@ def _generate_init_config(
319
319
  lines.append('# detect = "docker"')
320
320
  lines.append('# frequency = "monthly"')
321
321
  lines.append("#")
322
+ lines.append("# git_sync pulls configured repos daily (fast-forward only, skips dirty)")
323
+ lines.append("# [git_sync]")
324
+ lines.append("# repos = [")
325
+ lines.append('# "~/Documents/github/org/repo",')
326
+ lines.append('# "~/Documents/github/org/other-*", # glob supported')
327
+ lines.append("# ]")
328
+ lines.append("# skip_dirty = true")
329
+ lines.append("#")
322
330
  if detected:
323
331
  order_str = str([t for t, _ in detected]).replace("'", '"')
324
332
  lines.append("# [run]")
@@ -31,6 +31,7 @@ class TaskDef:
31
31
  shell: str = ""
32
32
  require_file: str = ""
33
33
  timeout: int = 300
34
+ handler: str = ""
34
35
 
35
36
 
36
37
  def get_brew_prefix() -> str:
@@ -133,12 +134,20 @@ def load_task_defs(
133
134
  run_order.append(name)
134
135
 
135
136
  # Validate task definitions
137
+ from mac_upkeep.tasks import KNOWN_HANDLERS # local import to avoid cycle
138
+
136
139
  for name, td in task_defs.items():
137
- if not td.command:
138
- raise ValueError(f"Task '{name}' has no command")
139
- if td.frequency not in ("weekly", "monthly"):
140
+ if td.command and td.handler:
141
+ raise ValueError(f"Task '{name}': cannot set both 'command' and 'handler'")
142
+ if not td.command and not td.handler:
143
+ raise ValueError(f"Task '{name}' has no command or handler")
144
+ if td.handler and td.handler not in KNOWN_HANDLERS:
145
+ known = ", ".join(sorted(KNOWN_HANDLERS)) or "(none registered)"
146
+ raise ValueError(f"Task '{name}': unknown handler '{td.handler}' (known: {known})")
147
+ if td.frequency not in ("daily", "weekly", "monthly"):
140
148
  raise ValueError(
141
- f"Task '{name}': frequency must be 'weekly' or 'monthly', got '{td.frequency}'"
149
+ f"Task '{name}': frequency must be 'daily', 'weekly', or 'monthly', "
150
+ f"got '{td.frequency}'"
142
151
  )
143
152
  for entry in run_order:
144
153
  if entry not in task_defs:
@@ -185,6 +194,7 @@ def _parse_task_def(name: str, data: dict) -> TaskDef:
185
194
  shell=data.get("shell", ""),
186
195
  require_file=data.get("require_file", ""),
187
196
  timeout=data.get("timeout", 300),
197
+ handler=data.get("handler", ""),
188
198
  )
189
199
 
190
200
 
@@ -197,6 +207,8 @@ class Config:
197
207
  brewfile: str | None = None
198
208
  notify: bool = True
199
209
  notify_sound: str = "Submarine"
210
+ git_sync_repos: list[str] = field(default_factory=list)
211
+ git_sync_skip_dirty: bool = True
200
212
 
201
213
  @classmethod
202
214
  def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> Config:
@@ -217,6 +229,12 @@ class Config:
217
229
  if "sound" in notif:
218
230
  config.notify_sound = str(notif["sound"])
219
231
 
232
+ # Extract git_sync settings from user config
233
+ if user_data and "git_sync" in user_data:
234
+ gs = user_data["git_sync"]
235
+ config.git_sync_repos = list(gs.get("repos", []))
236
+ config.git_sync_skip_dirty = bool(gs.get("skip_dirty", True))
237
+
220
238
  # Extract brewfile from user config
221
239
  if user_data and "paths" in user_data and "brewfile" in user_data["paths"]:
222
240
  config.brewfile = user_data["paths"]["brewfile"]
@@ -65,6 +65,12 @@ command = "brew bundle cleanup --force --file=${BREWFILE}"
65
65
  detect = "brew"
66
66
  require_file = "${BREWFILE}"
67
67
 
68
+ [tasks.git_sync]
69
+ description = "Pull configured git repositories"
70
+ handler = "git_sync"
71
+ detect = "git"
72
+ frequency = "daily"
73
+
68
74
  [run]
69
75
  order = [
70
76
  "brew_update",
@@ -78,4 +84,5 @@ order = [
78
84
  "mo_purge",
79
85
  "brew_cleanup",
80
86
  "brew_bundle",
87
+ "git_sync",
81
88
  ]
@@ -0,0 +1,138 @@
1
+ """Built-in git_sync handler: fast-forward pulls a user-configured list of repos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from typing import TYPE_CHECKING
10
+
11
+ from mac_upkeep.output import TaskResult
12
+
13
+ if TYPE_CHECKING:
14
+ from mac_upkeep.config import Config
15
+ from mac_upkeep.output import Output
16
+
17
+ _ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
18
+
19
+
20
+ def _strip_ansi(text: str) -> str:
21
+ return _ANSI_PATTERN.sub("", text)
22
+
23
+
24
+ def _build_env() -> dict[str, str]:
25
+ env = os.environ.copy()
26
+ env["GIT_TERMINAL_PROMPT"] = "0"
27
+ env.setdefault("GIT_ASKPASS", "/usr/bin/true")
28
+ return env
29
+
30
+
31
+ def _run_git(path: str, args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]:
32
+ return subprocess.run(
33
+ ["git", "-C", path, *args],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=timeout,
37
+ stdin=subprocess.DEVNULL,
38
+ env=_build_env(),
39
+ )
40
+
41
+
42
+ def _resolve_paths(patterns: list[str], output: Output) -> list[str]:
43
+ """Expand user paths and globs; emit debug lines for empty matches."""
44
+ paths: list[str] = []
45
+ seen: set[str] = set()
46
+ for pattern in patterns:
47
+ expanded = os.path.expanduser(pattern)
48
+ if any(ch in expanded for ch in "*?["):
49
+ matches = sorted(glob.glob(expanded))
50
+ if not matches:
51
+ output.task_debug(f"no match: {pattern}")
52
+ continue
53
+ for m in matches:
54
+ if m not in seen:
55
+ seen.add(m)
56
+ paths.append(m)
57
+ else:
58
+ if expanded not in seen:
59
+ seen.add(expanded)
60
+ paths.append(expanded)
61
+ return paths
62
+
63
+
64
+ def _sync_repo(path: str, *, skip_dirty: bool) -> tuple[str, str]:
65
+ """Sync one repo. Returns (status, reason) where status is pulled|up-to-date|skipped|failed."""
66
+ r = _run_git(path, ["rev-parse", "--is-inside-work-tree"])
67
+ if r.returncode != 0:
68
+ return "skipped", "not a git repo"
69
+
70
+ r = _run_git(path, ["remote"])
71
+ if r.returncode != 0 or not r.stdout.strip():
72
+ return "skipped", "no remote configured"
73
+
74
+ branch_r = _run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])
75
+ branch = branch_r.stdout.strip() or "?"
76
+ r = _run_git(path, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
77
+ if r.returncode != 0:
78
+ return "skipped", f"no upstream (branch={branch})"
79
+
80
+ if skip_dirty:
81
+ r = _run_git(path, ["status", "--porcelain"])
82
+ if r.stdout.strip():
83
+ return "skipped", "dirty worktree"
84
+
85
+ r = _run_git(path, ["pull", "--ff-only"])
86
+ if r.returncode != 0:
87
+ stderr = _strip_ansi(r.stderr).strip().splitlines()
88
+ first = stderr[0] if stderr else f"exit {r.returncode}"
89
+ return "failed", first
90
+
91
+ stdout = _strip_ansi(r.stdout).strip().lower()
92
+ if "already up to date" in stdout or "already up-to-date" in stdout:
93
+ return "up-to-date", ""
94
+ return "pulled", ""
95
+
96
+
97
+ def run_git_sync(config: Config, output: Output, dry_run: bool) -> TaskResult:
98
+ """Handler entry point. Aggregate per-repo results into a single TaskResult."""
99
+ patterns = list(config.git_sync_repos)
100
+ if not patterns:
101
+ return TaskResult("git_sync", "skipped", reason="no repos configured")
102
+
103
+ paths = _resolve_paths(patterns, output)
104
+ if not paths:
105
+ return TaskResult("git_sync", "skipped", reason="no repos matched")
106
+
107
+ if dry_run:
108
+ for path in paths:
109
+ output.task_debug(f"would pull: {path}")
110
+ return TaskResult("git_sync", "ok", reason=f"dry-run: {len(paths)} repos")
111
+
112
+ n_pulled = 0
113
+ n_skipped = 0
114
+ failures: list[str] = []
115
+ for path in paths:
116
+ status, reason = _sync_repo(path, skip_dirty=config.git_sync_skip_dirty)
117
+ display = f"{path}: {status}"
118
+ if reason:
119
+ display = f"{display} ({reason})"
120
+ output.task_debug(display)
121
+ if status in ("pulled", "up-to-date"):
122
+ n_pulled += 1
123
+ elif status == "skipped":
124
+ n_skipped += 1
125
+ else:
126
+ failures.append(os.path.basename(path.rstrip("/")))
127
+
128
+ if failures:
129
+ names = ", ".join(failures)
130
+ return TaskResult("git_sync", "failed", reason=f"{len(failures)} failed: {names}")
131
+
132
+ parts = []
133
+ if n_pulled:
134
+ parts.append(f"{n_pulled} pulled")
135
+ if n_skipped:
136
+ parts.append(f"{n_skipped} skipped")
137
+ reason = ", ".join(parts) if parts else "no repos processed"
138
+ return TaskResult("git_sync", "ok", reason=reason)
@@ -10,7 +10,7 @@ import shlex
10
10
  import shutil
11
11
  import subprocess
12
12
  import time
13
- from datetime import datetime
13
+ from datetime import datetime, timedelta
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING
16
16
 
@@ -18,6 +18,8 @@ from mac_upkeep.config import Config, TaskDef, load_default_task_names
18
18
  from mac_upkeep.output import TaskResult
19
19
 
20
20
  if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
21
23
  from mac_upkeep.output import Output
22
24
 
23
25
  logger = logging.getLogger("mac_upkeep")
@@ -32,7 +34,27 @@ ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
32
34
  _xdg_state = os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))
33
35
  _STATE_DIR = Path(_xdg_state) / "mac-upkeep"
34
36
  _STATE_FILE = _STATE_DIR / "last-run.json"
35
- FREQUENCY_THRESHOLDS = {"weekly": 6, "monthly": 27} # days (buffer for schedule drift)
37
+ FREQUENCY_THRESHOLDS: dict[str, timedelta] = {
38
+ "daily": timedelta(hours=20),
39
+ "weekly": timedelta(days=6),
40
+ "monthly": timedelta(days=27),
41
+ } # buffer for schedule drift
42
+
43
+ # Handler registry: task handler name → (config, output, dry_run) -> TaskResult.
44
+ # Tasks set handler="<name>" in defaults.toml to dispatch here instead of running a command.
45
+ HANDLERS: dict[str, Callable[[Config, Output, bool], TaskResult]] = {}
46
+ KNOWN_HANDLERS: set[str] = set() # kept in sync with HANDLERS; read by config validation
47
+
48
+
49
+ def _register_handlers() -> None:
50
+ """Register built-in handlers. Local import avoids any import-cycle risk."""
51
+ from mac_upkeep import git_sync
52
+
53
+ HANDLERS["git_sync"] = git_sync.run_git_sync
54
+ KNOWN_HANDLERS.add("git_sync")
55
+
56
+
57
+ _register_handlers()
36
58
 
37
59
 
38
60
  def _load_state() -> dict[str, str]:
@@ -59,8 +81,8 @@ def _should_run(task_key: str, config: Config) -> bool:
59
81
  last_run = datetime.fromisoformat(last_run_str)
60
82
  except ValueError:
61
83
  return True
62
- threshold_days = FREQUENCY_THRESHOLDS.get(config.get_frequency(task_key), 6)
63
- return (datetime.now() - last_run).days >= threshold_days
84
+ threshold = FREQUENCY_THRESHOLDS.get(config.get_frequency(task_key), timedelta(days=6))
85
+ return (datetime.now() - last_run) >= threshold
64
86
 
65
87
 
66
88
  def _update_last_run(task_key: str) -> None:
@@ -71,23 +93,31 @@ def _update_last_run(task_key: str) -> None:
71
93
 
72
94
 
73
95
  def format_last_run(last_run_str: str | None) -> str:
74
- """Humanize a last-run ISO timestamp: 'today', '1 day ago', 'N days ago', or 'never'."""
96
+ """Humanize a last-run ISO timestamp: 'just now', 'Xh ago', 'N days ago', or 'never'."""
75
97
  if not last_run_str:
76
98
  return "never"
77
99
  try:
78
100
  last_run = datetime.fromisoformat(last_run_str)
79
101
  except ValueError:
80
102
  return "never"
81
- days = (datetime.now() - last_run).days
82
- if days == 0:
83
- return "today"
103
+ elapsed = datetime.now() - last_run
104
+ if elapsed < timedelta(days=1):
105
+ if elapsed < timedelta(minutes=5):
106
+ return "just now"
107
+ hours = int(elapsed.total_seconds() // 3600)
108
+ if hours == 0:
109
+ return "just now"
110
+ if hours == 1:
111
+ return "1 hour ago"
112
+ return f"{hours}h ago"
113
+ days = elapsed.days
84
114
  if days == 1:
85
115
  return "1 day ago"
86
116
  return f"{days} days ago"
87
117
 
88
118
 
89
119
  def format_next_run(task_key: str, config: Config, state: dict[str, str] | None = None) -> str:
90
- """Return relative time until task is next eligible: 'now', 'in 1 day', 'in N days'."""
120
+ """Return relative time until task is next eligible: 'now', 'in Xh', 'in N days'."""
91
121
  if state is None:
92
122
  state = _load_state()
93
123
  last_run_str = state.get(task_key)
@@ -98,13 +128,21 @@ def format_next_run(task_key: str, config: Config, state: dict[str, str] | None
98
128
  except ValueError:
99
129
  return "now"
100
130
  frequency = config.get_frequency(task_key)
101
- threshold = FREQUENCY_THRESHOLDS.get(frequency, 6)
102
- remaining = threshold - (datetime.now() - last_run).days
103
- if remaining <= 0:
131
+ threshold = FREQUENCY_THRESHOLDS.get(frequency, timedelta(days=6))
132
+ remaining = threshold - (datetime.now() - last_run)
133
+ if remaining <= timedelta(0):
104
134
  return "now"
105
- if remaining == 1:
135
+ if remaining < timedelta(days=1):
136
+ hours = int(remaining.total_seconds() // 3600)
137
+ if hours == 0:
138
+ return "in <1h"
139
+ if hours == 1:
140
+ return "in 1 hour"
141
+ return f"in {hours}h"
142
+ remaining_days = threshold.days - (datetime.now() - last_run).days
143
+ if remaining_days == 1:
106
144
  return "in 1 day"
107
- return f"in {remaining} days"
145
+ return f"in {remaining_days} days"
108
146
 
109
147
 
110
148
  def strip_ansi(text: str) -> str:
@@ -239,6 +277,57 @@ def _run(
239
277
  return result
240
278
 
241
279
 
280
+ def _run_handler(
281
+ name: str,
282
+ td: TaskDef,
283
+ *,
284
+ config: Config,
285
+ output: Output,
286
+ dry_run: bool,
287
+ force_tasks: set[str] | None,
288
+ ) -> TaskResult:
289
+ """Dispatch a handler-driven task. Mirrors _run's filter/frequency/detect contract."""
290
+ task_key = name.lower().replace(" ", "_")
291
+
292
+ if force_tasks is not None and task_key not in force_tasks:
293
+ result = TaskResult(name, "skipped", reason="not selected")
294
+ output.task_done(result)
295
+ return result
296
+
297
+ if not dry_run and force_tasks is None and not _should_run(task_key, config):
298
+ next_str = format_next_run(task_key, config)
299
+ result = TaskResult(name, "skipped", reason=f"ran recently, next {next_str}")
300
+ output.task_done(result)
301
+ return result
302
+
303
+ if not config.is_enabled(task_key):
304
+ result = TaskResult(name, "skipped", reason="disabled")
305
+ output.task_done(result)
306
+ return result
307
+
308
+ if td.detect and not shutil.which(td.detect):
309
+ result = TaskResult(name, "skipped", reason="not installed")
310
+ output.task_done(result)
311
+ return result
312
+
313
+ output.task_start(name)
314
+ start = time.monotonic()
315
+ result = HANDLERS[td.handler](config, output, dry_run)
316
+ if result.duration == 0.0:
317
+ result = TaskResult(
318
+ result.name,
319
+ result.status,
320
+ reason=result.reason,
321
+ duration=time.monotonic() - start,
322
+ )
323
+ output.task_done(result)
324
+
325
+ if result.status == "ok" and not dry_run:
326
+ _update_last_run(task_key)
327
+
328
+ return result
329
+
330
+
242
331
  def run_all_tasks(
243
332
  *,
244
333
  config: Config,
@@ -254,6 +343,20 @@ def run_all_tasks(
254
343
  if td is None:
255
344
  continue
256
345
 
346
+ # Handler-dispatched tasks bypass subprocess command building
347
+ if td.handler:
348
+ results.append(
349
+ _run_handler(
350
+ task_name,
351
+ td,
352
+ config=config,
353
+ output=output,
354
+ dry_run=dry_run,
355
+ force_tasks=force_tasks,
356
+ )
357
+ )
358
+ continue
359
+
257
360
  # require_file tasks: check filter → enabled → file exists
258
361
  # (preserves current brew_bundle delegation pattern)
259
362
  if td.require_file and not Path(td.require_file).is_file():
@@ -195,7 +195,7 @@ def test_show_config_user_config(tmp_path):
195
195
  def test_run_no_notification_when_all_skipped(tmp_path, monkeypatch):
196
196
  """Boot scenario: all tasks skip (ran recently) → no notification."""
197
197
  state_file = tmp_path / "last-run.json"
198
- recent = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
198
+ recent = (datetime.now() - timedelta(hours=1)).isoformat(timespec="seconds")
199
199
  state = {
200
200
  name: recent
201
201
  for name in [
@@ -210,6 +210,7 @@ def test_run_no_notification_when_all_skipped(tmp_path, monkeypatch):
210
210
  "mo_purge",
211
211
  "brew_cleanup",
212
212
  "brew_bundle",
213
+ "git_sync",
213
214
  ]
214
215
  }
215
216
  state_file.write_text(json.dumps(state))