pulli 0.2.13__tar.gz → 0.2.14__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.
- {pulli-0.2.13 → pulli-0.2.14}/PKG-INFO +1 -1
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/__init__.py +1 -1
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/cli.py +32 -26
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/discovery.py +120 -76
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/pull.py +8 -8
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/status.py +28 -0
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/tree.py +21 -4
- {pulli-0.2.13 → pulli-0.2.14}/tests/test_pulli.py +132 -0
- {pulli-0.2.13 → pulli-0.2.14}/.gitignore +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/LICENSE +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/README.md +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/pyproject.toml +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/scripts/release.sh +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/spinner.py +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/state.py +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/style.py +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/src/pulli/update.py +0 -0
- {pulli-0.2.13 → pulli-0.2.14}/uv.lock +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""pulli — discover git repos under a root and report their status."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.14"
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import argparse
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
|
+
import shutil
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
@@ -22,18 +23,10 @@ from . import __version__
|
|
|
22
23
|
from .update import update_hint
|
|
23
24
|
from .discovery import discover, iter_repos, set_rels
|
|
24
25
|
from .pull import pull
|
|
25
|
-
from .status import collect_status, fetch_all, fetch_and_status
|
|
26
|
+
from .status import collect_incoming, collect_status, fetch_all, fetch_and_status
|
|
26
27
|
from .tree import LiveTree, render, render_flat
|
|
27
28
|
from .style import BOLD, RESET
|
|
28
29
|
|
|
29
|
-
# Flags that belong to a subcommand. Used by _inject_tree to decide whether
|
|
30
|
-
# a leading argument is a subcommand or a path/flag for the default one.
|
|
31
|
-
_TREE_FLAGS = {
|
|
32
|
-
"--no-symlinks", "--no-fetch", "--no-color", "--max-depth",
|
|
33
|
-
"--dry-run", "--json", "--tree",
|
|
34
|
-
}
|
|
35
|
-
_PULL_FLAGS = {"--no-color", "--dry-run", "--json", "--no-fetch"}
|
|
36
|
-
|
|
37
30
|
SUBCOMMANDS = ("tree", "pull", "update")
|
|
38
31
|
|
|
39
32
|
class _VersionAction(argparse.Action):
|
|
@@ -257,21 +250,23 @@ def _run_update(args) -> int:
|
|
|
257
250
|
print(f"pulli {__version__} → {latest} would be installed. Run: pulli update")
|
|
258
251
|
return 0
|
|
259
252
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
253
|
+
# uv tool and pipx installs are indistinguishable by path (both land
|
|
254
|
+
# in ~/.local/bin), so try each installer found on PATH — and keep
|
|
255
|
+
# going when one fails: upgrading with the wrong tool errors out
|
|
256
|
+
# instead of pretending to succeed, and the right one may be next.
|
|
257
|
+
for name, cmd in (
|
|
258
|
+
("uv", ["uv", "tool", "upgrade", "pulli"]),
|
|
259
|
+
("pipx", ["pipx", "upgrade", "pulli"]),
|
|
260
|
+
):
|
|
261
|
+
if not shutil.which(cmd[0]):
|
|
262
|
+
continue
|
|
263
|
+
print(f"pulli {__version__} → {latest} (via {name})")
|
|
264
|
+
rc = subprocess.run(cmd).returncode
|
|
265
|
+
if rc == 0:
|
|
266
|
+
print(f"Updated to pulli {latest}.")
|
|
267
|
+
return 0
|
|
273
268
|
print(
|
|
274
|
-
f"pulli {latest} is available, but no
|
|
269
|
+
f"pulli {latest} is available, but no installer succeeded. Update manually, e.g.:\n"
|
|
275
270
|
f" pip install --upgrade pulli",
|
|
276
271
|
file=sys.stderr,
|
|
277
272
|
)
|
|
@@ -374,6 +369,8 @@ def _run_tree(args) -> int:
|
|
|
374
369
|
if not args.no_fetch:
|
|
375
370
|
fetch_all(repos, quiet=True, use_color=_use_color(args))
|
|
376
371
|
collect_status(tree)
|
|
372
|
+
if args.verbose:
|
|
373
|
+
collect_incoming(repos)
|
|
377
374
|
_print_json(tree)
|
|
378
375
|
return 0
|
|
379
376
|
|
|
@@ -382,7 +379,12 @@ def _run_tree(args) -> int:
|
|
|
382
379
|
# Filters decide on fetched status, so they cannot stream: the skeleton
|
|
383
380
|
# is printed before anything is known. Run the batch path instead.
|
|
384
381
|
filtered = bool(getattr(args, "behind", False) or getattr(args, "attention", False))
|
|
385
|
-
|
|
382
|
+
# -v needs the batch path too: the live tree rewrites each repo's line
|
|
383
|
+
# in place under a fixed row budget, and extra commit lines would break
|
|
384
|
+
# that arithmetic. Print complete, once — the point of -v is reading
|
|
385
|
+
# the commits, not watching them arrive.
|
|
386
|
+
verbose = bool(getattr(args, "verbose", False))
|
|
387
|
+
if sys.stdout.isatty() and not filtered and not verbose:
|
|
386
388
|
# Stream: print the skeleton immediately, then fill each repo's
|
|
387
389
|
# status line in as it becomes ready — no blank-screen wait.
|
|
388
390
|
live = LiveTree(tree, use_color=use_color, flat=flat)
|
|
@@ -410,6 +412,8 @@ def _run_tree(args) -> int:
|
|
|
410
412
|
if not args.no_fetch:
|
|
411
413
|
fetch_all(repos, quiet=True, use_color=use_color)
|
|
412
414
|
collect_status(tree)
|
|
415
|
+
if verbose:
|
|
416
|
+
collect_incoming(repos)
|
|
413
417
|
if flat:
|
|
414
418
|
shown = list(iter_repos(tree))
|
|
415
419
|
if args.behind:
|
|
@@ -425,12 +429,13 @@ def _run_tree(args) -> int:
|
|
|
425
429
|
what = "behind" if args.behind else "needing attention"
|
|
426
430
|
print(f"No repos {what} under {args.link_root}")
|
|
427
431
|
return 0
|
|
428
|
-
print(render_flat(tree, use_color=use_color, repos=shown
|
|
432
|
+
print(render_flat(tree, use_color=use_color, repos=shown,
|
|
433
|
+
verbose=verbose))
|
|
429
434
|
if not args.no_summary:
|
|
430
435
|
print()
|
|
431
436
|
print(_summary(shown, use_color))
|
|
432
437
|
else:
|
|
433
|
-
print(render(tree, use_color=use_color))
|
|
438
|
+
print(render(tree, use_color=use_color, verbose=verbose))
|
|
434
439
|
return 0
|
|
435
440
|
|
|
436
441
|
|
|
@@ -479,6 +484,7 @@ def _node_dict(n) -> dict:
|
|
|
479
484
|
"operation": n.operation,
|
|
480
485
|
"fetch_failed": n.fetch_failed or None,
|
|
481
486
|
"error": n.error,
|
|
487
|
+
"incoming": n.incoming or None,
|
|
482
488
|
}
|
|
483
489
|
|
|
484
490
|
|
|
@@ -57,6 +57,9 @@ class RepoNode:
|
|
|
57
57
|
fetch_failed: bool = False # fetch did not succeed (offline / unreachable)
|
|
58
58
|
fetch_reason: str | None = None # why the fetch failed
|
|
59
59
|
error: str | None = None
|
|
60
|
+
# incoming commits (git log --oneline HEAD..upstream), filled in only
|
|
61
|
+
# when the caller asked for -v — one git call per behind repo.
|
|
62
|
+
incoming: list[str] = field(default_factory=list)
|
|
60
63
|
|
|
61
64
|
def set_rel(self, root: Path) -> None:
|
|
62
65
|
"""Display path relative to `root`.
|
|
@@ -243,6 +246,53 @@ def discover(
|
|
|
243
246
|
return root_node
|
|
244
247
|
|
|
245
248
|
|
|
249
|
+
def _link_child(
|
|
250
|
+
node: RepoNode,
|
|
251
|
+
link: Path,
|
|
252
|
+
name: str,
|
|
253
|
+
display: Path,
|
|
254
|
+
visited: set[Path],
|
|
255
|
+
) -> RepoNode | None:
|
|
256
|
+
"""Record one symlink entry as a child of `node`. Shared by every
|
|
257
|
+
pass of the link walk.
|
|
258
|
+
|
|
259
|
+
A link whose target was already walked is an *alias*: the directory
|
|
260
|
+
exists here, but its repo is the one already listed elsewhere. Show
|
|
261
|
+
the link so the tree still reflects what is on disk, but not as a
|
|
262
|
+
second pull target — `pull` must never fast-forward the same work
|
|
263
|
+
tree twice. Returns None when the link is unusable (broken, not a
|
|
264
|
+
dir, unreadable).
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
target = link.resolve(strict=True)
|
|
268
|
+
except (OSError, RuntimeError):
|
|
269
|
+
return None
|
|
270
|
+
if not target.is_dir():
|
|
271
|
+
return None
|
|
272
|
+
already = target in visited
|
|
273
|
+
if not already:
|
|
274
|
+
visited.add(target)
|
|
275
|
+
try:
|
|
276
|
+
is_repo = _is_git_dir(target)
|
|
277
|
+
except OSError:
|
|
278
|
+
return None
|
|
279
|
+
child = RepoNode(
|
|
280
|
+
path=target,
|
|
281
|
+
link_path=display,
|
|
282
|
+
name=name,
|
|
283
|
+
depth=node.depth + 1,
|
|
284
|
+
parent=node,
|
|
285
|
+
is_symlink=True,
|
|
286
|
+
symlink_target=_readlink(link),
|
|
287
|
+
is_repo=is_repo and not already,
|
|
288
|
+
is_alias=already,
|
|
289
|
+
)
|
|
290
|
+
node.children.append(child)
|
|
291
|
+
if is_repo and not already:
|
|
292
|
+
_add_submodules(child, target, display, node.depth + 1, visited)
|
|
293
|
+
return child
|
|
294
|
+
|
|
295
|
+
|
|
246
296
|
def _walk_links(
|
|
247
297
|
node: RepoNode,
|
|
248
298
|
real_dir: Path,
|
|
@@ -286,9 +336,15 @@ def _walk_links(
|
|
|
286
336
|
# case) — but only one level of plain-dir descent, so a
|
|
287
337
|
# repo's src/ tree is not pointlessly scanned. The dir
|
|
288
338
|
# becomes a real node so links land under it, not under
|
|
289
|
-
# the repo root
|
|
339
|
+
# the repo root — unless the first walk already recorded
|
|
340
|
+
# it (a plain dir outside a repo is a node there): then
|
|
341
|
+
# the links join the existing node, or the tree shows the
|
|
342
|
+
# same directory twice.
|
|
290
343
|
if link.is_dir() and not _is_pruned(name):
|
|
291
|
-
|
|
344
|
+
existing = next(
|
|
345
|
+
(c for c in node.children if c.path == link), None
|
|
346
|
+
)
|
|
347
|
+
dir_node = existing if existing is not None else RepoNode(
|
|
292
348
|
path=link,
|
|
293
349
|
link_path=display_dir / name,
|
|
294
350
|
name=name,
|
|
@@ -297,65 +353,33 @@ def _walk_links(
|
|
|
297
353
|
)
|
|
298
354
|
_walk_links_plain(dir_node, link, display_dir / name,
|
|
299
355
|
max_depth, visited)
|
|
300
|
-
if dir_node.children:
|
|
356
|
+
if existing is None and dir_node.children:
|
|
301
357
|
node.children.append(dir_node)
|
|
302
358
|
continue
|
|
303
|
-
target = link.resolve(strict=True)
|
|
304
|
-
except (OSError, RuntimeError):
|
|
305
|
-
continue
|
|
306
|
-
if not target.is_dir():
|
|
307
|
-
continue
|
|
308
|
-
|
|
309
|
-
display = display_dir / name
|
|
310
|
-
already = target in visited
|
|
311
|
-
if not already:
|
|
312
|
-
visited.add(target)
|
|
313
|
-
|
|
314
|
-
try:
|
|
315
|
-
is_repo = _is_git_dir(target)
|
|
316
359
|
except OSError:
|
|
317
360
|
continue
|
|
318
361
|
|
|
319
|
-
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
link_path=display,
|
|
327
|
-
name=name,
|
|
328
|
-
depth=node.depth + 1,
|
|
329
|
-
parent=node,
|
|
330
|
-
is_symlink=True,
|
|
331
|
-
symlink_target=_readlink(link),
|
|
332
|
-
is_repo=is_repo and not already,
|
|
333
|
-
is_alias=already,
|
|
334
|
-
)
|
|
335
|
-
node.children.append(child)
|
|
336
|
-
|
|
337
|
-
if is_repo and not already:
|
|
338
|
-
_add_submodules(child, target, display, node.depth + 1, visited)
|
|
339
|
-
if not already:
|
|
340
|
-
_walk_links(child, target, display, max_depth, visited)
|
|
362
|
+
child = _link_child(node, link, name, display_dir / name, visited)
|
|
363
|
+
# Expand a fresh link target in place — aliases are listed under
|
|
364
|
+
# their real name already, and expanding them would duplicate the
|
|
365
|
+
# subtree.
|
|
366
|
+
if child is not None and not child.is_alias:
|
|
367
|
+
_walk_links(child, child.path, display_dir / name,
|
|
368
|
+
max_depth, visited)
|
|
341
369
|
|
|
342
370
|
node.children.sort(key=lambda c: c.name.lower())
|
|
343
371
|
|
|
344
372
|
|
|
345
|
-
def
|
|
373
|
+
def _record_direct_links(
|
|
346
374
|
node: RepoNode,
|
|
347
375
|
real_dir: Path,
|
|
348
376
|
display_dir: Path,
|
|
349
|
-
max_depth: int,
|
|
350
377
|
visited: set[Path],
|
|
351
378
|
) -> None:
|
|
352
|
-
"""
|
|
353
|
-
(plain dirs inside a repo are skipped there). Records only the links
|
|
354
|
-
themselves as children of `node` — the plain dir stays invisible, so the
|
|
355
|
-
tree does not grow a directory per level of indirection.
|
|
379
|
+
"""Each symlink directly inside `real_dir` becomes a child of `node`.
|
|
356
380
|
|
|
357
|
-
|
|
358
|
-
|
|
381
|
+
Terminal: never descends into plain dirs and never expands link
|
|
382
|
+
targets — those are `_walk_links_plain`'s and `_walk_links`' jobs.
|
|
359
383
|
"""
|
|
360
384
|
try:
|
|
361
385
|
entries = sorted(os.listdir(real_dir), key=lambda s: s.lower())
|
|
@@ -368,35 +392,60 @@ def _walk_links_plain(
|
|
|
368
392
|
try:
|
|
369
393
|
if not link.is_symlink():
|
|
370
394
|
continue
|
|
371
|
-
|
|
372
|
-
except (OSError, RuntimeError):
|
|
395
|
+
except OSError:
|
|
373
396
|
continue
|
|
374
|
-
|
|
397
|
+
_link_child(node, link, name, display_dir / name, visited)
|
|
398
|
+
node.children.sort(key=lambda c: c.name.lower())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _walk_links_plain(
|
|
402
|
+
node: RepoNode,
|
|
403
|
+
real_dir: Path,
|
|
404
|
+
display_dir: Path,
|
|
405
|
+
max_depth: int,
|
|
406
|
+
visited: set[Path],
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Look for links inside a plain dir the first walk never recorded
|
|
409
|
+
(plain dirs inside a repo are skipped there).
|
|
410
|
+
|
|
411
|
+
Records the dir's direct symlinks as children of `node` — and, one
|
|
412
|
+
level deeper (depth permitting), the direct symlinks of its plain
|
|
413
|
+
subdirs. That second level is the `.links/` layout *inside a repo*:
|
|
414
|
+
those links are the whole point of the layout, and without the
|
|
415
|
+
descent a repo found by the normal walk never showed them, while the
|
|
416
|
+
same repo reached via a symlink did — the path you took decided
|
|
417
|
+
what you saw.
|
|
418
|
+
|
|
419
|
+
Still bounded: the descent never goes below that one extra level,
|
|
420
|
+
and plain dirs become nodes only when they hold links, so the tree
|
|
421
|
+
does not grow a directory per level of indirection.
|
|
422
|
+
"""
|
|
423
|
+
_record_direct_links(node, real_dir, display_dir, visited)
|
|
424
|
+
if node.depth + 2 > max_depth:
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
entries = sorted(os.listdir(real_dir), key=lambda s: s.lower())
|
|
428
|
+
except OSError:
|
|
429
|
+
return
|
|
430
|
+
for name in entries:
|
|
431
|
+
if name == ".git":
|
|
375
432
|
continue
|
|
376
|
-
|
|
377
|
-
already = target in visited
|
|
378
|
-
if not already:
|
|
379
|
-
visited.add(target)
|
|
433
|
+
d = real_dir / name
|
|
380
434
|
try:
|
|
381
|
-
|
|
435
|
+
if d.is_symlink() or not d.is_dir() or _is_pruned(name):
|
|
436
|
+
continue
|
|
382
437
|
except OSError:
|
|
383
438
|
continue
|
|
384
|
-
|
|
385
|
-
path=
|
|
386
|
-
link_path=
|
|
439
|
+
sub = RepoNode(
|
|
440
|
+
path=d,
|
|
441
|
+
link_path=display_dir / name,
|
|
387
442
|
name=name,
|
|
388
443
|
depth=node.depth + 1,
|
|
389
444
|
parent=node,
|
|
390
|
-
is_symlink=True,
|
|
391
|
-
symlink_target=_readlink(link),
|
|
392
|
-
is_repo=is_repo and not already,
|
|
393
|
-
is_alias=already,
|
|
394
445
|
)
|
|
395
|
-
|
|
396
|
-
if
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
node.children.sort(key=lambda c: c.name.lower())
|
|
446
|
+
_record_direct_links(sub, d, display_dir / name, visited)
|
|
447
|
+
if sub.children:
|
|
448
|
+
node.children.append(sub)
|
|
400
449
|
|
|
401
450
|
|
|
402
451
|
def _add_submodules(
|
|
@@ -478,21 +527,16 @@ def _walk(
|
|
|
478
527
|
continue
|
|
479
528
|
except OSError:
|
|
480
529
|
continue
|
|
530
|
+
if link.is_symlink():
|
|
531
|
+
continue # symlinks are handled in the second pass
|
|
481
532
|
|
|
482
|
-
# Mark every directory we descend into as seen
|
|
483
|
-
# ever expanded twice
|
|
484
|
-
# work tree from two entries.
|
|
533
|
+
# Mark every directory we *descend into* as seen — and only those —
|
|
534
|
+
# so no directory is ever expanded twice: `pulli pull` must not
|
|
535
|
+
# fast-forward the same work tree from two entries.
|
|
485
536
|
if traverse in visited:
|
|
486
537
|
continue
|
|
487
538
|
visited.add(traverse)
|
|
488
539
|
|
|
489
|
-
try:
|
|
490
|
-
is_dir = link.is_dir()
|
|
491
|
-
except OSError:
|
|
492
|
-
continue
|
|
493
|
-
if not is_dir or link.is_symlink():
|
|
494
|
-
continue # symlinks are handled in the second pass
|
|
495
|
-
|
|
496
540
|
# Guard against permission errors on unreadable dirs.
|
|
497
541
|
try:
|
|
498
542
|
is_repo = _is_git_dir(traverse)
|
|
@@ -31,7 +31,13 @@ import sys
|
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
|
|
33
33
|
from .discovery import RepoNode, discover, iter_repos, set_rels
|
|
34
|
-
from .status import
|
|
34
|
+
from .status import (
|
|
35
|
+
collect_incoming,
|
|
36
|
+
collect_status,
|
|
37
|
+
fetch_all,
|
|
38
|
+
incoming_commits,
|
|
39
|
+
run_git,
|
|
40
|
+
)
|
|
35
41
|
from .style import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, Colorizer
|
|
36
42
|
|
|
37
43
|
_PULL_TIMEOUT = 120
|
|
@@ -207,13 +213,7 @@ def pull(
|
|
|
207
213
|
# fast-forward it is empty).
|
|
208
214
|
new_commits: list[str] = []
|
|
209
215
|
if verbose and node.upstream:
|
|
210
|
-
|
|
211
|
-
node.path,
|
|
212
|
-
"log", "--oneline", f"HEAD..{node.upstream}",
|
|
213
|
-
timeout=_PULL_TIMEOUT,
|
|
214
|
-
)
|
|
215
|
-
if rc == 0:
|
|
216
|
-
new_commits = [l for l in out.splitlines() if l.strip()]
|
|
216
|
+
new_commits = incoming_commits(node.path, node.upstream)
|
|
217
217
|
rc, stdout, stderr = run_git(
|
|
218
218
|
node.path, "pull", "--ff-only", "--quiet", timeout=_PULL_TIMEOUT
|
|
219
219
|
)
|
|
@@ -351,6 +351,34 @@ def fetch_and_status(
|
|
|
351
351
|
return failed
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
# ── incoming commits (-v) ───────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
_LOG_TIMEOUT = 30
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def incoming_commits(repo: Path, upstream: str) -> list[str]:
|
|
360
|
+
"""`git log --oneline HEAD..upstream` — the commits a pull would bring
|
|
361
|
+
in. Must run *before* any pull: after a fast-forward the range is
|
|
362
|
+
empty. Returns [] on any git failure (stale refs, no upstream object).
|
|
363
|
+
"""
|
|
364
|
+
rc, out, _ = run_git(repo, "log", "--oneline", f"HEAD..{upstream}",
|
|
365
|
+
timeout=_LOG_TIMEOUT)
|
|
366
|
+
if rc != 0:
|
|
367
|
+
return []
|
|
368
|
+
return [l for l in out.splitlines() if l.strip()]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def collect_incoming(nodes: list[RepoNode]) -> None:
|
|
372
|
+
"""Fill node.incoming for every repo that is behind its upstream.
|
|
373
|
+
|
|
374
|
+
Only repos with something incoming are asked — verbose is an extra git
|
|
375
|
+
call per *interesting* repo, not per repo in the tree.
|
|
376
|
+
"""
|
|
377
|
+
for n in nodes:
|
|
378
|
+
if n.upstream and (n.behind or 0) > 0 and not n.error:
|
|
379
|
+
n.incoming = incoming_commits(n.path, n.upstream)
|
|
380
|
+
|
|
381
|
+
|
|
354
382
|
# ── status collection ────────────────────────────────────────────────────
|
|
355
383
|
|
|
356
384
|
|
|
@@ -245,6 +245,7 @@ def _build_lines(
|
|
|
245
245
|
root: RepoNode,
|
|
246
246
|
C,
|
|
247
247
|
tail,
|
|
248
|
+
verbose: bool = False,
|
|
248
249
|
) -> tuple[list[str], dict[int, int], dict[int, str]]:
|
|
249
250
|
"""Build the tree lines.
|
|
250
251
|
|
|
@@ -286,6 +287,12 @@ def _build_lines(
|
|
|
286
287
|
lines.append(base + " " + tail(node))
|
|
287
288
|
repo_line[id(node)] = len(lines) - 1
|
|
288
289
|
repo_base[id(node)] = base
|
|
290
|
+
if verbose:
|
|
291
|
+
# -v: the incoming commits under the repo row, indented like
|
|
292
|
+
# a child so they read as belonging to it.
|
|
293
|
+
vp = prefix + (" " if is_last else "│ ")
|
|
294
|
+
for c in node.incoming:
|
|
295
|
+
lines.append(vp + C(c, DIM))
|
|
289
296
|
elif node.is_bare:
|
|
290
297
|
lines.append(f"{prefix}{connector}{name}/ {C(_BARE_NOTE, DIM)}")
|
|
291
298
|
elif node.is_symlink:
|
|
@@ -312,6 +319,7 @@ def _build_flat_lines(
|
|
|
312
319
|
C,
|
|
313
320
|
tail,
|
|
314
321
|
repos=None,
|
|
322
|
+
verbose: bool = False,
|
|
315
323
|
) -> tuple[list[str], dict[int, int], dict[int, str]]:
|
|
316
324
|
"""Build a flat list: one line per repo, no tree structure.
|
|
317
325
|
|
|
@@ -353,10 +361,16 @@ def _build_flat_lines(
|
|
|
353
361
|
lines.append(line)
|
|
354
362
|
repo_line[id(n)] = len(lines) - 1
|
|
355
363
|
repo_base[id(n)] = padded
|
|
364
|
+
if verbose:
|
|
365
|
+
# -v: incoming commits, aligned under the status column so they
|
|
366
|
+
# visually belong to their repo row.
|
|
367
|
+
indent = " " * (path_width + 2)
|
|
368
|
+
for c in n.incoming:
|
|
369
|
+
lines.append(indent + C(c, DIM))
|
|
356
370
|
return lines, repo_line, repo_base
|
|
357
371
|
|
|
358
372
|
|
|
359
|
-
def render(root: RepoNode, *, use_color: bool = True) -> str:
|
|
373
|
+
def render(root: RepoNode, *, use_color: bool = True, verbose: bool = False) -> str:
|
|
360
374
|
"""Render the full tree as a string."""
|
|
361
375
|
|
|
362
376
|
def C(s: str, *codes: str) -> str:
|
|
@@ -364,11 +378,13 @@ def render(root: RepoNode, *, use_color: bool = True) -> str:
|
|
|
364
378
|
return s
|
|
365
379
|
return "".join(codes) + s + RESET
|
|
366
380
|
|
|
367
|
-
lines, _, _ = _build_lines(root, C, lambda n: _repo_tail(n, C)
|
|
381
|
+
lines, _, _ = _build_lines(root, C, lambda n: _repo_tail(n, C),
|
|
382
|
+
verbose=verbose)
|
|
368
383
|
return "\n".join(lines)
|
|
369
384
|
|
|
370
385
|
|
|
371
|
-
def render_flat(root: RepoNode, *, use_color: bool = True, repos=None
|
|
386
|
+
def render_flat(root: RepoNode, *, use_color: bool = True, repos=None,
|
|
387
|
+
verbose: bool = False) -> str:
|
|
372
388
|
"""Render a flat list of repos (one line each), not the tree.
|
|
373
389
|
|
|
374
390
|
`repos` overrides the default "all repos sorted by path" list.
|
|
@@ -379,7 +395,8 @@ def render_flat(root: RepoNode, *, use_color: bool = True, repos=None) -> str:
|
|
|
379
395
|
return s
|
|
380
396
|
return "".join(codes) + s + RESET
|
|
381
397
|
|
|
382
|
-
lines, _, _ = _build_flat_lines(root, C, lambda n: _repo_tail(n, C),
|
|
398
|
+
lines, _, _ = _build_flat_lines(root, C, lambda n: _repo_tail(n, C),
|
|
399
|
+
repos=repos, verbose=verbose)
|
|
383
400
|
return "\n".join(lines)
|
|
384
401
|
|
|
385
402
|
|
|
@@ -174,6 +174,65 @@ def test_symlink_does_not_duplicate_a_repo(lab):
|
|
|
174
174
|
assert "link" not in names # and the link is not shown as well
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
def test_alias_link_in_recorded_dir_does_not_duplicate_it(lab):
|
|
178
|
+
"""A plain dir the first walk recorded (it holds a repo) must not be
|
|
179
|
+
duplicated when the link pass finds an alias link in it too — the tree
|
|
180
|
+
used to show the same directory twice, once per pass."""
|
|
181
|
+
clones = lab.root / "clones"
|
|
182
|
+
clones.mkdir()
|
|
183
|
+
_git(lab.root, "clone", "-q", str(lab.bare), "clones/real")
|
|
184
|
+
os.symlink(clones / "real", clones / "link")
|
|
185
|
+
tree = discover(lab.root)
|
|
186
|
+
set_rels(tree)
|
|
187
|
+
dup = [c for c in tree.children if c.name == "clones"]
|
|
188
|
+
assert len(dup) == 1
|
|
189
|
+
names = {c.name for c in dup[0].children}
|
|
190
|
+
assert names == {"real", "link"}
|
|
191
|
+
# and the rendered tree shows the dir once, with both children in it
|
|
192
|
+
out = render(tree, use_color=False)
|
|
193
|
+
dir_lines = [l for l in out.splitlines() if l.rstrip().endswith("clones/")]
|
|
194
|
+
assert len(dir_lines) == 1
|
|
195
|
+
assert "(alias)" in out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_links_dir_inside_normally_walked_repo_is_found(lab):
|
|
199
|
+
"""A repo's .links/ dir of sibling links must be found by the normal
|
|
200
|
+
walk — it used to appear only when the repo itself was reached via a
|
|
201
|
+
symlink, so the path you took decided what you saw (the a2e3fb9
|
|
202
|
+
regression)."""
|
|
203
|
+
outside = lab.root.parent / "elsewhere"
|
|
204
|
+
outside.mkdir(exist_ok=True)
|
|
205
|
+
_git(outside, "init", "-q", ".")
|
|
206
|
+
outer = lab.clone("outer")
|
|
207
|
+
(outer / ".links").mkdir()
|
|
208
|
+
os.symlink(outside, outer / ".links" / "sib")
|
|
209
|
+
tree = discover(lab.root, max_depth=4)
|
|
210
|
+
set_rels(tree)
|
|
211
|
+
outer_node = [n for n in iter_repos(tree) if n.name == "outer"][0]
|
|
212
|
+
links_dir = [c for c in outer_node.children if c.name == ".links"]
|
|
213
|
+
assert links_dir, "`.links` dir not found under the repo"
|
|
214
|
+
sib = [c for c in links_dir[0].children if c.name == "sib"]
|
|
215
|
+
assert sib and sib[0].is_symlink
|
|
216
|
+
# target is outside the walk — a real repo, not an alias, and pullable
|
|
217
|
+
assert not sib[0].is_alias
|
|
218
|
+
assert sib[0].path == outside.resolve()
|
|
219
|
+
assert any(n.path == outside.resolve() for n in iter_repos(tree))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_links_dir_descent_respects_max_depth(lab):
|
|
223
|
+
"""The extra .links descent is depth-budgeted like everything else:
|
|
224
|
+
at max_depth 2 the link (depth 3) stays hidden, at 3 it shows."""
|
|
225
|
+
outer = lab.clone("outer")
|
|
226
|
+
(outer / ".links").mkdir()
|
|
227
|
+
os.symlink(lab.root / "outer", outer / ".links" / "self")
|
|
228
|
+
shallow = discover(lab.root, max_depth=2)
|
|
229
|
+
outer_node = [n for n in iter_repos(shallow) if n.name == "outer"][0]
|
|
230
|
+
assert not [c for c in outer_node.children if c.name == ".links"]
|
|
231
|
+
deep = discover(lab.root, max_depth=3)
|
|
232
|
+
outer_node = [n for n in iter_repos(deep) if n.name == "outer"][0]
|
|
233
|
+
assert [c for c in outer_node.children if c.name == ".links"]
|
|
234
|
+
|
|
235
|
+
|
|
177
236
|
def test_no_symlinks_flag_skips_links(lab):
|
|
178
237
|
lab.clone("real")
|
|
179
238
|
os.symlink(lab.root / "real", lab.root / "link")
|
|
@@ -775,6 +834,38 @@ def test_cli_attention_filter(lab, capsys):
|
|
|
775
834
|
assert "current" not in out
|
|
776
835
|
|
|
777
836
|
|
|
837
|
+
def test_cli_verbose_lists_incoming_commits(lab, capsys):
|
|
838
|
+
"""tree -v lists the commits a pull would bring in, under the repo
|
|
839
|
+
row — the flag used to be parsed and then ignored."""
|
|
840
|
+
r = lab.clone("r")
|
|
841
|
+
lab.advance_upstream()
|
|
842
|
+
_git(r, "fetch", "-q")
|
|
843
|
+
assert cli.main(["-v", "--no-fetch", str(lab.root)]) == 0
|
|
844
|
+
out = capsys.readouterr().out
|
|
845
|
+
assert "two" in out # the upstream commit's message
|
|
846
|
+
# and only for -v: the plain run stays one line per repo
|
|
847
|
+
assert cli.main(["--no-fetch", str(lab.root)]) == 0
|
|
848
|
+
assert "two" not in capsys.readouterr().out
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def test_pull_verbose_lists_new_commits(lab, capsys):
|
|
852
|
+
"""pull -v shows each pulled repo's new commits (regression for the
|
|
853
|
+
refactor onto status.incoming_commits)."""
|
|
854
|
+
import contextlib
|
|
855
|
+
import io
|
|
856
|
+
|
|
857
|
+
r = lab.clone("r")
|
|
858
|
+
lab.advance_upstream()
|
|
859
|
+
_git(r, "fetch", "-q")
|
|
860
|
+
buf = io.StringIO()
|
|
861
|
+
with contextlib.redirect_stdout(buf):
|
|
862
|
+
rc = pull(lab.root, fetch=False, verbose=True, use_color=False)
|
|
863
|
+
assert rc == 0
|
|
864
|
+
out = buf.getvalue()
|
|
865
|
+
assert "pulled" in out
|
|
866
|
+
assert "two" in out # the commit that just landed
|
|
867
|
+
|
|
868
|
+
|
|
778
869
|
def test_cli_filter_empty_says_so(lab, capsys):
|
|
779
870
|
"""An empty filter result prints a message, not silence (clig.dev:
|
|
780
871
|
'It's rare that printing nothing at all is the best default')."""
|
|
@@ -986,3 +1077,44 @@ def test_update_check_up_to_date(tmp_path, monkeypatch, capsys):
|
|
|
986
1077
|
assert cli.main(["update", "--check"]) == 0
|
|
987
1078
|
out = capsys.readouterr().out
|
|
988
1079
|
assert "up to date" in out
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def test_update_falls_through_to_next_installer(tmp_path, monkeypatch, capsys):
|
|
1083
|
+
"""A failing uv upgrade must not end the run — pulli may be a pipx
|
|
1084
|
+
install (both land in ~/.local/bin), so pipx gets its turn instead of
|
|
1085
|
+
exiting 1 with the job undone."""
|
|
1086
|
+
from pulli import update
|
|
1087
|
+
|
|
1088
|
+
monkeypatch.setenv("PULLI_CACHE_DIR", str(tmp_path))
|
|
1089
|
+
monkeypatch.setattr(update, "_fetch_latest", lambda: "9.9.9")
|
|
1090
|
+
monkeypatch.setattr(
|
|
1091
|
+
cli.shutil, "which", lambda n: {"uv": "/x/uv", "pipx": "/x/pipx"}[n]
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
tried: list[str] = []
|
|
1095
|
+
|
|
1096
|
+
class _R:
|
|
1097
|
+
def __init__(self, rc: int) -> None:
|
|
1098
|
+
self.returncode = rc
|
|
1099
|
+
|
|
1100
|
+
def fake_run(cmd, *a, **kw):
|
|
1101
|
+
tried.append(cmd[0])
|
|
1102
|
+
return _R(0 if cmd[0] == "pipx" else 1) # uv fails, pipx wins
|
|
1103
|
+
|
|
1104
|
+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
|
1105
|
+
assert cli.main(["update"]) == 0
|
|
1106
|
+
out = capsys.readouterr().out
|
|
1107
|
+
assert tried == ["uv", "pipx"]
|
|
1108
|
+
assert "via pipx" in out
|
|
1109
|
+
assert "Updated" in out
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def test_update_reports_when_no_installer_succeeds(tmp_path, monkeypatch, capsys):
|
|
1113
|
+
from pulli import update
|
|
1114
|
+
|
|
1115
|
+
monkeypatch.setenv("PULLI_CACHE_DIR", str(tmp_path))
|
|
1116
|
+
monkeypatch.setattr(update, "_fetch_latest", lambda: "9.9.9")
|
|
1117
|
+
monkeypatch.setattr(cli.shutil, "which", lambda n: None) # nothing on PATH
|
|
1118
|
+
assert cli.main(["update"]) == 1
|
|
1119
|
+
out = capsys.readouterr()
|
|
1120
|
+
assert "no installer succeeded" in out.err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|