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/__init__.py +2 -0
- pulli/cli.py +316 -0
- pulli/discovery.py +541 -0
- pulli/pull.py +276 -0
- pulli/spinner.py +101 -0
- pulli/status.py +441 -0
- pulli/tree.py +447 -0
- pulli-0.2.1.dist-info/METADATA +207 -0
- pulli-0.2.1.dist-info/RECORD +12 -0
- pulli-0.2.1.dist-info/WHEEL +4 -0
- pulli-0.2.1.dist-info/entry_points.txt +2 -0
- pulli-0.2.1.dist-info/licenses/LICENSE +21 -0
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)
|