salla-gitpuller 1.1.1__tar.gz → 1.2.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.
- salla_gitpuller-1.2.0/PKG-INFO +320 -0
- salla_gitpuller-1.2.0/README.md +308 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/gitpuller/__init__.py +1 -1
- salla_gitpuller-1.2.0/gitpuller/gitpull.py +341 -0
- salla_gitpuller-1.2.0/gitpuller/slack_notifier.py +579 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/pyproject.toml +5 -2
- salla_gitpuller-1.2.0/salla_gitpuller.egg-info/PKG-INFO +320 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/salla_gitpuller.egg-info/SOURCES.txt +1 -0
- salla_gitpuller-1.2.0/salla_gitpuller.egg-info/requires.txt +1 -0
- salla_gitpuller-1.1.1/PKG-INFO +0 -35
- salla_gitpuller-1.1.1/README.md +0 -24
- salla_gitpuller-1.1.1/gitpuller/gitpull.py +0 -201
- salla_gitpuller-1.1.1/gitpuller/slack_notifier.py +0 -60
- salla_gitpuller-1.1.1/salla_gitpuller.egg-info/PKG-INFO +0 -35
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/LICENSE +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/gitpuller/alert_manager.py +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/gitpuller/state_manager.py +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/gitpuller/utils.py +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/salla_gitpuller.egg-info/dependency_links.txt +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/salla_gitpuller.egg-info/top_level.txt +0 -0
- {salla_gitpuller-1.1.1 → salla_gitpuller-1.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: salla_gitpuller
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A lightweight utility to git pull a repository using SSH deploy keys stored in environment variables
|
|
5
|
+
Author-email: Mohammed Junaid <safijunaid.ss@gmail.com>, Muhammad Zahid <zahidmuhammad127@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Zahid07/gitpuller
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: requests>=2.20
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# gitpuller
|
|
14
|
+
|
|
15
|
+
A lightweight utility to keep a git repository in sync with its remote, designed
|
|
16
|
+
to run **inside Mage AI pipelines** as an auto-pull step. It authenticates with an
|
|
17
|
+
SSH deploy key stored in an environment variable, forces the local checkout to
|
|
18
|
+
match the remote branch (even if someone manually edited files on the runner),
|
|
19
|
+
and sends a de-duplicated Slack alert if anything goes wrong.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why this exists
|
|
24
|
+
|
|
25
|
+
Mage runners are long-lived boxes. If anyone manually edits a tracked file,
|
|
26
|
+
adds a stray file, or commits locally, a plain `git pull` fails with an opaque
|
|
27
|
+
error like:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Command '['git', '-c', 'core.sshCommand=...', 'pull', 'git@github.com:...', 'master']'
|
|
31
|
+
returned non-zero exit status 1.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
gitpuller solves three problems at once:
|
|
35
|
+
|
|
36
|
+
1. **Clear errors** — surfaces the actual git output, not the wrapper message.
|
|
37
|
+
2. **Self-healing sync** — discards local drift so the pull can't be blocked.
|
|
38
|
+
3. **Alerting without spam** — pings Slack on failure, but suppresses repeats of
|
|
39
|
+
the same error within a configurable window.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install salla_gitpuller
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Dependency: [`requests`](https://pypi.org/project/requests/) (installed
|
|
50
|
+
automatically). Optional: `mage-ai` — only needed if you want alert-suppression
|
|
51
|
+
state to persist across pipeline runs (see [State management](#state-management)).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from gitpuller import GitPullExecutor
|
|
59
|
+
|
|
60
|
+
executor = GitPullExecutor(
|
|
61
|
+
use_mage_ai=True, # persist alert state via Mage
|
|
62
|
+
# Slack: set CDM_PROD_SLACK_BOT_TOKEN (threaded daily alerts).
|
|
63
|
+
# Optional: CDM_PROD_DB_SLACK_CHANNEL (defaults to C05MLHR55JT).
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
result = executor.execute_with_alerting(
|
|
67
|
+
repo_path="/home/src/my-repo",
|
|
68
|
+
git_url="git@github.com:my-org/my-repo.git",
|
|
69
|
+
workspace_name="myworkspace", # reads the private key from {workspace_name}_SSHKEY
|
|
70
|
+
# branch omitted -> defaults to "master"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
print(result["git_pull_status"]) # "success"
|
|
74
|
+
print(result["discarded_changes"]) # what local drift (if any) was wiped
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
On failure, `execute_with_alerting` sends a Slack alert (subject to suppression)
|
|
78
|
+
as a **reply in today's daily thread**, then **re-raises**, so the Mage pipeline
|
|
79
|
+
still fails loudly.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
`execute_git_pull` does **not** run `git pull`. Instead it forces the local repo
|
|
86
|
+
to exactly match the remote, which is robust against manual edits *and* divergent
|
|
87
|
+
history (local commits / rewritten history) that a stash-based approach can't
|
|
88
|
+
handle:
|
|
89
|
+
|
|
90
|
+
1. **Prepare the SSH key** — the private deploy key is read from an env var,
|
|
91
|
+
normalized (strips wrapping quotes, converts literal `\n` to real newlines,
|
|
92
|
+
ensures a trailing newline), and written to `~/.ssh` with strict `0600`
|
|
93
|
+
permissions. It is injected per-command via
|
|
94
|
+
`git -c core.sshCommand="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`.
|
|
95
|
+
2. **`git fetch <url> <branch>`** — `FETCH_HEAD` now points at the remote tip.
|
|
96
|
+
3. **Snapshot + log local drift** — before anything is discarded, it records:
|
|
97
|
+
- **working tree changes** — uncommitted edits and untracked files
|
|
98
|
+
(`git status --porcelain`),
|
|
99
|
+
- **local-only commits** — commits on the runner but not the remote
|
|
100
|
+
(`FETCH_HEAD..HEAD`),
|
|
101
|
+
and prints them to the pipeline log so you have a record of what was wiped.
|
|
102
|
+
4. **`git reset --hard FETCH_HEAD`** — makes the working tree and branch pointer
|
|
103
|
+
match the remote exactly.
|
|
104
|
+
5. **`git clean -fd`** — removes untracked files/directories so the tree truly
|
|
105
|
+
matches remote. **Ignored files are preserved** (no `-x`), so runner-local
|
|
106
|
+
`.env` files and deploy keys survive.
|
|
107
|
+
6. **Cleanup** — the key file is removed and the working directory is restored,
|
|
108
|
+
even on failure (`finally`).
|
|
109
|
+
|
|
110
|
+
Any failing step raises a `RuntimeError` containing the **real git stdout/stderr
|
|
111
|
+
and exit code**, which becomes the Slack alert body and the pipeline error.
|
|
112
|
+
|
|
113
|
+
> ⚠️ **This is destructive by design.** Local changes on the runner are treated as
|
|
114
|
+
> contamination and discarded. Don't point gitpuller at a repo where the runner
|
|
115
|
+
> holds work you intend to keep.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## API
|
|
120
|
+
|
|
121
|
+
### `GitPullExecutor(slack_webhook_url=None, slack_bot_token=None, slack_channel=None, use_mage_ai=False, state_manager=None)`
|
|
122
|
+
|
|
123
|
+
| Param | Description |
|
|
124
|
+
|-------|-------------|
|
|
125
|
+
| `slack_bot_token` | Slack bot token (`xoxb-...`). Falls back to `CDM_PROD_SLACK_BOT_TOKEN`. Preferred — enables daily-thread replies. |
|
|
126
|
+
| `slack_channel` | Channel ID/name. Falls back to `CDM_PROD_DB_SLACK_CHANNEL`, then `C05MLHR55JT`. |
|
|
127
|
+
| `slack_webhook_url` | Incoming-webhook URL. Falls back to `CDM_SLACK_WEBHOOK_URL`. Used only when no bot token is set (no threading). |
|
|
128
|
+
| `use_mage_ai` | If `True`, persist alert-suppression state via Mage global variables (falls back to in-memory if Mage isn't installed). |
|
|
129
|
+
| `state_manager` | Inject a custom `StateManager`; overrides `use_mage_ai`. |
|
|
130
|
+
|
|
131
|
+
If neither a bot token nor a webhook is configured, gitpuller still runs and
|
|
132
|
+
prints a warning instead of failing construction.
|
|
133
|
+
|
|
134
|
+
### Slack daily thread
|
|
135
|
+
|
|
136
|
+
When `CDM_PROD_SLACK_BOT_TOKEN` is set, failures are posted with `chat.postMessage`:
|
|
137
|
+
|
|
138
|
+
1. Open (or reuse) one parent message **per workspace per calendar day**:
|
|
139
|
+
`🚨 Git Pull Failures {workspace_name} — YYYY-MM-DD`.
|
|
140
|
+
2. Post each later failure for that workspace as a **thread reply**.
|
|
141
|
+
|
|
142
|
+
Daily `thread_ts` values are stored in **one unified Redis hash** shared by every
|
|
143
|
+
Mage workspace (the key is **not** prefixed with `MAGE_WORKSPACE_NAME`):
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
gitpuller:slack_thread:{channel}:{YYYY-MM-DD}
|
|
147
|
+
cloud_data → JSON { workspace, thread_ts, errors: [{workspace, repo, error, at, reply_ts}, ...] }
|
|
148
|
+
partner → JSON { workspace, thread_ts, errors: [...] }
|
|
149
|
+
TTL: until midnight (CDM_SLACK_THREAD_TZ, default UTC)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Each workspace keeps the Slack parent `thread_ts` plus up to 20 git errors
|
|
153
|
+
from that day (error text capped at 1500 chars). Legacy plain `thread_ts`
|
|
154
|
+
strings are still read.
|
|
155
|
+
|
|
156
|
+
Redis comes from Mage `io_config.yaml` (`REDIS_HOST` / `REDIS_PORT` /
|
|
157
|
+
`REDIS_PASSWORD`), or those same env vars. If Redis is unavailable, gitpuller
|
|
158
|
+
falls back to Slack `conversations.history`.
|
|
159
|
+
|
|
160
|
+
| Env var | Role |
|
|
161
|
+
|---------|------|
|
|
162
|
+
| `CDM_PROD_SLACK_BOT_TOKEN` | Required for threading. |
|
|
163
|
+
| `CDM_PROD_DB_SLACK_CHANNEL` | Channel; default `C05MLHR55JT`. |
|
|
164
|
+
| `CDM_SLACK_THREAD_TZ` | Timezone for the daily parent date; default `UTC`. |
|
|
165
|
+
| `CDM_PAUSE_SLACK_MESSAGES` | Set to `1` to skip Slack. |
|
|
166
|
+
| `CDM_SLACK_WEBHOOK_URL` | Legacy fallback when no bot token is set. |
|
|
167
|
+
|
|
168
|
+
The bot must be in the channel (`chat:write`). History reuse also needs
|
|
169
|
+
`channels:history` (or `groups:history` for a private channel).
|
|
170
|
+
|
|
171
|
+
### `execute_with_alerting(...)` → `dict`
|
|
172
|
+
|
|
173
|
+
Runs the sync and, on failure, alerts Slack (with suppression) then re-raises.
|
|
174
|
+
|
|
175
|
+
| Param | Default | Description |
|
|
176
|
+
|-------|---------|-------------|
|
|
177
|
+
| `repo_path` | — | Absolute path to the local repo (must exist). |
|
|
178
|
+
| `git_url` | — | SSH remote URL, e.g. `git@github.com:Org/repo.git`. |
|
|
179
|
+
| `branch` | `"master"` | Branch to sync to. **Note: defaults to `master`, not `main`.** |
|
|
180
|
+
| `ssh_key` | `None` | Private key material. If omitted, read from `{workspace_name}_SSHKEY`. |
|
|
181
|
+
| `workspace_name` | `None` | Used to locate the key env var and name the key file. |
|
|
182
|
+
| `pipeline_uuid` | `"auto_git_pull"` | Key under which alert state is stored. |
|
|
183
|
+
| `suppression_hours` | `1` | Don't re-alert on the *same* error within this many hours. |
|
|
184
|
+
| `key_filename` | `None` | Override the on-disk key filename. |
|
|
185
|
+
| `ssh_dir` | `"/home/src/.ssh"` | Directory to write the key into. |
|
|
186
|
+
| `webhook_url` | `None` | Legacy webhook override for this call. Ignored when a bot token is configured. |
|
|
187
|
+
|
|
188
|
+
### `execute_git_pull(...)` → `dict`
|
|
189
|
+
|
|
190
|
+
Same signature as above (minus `pipeline_uuid` / `suppression_hours`). Performs
|
|
191
|
+
the sync **without** alerting — use this if you handle errors yourself.
|
|
192
|
+
|
|
193
|
+
### Return value
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
{
|
|
197
|
+
"workspace": "myworkspace",
|
|
198
|
+
"repo_path": "/home/src/my-repo",
|
|
199
|
+
"git_pull_status": "success", # or raises on error
|
|
200
|
+
"git_pull_output": "HEAD is now at <sha> <subject>",
|
|
201
|
+
"discarded_changes": {
|
|
202
|
+
"working_tree_changes": "?? stray.txt", # git status --porcelain output
|
|
203
|
+
"local_commits": "949688e local-only commit" # FETCH_HEAD..HEAD output
|
|
204
|
+
},
|
|
205
|
+
"key_env_var_used": "myworkspace_SSHKEY",
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## SSH key setup
|
|
212
|
+
|
|
213
|
+
Provide the **private** deploy key as an environment variable named
|
|
214
|
+
`{workspace_name}_SSHKEY` (e.g. `myworkspace_SSHKEY`), or pass `ssh_key=` directly.
|
|
215
|
+
The matching public key must be registered as a deploy key on the GitHub repo.
|
|
216
|
+
|
|
217
|
+
The key may be stored with literal `\n` (single-line) or real newlines — both are
|
|
218
|
+
handled. Wrapping quotes are stripped automatically.
|
|
219
|
+
|
|
220
|
+
### Use a read-only deploy key
|
|
221
|
+
|
|
222
|
+
gitpuller is a one-way mirror (remote → runner) and **never pushes**. Its only
|
|
223
|
+
remote operation is `git fetch`; the reset and clean steps are local. So the
|
|
224
|
+
deploy key only needs **read access** — leave GitHub's *"Allow write access"*
|
|
225
|
+
checkbox **unchecked**. This is the least-privilege setup and means the runner
|
|
226
|
+
can never push its discarded local changes back upstream.
|
|
227
|
+
|
|
228
|
+
Setup steps:
|
|
229
|
+
|
|
230
|
+
1. Generate a dedicated key pair: `ssh-keygen -t ed25519 -f deploy_key -N ""`.
|
|
231
|
+
2. On the GitHub repo: **Settings → Deploy keys → Add deploy key**, paste
|
|
232
|
+
`deploy_key.pub`, and leave **Allow write access unchecked**.
|
|
233
|
+
3. Store the **private** key (`deploy_key`) in the `{workspace_name}_SSHKEY` env var.
|
|
234
|
+
|
|
235
|
+
**Note:** GitHub deploy keys are **per-repository** — each repo you sync needs its
|
|
236
|
+
own key pair and its own `{workspace_name}_SSHKEY` env var.
|
|
237
|
+
|
|
238
|
+
On first connection the remote host key is auto-accepted
|
|
239
|
+
(`StrictHostKeyChecking=accept-new`), i.e. trust-on-first-use rather than a
|
|
240
|
+
pre-pinned fingerprint.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## State management
|
|
245
|
+
|
|
246
|
+
Alert suppression needs to remember the last error and when it was alerted:
|
|
247
|
+
|
|
248
|
+
- **`InMemoryStateManager`** (default) — process-local; suppression only works
|
|
249
|
+
within a single run.
|
|
250
|
+
- **`MageAIStateManager`** (`use_mage_ai=True`) — persists across runs via Mage
|
|
251
|
+
global variables, so repeated failures across scheduled runs stay de-duplicated.
|
|
252
|
+
- **`StateManager`** — subclass it to plug in your own backend (e.g. Redis, a DB).
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Build & release
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
rm -rf build dist *.egg-info
|
|
260
|
+
python -m build
|
|
261
|
+
# then upload to PyPI (twine upload dist/*) and bump the version in pyproject.toml
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Keep the version in sync in **both** `pyproject.toml` and `gitpuller/__init__.py`.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Changelog
|
|
269
|
+
|
|
270
|
+
### 1.2.0 (current)
|
|
271
|
+
|
|
272
|
+
- **Threaded Slack alerts.** Failures post as replies under one daily parent
|
|
273
|
+
**per workspace** (`🚨 Git Pull Failures {workspace} — YYYY-MM-DD`) via
|
|
274
|
+
`CDM_PROD_SLACK_BOT_TOKEN` and `CDM_PROD_DB_SLACK_CHANNEL`
|
|
275
|
+
(default `C05MLHR55JT`). Daily `thread_ts` lives in one unified Redis hash
|
|
276
|
+
(`gitpuller:slack_thread:{channel}:{YYYY-MM-DD}`, field = workspace, TTL
|
|
277
|
+
until midnight) shared across all Mage workspaces. Each workspace field is
|
|
278
|
+
JSON: `thread_ts` plus that day's git errors.
|
|
279
|
+
- Incoming webhooks (`CDM_SLACK_WEBHOOK_URL` / `slack_webhook_url`) remain as a
|
|
280
|
+
non-threaded fallback when no bot token is set.
|
|
281
|
+
- Missing Slack credentials no longer raise on `GitPullExecutor` construction;
|
|
282
|
+
alerts are skipped with a warning.
|
|
283
|
+
|
|
284
|
+
### 1.1.0
|
|
285
|
+
|
|
286
|
+
Reliability and clarity overhaul.
|
|
287
|
+
|
|
288
|
+
- **Self-healing sync.** Replaced `git pull` with `git fetch` →
|
|
289
|
+
`git reset --hard FETCH_HEAD` → `git clean -fd`. Manual edits, stray files, and
|
|
290
|
+
even local commits / divergent history on the runner no longer break the sync.
|
|
291
|
+
Ignored files (`.env`, keys) are preserved.
|
|
292
|
+
- **Clear error messages.** Failures now raise with the real git stdout/stderr and
|
|
293
|
+
exit code instead of the opaque
|
|
294
|
+
`Command '[...]' returned non-zero exit status 1.` wrapper. The same detail flows
|
|
295
|
+
into the Slack alert.
|
|
296
|
+
- **Audit log of discarded changes.** Before resetting, the working-tree drift and
|
|
297
|
+
any local-only commits are logged and returned under `discarded_changes`, so
|
|
298
|
+
there's always a record of what was wiped.
|
|
299
|
+
- **Packaging fixes.** Declared the previously-missing `requests` dependency; synced
|
|
300
|
+
the version between `pyproject.toml` and `__init__.py`.
|
|
301
|
+
- **Docs & comments.** Full README and inline documentation across all modules.
|
|
302
|
+
|
|
303
|
+
> **Migration note:** `git_pull_output` now reflects `reset --hard` output
|
|
304
|
+
> (`HEAD is now at <sha> <subject>`) rather than pull's `Updating x..y` /
|
|
305
|
+
> `Already up to date`. The result key `recovery_steps` (briefly present during
|
|
306
|
+
> development) is replaced by `discarded_changes`. Update any code that parses
|
|
307
|
+
> these. The public method signatures are unchanged.
|
|
308
|
+
|
|
309
|
+
### 1.0.x (previous)
|
|
310
|
+
|
|
311
|
+
- Initial release. Ran a plain `git pull <url> <branch>` over an SSH deploy key.
|
|
312
|
+
- Slack alerting with same-error suppression (`AlertManager` + `StateManager`,
|
|
313
|
+
in-memory or Mage-backed).
|
|
314
|
+
- **Limitations addressed in 1.1.0:** any manual change on the runner caused the
|
|
315
|
+
pull to fail; errors were opaque wrapper messages; `requests` was imported but
|
|
316
|
+
not declared as a dependency.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
<sub>Created and maintained by Mohammed Junaid and Muhammad Zahid.</sub>
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# gitpuller
|
|
2
|
+
|
|
3
|
+
A lightweight utility to keep a git repository in sync with its remote, designed
|
|
4
|
+
to run **inside Mage AI pipelines** as an auto-pull step. It authenticates with an
|
|
5
|
+
SSH deploy key stored in an environment variable, forces the local checkout to
|
|
6
|
+
match the remote branch (even if someone manually edited files on the runner),
|
|
7
|
+
and sends a de-duplicated Slack alert if anything goes wrong.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
Mage runners are long-lived boxes. If anyone manually edits a tracked file,
|
|
14
|
+
adds a stray file, or commits locally, a plain `git pull` fails with an opaque
|
|
15
|
+
error like:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Command '['git', '-c', 'core.sshCommand=...', 'pull', 'git@github.com:...', 'master']'
|
|
19
|
+
returned non-zero exit status 1.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
gitpuller solves three problems at once:
|
|
23
|
+
|
|
24
|
+
1. **Clear errors** — surfaces the actual git output, not the wrapper message.
|
|
25
|
+
2. **Self-healing sync** — discards local drift so the pull can't be blocked.
|
|
26
|
+
3. **Alerting without spam** — pings Slack on failure, but suppresses repeats of
|
|
27
|
+
the same error within a configurable window.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install salla_gitpuller
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Dependency: [`requests`](https://pypi.org/project/requests/) (installed
|
|
38
|
+
automatically). Optional: `mage-ai` — only needed if you want alert-suppression
|
|
39
|
+
state to persist across pipeline runs (see [State management](#state-management)).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from gitpuller import GitPullExecutor
|
|
47
|
+
|
|
48
|
+
executor = GitPullExecutor(
|
|
49
|
+
use_mage_ai=True, # persist alert state via Mage
|
|
50
|
+
# Slack: set CDM_PROD_SLACK_BOT_TOKEN (threaded daily alerts).
|
|
51
|
+
# Optional: CDM_PROD_DB_SLACK_CHANNEL (defaults to C05MLHR55JT).
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
result = executor.execute_with_alerting(
|
|
55
|
+
repo_path="/home/src/my-repo",
|
|
56
|
+
git_url="git@github.com:my-org/my-repo.git",
|
|
57
|
+
workspace_name="myworkspace", # reads the private key from {workspace_name}_SSHKEY
|
|
58
|
+
# branch omitted -> defaults to "master"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
print(result["git_pull_status"]) # "success"
|
|
62
|
+
print(result["discarded_changes"]) # what local drift (if any) was wiped
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
On failure, `execute_with_alerting` sends a Slack alert (subject to suppression)
|
|
66
|
+
as a **reply in today's daily thread**, then **re-raises**, so the Mage pipeline
|
|
67
|
+
still fails loudly.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
`execute_git_pull` does **not** run `git pull`. Instead it forces the local repo
|
|
74
|
+
to exactly match the remote, which is robust against manual edits *and* divergent
|
|
75
|
+
history (local commits / rewritten history) that a stash-based approach can't
|
|
76
|
+
handle:
|
|
77
|
+
|
|
78
|
+
1. **Prepare the SSH key** — the private deploy key is read from an env var,
|
|
79
|
+
normalized (strips wrapping quotes, converts literal `\n` to real newlines,
|
|
80
|
+
ensures a trailing newline), and written to `~/.ssh` with strict `0600`
|
|
81
|
+
permissions. It is injected per-command via
|
|
82
|
+
`git -c core.sshCommand="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`.
|
|
83
|
+
2. **`git fetch <url> <branch>`** — `FETCH_HEAD` now points at the remote tip.
|
|
84
|
+
3. **Snapshot + log local drift** — before anything is discarded, it records:
|
|
85
|
+
- **working tree changes** — uncommitted edits and untracked files
|
|
86
|
+
(`git status --porcelain`),
|
|
87
|
+
- **local-only commits** — commits on the runner but not the remote
|
|
88
|
+
(`FETCH_HEAD..HEAD`),
|
|
89
|
+
and prints them to the pipeline log so you have a record of what was wiped.
|
|
90
|
+
4. **`git reset --hard FETCH_HEAD`** — makes the working tree and branch pointer
|
|
91
|
+
match the remote exactly.
|
|
92
|
+
5. **`git clean -fd`** — removes untracked files/directories so the tree truly
|
|
93
|
+
matches remote. **Ignored files are preserved** (no `-x`), so runner-local
|
|
94
|
+
`.env` files and deploy keys survive.
|
|
95
|
+
6. **Cleanup** — the key file is removed and the working directory is restored,
|
|
96
|
+
even on failure (`finally`).
|
|
97
|
+
|
|
98
|
+
Any failing step raises a `RuntimeError` containing the **real git stdout/stderr
|
|
99
|
+
and exit code**, which becomes the Slack alert body and the pipeline error.
|
|
100
|
+
|
|
101
|
+
> ⚠️ **This is destructive by design.** Local changes on the runner are treated as
|
|
102
|
+
> contamination and discarded. Don't point gitpuller at a repo where the runner
|
|
103
|
+
> holds work you intend to keep.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## API
|
|
108
|
+
|
|
109
|
+
### `GitPullExecutor(slack_webhook_url=None, slack_bot_token=None, slack_channel=None, use_mage_ai=False, state_manager=None)`
|
|
110
|
+
|
|
111
|
+
| Param | Description |
|
|
112
|
+
|-------|-------------|
|
|
113
|
+
| `slack_bot_token` | Slack bot token (`xoxb-...`). Falls back to `CDM_PROD_SLACK_BOT_TOKEN`. Preferred — enables daily-thread replies. |
|
|
114
|
+
| `slack_channel` | Channel ID/name. Falls back to `CDM_PROD_DB_SLACK_CHANNEL`, then `C05MLHR55JT`. |
|
|
115
|
+
| `slack_webhook_url` | Incoming-webhook URL. Falls back to `CDM_SLACK_WEBHOOK_URL`. Used only when no bot token is set (no threading). |
|
|
116
|
+
| `use_mage_ai` | If `True`, persist alert-suppression state via Mage global variables (falls back to in-memory if Mage isn't installed). |
|
|
117
|
+
| `state_manager` | Inject a custom `StateManager`; overrides `use_mage_ai`. |
|
|
118
|
+
|
|
119
|
+
If neither a bot token nor a webhook is configured, gitpuller still runs and
|
|
120
|
+
prints a warning instead of failing construction.
|
|
121
|
+
|
|
122
|
+
### Slack daily thread
|
|
123
|
+
|
|
124
|
+
When `CDM_PROD_SLACK_BOT_TOKEN` is set, failures are posted with `chat.postMessage`:
|
|
125
|
+
|
|
126
|
+
1. Open (or reuse) one parent message **per workspace per calendar day**:
|
|
127
|
+
`🚨 Git Pull Failures {workspace_name} — YYYY-MM-DD`.
|
|
128
|
+
2. Post each later failure for that workspace as a **thread reply**.
|
|
129
|
+
|
|
130
|
+
Daily `thread_ts` values are stored in **one unified Redis hash** shared by every
|
|
131
|
+
Mage workspace (the key is **not** prefixed with `MAGE_WORKSPACE_NAME`):
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
gitpuller:slack_thread:{channel}:{YYYY-MM-DD}
|
|
135
|
+
cloud_data → JSON { workspace, thread_ts, errors: [{workspace, repo, error, at, reply_ts}, ...] }
|
|
136
|
+
partner → JSON { workspace, thread_ts, errors: [...] }
|
|
137
|
+
TTL: until midnight (CDM_SLACK_THREAD_TZ, default UTC)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each workspace keeps the Slack parent `thread_ts` plus up to 20 git errors
|
|
141
|
+
from that day (error text capped at 1500 chars). Legacy plain `thread_ts`
|
|
142
|
+
strings are still read.
|
|
143
|
+
|
|
144
|
+
Redis comes from Mage `io_config.yaml` (`REDIS_HOST` / `REDIS_PORT` /
|
|
145
|
+
`REDIS_PASSWORD`), or those same env vars. If Redis is unavailable, gitpuller
|
|
146
|
+
falls back to Slack `conversations.history`.
|
|
147
|
+
|
|
148
|
+
| Env var | Role |
|
|
149
|
+
|---------|------|
|
|
150
|
+
| `CDM_PROD_SLACK_BOT_TOKEN` | Required for threading. |
|
|
151
|
+
| `CDM_PROD_DB_SLACK_CHANNEL` | Channel; default `C05MLHR55JT`. |
|
|
152
|
+
| `CDM_SLACK_THREAD_TZ` | Timezone for the daily parent date; default `UTC`. |
|
|
153
|
+
| `CDM_PAUSE_SLACK_MESSAGES` | Set to `1` to skip Slack. |
|
|
154
|
+
| `CDM_SLACK_WEBHOOK_URL` | Legacy fallback when no bot token is set. |
|
|
155
|
+
|
|
156
|
+
The bot must be in the channel (`chat:write`). History reuse also needs
|
|
157
|
+
`channels:history` (or `groups:history` for a private channel).
|
|
158
|
+
|
|
159
|
+
### `execute_with_alerting(...)` → `dict`
|
|
160
|
+
|
|
161
|
+
Runs the sync and, on failure, alerts Slack (with suppression) then re-raises.
|
|
162
|
+
|
|
163
|
+
| Param | Default | Description |
|
|
164
|
+
|-------|---------|-------------|
|
|
165
|
+
| `repo_path` | — | Absolute path to the local repo (must exist). |
|
|
166
|
+
| `git_url` | — | SSH remote URL, e.g. `git@github.com:Org/repo.git`. |
|
|
167
|
+
| `branch` | `"master"` | Branch to sync to. **Note: defaults to `master`, not `main`.** |
|
|
168
|
+
| `ssh_key` | `None` | Private key material. If omitted, read from `{workspace_name}_SSHKEY`. |
|
|
169
|
+
| `workspace_name` | `None` | Used to locate the key env var and name the key file. |
|
|
170
|
+
| `pipeline_uuid` | `"auto_git_pull"` | Key under which alert state is stored. |
|
|
171
|
+
| `suppression_hours` | `1` | Don't re-alert on the *same* error within this many hours. |
|
|
172
|
+
| `key_filename` | `None` | Override the on-disk key filename. |
|
|
173
|
+
| `ssh_dir` | `"/home/src/.ssh"` | Directory to write the key into. |
|
|
174
|
+
| `webhook_url` | `None` | Legacy webhook override for this call. Ignored when a bot token is configured. |
|
|
175
|
+
|
|
176
|
+
### `execute_git_pull(...)` → `dict`
|
|
177
|
+
|
|
178
|
+
Same signature as above (minus `pipeline_uuid` / `suppression_hours`). Performs
|
|
179
|
+
the sync **without** alerting — use this if you handle errors yourself.
|
|
180
|
+
|
|
181
|
+
### Return value
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
{
|
|
185
|
+
"workspace": "myworkspace",
|
|
186
|
+
"repo_path": "/home/src/my-repo",
|
|
187
|
+
"git_pull_status": "success", # or raises on error
|
|
188
|
+
"git_pull_output": "HEAD is now at <sha> <subject>",
|
|
189
|
+
"discarded_changes": {
|
|
190
|
+
"working_tree_changes": "?? stray.txt", # git status --porcelain output
|
|
191
|
+
"local_commits": "949688e local-only commit" # FETCH_HEAD..HEAD output
|
|
192
|
+
},
|
|
193
|
+
"key_env_var_used": "myworkspace_SSHKEY",
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## SSH key setup
|
|
200
|
+
|
|
201
|
+
Provide the **private** deploy key as an environment variable named
|
|
202
|
+
`{workspace_name}_SSHKEY` (e.g. `myworkspace_SSHKEY`), or pass `ssh_key=` directly.
|
|
203
|
+
The matching public key must be registered as a deploy key on the GitHub repo.
|
|
204
|
+
|
|
205
|
+
The key may be stored with literal `\n` (single-line) or real newlines — both are
|
|
206
|
+
handled. Wrapping quotes are stripped automatically.
|
|
207
|
+
|
|
208
|
+
### Use a read-only deploy key
|
|
209
|
+
|
|
210
|
+
gitpuller is a one-way mirror (remote → runner) and **never pushes**. Its only
|
|
211
|
+
remote operation is `git fetch`; the reset and clean steps are local. So the
|
|
212
|
+
deploy key only needs **read access** — leave GitHub's *"Allow write access"*
|
|
213
|
+
checkbox **unchecked**. This is the least-privilege setup and means the runner
|
|
214
|
+
can never push its discarded local changes back upstream.
|
|
215
|
+
|
|
216
|
+
Setup steps:
|
|
217
|
+
|
|
218
|
+
1. Generate a dedicated key pair: `ssh-keygen -t ed25519 -f deploy_key -N ""`.
|
|
219
|
+
2. On the GitHub repo: **Settings → Deploy keys → Add deploy key**, paste
|
|
220
|
+
`deploy_key.pub`, and leave **Allow write access unchecked**.
|
|
221
|
+
3. Store the **private** key (`deploy_key`) in the `{workspace_name}_SSHKEY` env var.
|
|
222
|
+
|
|
223
|
+
**Note:** GitHub deploy keys are **per-repository** — each repo you sync needs its
|
|
224
|
+
own key pair and its own `{workspace_name}_SSHKEY` env var.
|
|
225
|
+
|
|
226
|
+
On first connection the remote host key is auto-accepted
|
|
227
|
+
(`StrictHostKeyChecking=accept-new`), i.e. trust-on-first-use rather than a
|
|
228
|
+
pre-pinned fingerprint.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## State management
|
|
233
|
+
|
|
234
|
+
Alert suppression needs to remember the last error and when it was alerted:
|
|
235
|
+
|
|
236
|
+
- **`InMemoryStateManager`** (default) — process-local; suppression only works
|
|
237
|
+
within a single run.
|
|
238
|
+
- **`MageAIStateManager`** (`use_mage_ai=True`) — persists across runs via Mage
|
|
239
|
+
global variables, so repeated failures across scheduled runs stay de-duplicated.
|
|
240
|
+
- **`StateManager`** — subclass it to plug in your own backend (e.g. Redis, a DB).
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Build & release
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
rm -rf build dist *.egg-info
|
|
248
|
+
python -m build
|
|
249
|
+
# then upload to PyPI (twine upload dist/*) and bump the version in pyproject.toml
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Keep the version in sync in **both** `pyproject.toml` and `gitpuller/__init__.py`.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Changelog
|
|
257
|
+
|
|
258
|
+
### 1.2.0 (current)
|
|
259
|
+
|
|
260
|
+
- **Threaded Slack alerts.** Failures post as replies under one daily parent
|
|
261
|
+
**per workspace** (`🚨 Git Pull Failures {workspace} — YYYY-MM-DD`) via
|
|
262
|
+
`CDM_PROD_SLACK_BOT_TOKEN` and `CDM_PROD_DB_SLACK_CHANNEL`
|
|
263
|
+
(default `C05MLHR55JT`). Daily `thread_ts` lives in one unified Redis hash
|
|
264
|
+
(`gitpuller:slack_thread:{channel}:{YYYY-MM-DD}`, field = workspace, TTL
|
|
265
|
+
until midnight) shared across all Mage workspaces. Each workspace field is
|
|
266
|
+
JSON: `thread_ts` plus that day's git errors.
|
|
267
|
+
- Incoming webhooks (`CDM_SLACK_WEBHOOK_URL` / `slack_webhook_url`) remain as a
|
|
268
|
+
non-threaded fallback when no bot token is set.
|
|
269
|
+
- Missing Slack credentials no longer raise on `GitPullExecutor` construction;
|
|
270
|
+
alerts are skipped with a warning.
|
|
271
|
+
|
|
272
|
+
### 1.1.0
|
|
273
|
+
|
|
274
|
+
Reliability and clarity overhaul.
|
|
275
|
+
|
|
276
|
+
- **Self-healing sync.** Replaced `git pull` with `git fetch` →
|
|
277
|
+
`git reset --hard FETCH_HEAD` → `git clean -fd`. Manual edits, stray files, and
|
|
278
|
+
even local commits / divergent history on the runner no longer break the sync.
|
|
279
|
+
Ignored files (`.env`, keys) are preserved.
|
|
280
|
+
- **Clear error messages.** Failures now raise with the real git stdout/stderr and
|
|
281
|
+
exit code instead of the opaque
|
|
282
|
+
`Command '[...]' returned non-zero exit status 1.` wrapper. The same detail flows
|
|
283
|
+
into the Slack alert.
|
|
284
|
+
- **Audit log of discarded changes.** Before resetting, the working-tree drift and
|
|
285
|
+
any local-only commits are logged and returned under `discarded_changes`, so
|
|
286
|
+
there's always a record of what was wiped.
|
|
287
|
+
- **Packaging fixes.** Declared the previously-missing `requests` dependency; synced
|
|
288
|
+
the version between `pyproject.toml` and `__init__.py`.
|
|
289
|
+
- **Docs & comments.** Full README and inline documentation across all modules.
|
|
290
|
+
|
|
291
|
+
> **Migration note:** `git_pull_output` now reflects `reset --hard` output
|
|
292
|
+
> (`HEAD is now at <sha> <subject>`) rather than pull's `Updating x..y` /
|
|
293
|
+
> `Already up to date`. The result key `recovery_steps` (briefly present during
|
|
294
|
+
> development) is replaced by `discarded_changes`. Update any code that parses
|
|
295
|
+
> these. The public method signatures are unchanged.
|
|
296
|
+
|
|
297
|
+
### 1.0.x (previous)
|
|
298
|
+
|
|
299
|
+
- Initial release. Ran a plain `git pull <url> <branch>` over an SSH deploy key.
|
|
300
|
+
- Slack alerting with same-error suppression (`AlertManager` + `StateManager`,
|
|
301
|
+
in-memory or Mage-backed).
|
|
302
|
+
- **Limitations addressed in 1.1.0:** any manual change on the runner caused the
|
|
303
|
+
pull to fail; errors were opaque wrapper messages; `requests` was imported but
|
|
304
|
+
not declared as a dependency.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
<sub>Created and maintained by Mohammed Junaid and Muhammad Zahid.</sub>
|