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/__init__.py +2 -0
- pulli/cli.py +316 -0
- pulli/discovery.py +541 -0
- pulli/pull.py +276 -0
- pulli/spinner.py +101 -0
- pulli/status.py +441 -0
- pulli/tree.py +447 -0
- pulli-0.2.1.dist-info/METADATA +207 -0
- pulli-0.2.1.dist-info/RECORD +12 -0
- pulli-0.2.1.dist-info/WHEEL +4 -0
- pulli-0.2.1.dist-info/entry_points.txt +2 -0
- pulli-0.2.1.dist-info/licenses/LICENSE +21 -0
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()
|