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/tree.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Render the repo tree to the terminal.
|
|
2
|
+
|
|
3
|
+
Layout:
|
|
4
|
+
|
|
5
|
+
~/code
|
|
6
|
+
├── clones/
|
|
7
|
+
│ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
|
|
8
|
+
│ ├── pi earendil-works/pi main ↓5 ↑0 ◐ dirty 2
|
|
9
|
+
│ └── ghostty ghostty-org/ghostty main ↓0 ↑0 ● clean
|
|
10
|
+
├── kontext.one/ devskale/kontext.one main ↓0 ↑3 ● clean
|
|
11
|
+
│ ├── klark0
|
|
12
|
+
│ └── python-utils
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
Conventions:
|
|
16
|
+
- Plain (non-repo) directories are shown with a trailing `/` and no status.
|
|
17
|
+
- Repos get a status line: remote, branch, ahead/behind, clean/dirty.
|
|
18
|
+
- A repo with no upstream (fresh init, detached HEAD, no remote) shows
|
|
19
|
+
`· ·` instead of a misleading `↓0 ↑0`.
|
|
20
|
+
- A repo whose fetch failed shows `offline (stale)` — the numbers may be
|
|
21
|
+
out of date and we say so instead of implying freshness.
|
|
22
|
+
- Repos with an operation in progress (merge/rebase/bisect) are marked
|
|
23
|
+
and treated as unsafe to touch.
|
|
24
|
+
- Symlinks are suffixed with ` -> target` and a `(symlink)` marker.
|
|
25
|
+
A link whose target is also reachable under its real name is shown as
|
|
26
|
+
an alias, and never as a second pull target.
|
|
27
|
+
- Submodules are suffixed with `(submodule)`.
|
|
28
|
+
- Bare repos (`x.git/`) are shown but marked: no working tree, no pull.
|
|
29
|
+
- Errors are shown in red.
|
|
30
|
+
|
|
31
|
+
Every value here comes from `RepoNode` fields filled in by `status.py`;
|
|
32
|
+
this module never shells out to git. That keeps the walk fast (git runs
|
|
33
|
+
once per repo during status collection, not again during rendering) and
|
|
34
|
+
keeps the credential scrubbing in exactly one place.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
import shutil
|
|
40
|
+
import sys
|
|
41
|
+
import threading
|
|
42
|
+
|
|
43
|
+
from .discovery import RepoNode, iter_repos
|
|
44
|
+
|
|
45
|
+
# ANSI colors — kept minimal; degrade gracefully on non-TTY (we strip them).
|
|
46
|
+
_RESET = "\x1b[0m"
|
|
47
|
+
_DIM = "\x1b[2m"
|
|
48
|
+
_BOLD = "\x1b[1m"
|
|
49
|
+
_GREEN = "\x1b[32m"
|
|
50
|
+
_YELLOW = "\x1b[33m"
|
|
51
|
+
_RED = "\x1b[31m"
|
|
52
|
+
_CYAN = "\x1b[36m"
|
|
53
|
+
_MAGENTA = "\x1b[35m"
|
|
54
|
+
|
|
55
|
+
_BARE_NOTE = "(bare repo — nothing to pull)"
|
|
56
|
+
|
|
57
|
+
# Matches a single ANSI SGR escape sequence (color / style codes).
|
|
58
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _visible_width(s: str) -> int:
|
|
62
|
+
"""Visible column count, ignoring ANSI escape sequences."""
|
|
63
|
+
return len(_ANSI_RE.sub("", s))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clip_ansi(s: str, width: int) -> str:
|
|
67
|
+
"""Clip `s` to `width` *visible* columns, preserving ANSI escape codes.
|
|
68
|
+
|
|
69
|
+
The live tree rewrites lines in place with cursor-up/down arithmetic that
|
|
70
|
+
assumes every line occupies exactly one terminal row. A line wider than
|
|
71
|
+
the terminal wraps onto a second row, and the arithmetic then lands on
|
|
72
|
+
the wrong row — corrupting the display (the `mytestdir` bug). Clipping to
|
|
73
|
+
the terminal width guarantees no line ever wraps.
|
|
74
|
+
|
|
75
|
+
If truncation cuts into a colored region we append a reset so the color
|
|
76
|
+
doesn't bleed into the next line.
|
|
77
|
+
"""
|
|
78
|
+
if width <= 0 or _visible_width(s) <= width:
|
|
79
|
+
return s
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
vis = 0
|
|
82
|
+
i = 0
|
|
83
|
+
n = len(s)
|
|
84
|
+
truncated = False
|
|
85
|
+
while i < n:
|
|
86
|
+
c = s[i]
|
|
87
|
+
if c == "\x1b":
|
|
88
|
+
# Copy the whole escape sequence through unchanged.
|
|
89
|
+
if i + 1 < n and s[i + 1] == "[":
|
|
90
|
+
j = i + 2
|
|
91
|
+
while j < n and not ("\x40" <= s[j] <= "\x7e"):
|
|
92
|
+
j += 1
|
|
93
|
+
j += 1 # the final byte (letter)
|
|
94
|
+
else:
|
|
95
|
+
j = i + 1
|
|
96
|
+
out.append(s[i:j])
|
|
97
|
+
i = j
|
|
98
|
+
else:
|
|
99
|
+
if vis >= width:
|
|
100
|
+
truncated = True
|
|
101
|
+
break
|
|
102
|
+
out.append(c)
|
|
103
|
+
vis += 1
|
|
104
|
+
i += 1
|
|
105
|
+
if truncated:
|
|
106
|
+
out.append(_RESET)
|
|
107
|
+
return "".join(out)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _shorten_url(url: str) -> str:
|
|
111
|
+
"""git@github.com:owner/repo.git -> owner/repo
|
|
112
|
+
|
|
113
|
+
Strips credentials (user:pass@ or token@) so secrets never hit the
|
|
114
|
+
terminal. This is the only place a remote URL becomes display text.
|
|
115
|
+
"""
|
|
116
|
+
if not url:
|
|
117
|
+
return ""
|
|
118
|
+
# Strip userinfo from scheme://user:pass@host/path
|
|
119
|
+
if "://" in url:
|
|
120
|
+
scheme, rest = url.split("://", 1)
|
|
121
|
+
if "@" in rest:
|
|
122
|
+
rest = rest.split("@", 1)[1]
|
|
123
|
+
url = f"{scheme}://{rest}"
|
|
124
|
+
elif url.startswith("git@"):
|
|
125
|
+
# git@host:owner/repo.git — scp-like; 'git' is a username, not a
|
|
126
|
+
# secret. Drop the git@host: prefix for display.
|
|
127
|
+
url = url.split(":", 1)[1] if ":" in url else url
|
|
128
|
+
elif "@" in url and ":" in url.split("@", 1)[0]:
|
|
129
|
+
# user:pass@host:path — strip creds
|
|
130
|
+
url = url.split("@", 1)[1]
|
|
131
|
+
# For the common hosts, drop scheme+host so the display is just
|
|
132
|
+
# `owner/repo` — the same compact form as a scp-style `git@host:owner/repo`
|
|
133
|
+
# URL. A less common host keeps its name so the repo is identifiable.
|
|
134
|
+
for host in ("https://github.com/", "https://gitlab.com/",
|
|
135
|
+
"https://bitbucket.org/", "http://github.com/",
|
|
136
|
+
"http://gitlab.com/", "http://bitbucket.org/"):
|
|
137
|
+
if url.startswith(host):
|
|
138
|
+
url = url[len(host):]
|
|
139
|
+
break
|
|
140
|
+
if url.endswith(".git"):
|
|
141
|
+
url = url[: -len(".git")]
|
|
142
|
+
return url
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _status_glyph(node: RepoNode) -> tuple[str, str]:
|
|
146
|
+
"""Return (glyph, color) for the clean/dirty/error state."""
|
|
147
|
+
if node.error:
|
|
148
|
+
return "✗", _RED
|
|
149
|
+
if node.dirty or node.operation:
|
|
150
|
+
return "◐", _YELLOW
|
|
151
|
+
return "●", _GREEN
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ahead_behind(node: RepoNode) -> str:
|
|
155
|
+
"""↓behind ↑ahead, or a clear placeholder when there is no upstream.
|
|
156
|
+
|
|
157
|
+
Showing `↓0 ↑0` for a repo with no upstream reads as "in sync with
|
|
158
|
+
something", which is a lie — there is nothing to be in sync with.
|
|
159
|
+
"""
|
|
160
|
+
if not node.upstream:
|
|
161
|
+
return "· ·"
|
|
162
|
+
return f"↓{node.behind or 0} ↑{node.ahead or 0}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _repo_tail_parts(node: RepoNode, C) -> list[str]:
|
|
166
|
+
"""The status columns for a repo node, as a list of aligned-able parts.
|
|
167
|
+
|
|
168
|
+
Returns `[url, branch, ahead/behind, state]` so a flat renderer can pad
|
|
169
|
+
the url column to a fixed width before joining. The tree renderer just
|
|
170
|
+
joins them with two spaces.
|
|
171
|
+
"""
|
|
172
|
+
if node.error:
|
|
173
|
+
return [C(f"✗ {node.error}", _RED)]
|
|
174
|
+
|
|
175
|
+
url = _shorten_url(node.url)
|
|
176
|
+
parts = [
|
|
177
|
+
C(url, _DIM) if url else "",
|
|
178
|
+
C(node.branch or "?", _BOLD),
|
|
179
|
+
_ahead_behind(node),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if node.operation:
|
|
183
|
+
state = C(f"◐ {node.operation} — skipping", _YELLOW)
|
|
184
|
+
elif node.fetch_failed:
|
|
185
|
+
# Fetch didn't work, so ↓↑ may be stale. Say so rather than lie.
|
|
186
|
+
state = C(f"◐ offline — {node.fetch_reason or 'unreachable'}", _YELLOW)
|
|
187
|
+
else:
|
|
188
|
+
glyph, gcolor = _status_glyph(node)
|
|
189
|
+
state = (
|
|
190
|
+
C(f"{glyph} dirty {len(node.dirty_files)}", _YELLOW)
|
|
191
|
+
if node.dirty
|
|
192
|
+
else C(f"{glyph} clean", gcolor)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
parts.append(state)
|
|
196
|
+
return parts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _repo_tail(node: RepoNode, C) -> str:
|
|
200
|
+
"""The status column for a repo node: remote, branch, ↓↑, glyph + state."""
|
|
201
|
+
return " ".join(p for p in _repo_tail_parts(node, C) if p)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _label(node: RepoNode, C) -> str:
|
|
205
|
+
name = node.name
|
|
206
|
+
if node.is_submodule:
|
|
207
|
+
return C(name, _CYAN) + C(" (submodule)", _DIM)
|
|
208
|
+
if node.is_symlink:
|
|
209
|
+
s = C(name, _MAGENTA) + C(" -> " + (node.symlink_target or ""), _DIM)
|
|
210
|
+
# A link that is the only route to its target is just a
|
|
211
|
+
# symlink. One whose target is also listed under its real name
|
|
212
|
+
# is an alias: it shows a second name for a repo that appears
|
|
213
|
+
# elsewhere in the tree, and must not look like a second one.
|
|
214
|
+
return s + (C(" (alias)", _DIM) if node.is_alias else C(" (symlink)", _DIM))
|
|
215
|
+
return name
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_lines(
|
|
219
|
+
root: RepoNode,
|
|
220
|
+
C,
|
|
221
|
+
tail,
|
|
222
|
+
) -> tuple[list[str], dict[int, int], dict[int, str]]:
|
|
223
|
+
"""Build the tree lines.
|
|
224
|
+
|
|
225
|
+
`tail(node)` renders the status column for a repo node (the batch
|
|
226
|
+
renderer passes `_repo_tail`; the live renderer passes a placeholder).
|
|
227
|
+
Returns `(lines, repo_line, repo_base)`:
|
|
228
|
+
* `lines` — the rendered strings, one per row;
|
|
229
|
+
* `repo_line` — maps `id(node)` -> line index for every repo node;
|
|
230
|
+
* `repo_base` — maps `id(node)` -> the `prefix+connector+name` part of
|
|
231
|
+
that line, so a live renderer can swap just the status tail in place.
|
|
232
|
+
"""
|
|
233
|
+
lines: list[str] = []
|
|
234
|
+
repo_line: dict[int, int] = {}
|
|
235
|
+
repo_base: dict[int, str] = {}
|
|
236
|
+
|
|
237
|
+
# Root header — annotated too when the root is itself a repo, so the
|
|
238
|
+
# head of the tree isn't a bare path with no status next to it.
|
|
239
|
+
root_label = C(str(root.link_path or root.path), _BOLD)
|
|
240
|
+
header = root_label
|
|
241
|
+
if root.is_repo:
|
|
242
|
+
header += " " + tail(root)
|
|
243
|
+
repo_line[id(root)] = 0
|
|
244
|
+
repo_base[id(root)] = root_label
|
|
245
|
+
elif root.is_bare:
|
|
246
|
+
header += " " + C(_BARE_NOTE, _DIM)
|
|
247
|
+
lines.append(header)
|
|
248
|
+
|
|
249
|
+
def render_node(node: RepoNode, prefix: str, is_last: bool) -> None:
|
|
250
|
+
connector = "└── " if is_last else "├── "
|
|
251
|
+
name = _label(node, C)
|
|
252
|
+
|
|
253
|
+
if node.is_alias:
|
|
254
|
+
# The target is listed under its real name elsewhere; this is a
|
|
255
|
+
# second name for it, not a second repo to pull. No status
|
|
256
|
+
# columns — there is nothing here to report.
|
|
257
|
+
lines.append(f"{prefix}{connector}{name}")
|
|
258
|
+
elif node.is_repo:
|
|
259
|
+
base = f"{prefix}{connector}{name}"
|
|
260
|
+
lines.append(base + " " + tail(node))
|
|
261
|
+
repo_line[id(node)] = len(lines) - 1
|
|
262
|
+
repo_base[id(node)] = base
|
|
263
|
+
elif node.is_bare:
|
|
264
|
+
lines.append(f"{prefix}{connector}{name}/ {C(_BARE_NOTE, _DIM)}")
|
|
265
|
+
else:
|
|
266
|
+
# plain directory — show with trailing /, no status
|
|
267
|
+
lines.append(f"{prefix}{connector}{C(name + '/', _DIM)}")
|
|
268
|
+
|
|
269
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
270
|
+
for i, child in enumerate(node.children):
|
|
271
|
+
render_node(child, child_prefix, i == len(node.children) - 1)
|
|
272
|
+
|
|
273
|
+
last = len(root.children) - 1
|
|
274
|
+
for i, child in enumerate(root.children):
|
|
275
|
+
render_node(child, "", i == last)
|
|
276
|
+
|
|
277
|
+
return lines, repo_line, repo_base
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _build_flat_lines(
|
|
281
|
+
root: RepoNode,
|
|
282
|
+
C,
|
|
283
|
+
tail,
|
|
284
|
+
) -> tuple[list[str], dict[int, int], dict[int, str]]:
|
|
285
|
+
"""Build a flat list: one line per repo, no tree structure.
|
|
286
|
+
|
|
287
|
+
`tail(node)` renders the status column for a repo node. Returns
|
|
288
|
+
`(lines, repo_line, repo_base)` with the same contract as
|
|
289
|
+
`_build_lines`, so a live renderer can swap a repo's status in place.
|
|
290
|
+
Repos are ordered by display path, so two identical runs are identical.
|
|
291
|
+
"""
|
|
292
|
+
lines: list[str] = []
|
|
293
|
+
repo_line: dict[int, int] = {}
|
|
294
|
+
repo_base: dict[int, str] = {}
|
|
295
|
+
repos = sorted(iter_repos(root), key=lambda n: n.rel)
|
|
296
|
+
if not repos:
|
|
297
|
+
return lines, repo_line, repo_base
|
|
298
|
+
# Pad every path to the widest one so the status columns line up like a
|
|
299
|
+
# table instead of starting wherever the previous path happened to end.
|
|
300
|
+
path_width = max(_visible_width(C(n.rel, _DIM)) for n in repos)
|
|
301
|
+
# Pad the url column too, so branch / ahead-behind / state also align.
|
|
302
|
+
url_width = max((_visible_width(_shorten_url(n.url)) for n in repos), default=0)
|
|
303
|
+
for n in repos:
|
|
304
|
+
base = C(n.rel, _DIM)
|
|
305
|
+
padded = base + " " * (path_width - _visible_width(base))
|
|
306
|
+
# Rebuild the tail with a url column padded to a fixed width. Always
|
|
307
|
+
# emit the url cell (even when empty) so the branch column lines up
|
|
308
|
+
# for repos that have no remote too.
|
|
309
|
+
cols = _repo_tail_parts(n, C)
|
|
310
|
+
if len(cols) >= 4:
|
|
311
|
+
cols[0] = cols[0] + " " * (url_width - _visible_width(cols[0]))
|
|
312
|
+
text = " ".join(c for c in cols if c)
|
|
313
|
+
lines.append(padded + " " + text)
|
|
314
|
+
repo_line[id(n)] = len(lines) - 1
|
|
315
|
+
repo_base[id(n)] = padded
|
|
316
|
+
return lines, repo_line, repo_base
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def render(root: RepoNode, *, use_color: bool = True) -> str:
|
|
320
|
+
"""Render the full tree as a string."""
|
|
321
|
+
|
|
322
|
+
def C(s: str, *codes: str) -> str:
|
|
323
|
+
if not use_color:
|
|
324
|
+
return s
|
|
325
|
+
return "".join(codes) + s + _RESET
|
|
326
|
+
|
|
327
|
+
lines, _, _ = _build_lines(root, C, lambda n: _repo_tail(n, C))
|
|
328
|
+
return "\n".join(lines)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def render_flat(root: RepoNode, *, use_color: bool = True) -> str:
|
|
332
|
+
"""Render a flat list of repos (one line each), not the tree."""
|
|
333
|
+
|
|
334
|
+
def C(s: str, *codes: str) -> str:
|
|
335
|
+
if not use_color:
|
|
336
|
+
return s
|
|
337
|
+
return "".join(codes) + s + _RESET
|
|
338
|
+
|
|
339
|
+
lines, _, _ = _build_flat_lines(root, C, lambda n: _repo_tail(n, C))
|
|
340
|
+
return "\n".join(lines)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class LiveTree:
|
|
344
|
+
"""Streams the tree to a terminal.
|
|
345
|
+
|
|
346
|
+
Prints the skeleton immediately — every repo line shows a dim `…` where
|
|
347
|
+
its status will go — then rewrites each repo's line in place as its
|
|
348
|
+
status becomes available. The final output is identical to `render()`,
|
|
349
|
+
it just appears incrementally instead of all at once after a multi-second
|
|
350
|
+
fetch.
|
|
351
|
+
|
|
352
|
+
Only meaningful when the stream is a real terminal. Off a TTY it falls
|
|
353
|
+
back to printing the fully-rendered tree once, and `update()` becomes a
|
|
354
|
+
no-op, so it is safe to construct anywhere.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(self, root: RepoNode, *, use_color: bool = True, stream=None,
|
|
358
|
+
flat: bool = False) -> None:
|
|
359
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
360
|
+
self.use_color = use_color
|
|
361
|
+
self._lock = threading.Lock()
|
|
362
|
+
# Terminal width: every rendered line is clipped to this so nothing
|
|
363
|
+
# wraps. A wrapped line is two rows, which breaks the cursor
|
|
364
|
+
# arithmetic the in-place rewrite relies on.
|
|
365
|
+
self._width = self._terminal_width()
|
|
366
|
+
|
|
367
|
+
def C(s: str, *codes: str) -> str:
|
|
368
|
+
if not use_color:
|
|
369
|
+
return s
|
|
370
|
+
return "".join(codes) + s + _RESET
|
|
371
|
+
|
|
372
|
+
self._C = C
|
|
373
|
+
self._flat = flat
|
|
374
|
+
# When flat, pad the url column so branch / ahead-behind / state
|
|
375
|
+
# align; store the width so update() can match the skeleton.
|
|
376
|
+
self._url_width = (
|
|
377
|
+
max((_visible_width(_shorten_url(c.url)) for c in iter_repos(root)), default=0)
|
|
378
|
+
if flat else 0
|
|
379
|
+
)
|
|
380
|
+
builder = _build_flat_lines if flat else _build_lines
|
|
381
|
+
|
|
382
|
+
if not self._is_tty():
|
|
383
|
+
# Not a real terminal: there is no in-place cursor control, so
|
|
384
|
+
# streaming is impossible. Print the skeleton (with `…`
|
|
385
|
+
# placeholders) once and leave update() inert. The CLI only uses
|
|
386
|
+
# LiveTree on a TTY, so this path is defensive.
|
|
387
|
+
self._active = False
|
|
388
|
+
lines, repo_line, repo_base = builder(root, C, lambda n: C("…", _DIM))
|
|
389
|
+
self.lines = [_clip_ansi(line, self._width) for line in lines]
|
|
390
|
+
self.repo_line = repo_line
|
|
391
|
+
self.repo_base = repo_base
|
|
392
|
+
self._bottom = len(self.lines)
|
|
393
|
+
self._print_skeleton()
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
self._active = True
|
|
397
|
+
lines, repo_line, repo_base = builder(root, C, lambda n: C("…", _DIM))
|
|
398
|
+
self.lines = [_clip_ansi(line, self._width) for line in lines]
|
|
399
|
+
self.repo_line = repo_line
|
|
400
|
+
self.repo_base = repo_base
|
|
401
|
+
self._bottom = len(self.lines)
|
|
402
|
+
self._print_skeleton()
|
|
403
|
+
|
|
404
|
+
def _is_tty(self) -> bool:
|
|
405
|
+
try:
|
|
406
|
+
return self.stream.isatty()
|
|
407
|
+
except (AttributeError, OSError):
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
def _terminal_width(self) -> int:
|
|
411
|
+
"""Columns of the terminal, or a sane default when unknown."""
|
|
412
|
+
try:
|
|
413
|
+
return shutil.get_terminal_size().columns
|
|
414
|
+
except (OSError, ValueError):
|
|
415
|
+
return 80
|
|
416
|
+
|
|
417
|
+
def _print_skeleton(self) -> None:
|
|
418
|
+
for line in self.lines:
|
|
419
|
+
self.stream.write(line + "\n")
|
|
420
|
+
self.stream.flush()
|
|
421
|
+
|
|
422
|
+
def update(self, node: RepoNode) -> None:
|
|
423
|
+
"""Swap a repo's `…` placeholder for its real status, in place."""
|
|
424
|
+
if not self._active:
|
|
425
|
+
return
|
|
426
|
+
nid = id(node)
|
|
427
|
+
i = self.repo_line.get(nid)
|
|
428
|
+
if i is None:
|
|
429
|
+
return
|
|
430
|
+
cols = _repo_tail_parts(node, self._C)
|
|
431
|
+
if self._flat and len(cols) >= 4:
|
|
432
|
+
cols[0] = cols[0] + " " * (self._url_width - _visible_width(cols[0]))
|
|
433
|
+
text = self.repo_base[nid] + " " + " ".join(c for c in cols if c)
|
|
434
|
+
text = _clip_ansi(text, self._width)
|
|
435
|
+
with self._lock:
|
|
436
|
+
self._rewrite(i, text)
|
|
437
|
+
|
|
438
|
+
def _rewrite(self, i: int, text: str) -> None:
|
|
439
|
+
"""Rewrite line `i` in place, then restore the cursor to the bottom."""
|
|
440
|
+
up = self._bottom - i
|
|
441
|
+
if up > 0:
|
|
442
|
+
self.stream.write(f"\x1b[{up}A")
|
|
443
|
+
self.stream.write("\r\x1b[2K" + text)
|
|
444
|
+
if up > 0:
|
|
445
|
+
self.stream.write(f"\x1b[{up}B")
|
|
446
|
+
self.stream.write("\r")
|
|
447
|
+
self.stream.flush()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pulli
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Walk a directory tree, show git repos with status, and fast-forward the ones that are behind upstream.
|
|
5
|
+
Author: Stefan Waldherr
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,developer-tools,git,pull,status
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# pulli
|
|
19
|
+
|
|
20
|
+
Discover git repositories under a directory, show their status, and
|
|
21
|
+
fast-forward the ones that are behind upstream.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install git+https://github.com/devskale/pulli
|
|
27
|
+
# or
|
|
28
|
+
pipx install git+https://github.com/devskale/pulli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Both put `pulli` on your PATH (`~/.local/bin/pulli`). To install a local
|
|
32
|
+
checkout instead (development):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd ~/code/pulli
|
|
36
|
+
uv tool install --force .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pulli # status tree of the current dir
|
|
43
|
+
pulli ~/code # status tree of ~/code (= `pulli tree ~/code`)
|
|
44
|
+
pulli --no-fetch ~/code # flags may come before or after the path
|
|
45
|
+
pulli pull # pull the repos that are behind
|
|
46
|
+
pulli pull --dry-run ~/code # show what would be pulled, don't pull
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `tree` (default)
|
|
50
|
+
|
|
51
|
+
By default `pulli` prints a **flat list of the repos only** — one line per
|
|
52
|
+
repo, sorted by path — with no directory scaffolding, no `node_modules`/
|
|
53
|
+
`.claude`/`dist` noise. Pass `--tree` to get the full directory tree
|
|
54
|
+
instead (plain dirs, pruned dirs, symlinks, submodules, and the box-drawing
|
|
55
|
+
connectors).
|
|
56
|
+
|
|
57
|
+
| flag | effect |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `--tree` | show the full directory tree instead of the flat repo list |
|
|
60
|
+
| `--no-fetch` | don't fetch remotes before showing status |
|
|
61
|
+
| `--no-symlinks` | don't follow symlinks (default: follow, marked) |
|
|
62
|
+
| `--max-depth N` | limit recursion (default: 50) |
|
|
63
|
+
| `--no-color` | disable ANSI colours (also implied off a TTY) |
|
|
64
|
+
| `--json` | one JSON object per line, for scripts |
|
|
65
|
+
|
|
66
|
+
Fetches run in parallel by default, so ahead/behind is current, and each is
|
|
67
|
+
bounded by a short timeout — if you're offline (on a train) unreachable
|
|
68
|
+
remotes are reported as `offline` and the tree still renders.
|
|
69
|
+
|
|
70
|
+
While remotes are being fetched, a spinner animates on stderr with live
|
|
71
|
+
progress (`⠋ Fetching remotes… 3/8`), so a multi-second fetch never looks
|
|
72
|
+
frozen. The spinner only appears on a real terminal; when output is piped
|
|
73
|
+
or captured (`pulli | less`, CI, scripts) it is a no-op and the output stays
|
|
74
|
+
byte-clean. `--no-color` disables the spinner's colouring too.
|
|
75
|
+
|
|
76
|
+
### `pull`
|
|
77
|
+
|
|
78
|
+
| flag | effect |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `--dry-run` | decide and report, change nothing |
|
|
81
|
+
| `--no-fetch` | use local refs only (faster, may be stale) |
|
|
82
|
+
| `--json` | the same decision as data; implies `--dry-run` |
|
|
83
|
+
| `--no-color`, `--no-symlinks`, `--max-depth` | as above |
|
|
84
|
+
|
|
85
|
+
Fetches every repo first, then fast-forwards the ones that are behind.
|
|
86
|
+
|
|
87
|
+
## What `pull` does and does not touch
|
|
88
|
+
|
|
89
|
+
Every rule below is a **skip, never a force** — pulli will not destroy or
|
|
90
|
+
corrupt work to make progress.
|
|
91
|
+
|
|
92
|
+
| repo state | action |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| behind upstream, clean | `git pull --ff-only` |
|
|
95
|
+
| up to date | nothing (silent) |
|
|
96
|
+
| ahead only | nothing — local commits need a *push*, not a pull |
|
|
97
|
+
| uncommitted changes | skipped, reported |
|
|
98
|
+
| diverged (ahead **and** behind) | skipped — a fast-forward is impossible; merge or rebase is your call |
|
|
99
|
+
| merge / rebase / bisect in progress | skipped, reported — never touched |
|
|
100
|
+
| no upstream (detached HEAD, no remote) | nothing to pull |
|
|
101
|
+
| remote unreachable | reported as `offline`; **not** a failure |
|
|
102
|
+
| broken repo | reported; exit code 1 |
|
|
103
|
+
|
|
104
|
+
`--ff-only` is the important part: a plain `git pull` can *create* a merge
|
|
105
|
+
commit (and a merge conflict) in a repo you never touched. `--ff-only`
|
|
106
|
+
refuses instead.
|
|
107
|
+
|
|
108
|
+
Exit code is `0` when nothing needs you, `1` on a broken repo or a failed
|
|
109
|
+
pull. Being offline is not a failure.
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
$ pulli pull --dry-run ~/code
|
|
113
|
+
↕ aiuis/pi-gui ↓4 ↑2 diverged — needs merge or rebase, skipping
|
|
114
|
+
◐ www/chopdok ↓5 dirty 1 (MERGE-REVIEW.md), skipping pull
|
|
115
|
+
↑ throway ↑1 ↑1 ahead (not pushed)
|
|
116
|
+
◐ klark0 merge in progress — skipping pull
|
|
117
|
+
↓ chopdok ↓5 would pull
|
|
118
|
+
|
|
119
|
+
Dry run — no pulls performed.
|
|
120
|
+
|
|
121
|
+
Would pull 1 repo(s).
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## What the output shows
|
|
125
|
+
|
|
126
|
+
### Flat list (default)
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
clones/pi earendil-works/pi main ↓0 ↑0 ● clean
|
|
130
|
+
clones/gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
|
|
131
|
+
kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Each line is `path remote branch ↓behind ↑ahead state`. Repos are
|
|
135
|
+
sorted by path, so a run is reproducible.
|
|
136
|
+
|
|
137
|
+
### Tree (`--tree`)
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
~/code
|
|
141
|
+
├── clones/
|
|
142
|
+
│ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
|
|
143
|
+
│ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
|
|
144
|
+
│ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
|
|
145
|
+
├── handoffs -> code/skaleshare/handoffs (alias)
|
|
146
|
+
├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
|
|
147
|
+
│ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
|
|
148
|
+
│ └── python-utils (submodule) ↓0 ↑0 ● clean
|
|
149
|
+
└── backups/
|
|
150
|
+
└── model-proxy.git/ (bare repo — nothing to pull)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## What the tree shows
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
~/code
|
|
157
|
+
├── clones/
|
|
158
|
+
│ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
|
|
159
|
+
│ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
|
|
160
|
+
│ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
|
|
161
|
+
├── handoffs -> code/skaleshare/handoffs (alias)
|
|
162
|
+
├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
|
|
163
|
+
│ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
|
|
164
|
+
│ └── python-utils (submodule) ↓0 ↑0 ● clean
|
|
165
|
+
└── backups/
|
|
166
|
+
└── model-proxy.git/ (bare repo — nothing to pull)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- **`↓N ↑M`** — commits behind / ahead of upstream; `· ·` means there is
|
|
170
|
+
no upstream to compare against (a fresh `git init`, a detached HEAD, a
|
|
171
|
+
remote-less clone), which is not the same as "in sync"
|
|
172
|
+
- **`●` clean / `◐` dirty N / `✗` error** — `◐ offline` means the fetch
|
|
173
|
+
failed, so the numbers may be stale
|
|
174
|
+
- **symlinks** are followed by default and marked; one whose target is also
|
|
175
|
+
reachable under its real name is shown as an `(alias)` and is never a
|
|
176
|
+
second pull target. `--no-symlinks` skips them
|
|
177
|
+
- **submodules** are their own nodes, marked `(submodule)`
|
|
178
|
+
- **nested repos** (a repo inside another repo) are found and shown
|
|
179
|
+
- **bare repos** (`x.git/`) have no working tree: marked, never pulled
|
|
180
|
+
- **credentials** in remote URLs (tokens, `user:pass@`) are stripped before
|
|
181
|
+
display
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv run --extra dev pytest # 45 tests, all against real git repos in tmpdirs
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Tests build actual repositories in every state pulli reasons about
|
|
190
|
+
(behind, ahead, diverged, dirty, detached, mid-merge, offline, bare,
|
|
191
|
+
symlinked, vendored) and assert on the *decision*, not the formatting —
|
|
192
|
+
plus a cross-check that ahead/behind matches what `git rev-list` reports.
|
|
193
|
+
|
|
194
|
+
## Layout
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
src/pulli/
|
|
198
|
+
├── cli.py # argparse entry point (tree + pull subcommands)
|
|
199
|
+
├── discovery.py # tree walk: repos, nested repos, submodules, symlinks, bare
|
|
200
|
+
├── status.py # per-repo git status (branch, ahead/behind, dirty, operation)
|
|
201
|
+
├── pull.py # the decision: what to skip, what to fast-forward
|
|
202
|
+
└── tree.py # ANSI tree renderer with credential scrubbing
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`status.py` is the only module that shells out for status; `tree.py` renders
|
|
206
|
+
from the fields it fills in. That keeps git to one call per repo and keeps
|
|
207
|
+
credential scrubbing in exactly one place.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pulli/__init__.py,sha256=dro1-yY3kwnakVo6FgLmEcu1wy9Gk0Fw9CAFjn5F4XA,95
|
|
2
|
+
pulli/cli.py,sha256=I2AYZKnCYAgzBNQXOeqAlw4GpKHCCBGafaZwjM9eO_4,9843
|
|
3
|
+
pulli/discovery.py,sha256=PTShA9NCGTJww5i0WFihVesx86-LW0jvY-nJGhBXnjI,18560
|
|
4
|
+
pulli/pull.py,sha256=ANzXceOF1Lw_UFBB0UkxXBTIk1ixEsDabmS9noT_y8k,9980
|
|
5
|
+
pulli/spinner.py,sha256=hEXLOuJf6271TvSH1yqnoc2cKVz8vJfu260u7qEShWQ,3795
|
|
6
|
+
pulli/status.py,sha256=L75DyGBlrBj-fCon85h9m17uVN6i7mr13t-BSqj-Inw,16849
|
|
7
|
+
pulli/tree.py,sha256=gODfKz4bVwxWqWs045YlvVPSyCPZcF0xLBe5yZ62g8o,16729
|
|
8
|
+
pulli-0.2.1.dist-info/METADATA,sha256=VdwdjvgP2Fh4IueqrBukUq7lmrmVryfoMXp8JtxwwYc,8053
|
|
9
|
+
pulli-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
pulli-0.2.1.dist-info/entry_points.txt,sha256=2Co7Jl-YHCLalGvzeciA_4DvFD-1N4JIG48TMUzZAVQ,41
|
|
11
|
+
pulli-0.2.1.dist-info/licenses/LICENSE,sha256=i3i8bBdnKsy4kZ3aSW0pDOtU8GG69Vuu1irJDYhLRNw,1072
|
|
12
|
+
pulli-0.2.1.dist-info/RECORD,,
|