pulli 0.2.1__py3-none-any.whl

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.
pulli/pull.py ADDED
@@ -0,0 +1,276 @@
1
+ """Pull — update repos and report ahead/behind.
2
+
3
+ For every discovered work tree we report how far ahead/behind it is vs its
4
+ upstream, then fast-forward the repos that are behind.
5
+
6
+ Safety rules, in the order they are checked. All of them are *skip*, never
7
+ *force* — pulli must never destroy or corrupt work:
8
+
9
+ 1. broken repo / no git work tree → report, skip
10
+ 2. operation in progress (merge, rebase,
11
+ bisect, cherry-pick, revert) → report, skip
12
+ 3. no upstream (detached HEAD, no remote) → skip, nothing to pull
13
+ 4. diverged (ahead *and* behind) → report, skip (ff impossible)
14
+ 5. uncommitted changes → report, skip
15
+ 6. up to date → silent
16
+ 7. behind only → `git pull --ff-only`
17
+
18
+ Pull is `git pull --ff-only`, never a merge or rebase. A plain `git pull`
19
+ can *create* a merge commit (and a merge conflict) in a repo the user never
20
+ touched; `--ff-only` refuses instead. Where a repo has diverged, pulli
21
+ reports it and leaves the merge-or-rebase decision to the human.
22
+
23
+ Repos unreachable from the network are never a failure: fetch is expected to
24
+ fail offline, so those repos are reported with a "stale" note and the exit
25
+ code stays 0. A non-zero exit means something needs a human — a broken repo
26
+ or a pull that actually failed.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ from .discovery import RepoNode, discover, iter_repos, set_rels
34
+ from .status import collect_status, fetch_all, git_env, run_git
35
+
36
+ # ANSI colors (mirrors tree.py).
37
+ _RESET = "\x1b[0m"
38
+ _DIM = "\x1b[2m"
39
+ _BOLD = "\x1b[1m"
40
+ _GREEN = "\x1b[32m"
41
+ _YELLOW = "\x1b[33m"
42
+ _RED = "\x1b[31m"
43
+ _CYAN = "\x1b[36m"
44
+
45
+ _PULL_TIMEOUT = 120
46
+
47
+
48
+ def _color(s: str, code: str, use_color: bool) -> str:
49
+ return code + s + _RESET if use_color else s
50
+
51
+
52
+ def _fmt_ahead_behind(node: RepoNode) -> str:
53
+ """Human string for ahead/behind vs upstream."""
54
+ if not node.upstream:
55
+ return "no upstream"
56
+ if node.behind and node.ahead:
57
+ return f"↓{node.behind} ↑{node.ahead}"
58
+ if node.behind:
59
+ return f"↓{node.behind}"
60
+ if node.ahead:
61
+ return f"↑{node.ahead}"
62
+ return "up to date"
63
+
64
+
65
+ def _fmt_dirty(node: RepoNode, use_color: bool) -> str:
66
+ """'dirty 2 (a.md, b.txt)' — count + file names (max 4, then +N more)."""
67
+ files = node.dirty_files or []
68
+ if not files:
69
+ return _color("dirty", _YELLOW, use_color)
70
+ shown = files[:4]
71
+ more = len(files) - len(shown)
72
+ listing = ", ".join(shown) + (f", +{more} more" if more > 0 else "")
73
+ return (
74
+ f"{_color('dirty', _YELLOW, use_color)} {len(files)} "
75
+ f"({_color(listing, _DIM, use_color)})"
76
+ )
77
+
78
+
79
+ def _classify(node: RepoNode) -> str:
80
+ """Which bucket a repo falls into. One place, so the report, the
81
+ counters and the summary can never disagree with each other."""
82
+ if node.error:
83
+ return "broken"
84
+ if node.operation:
85
+ return "busy"
86
+ behind, ahead = node.behind or 0, node.ahead or 0
87
+ if not node.upstream or (not behind and not ahead):
88
+ return "current"
89
+ if behind and ahead:
90
+ return "diverged"
91
+ if node.dirty:
92
+ return "dirty"
93
+ if behind:
94
+ return "pullable"
95
+ return "ahead"
96
+
97
+
98
+ def _sort_key(node: RepoNode) -> tuple[int, str]:
99
+ """Report order: what needs attention first. Tie-break on the display
100
+ path so two identical runs always print in the same order."""
101
+ rank = {
102
+ "broken": 0, "busy": 1, "diverged": 2,
103
+ "pullable": 3, "dirty": 4, "ahead": 5, "current": 6,
104
+ }[ _classify(node) ]
105
+ return (rank, node.rel)
106
+
107
+
108
+ def _first_meaningful_line(text: str) -> str:
109
+ """The first line of git's stderr worth showing a human.
110
+
111
+ `git pull --ff-only` failures print several lines of `hint:` advice (the
112
+ same three lines every time) before the actual `fatal:`. Leading with
113
+ the hint buries the reason, so prefer a non-hint line.
114
+ """
115
+ for line in (text or "").splitlines():
116
+ s = line.strip()
117
+ if not s or s.lower().startswith("hint:"):
118
+ continue
119
+ return s
120
+ return ""
121
+
122
+
123
+ def _report(node: RepoNode, kind: str, *, use_color: bool, dry_run: bool) -> str | None:
124
+ """One output line per noteworthy repo. Returns the printed line."""
125
+ C = _color
126
+ rel = node.rel
127
+ ab = _fmt_ahead_behind(node)
128
+
129
+ if kind == "broken":
130
+ line = f" {C('✗', _RED, use_color)} {rel} {C(node.error or 'error', _RED, use_color)}"
131
+ elif kind == "busy":
132
+ line = f" {C('◐', _YELLOW, use_color)} {rel} {C(node.operation + ' — skipping pull', _YELLOW, use_color)}"
133
+ elif kind == "diverged":
134
+ line = (
135
+ f" {C('↕', _YELLOW, use_color)} {rel} {ab} "
136
+ f"{C('diverged — needs merge or rebase, skipping', _YELLOW, use_color)}"
137
+ )
138
+ elif kind == "dirty":
139
+ line = (
140
+ f" {C('◐', _YELLOW, use_color)} {rel} {ab} "
141
+ f"{_fmt_dirty(node, use_color)}, skipping pull"
142
+ )
143
+ elif kind == "ahead":
144
+ line = f" {C('↑', _CYAN, use_color)} {rel} {C(f'↑{node.ahead} ahead (not pushed)', _CYAN, use_color)}"
145
+ elif kind == "current":
146
+ # Nothing to do. A dirty repo with nothing to pull is only worth
147
+ # mentioning in dry-run mode, where the point is a full inventory.
148
+ if not (dry_run and node.dirty):
149
+ return None
150
+ line = f" {C('◐', _YELLOW, use_color)} {rel} up to date {_fmt_dirty(node, use_color)}"
151
+ else: # pullable
152
+ return None # printed by the pull loop, which knows the outcome
153
+ print(line)
154
+ return line
155
+
156
+
157
+ def pull(
158
+ root: Path,
159
+ *,
160
+ use_color: bool = True,
161
+ dry_run: bool = False,
162
+ fetch: bool = True,
163
+ follow_symlinks: bool = True,
164
+ max_depth: int = 50,
165
+ link_root: Path | None = None,
166
+ ) -> int:
167
+ """Pull every repo under `root` that is behind upstream.
168
+
169
+ Fetches all repos first, so the behind/ahead decision is based on the
170
+ current remote state rather than stale local refs.
171
+
172
+ Returns 0 when nothing needs a human, 1 on a broken repo or a failed
173
+ pull. Being offline is not a failure.
174
+ """
175
+ tree = discover(
176
+ root,
177
+ follow_symlinks=follow_symlinks,
178
+ max_depth=max_depth,
179
+ link_root=link_root,
180
+ )
181
+ if tree is None:
182
+ print(f"pulli: could not read {root}", file=sys.stderr)
183
+ return 1
184
+
185
+ set_rels(tree)
186
+ # Display paths are relative to what the user typed, not the resolved
187
+ # path, so a root reached through a symlink still reads naturally.
188
+ repos = sorted(iter_repos(tree), key=_sort_key) # bare repos excluded
189
+ if not repos:
190
+ print("No git repos found.")
191
+ return 0
192
+
193
+ # Fetch everything *first* so one pass over the results decides what to
194
+ # do. Interleaving fetch and pull would decide on data that is stale by
195
+ # the time we act on it.
196
+ if fetch:
197
+ fetch_all(repos, quiet=True, use_color=use_color)
198
+ collect_status(tree)
199
+ repos.sort(key=_sort_key)
200
+
201
+ buckets: dict[str, list[RepoNode]] = {}
202
+ for node in repos:
203
+ kind = _classify(node)
204
+ buckets.setdefault(kind, []).append(node)
205
+ if kind != "pullable":
206
+ _report(node, kind, use_color=use_color, dry_run=dry_run)
207
+
208
+ pullable = buckets.get("pullable", [])
209
+ pulled: list[RepoNode] = []
210
+ failed: list[tuple[RepoNode, str]] = []
211
+
212
+ if dry_run:
213
+ print()
214
+ print(_color("Dry run — no pulls performed.", _BOLD, use_color))
215
+ for node in pullable:
216
+ print(
217
+ f" {_color('↓', _GREEN, use_color)} {node.rel} "
218
+ f"{_fmt_ahead_behind(node)} {_color('would pull', _GREEN, use_color)}"
219
+ )
220
+ else:
221
+ for node in pullable:
222
+ rc, stdout, stderr = run_git(
223
+ node.path, "pull", "--ff-only", "--quiet", timeout=_PULL_TIMEOUT
224
+ )
225
+ if rc == 0:
226
+ pulled.append(node)
227
+ print(
228
+ f" {_color('✓', _GREEN, use_color)} {node.rel} "
229
+ f"{_fmt_ahead_behind(node)} pulled"
230
+ )
231
+ else:
232
+ reason = _first_meaningful_line(stderr or stdout) or f"exit {rc}"
233
+ failed.append((node, reason))
234
+ print(
235
+ f" {_color('✗', _RED, use_color)} {node.rel} "
236
+ f"{_fmt_ahead_behind(node)} "
237
+ f"{_color('pull failed: ' + reason, _RED, use_color)}"
238
+ )
239
+
240
+ # ── summary ──────────────────────────────────────────────────────────
241
+ counts = {k: len(v) for k, v in buckets.items()}
242
+ offline = [n for n in repos if n.fetch_failed]
243
+
244
+ print()
245
+ if offline:
246
+ print(
247
+ _color(
248
+ f"note: {len(offline)} repo(s) unreachable — their ↓↑ may be "
249
+ "stale (rerun when online).",
250
+ _YELLOW,
251
+ use_color,
252
+ )
253
+ )
254
+ if dry_run:
255
+ print(f"Would pull {len(pullable)} repo(s).")
256
+ else:
257
+ summary = f"Pulled {len(pulled)}"
258
+ if counts.get("diverged"):
259
+ summary += f", {counts['diverged']} diverged"
260
+ if counts.get("dirty"):
261
+ summary += f", {counts['dirty']} skipped dirty"
262
+ if counts.get("busy"):
263
+ summary += f", {counts['busy']} in progress"
264
+ if failed:
265
+ summary += f", {len(failed)} failed"
266
+ summary += f", {counts.get('current', 0)} up to date"
267
+ if counts.get("broken"):
268
+ summary += f", {counts['broken']} broken"
269
+ print(summary + ".")
270
+ if counts.get("ahead"):
271
+ print(
272
+ f"{counts['ahead']} repo(s) ahead of upstream — push them; "
273
+ "pulli only pulls."
274
+ )
275
+
276
+ return 1 if (failed or counts.get("broken")) else 0
pulli/spinner.py ADDED
@@ -0,0 +1,101 @@
1
+ """A minimal TTY spinner for pulli's slow phase.
2
+
3
+ pulli's slow phase is fetching remotes: network-bound, up to `timeout`
4
+ seconds per repo, and every repo is fetched in parallel. Without feedback
5
+ the user stares at a blank screen for several seconds. This module renders
6
+ a single-line spinner on stderr that updates in place, so it is obvious
7
+ that pulli is working — and roughly how far along it is.
8
+
9
+ The spinner only animates when stderr is a real terminal. When output is
10
+ piped or captured (CI, `pulli | less`, pytest) it is a no-op: no escape
11
+ codes, no extra lines, no background thread. That keeps every consumer of
12
+ pulli's stdout byte-for-byte clean.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import threading
18
+ import time
19
+
20
+ # Braille spinner frames, in order. Same set and cadence as pi's loader.
21
+ _FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
22
+ _INTERVAL = 0.08
23
+ # Carriage return + erase-to-end-of-line: rewrite the line in place, and
24
+ # wipe it completely when we're done so it never trails into real output.
25
+ _CLEAR = "\r\x1b[2K"
26
+
27
+ # ANSI colors, matching pulli's palette (tree.py / pull.py). The glyph is
28
+ # cyan (accent-ish), the message is dim (muted) — like pi's loader.
29
+ _CYAN = "\x1b[36m"
30
+ _DIM = "\x1b[2m"
31
+ _RESET = "\x1b[0m"
32
+
33
+
34
+ class Spinner:
35
+ """An animated spinner that rewrites one stderr line in place.
36
+
37
+ Only animates when the stream is a TTY. `start()`/`stop()` bracket a
38
+ phase; `update(msg)` swaps the trailing message live (e.g. progress
39
+ counts). The spinner runs on a daemon thread so the caller's work is
40
+ never blocked by it.
41
+ """
42
+
43
+ def __init__(self, message: str = "", *, stream=None, use_color: bool = True) -> None:
44
+ self.message = message
45
+ self.stream = stream if stream is not None else sys.stderr
46
+ self.use_color = use_color
47
+ self._thread: threading.Thread | None = None
48
+ self._stop = threading.Event()
49
+ self._frame = 0
50
+ # Serializes writes to `stream`: the animator thread and the caller
51
+ # (via `update`) both write, and two threads interleaving a
52
+ # carriage-return rewrite would garble a frame.
53
+ self._write_lock = threading.Lock()
54
+
55
+ @property
56
+ def enabled(self) -> bool:
57
+ """True when we may animate — i.e. the stream is a real terminal."""
58
+ try:
59
+ return self.stream.isatty()
60
+ except (AttributeError, OSError):
61
+ return False
62
+
63
+ def start(self) -> None:
64
+ if not self.enabled or self._thread is not None:
65
+ return
66
+ self._stop.clear()
67
+ self._thread = threading.Thread(target=self._run, daemon=True)
68
+ self._thread.start()
69
+
70
+ def _run(self) -> None:
71
+ while not self._stop.is_set():
72
+ self._draw()
73
+ time.sleep(_INTERVAL)
74
+
75
+ def _draw(self) -> None:
76
+ frame = _FRAMES[self._frame % len(_FRAMES)]
77
+ self._frame += 1
78
+ with self._write_lock:
79
+ if self.use_color:
80
+ self.stream.write(f"\r{_CYAN}{frame}{_RESET} {_DIM}{self.message}{_RESET}")
81
+ else:
82
+ self.stream.write(f"\r{frame} {self.message}")
83
+ self.stream.flush()
84
+
85
+ def update(self, message: str) -> None:
86
+ """Change the message; redraw immediately if we're animating."""
87
+ self.message = message
88
+ if self._thread is not None:
89
+ self._draw()
90
+
91
+ def stop(self) -> None:
92
+ """Stop animating and wipe the spinner line."""
93
+ if self._thread is None:
94
+ return
95
+ self._stop.set()
96
+ # The thread sleeps `_INTERVAL` between frames, so it exits quickly.
97
+ self._thread.join(timeout=0.3)
98
+ self._thread = None
99
+ with self._write_lock:
100
+ self.stream.write(_CLEAR)
101
+ self.stream.flush()