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.
Files changed (34) hide show
  1. mac_upkeep-2.4.1/.release-please-manifest.json +3 -0
  2. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CHANGELOG.md +14 -0
  3. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CLAUDE.md +8 -2
  4. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/PKG-INFO +31 -5
  5. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/README.md +30 -4
  6. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/docs/reusable-patterns.md +1 -0
  7. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/llms.txt +3 -3
  8. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/pyproject.toml +1 -1
  9. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/cli.py +9 -1
  10. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/config.py +22 -4
  11. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/defaults.toml +7 -0
  12. mac_upkeep-2.4.1/src/mac_upkeep/git_sync.py +138 -0
  13. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/tasks.py +117 -14
  14. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_cli.py +2 -1
  15. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_config.py +83 -13
  16. mac_upkeep-2.4.1/tests/test_git_sync.py +279 -0
  17. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_tasks.py +206 -3
  18. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/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.1}/.github/workflows/release.yml +0 -0
  21. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/.github/workflows/test.yml +0 -0
  22. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/.gitignore +0 -0
  23. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/CONTRIBUTING.md +0 -0
  24. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/LICENSE +0 -0
  25. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/demo/demo.gif +0 -0
  26. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/demo/record.sh +0 -0
  27. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/release-please-config.json +0 -0
  28. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/__init__.py +0 -0
  29. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/notify.py +0 -0
  30. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/output.py +0 -0
  31. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/src/mac_upkeep/py.typed +0 -0
  32. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/__init__.py +0 -0
  33. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_notify.py +0 -0
  34. {mac_upkeep-2.3.0 → mac_upkeep-2.4.1}/tests/test_output.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.1"
3
+ }
@@ -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 (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.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 ([mole](https://github.com/nicehash/mole)) | Weekly |
65
- | `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([mole](https://github.com/nicehash/mole)) | Weekly |
66
- | `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
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 ([mole](https://github.com/nicehash/mole)) | Weekly |
38
- | `mo_optimize` | Optimize DNS, Spotlight, fonts, Dock ([mole](https://github.com/nicehash/mole)) | Weekly |
39
- | `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
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 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.1"
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)