mac-upkeep 2.3.0__tar.gz → 2.4.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.
- mac_upkeep-2.4.1/.release-please-manifest.json +3 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CHANGELOG.md +14 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CLAUDE.md +8 -2
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/PKG-INFO +31 -5
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/README.md +30 -4
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/docs/reusable-patterns.md +1 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/llms.txt +3 -3
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/pyproject.toml +1 -1
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/cli.py +9 -1
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/config.py +22 -4
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/defaults.toml +7 -0
- mac_upkeep-2.4.1/src/mac_upkeep/git_sync.py +138 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/tasks.py +117 -14
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_cli.py +2 -1
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_config.py +83 -13
- mac_upkeep-2.4.1/tests/test_git_sync.py +279 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_tasks.py +206 -3
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/uv.lock +1 -1
- mac_upkeep-2.3.0/.release-please-manifest.json +0 -3
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/.github/workflows/release.yml +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/.github/workflows/test.yml +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/.gitignore +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CONTRIBUTING.md +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/LICENSE +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/demo/demo.gif +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/demo/record.sh +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/release-please-config.json +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/__init__.py +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/notify.py +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/output.py +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/py.typed +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/__init__.py +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_notify.py +0 -0
- {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_output.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.1](https://github.com/calvindotsg/mac-upkeep/compare/v2.4.0...v2.4.1) (2026-04-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* correct Mole link in README to tw93/Mole ([#37](https://github.com/calvindotsg/mac-upkeep/issues/37)) ([6f0b1ff](https://github.com/calvindotsg/mac-upkeep/commit/6f0b1ff5ba107ab9b9aab0242c823e5aaf4e1a08))
|
|
9
|
+
|
|
10
|
+
## [2.4.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.3.0...v2.4.0) (2026-04-20)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* 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))
|
|
16
|
+
|
|
3
17
|
## [2.3.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.1...v2.3.0) (2026-04-13)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
## Architecture
|
|
20
20
|
|
|
21
21
|
```
|
|
22
|
-
defaults.toml → bundled task definitions
|
|
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
|
|
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
|
+
Version: 2.4.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
|
|
@@ -61,11 +61,12 @@ uvx mac-upkeep run # one-off without installing
|
|
|
61
61
|
| `pnpm` | Prune pnpm content-addressable store | Monthly |
|
|
62
62
|
| `uv` | Prune uv package cache | Monthly |
|
|
63
63
|
| `fisher` | Update Fish shell plugins | Weekly |
|
|
64
|
-
| `mo_clean` | Clean system and user caches ([
|
|
65
|
-
| `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([
|
|
66
|
-
| `mo_purge` | Remove old project artifacts ([
|
|
64
|
+
| `mo_clean` | Clean system and user caches ([Mole](https://github.com/tw93/Mole)) | Weekly |
|
|
65
|
+
| `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([Mole](https://github.com/tw93/Mole)) | Weekly |
|
|
66
|
+
| `mo_purge` | Remove old project artifacts ([Mole](https://github.com/tw93/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
|
|
@@ -34,11 +34,12 @@ uvx mac-upkeep run # one-off without installing
|
|
|
34
34
|
| `pnpm` | Prune pnpm content-addressable store | Monthly |
|
|
35
35
|
| `uv` | Prune uv package cache | Monthly |
|
|
36
36
|
| `fisher` | Update Fish shell plugins | Weekly |
|
|
37
|
-
| `mo_clean` | Clean system and user caches ([
|
|
38
|
-
| `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([
|
|
39
|
-
| `mo_purge` | Remove old project artifacts ([
|
|
37
|
+
| `mo_clean` | Clean system and user caches ([Mole](https://github.com/tw93/Mole)) | Weekly |
|
|
38
|
+
| `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([Mole](https://github.com/tw93/Mole)) | Weekly |
|
|
39
|
+
| `mo_purge` | Remove old project artifacts ([Mole](https://github.com/tw93/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
|
|
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
|
|
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
|
|
@@ -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
|
|
138
|
-
raise ValueError(f"Task '{name}'
|
|
139
|
-
if td.
|
|
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',
|
|
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)
|