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 ADDED
@@ -0,0 +1,2 @@
1
+ """pulli — discover git repos under a root and report their status."""
2
+ __version__ = "0.2.1"
pulli/cli.py ADDED
@@ -0,0 +1,316 @@
1
+ """pulli CLI — discover and display the repo tree, and pull what's behind.
2
+
3
+ Two commands:
4
+
5
+ pulli [tree] [root] [options] status tree (default)
6
+ pulli pull [root] [options] fast-forward the repos that are behind
7
+
8
+ Invocation is forgiving by design: `pulli ~/code`, `pulli --no-fetch ~/code`
9
+ and `pulli tree ~/code` all mean the same thing, and flags may appear before
10
+ or after the subcommand.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .discovery import discover, iter_repos, set_rels
21
+ from .pull import pull
22
+ from .status import collect_status, fetch_all, fetch_and_status
23
+ from .tree import LiveTree, render, render_flat
24
+
25
+ # Flags that belong to a subcommand. Used by _inject_tree to decide whether
26
+ # a leading argument is a subcommand or a path/flag for the default one.
27
+ _TREE_FLAGS = {
28
+ "--no-symlinks", "--no-fetch", "--no-color", "--max-depth",
29
+ "--dry-run", "--json", "--tree",
30
+ }
31
+ _PULL_FLAGS = {"--no-color", "--dry-run", "--json", "--no-fetch"}
32
+
33
+ SUBCOMMANDS = ("tree", "pull")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ p = argparse.ArgumentParser(
38
+ prog="pulli",
39
+ description=(
40
+ "Discover git repos under a directory, show their status, and "
41
+ "fast-forward the ones that are behind upstream."
42
+ ),
43
+ )
44
+ sub = p.add_subparsers(dest="command")
45
+
46
+ def add_common(sp: argparse.ArgumentParser) -> None:
47
+ sp.add_argument(
48
+ "root",
49
+ nargs="?",
50
+ default=".",
51
+ help="Root directory to scan (default: current dir).",
52
+ )
53
+ sp.add_argument(
54
+ "-V", "--version",
55
+ action="version",
56
+ version=f"pulli {__version__}",
57
+ )
58
+ sp.add_argument(
59
+ "--no-color",
60
+ action="store_true",
61
+ help="Disable ANSI colors (also implied when not a TTY).",
62
+ )
63
+ sp.add_argument(
64
+ "--json",
65
+ action="store_true",
66
+ help="Machine-readable output (one JSON object per line).",
67
+ )
68
+
69
+ # `pulli tree` — the status tree (default when no command given).
70
+ tree_p = sub.add_parser("tree", help="Show the repo status tree (default).")
71
+ add_common(tree_p)
72
+ tree_p.add_argument(
73
+ "--no-symlinks",
74
+ action="store_true",
75
+ help="Do not follow symlinks (default: follow, marked).",
76
+ )
77
+ tree_p.add_argument(
78
+ "--no-fetch",
79
+ action="store_true",
80
+ help="Do not fetch remotes before showing status (default: fetch).",
81
+ )
82
+ tree_p.add_argument(
83
+ "--max-depth",
84
+ type=int,
85
+ default=50,
86
+ help="Maximum recursion depth (default: 50).",
87
+ )
88
+ tree_p.add_argument(
89
+ "--tree",
90
+ action="store_true",
91
+ help="Show the full directory tree instead of a flat repo list.",
92
+ )
93
+
94
+ # `pulli pull` — pull repos that are behind.
95
+ pull_p = sub.add_parser("pull", help="Pull repos that are behind upstream.")
96
+ add_common(pull_p)
97
+ pull_p.add_argument(
98
+ "--dry-run",
99
+ action="store_true",
100
+ help="Show what would be pulled without pulling.",
101
+ )
102
+ pull_p.add_argument(
103
+ "--no-fetch",
104
+ action="store_true",
105
+ help="Use local refs only (no fetch) — faster, may be stale.",
106
+ )
107
+ pull_p.add_argument(
108
+ "--no-symlinks",
109
+ action="store_true",
110
+ help="Do not follow symlinks (default: follow).",
111
+ )
112
+ pull_p.add_argument(
113
+ "--max-depth",
114
+ type=int,
115
+ default=50,
116
+ help="Maximum recursion depth (default: 50).",
117
+ )
118
+
119
+ p.add_argument("-V", "--version", action="version", version=f"pulli {__version__}")
120
+ return p
121
+
122
+
123
+ def _inject_tree(argv: list[str]) -> list[str]:
124
+ """Make `tree` the default subcommand, wherever the flags are.
125
+
126
+ `pulli`, `pulli ~/code`, `pulli --no-fetch --max-depth 3 ~/code` and
127
+ `pulli tree ~/code` all mean `tree`. Without this, a leading flag
128
+ (`pulli --no-color .`) makes argparse reject the path as an invalid
129
+ subcommand choice — which is exactly how the old version behaved.
130
+ """
131
+ if not argv:
132
+ return ["tree"]
133
+ first = argv[0]
134
+ if first in SUBCOMMANDS or first in ("-V", "--version", "-h", "--help"):
135
+ return argv
136
+ # Anything that isn't the `pull` subcommand falls through to `tree`.
137
+ return ["tree", *argv]
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ args = build_parser().parse_args(
142
+ _inject_tree(list(sys.argv[1:] if argv is None else argv))
143
+ )
144
+ if args.command == "pull":
145
+ return _run_pull(args)
146
+ return _run_tree(args)
147
+
148
+
149
+ def _use_color(args) -> bool:
150
+ # Explicit --no-color wins; otherwise colour only on a TTY, and never
151
+ # when --json (a JSON consumer does not want escape codes in strings).
152
+ if args.no_color or getattr(args, "json", False):
153
+ return False
154
+ return sys.stdout.isatty()
155
+
156
+
157
+ def _root_arg(args) -> Path:
158
+ """Resolve the root, keeping the path the user typed for display.
159
+
160
+ `resolve()` is what the walk needs (symlink-safe, comparable paths), but
161
+ showing `/private/tmp/x` when the user asked for `~/code` is confusing —
162
+ so the link path is what gets rendered, the resolved path is what git
163
+ commands run against.
164
+ """
165
+ root = Path(args.root).expanduser()
166
+ try:
167
+ resolved = root.resolve(strict=True)
168
+ except (OSError, RuntimeError):
169
+ resolved = root.absolute()
170
+ args.link_root = root.absolute()
171
+ return resolved
172
+
173
+
174
+ def _check_root(root: Path) -> int | None:
175
+ if not root.exists():
176
+ print(f"pulli: no such directory: {root}", file=sys.stderr)
177
+ return 2
178
+ if not root.is_dir():
179
+ print(f"pulli: not a directory: {root}", file=sys.stderr)
180
+ return 2
181
+ return None
182
+
183
+
184
+ def _run_tree(args) -> int:
185
+ root = _root_arg(args)
186
+ if (rc := _check_root(root)) is not None:
187
+ return rc
188
+
189
+ tree = discover(
190
+ root,
191
+ follow_symlinks=not args.no_symlinks,
192
+ max_depth=args.max_depth,
193
+ link_root=getattr(args, "link_root", None),
194
+ )
195
+ if tree is None:
196
+ print(f"pulli: could not read {root}", file=sys.stderr)
197
+ return 1
198
+
199
+ set_rels(tree)
200
+ repos = list(iter_repos(tree))
201
+
202
+ if args.json:
203
+ if not args.no_fetch:
204
+ fetch_all(repos, quiet=True, use_color=_use_color(args))
205
+ collect_status(tree)
206
+ _print_json(tree)
207
+ return 0
208
+
209
+ use_color = _use_color(args)
210
+ flat = not args.tree
211
+ if sys.stdout.isatty():
212
+ # Stream: print the skeleton immediately, then fill each repo's
213
+ # status line in as it becomes ready — no blank-screen wait.
214
+ live = LiveTree(tree, use_color=use_color, flat=flat)
215
+ fetch_and_status(
216
+ repos,
217
+ quiet=True,
218
+ use_color=use_color,
219
+ fetch=not args.no_fetch,
220
+ on_done=live.update,
221
+ )
222
+ else:
223
+ # Not a terminal: no in-place cursor control. Do the work, then
224
+ # print the complete output once (byte-identical to before).
225
+ if not args.no_fetch:
226
+ fetch_all(repos, quiet=True, use_color=use_color)
227
+ collect_status(tree)
228
+ print(render_flat(tree, use_color=use_color) if flat else render(tree, use_color=use_color))
229
+ return 0
230
+
231
+
232
+ def _run_pull(args) -> int:
233
+ root = _root_arg(args)
234
+ if (rc := _check_root(root)) is not None:
235
+ return rc
236
+
237
+ if args.json:
238
+ # JSON output is an inventory: report what would happen, change
239
+ # nothing. Silently ignoring --dry-run next to --json would be a
240
+ # trap, so treat the combination as dry-run and say so.
241
+ if not args.dry_run:
242
+ print("pulli: --json implies --dry-run (no changes are made)", file=sys.stderr)
243
+ return _pull_json(root, args)
244
+
245
+ return pull(
246
+ root,
247
+ use_color=_use_color(args),
248
+ dry_run=args.dry_run,
249
+ fetch=not args.no_fetch,
250
+ follow_symlinks=not args.no_symlinks,
251
+ max_depth=args.max_depth,
252
+ link_root=getattr(args, "link_root", None),
253
+ )
254
+
255
+
256
+ def _node_dict(n) -> dict:
257
+ from .tree import _shorten_url
258
+
259
+ return {
260
+ "path": n.rel,
261
+ "name": n.name,
262
+ "repo": n.is_repo,
263
+ "bare": n.is_bare,
264
+ "submodule": n.is_submodule,
265
+ "symlink": n.is_symlink or None,
266
+ "url": _shorten_url(n.url) or None,
267
+ "branch": n.branch,
268
+ "upstream": n.upstream,
269
+ "behind": n.behind,
270
+ "ahead": n.ahead,
271
+ "dirty": n.dirty,
272
+ "dirty_files": n.dirty_files or None,
273
+ "operation": n.operation,
274
+ "fetch_failed": n.fetch_failed or None,
275
+ "error": n.error,
276
+ }
277
+
278
+
279
+ def _print_json(tree) -> None:
280
+ for n in iter_repos(tree):
281
+ print(json.dumps(_node_dict(n), ensure_ascii=False))
282
+
283
+
284
+ def _pull_json(root: Path, args) -> int:
285
+ """`pulli pull --json`: the same decision the pull makes, as data."""
286
+ from .pull import _classify
287
+
288
+ tree = discover(
289
+ root,
290
+ follow_symlinks=not args.no_symlinks,
291
+ max_depth=args.max_depth,
292
+ link_root=getattr(args, "link_root", None),
293
+ )
294
+ set_rels(tree)
295
+ repos = list(iter_repos(tree))
296
+ if not args.no_fetch:
297
+ fetch_all(repos, quiet=True, use_color=_use_color(args))
298
+ collect_status(tree)
299
+
300
+ counts: dict[str, int] = {}
301
+ for n in sorted(repos, key=lambda x: x.rel):
302
+ kind = _classify(n)
303
+ counts[kind] = counts.get(kind, 0) + 1
304
+ print(json.dumps({"path": n.rel, "action": kind, **_node_dict(n)}, ensure_ascii=False))
305
+
306
+ print(
307
+ json.dumps(
308
+ {"summary": {"total": len(repos), **{f"n_{k}": v for k, v in counts.items()}}},
309
+ ensure_ascii=False,
310
+ )
311
+ )
312
+ return 1 if counts.get("broken") else 0
313
+
314
+
315
+ if __name__ == "__main__":
316
+ raise SystemExit(main())