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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pulli
3
- Version: 0.2.13
3
+ Version: 0.2.14
4
4
  Summary: Walk a directory tree, show git repos with status, and fast-forward the ones that are behind upstream.
5
5
  Author: Stefan Waldherr
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """pulli — discover git repos under a root and report their status."""
2
- __version__ = "0.2.13"
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
- exe = Path(sys.argv[0]).resolve()
261
- candidates: list[tuple[str, list[str]]] = []
262
- if ".local/bin" in str(exe) or True: # uv tool and pipx both live here
263
- candidates.append(("uv", ["uv", "tool", "upgrade", "pulli"]))
264
- candidates.append(("pipx", ["pipx", "upgrade", "pulli"]))
265
- import shutil
266
- for name, cmd in candidates:
267
- if shutil.which(cmd[0]):
268
- print(f"pulli {__version__} → {latest} (via {name})")
269
- rc = subprocess.run(cmd).returncode
270
- if rc == 0:
271
- print(f"Updated to pulli {latest}.")
272
- return rc
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 uv/pipx found. Update manually, e.g.:\n"
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
- if sys.stdout.isatty() and not filtered:
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
- dir_node = RepoNode(
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
- # A link whose target was already walked is an *alias*: the
320
- # directory exists here, but its repo is the one already listed
321
- # elsewhere. Show the link so the tree still reflects what is on
322
- # disk, but not as a second pull target — `pull` must never
323
- # fast-forward the same work tree twice.
324
- child = RepoNode(
325
- path=target,
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 _walk_links_plain(
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
- """Look for symlinks inside a plain dir the first walk never recorded
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
- Bounded to one level: the `.links/` layout is one dir of links; a full
358
- recursive scan of a repo's working tree would be noise and cost.
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
- target = link.resolve(strict=True)
372
- except (OSError, RuntimeError):
395
+ except OSError:
373
396
  continue
374
- if not target.is_dir():
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
- display = display_dir / name
377
- already = target in visited
378
- if not already:
379
- visited.add(target)
433
+ d = real_dir / name
380
434
  try:
381
- is_repo = _is_git_dir(target)
435
+ if d.is_symlink() or not d.is_dir() or _is_pruned(name):
436
+ continue
382
437
  except OSError:
383
438
  continue
384
- child = RepoNode(
385
- path=target,
386
- link_path=display,
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
- node.children.append(child)
396
- if is_repo and not already:
397
- _add_submodules(child, target, display, node.depth + 1, visited)
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, so no directory is
483
- # ever expanded twice — `pulli pull` must not fast-forward the same
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 collect_status, fetch_all, git_env, run_git
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
- rc, out, _ = run_git(
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) -> str:
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), repos=repos)
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