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/__init__.py
ADDED
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())
|