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/discovery.py ADDED
@@ -0,0 +1,541 @@
1
+ """Discover git repositories under a root directory.
2
+
3
+ Rules (confirmed with the user):
4
+ 1b. When we hit a `.git`, we record that dir as a repo AND keep recursing
5
+ into it to find nested repos (a repo can live inside another repo).
6
+ 2. Symlinks are followed by default and marked clearly in the output.
7
+ `--no-symlinks` turns following off.
8
+ 3. Submodules are shown as separate nodes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Iterator
16
+
17
+
18
+ @dataclass
19
+ class RepoNode:
20
+ """A node in the discovered tree.
21
+
22
+ May be a git repo (is_repo=True), a plain directory (is_repo=False),
23
+ or a submodule (is_submodule=True).
24
+
25
+ Two paths are tracked, and they are not interchangeable:
26
+
27
+ * `path` — where git commands must run and what gets reported. Always
28
+ the *real* (symlink-resolved) path, so `pulli` never pulls the same
29
+ working tree twice because it was reachable by two links.
30
+ * `link_path` — the path as the user wrote it, i.e. what a human expects
31
+ to see in the tree. Falls back to `path` when there is no symlink.
32
+ """
33
+
34
+ path: Path # real path: use this for git commands
35
+ name: str
36
+ depth: int # 0 = the root arg itself
37
+ parent: "RepoNode | None" = None
38
+ children: list["RepoNode"] = field(default_factory=list)
39
+ is_symlink: bool = False
40
+ symlink_target: str | None = None # raw readlink target if is_symlink
41
+ link_path: Path | None = None # path as displayed (default: same as `path`)
42
+ is_submodule: bool = False
43
+ is_repo: bool = False # True if this dir has a git work tree
44
+ is_alias: bool = False # symlink whose target is listed elsewhere
45
+ is_bare: bool = False # True for bare repos — nothing to pull there
46
+ # status fields filled in by status.py
47
+ branch: str | None = None
48
+ ahead: int | None = None
49
+ behind: int | None = None
50
+ dirty: bool | None = None
51
+ dirty_files: list[str] = field(default_factory=list) # paths from status --porcelain
52
+ upstream: str | None = None
53
+ url: str = "" # remote url (credentials never stored — scrubbed at render)
54
+ operation: str | None = None # "merge in progress", "rebase in progress", …
55
+ fetch_failed: bool = False # fetch did not succeed (offline / unreachable)
56
+ fetch_reason: str | None = None # why the fetch failed
57
+ error: str | None = None
58
+
59
+ def set_rel(self, root: Path) -> None:
60
+ """Display path relative to `root`.
61
+
62
+ Built from the *link* path, not the real one: when the walk starts
63
+ at a resolved root, the real paths have no common suffix with the
64
+ root the user typed, and every entry would print as an absolute
65
+ path into the symlink target.
66
+
67
+ The root itself gets "." rather than "" — `pulli pull .` printing
68
+ `✗ . unreachable` is a message about the repo, and a bare "" is
69
+ not a name a human can act on.
70
+ """
71
+ base = self.link_path or self.path
72
+ if base == root:
73
+ self._rel = "."
74
+ return
75
+ try:
76
+ self._rel = str(base.relative_to(root))
77
+ except ValueError:
78
+ self._rel = str(base)
79
+
80
+ @property
81
+ def rel(self) -> str:
82
+ # Deliberately loud: a caller that forgets set_rels() used to get a
83
+ # silently-wrong absolute path, which then leaked into the report
84
+ # and into sort order. Failing here points at the actual mistake.
85
+ try:
86
+ return self._rel
87
+ except AttributeError:
88
+ raise RuntimeError(
89
+ f"RepoNode.rel read before set_rels(): {self.path}"
90
+ ) from None
91
+
92
+
93
+ def _readlink(p: Path) -> str | None:
94
+ """The raw target of a symlink, for display. Never raises."""
95
+ try:
96
+ return os.readlink(p)
97
+ except OSError:
98
+ return None
99
+
100
+
101
+ def _is_git_dir(p: Path) -> bool:
102
+ """A directory is a git repo if it has a .git entry (dir for normal
103
+ repos, file for worktrees/submodules pointing elsewhere)."""
104
+ try:
105
+ return (p / ".git").exists()
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def _is_bare_repo(p: Path) -> bool:
111
+ """True if `p` is itself a git dir (bare repo or `.git` directory).
112
+
113
+ Bare repos have no working tree: there is nothing to pull, and running
114
+ `git fetch` on one just writes objects nobody reads — so they are
115
+ recorded but excluded from fetch/pull.
116
+ """
117
+ try:
118
+ return p.is_dir() and _is_git_dir_inner(p)
119
+ except OSError:
120
+ return False
121
+
122
+
123
+ def _is_git_dir_inner(p: Path) -> bool:
124
+ """Cheap structural test for a git directory (no subprocess)."""
125
+ for required in ("HEAD", "objects", "refs"):
126
+ if not (p / required).exists():
127
+ return False
128
+ try:
129
+ return (p / "HEAD").read_text(errors="replace").startswith("ref:")
130
+ except OSError:
131
+ return False
132
+
133
+
134
+ # Directories we never descend into. They may still be shown as leaf nodes
135
+ # (so the user sees they exist) but their contents are not expanded. This
136
+ # keeps the tree readable on real projects (node_modules alone can be huge).
137
+ _PRUNED_DIRS: frozenset[str] = frozenset({
138
+ # JS/TS
139
+ "node_modules", ".pnpm", ".parcel-cache", ".turbo", ".svelte-kit",
140
+ ".next", ".nuxt", ".astro", ".remax", "dist", "build", "out",
141
+ # Python
142
+ ".venv", "venv", "env", "__pycache__", ".mypy_cache", ".ruff_cache",
143
+ ".pytest_cache", ".tox", ".eggs",
144
+ # Rust / Go
145
+ "target", "pkg",
146
+ # VCS / metadata
147
+ ".git", ".hg", ".svn", ".pi", ".claude", ".idea", ".vscode",
148
+ # misc
149
+ ".cache", ".gradle", ".terraform", "coverage", ".nyc_output",
150
+ # dependency trees that are enormous and never contain repos
151
+ "vendor", "Pods", "DerivedData", "site-packages", ".direnv",
152
+ ".bundle", ".yarn", ".cargo", ".rustup", "go", "obj", "bin",
153
+ })
154
+
155
+
156
+ def _is_pruned(name: str) -> bool:
157
+ """True if `name` is a directory we should not descend into."""
158
+ if name in _PRUNED_DIRS:
159
+ return True
160
+ if name.endswith(".egg-info"):
161
+ return True
162
+ return False
163
+
164
+
165
+ def _read_submodule_paths(repo_root: Path) -> list[str]:
166
+ """Parse .gitmodules and return the list of submodule `path` values
167
+ that are actually checked out (have a .git file/dir)."""
168
+ gm = repo_root / ".gitmodules"
169
+ if not gm.is_file():
170
+ return []
171
+ out: list[str] = []
172
+ cur_path: str | None = None
173
+ try:
174
+ for line in gm.read_text(encoding="utf-8", errors="replace").splitlines():
175
+ s = line.strip()
176
+ if s.startswith("[submodule"):
177
+ if cur_path and (repo_root / cur_path / ".git").exists():
178
+ out.append(cur_path)
179
+ cur_path = None
180
+ elif s.startswith("path") and "=" in s:
181
+ cur_path = s.split("=", 1)[1].strip()
182
+ if cur_path and (repo_root / cur_path / ".git").exists():
183
+ out.append(cur_path)
184
+ except OSError:
185
+ pass
186
+ return out
187
+
188
+
189
+ def discover(
190
+ root: Path,
191
+ *,
192
+ follow_symlinks: bool = True,
193
+ max_depth: int = 50,
194
+ link_root: Path | None = None,
195
+ ) -> RepoNode | None:
196
+ """Walk `root` and build a tree of RepoNodes.
197
+
198
+ Returns the root RepoNode (which may or may not itself be a repo), or
199
+ None if root doesn't exist / isn't a directory.
200
+
201
+ `link_root` is the path as the user spelled it. The walk runs on the
202
+ resolved `root` (symlink-safe), while display paths are built from
203
+ `link_root`, so `pulli ~/link-to-code` reports `~/link-to-code/foo`
204
+ instead of leaking the resolved target.
205
+ """
206
+ if not root.is_dir():
207
+ return None
208
+
209
+ try:
210
+ root_is_repo = _is_git_dir(root)
211
+ except OSError:
212
+ root_is_repo = False
213
+
214
+ display_base = link_root if link_root is not None else root
215
+
216
+ root_node = RepoNode(
217
+ path=root,
218
+ link_path=display_base,
219
+ name=display_base.name or str(display_base),
220
+ depth=0,
221
+ is_repo=root_is_repo,
222
+ is_bare=(not root_is_repo) and _is_bare_repo(root),
223
+ )
224
+ root_node.set_rel(display_base)
225
+
226
+ # Real paths we've already walked, so a directory is never expanded
227
+ # twice. Seeded with the root.
228
+ visited: set[Path] = {root}
229
+
230
+ _walk(
231
+ root,
232
+ display_base,
233
+ root_node,
234
+ max_depth=max_depth,
235
+ visited=visited,
236
+ inside_repo=root_is_repo,
237
+ depth=1,
238
+ )
239
+ if follow_symlinks:
240
+ _walk_links(root_node, root, display_base, max_depth, visited)
241
+ return root_node
242
+
243
+
244
+ def _walk_links(
245
+ node: RepoNode,
246
+ real_dir: Path,
247
+ display_dir: Path,
248
+ max_depth: int,
249
+ visited: set[Path],
250
+ ) -> None:
251
+ """Second pass: expand symlinks in an already-walked tree.
252
+
253
+ Symlinks are handled *after* the plain walk on purpose. During the walk
254
+ a link can claim a path that a real directory would have reached
255
+ moments later — `link -> ../real` sorts before `real`, so the link
256
+ would win and the actual directory would disappear from the tree, which
257
+ is backwards. Walking links last means a real directory always wins, and
258
+ a link is only shown when nothing else reaches its target.
259
+ """
260
+ try:
261
+ entries = sorted(os.listdir(real_dir), key=lambda s: s.lower())
262
+ except OSError:
263
+ entries = []
264
+
265
+ for name in entries:
266
+ if name == ".git":
267
+ continue
268
+ link = real_dir / name
269
+ try:
270
+ if not link.is_symlink():
271
+ continue
272
+ target = link.resolve(strict=True)
273
+ except (OSError, RuntimeError):
274
+ continue
275
+ if not target.is_dir():
276
+ continue
277
+
278
+ display = display_dir / name
279
+ already = target in visited
280
+ if not already:
281
+ visited.add(target)
282
+
283
+ try:
284
+ is_repo = _is_git_dir(target)
285
+ except OSError:
286
+ continue
287
+
288
+ # A link whose target was already walked is an *alias*: the
289
+ # directory exists here, but its repo is the one already listed
290
+ # elsewhere. Show the link so the tree still reflects what is on
291
+ # disk, but not as a second pull target — `pull` must never
292
+ # fast-forward the same work tree twice.
293
+ child = RepoNode(
294
+ path=target,
295
+ link_path=display,
296
+ name=name,
297
+ depth=node.depth + 1,
298
+ parent=node,
299
+ is_symlink=True,
300
+ symlink_target=_readlink(link),
301
+ is_repo=is_repo and not already,
302
+ is_alias=already,
303
+ )
304
+ node.children.append(child)
305
+
306
+ if is_repo and not already:
307
+ _add_submodules(child, target, display, node.depth + 1, visited)
308
+ if not already:
309
+ _walk_links(child, target, display, max_depth, visited)
310
+
311
+ node.children.sort(key=lambda c: c.name.lower())
312
+
313
+
314
+ def _add_submodules(
315
+ parent: RepoNode,
316
+ repo_root: Path,
317
+ display: Path,
318
+ depth: int,
319
+ visited: set[Path],
320
+ ) -> None:
321
+ """Attach each checked-out submodule to `parent` as its own node."""
322
+ for sm_rel in _read_submodule_paths(repo_root):
323
+ sm_dir = repo_root / sm_rel
324
+ if not sm_dir.is_dir():
325
+ continue
326
+ try:
327
+ visited.add(sm_dir.resolve())
328
+ except (OSError, RuntimeError):
329
+ pass
330
+ parent.children.append(
331
+ RepoNode(
332
+ path=sm_dir,
333
+ link_path=display / sm_rel,
334
+ name=Path(sm_rel).name,
335
+ depth=depth + 1,
336
+ parent=parent,
337
+ is_submodule=True,
338
+ is_repo=True,
339
+ )
340
+ )
341
+
342
+
343
+ def _walk(
344
+ current_dir: Path,
345
+ display_dir: Path,
346
+ current_node: RepoNode,
347
+ *,
348
+ max_depth: int,
349
+ visited: set[Path],
350
+ inside_repo: bool = False,
351
+ depth: int,
352
+ repos_only: bool = False,
353
+ search_pruned: bool = False,
354
+ ) -> None:
355
+ """Recurse into current_dir.
356
+
357
+ - Outside any repo: every directory becomes a RepoNode so the tree
358
+ structure is preserved for display/selection.
359
+ - Inside a repo: only nested repos and submodules become nodes; plain
360
+ directories inside a repo's working tree are skipped (we don't want
361
+ to expand the repo's internal src/ layout).
362
+
363
+ `depth` is threaded explicitly: a submodule node is *displayed* as a
364
+ child of its parent repo, but on disk it is nested one level deeper, so
365
+ computing depth from the display parent (as an earlier version did)
366
+ inflated it and made `--max-depth` cut walks short.
367
+
368
+ `repos_only` descends through pruned dirs (node_modules, vendor, …)
369
+ looking for nested repos without recording the plain directories on the
370
+ way — the point of those dirs is that they are not part of the tree.
371
+ """
372
+ if depth > max_depth:
373
+ return
374
+
375
+ try:
376
+ entries = sorted(os.listdir(current_dir), key=lambda s: s.lower())
377
+ except (PermissionError, NotADirectoryError, OSError):
378
+ return
379
+
380
+ for name in entries:
381
+ if name == ".git":
382
+ continue # never descend into the .git metadata dir
383
+
384
+ link = current_dir / name # real path (parent is already real)
385
+ display = display_dir / name # what the user sees
386
+
387
+ traverse = link
388
+ try:
389
+ if not link.is_dir():
390
+ continue
391
+ except OSError:
392
+ continue
393
+
394
+ # Mark every directory we descend into as seen, so no directory is
395
+ # ever expanded twice — `pulli pull` must not fast-forward the same
396
+ # work tree from two entries.
397
+ if traverse in visited:
398
+ continue
399
+ visited.add(traverse)
400
+
401
+ try:
402
+ is_dir = link.is_dir()
403
+ except OSError:
404
+ continue
405
+ if not is_dir or link.is_symlink():
406
+ continue # symlinks are handled in the second pass
407
+
408
+ # Guard against permission errors on unreadable dirs.
409
+ try:
410
+ is_repo = _is_git_dir(traverse)
411
+ except OSError:
412
+ continue
413
+
414
+ if is_repo:
415
+ child = RepoNode(
416
+ path=traverse,
417
+ link_path=display,
418
+ name=name,
419
+ depth=depth,
420
+ parent=current_node,
421
+ is_repo=True,
422
+ )
423
+ current_node.children.append(child)
424
+
425
+ # 3. Submodules: add each checked-out submodule as a child node.
426
+ for sm_rel in _read_submodule_paths(traverse):
427
+ sm_dir = traverse / sm_rel # real path: git commands run here
428
+ if not sm_dir.is_dir():
429
+ continue
430
+ sm_node = RepoNode(
431
+ path=sm_dir,
432
+ link_path=display_dir / sm_rel,
433
+ name=Path(sm_rel).name,
434
+ depth=depth + 1,
435
+ parent=child,
436
+ is_submodule=True,
437
+ is_repo=True,
438
+ )
439
+ child.children.append(sm_node)
440
+ # Claim it so the walk below can't rediscover it.
441
+ try:
442
+ visited.add(sm_dir.resolve())
443
+ except (OSError, RuntimeError):
444
+ pass
445
+
446
+ # 1b: recurse INTO this repo looking for nested repos. Pass
447
+ # inside_repo=True so plain dirs inside it are skipped — but
448
+ # pruned dirs inside the repo (vendor/, node_modules/) are still
449
+ # searched, because a repo checked out under one is a real repo
450
+ # with its own remote.
451
+ _walk(
452
+ traverse,
453
+ display,
454
+ child,
455
+ max_depth=max_depth,
456
+ visited=visited,
457
+ inside_repo=True,
458
+ depth=depth + 1,
459
+ search_pruned=True,
460
+ )
461
+ elif not inside_repo and not repos_only:
462
+ # Plain dir OUTSIDE any repo — record it and recurse to keep
463
+ # the tree structure.
464
+ is_bare = _is_bare_repo(traverse) if name.endswith(".git") else False
465
+ child = RepoNode(
466
+ path=traverse,
467
+ link_path=display,
468
+ name=name,
469
+ depth=depth,
470
+ parent=current_node,
471
+ is_repo=False,
472
+ is_bare=is_bare,
473
+ )
474
+ current_node.children.append(child)
475
+ if is_bare:
476
+ # A bare repo's interior (objects/, refs/) is noise; there is
477
+ # no working tree to show or pull.
478
+ continue
479
+ _walk(
480
+ traverse,
481
+ display,
482
+ child,
483
+ max_depth=max_depth,
484
+ visited=visited,
485
+ inside_repo=False,
486
+ depth=depth + 1,
487
+ search_pruned=_is_pruned(name),
488
+ repos_only=_is_pruned(name),
489
+ )
490
+ elif search_pruned and not repos_only and _is_pruned(name):
491
+ # A pruned dir inside a repo: shown as a node (so the user can
492
+ # see there is a vendored tree) and searched for repos, but its
493
+ # plain contents are never expanded.
494
+ child = RepoNode(
495
+ path=traverse,
496
+ link_path=display,
497
+ name=name,
498
+ depth=depth,
499
+ parent=current_node,
500
+ is_repo=False,
501
+ )
502
+ current_node.children.append(child)
503
+ _walk(
504
+ traverse,
505
+ display,
506
+ child,
507
+ max_depth=max_depth,
508
+ visited=visited,
509
+ inside_repo=inside_repo,
510
+ depth=depth + 1,
511
+ search_pruned=True,
512
+ repos_only=True,
513
+ )
514
+ # else: plain dir INSIDE a repo — skip entirely.
515
+
516
+
517
+ def set_rels(root: RepoNode, *, base: Path | None = None) -> None:
518
+ """Fix up the .rel display paths for every node relative to `base`
519
+ (default: the root's own resolved path)."""
520
+
521
+ def _fix(n: RepoNode) -> None:
522
+ n.set_rel(base if base is not None else root.path)
523
+ for c in n.children:
524
+ _fix(c)
525
+
526
+ _fix(root)
527
+
528
+
529
+ def iter_repos(root: RepoNode, *, include_bare: bool = False) -> Iterator[RepoNode]:
530
+ """Yield every RepoNode that is a pullable git work tree.
531
+
532
+ Bare repos (`repo.git/`) are excluded by default: they have no working
533
+ tree, so there is nothing to pull. Plain dirs are never yielded.
534
+ """
535
+ def _walk_node(n: RepoNode) -> Iterator[RepoNode]:
536
+ if n.is_repo and (include_bare or not n.is_bare):
537
+ yield n
538
+ for c in n.children:
539
+ yield from _walk_node(c)
540
+
541
+ yield from _walk_node(root)