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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pulli = pulli.cli:main