pulli 0.2.10__tar.gz → 0.2.12__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.10
3
+ Version: 0.2.12
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.10"
2
+ __version__ = "0.2.12"
@@ -386,6 +386,12 @@ def _run_tree(args) -> int:
386
386
  # Stream: print the skeleton immediately, then fill each repo's
387
387
  # status line in as it becomes ready — no blank-screen wait.
388
388
  live = LiveTree(tree, use_color=use_color, flat=flat)
389
+ if flat and not args.no_summary:
390
+ # The summary prints its own skeleton line right away ("N
391
+ # repos…") and rewrites it in place as statuses land — same
392
+ # deal as the repo lines, so the eye has the answer's shape from
393
+ # the first frame on.
394
+ print(f"{len(repos)} repos…")
389
395
  fetch_and_status(
390
396
  repos,
391
397
  quiet=True,
@@ -394,10 +400,10 @@ def _run_tree(args) -> int:
394
400
  on_done=live.update,
395
401
  )
396
402
  if flat and not args.no_summary:
397
- # The live tree is done rewriting lines; the summary lands
398
- # below it, where the eye ends up anyway.
399
- print()
400
- print(_summary(list(iter_repos(tree)), use_color))
403
+ # Rewrite the skeleton summary in place: one line up, clear,
404
+ # print the real one. The tree is done rewriting its lines, so
405
+ # the cursor is already at the bottom.
406
+ sys.stdout.write("\r\x1b[2K" + _summary(list(iter_repos(tree)), use_color) + "\n")
401
407
  else:
402
408
  # Not a terminal: no in-place cursor control. Do the work, then
403
409
  # print the complete output once (byte-identical to before).
@@ -258,6 +258,13 @@ def _walk_links(
258
258
  would win and the actual directory would disappear from the tree, which
259
259
  is backwards. Walking links last means a real directory always wins, and
260
260
  a link is only shown when nothing else reaches its target.
261
+
262
+ This pass also descends into plain directories the first walk skipped
263
+ (plain dirs inside a repo are never recorded as nodes) looking for
264
+ symlinks: a repo checked out *inside* another repo often keeps links to
265
+ sibling repos in a dot-dir (`.links/`), and those links are the whole
266
+ point of the layout. Plain dirs found this way are traversed, not
267
+ recorded — the tree stays as small as before.
261
268
  """
262
269
  # As in _walk: a node deeper than max_depth is not expanded. The link
263
270
  # pass must respect the same limit as the plain walk, or --max-depth
@@ -275,6 +282,23 @@ def _walk_links(
275
282
  link = real_dir / name
276
283
  try:
277
284
  if not link.is_symlink():
285
+ # Not a link: still look *inside* for links (the .links/
286
+ # case) — but only one level of plain-dir descent, so a
287
+ # repo's src/ tree is not pointlessly scanned. The dir
288
+ # becomes a real node so links land under it, not under
289
+ # the repo root.
290
+ if link.is_dir() and not _is_pruned(name):
291
+ dir_node = RepoNode(
292
+ path=link,
293
+ link_path=display_dir / name,
294
+ name=name,
295
+ depth=node.depth + 1,
296
+ parent=node,
297
+ )
298
+ _walk_links_plain(dir_node, link, display_dir / name,
299
+ max_depth, visited)
300
+ if dir_node.children:
301
+ node.children.append(dir_node)
278
302
  continue
279
303
  target = link.resolve(strict=True)
280
304
  except (OSError, RuntimeError):
@@ -318,6 +342,63 @@ def _walk_links(
318
342
  node.children.sort(key=lambda c: c.name.lower())
319
343
 
320
344
 
345
+ def _walk_links_plain(
346
+ node: RepoNode,
347
+ real_dir: Path,
348
+ display_dir: Path,
349
+ max_depth: int,
350
+ visited: set[Path],
351
+ ) -> 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.
356
+
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.
359
+ """
360
+ try:
361
+ entries = sorted(os.listdir(real_dir), key=lambda s: s.lower())
362
+ except OSError:
363
+ return
364
+ for name in entries:
365
+ if name == ".git":
366
+ continue
367
+ link = real_dir / name
368
+ try:
369
+ if not link.is_symlink():
370
+ continue
371
+ target = link.resolve(strict=True)
372
+ except (OSError, RuntimeError):
373
+ continue
374
+ if not target.is_dir():
375
+ continue
376
+ display = display_dir / name
377
+ already = target in visited
378
+ if not already:
379
+ visited.add(target)
380
+ try:
381
+ is_repo = _is_git_dir(target)
382
+ except OSError:
383
+ continue
384
+ child = RepoNode(
385
+ path=target,
386
+ link_path=display,
387
+ name=name,
388
+ depth=node.depth + 1,
389
+ parent=node,
390
+ is_symlink=True,
391
+ symlink_target=_readlink(link),
392
+ is_repo=is_repo and not already,
393
+ is_alias=already,
394
+ )
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())
400
+
401
+
321
402
  def _add_submodules(
322
403
  parent: RepoNode,
323
404
  repo_root: Path,
@@ -157,22 +157,28 @@ def _remote_url(repo: Path, env: dict[str, str] | None = None) -> str:
157
157
  def _is_fork(repo: Path, env: dict[str, str] | None = None) -> bool:
158
158
  """True when the repo looks like a fork.
159
159
 
160
- A fork's remotes point at the same repo *name* under different *owners*
161
- — `devskale/rodney` (origin, the user's copy) and `simonw/rodney`
162
- (fork/upstream, the parent). Same name + different owner across 2+
163
- remotes is the signal; we never need to ask GitHub.
160
+ Two signals, either one is enough:
161
+ 1. a remote named `upstream` — the documented GitHub convention for
162
+ "the repo I forked from";
163
+ 2. remotes pointing at the same repo *name* under different *owners*
164
+ (`devskale/rodney` + `simonw/rodney`), compared case-insensitively.
165
+
166
+ Name comparison alone misses forks whose copy was renamed
167
+ (`devskale/handyST` forked from `cjpais/Handy`) — hence signal 1.
164
168
  """
165
169
  remotes = _git_one(repo, "remote", env=env).split()
166
170
  if len(remotes) < 2:
167
171
  return False
172
+ if "upstream" in remotes:
173
+ return True
168
174
  owners: set[str] = set()
169
175
  names: set[str] = set()
170
176
  for name in remotes:
171
177
  url = _git_one(repo, "remote", "get-url", name, env=env)
172
178
  owner, rname = _owner_repo(url)
173
179
  if rname:
174
- owners.add(owner)
175
- names.add(rname)
180
+ owners.add(owner.lower())
181
+ names.add(rname.lower())
176
182
  # same repo name under more than one owner = a fork and its parent
177
183
  return len(names) == 1 and len(owners) >= 2
178
184
 
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
File without changes
File without changes
File without changes