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/status.py ADDED
@@ -0,0 +1,441 @@
1
+ """Collect git status for each discovered repo.
2
+
3
+ For each repo we run a small set of git commands to get:
4
+ - branch (current ref name)
5
+ - ahead/behind vs upstream
6
+ - dirty (uncommitted changes)
7
+ - remote url (for display)
8
+
9
+ We avoid `git status -sb` parsing quirks by using porcelain + rev-list.
10
+
11
+ The single most important correctness rule in this module: **behind/ahead
12
+ must come from one rev-list invocation.** A previous version ran
13
+ `rev-list @{u}...HEAD` and then `rev-list HEAD..@{u}` as two separate git
14
+ calls. If the remote moved in between (or a fetch interleaved), the two
15
+ halves describe different worlds and the result is a bogus ahead/behind
16
+ pair. `rev-list --left-right --count A...B` answers both sides
17
+ atomically in one process, so that is the only form we use.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ from collections.abc import Iterator
26
+ from concurrent.futures import ThreadPoolExecutor
27
+ from contextlib import contextmanager
28
+ from pathlib import Path
29
+
30
+ from .discovery import RepoNode, iter_repos
31
+ from .spinner import Spinner
32
+
33
+ # ── shared git plumbing ──────────────────────────────────────────────────
34
+
35
+ #: Fetch exit codes that only mean "the network/remote was unavailable".
36
+ #: Anything else (bad config, broken objects, permission denied) is a real
37
+ #: failure and should be surfaced.
38
+ _OFFLINE_FETCH_CODES = frozenset({
39
+ 5, # couldn't read Username / auth prompt failed
40
+ 7, # couldn't connect
41
+ 17, # remote end hung up unexpectedly
42
+ 23, # write error
43
+ 28, # operation timed out
44
+ 35, # SSL connect error
45
+ 56, # connection reset by peer
46
+ 58, # could not read from remote repository
47
+ 65, # no match for destination
48
+ 68, # ssh: could not resolve hostname
49
+ 69, # ssh: no route to host
50
+ 110, # connection timed out
51
+ 111, # connection refused
52
+ })
53
+
54
+ _env_lock = threading.Lock()
55
+ _env_cache: dict[str, str] | None = None
56
+
57
+
58
+ def git_env() -> dict[str, str]:
59
+ """A process-wide git environment that never blocks on prompts.
60
+
61
+ Built once and cached: the subprocess API is global state, so mutating
62
+ os.environ per-call would race across the fetch threads. Read-only
63
+ checkouts must not abort because a remote is unreachable.
64
+ """
65
+ global _env_cache
66
+ with _env_lock:
67
+ if _env_cache is None:
68
+ env = dict(os.environ)
69
+ # Non-interactive: never block a fetch on a credential prompt.
70
+ env["GIT_TERMINAL_PROMPT"] = "0"
71
+ env["GIT_ASKPASS"] = env.get("GIT_ASKPASS", "echo")
72
+ env["SSH_ASKPASS"] = env.get("SSH_ASKPASS", "echo")
73
+ # Hanging the SSH connection is better than a 30s frozen tree.
74
+ # We still bound the subprocess with a timeout.
75
+ env.setdefault("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
76
+ # Deterministic, locale-independent parsing of git output.
77
+ env.setdefault("LC_ALL", "C")
78
+ _env_cache = env
79
+ return _env_cache
80
+
81
+
82
+ @contextmanager
83
+ def _push_env() -> Iterator[None]:
84
+ """Temporarily apply the sanitized git environment to os.environ.
85
+
86
+ Only for strictly sequential code paths. Everywhere else (fetch and
87
+ status both run in a thread pool) the env is passed per-subprocess:
88
+ mutating os.environ there would race across threads and leak
89
+ GIT_TERMINAL_PROMPT into unrelated processes.
90
+ """
91
+ wanted = git_env()
92
+ saved = {k: os.environ.get(k) for k in wanted}
93
+ os.environ.update(wanted)
94
+ try:
95
+ yield
96
+ finally:
97
+ for k, v in saved.items():
98
+ if v is None:
99
+ os.environ.pop(k, None)
100
+ else:
101
+ os.environ[k] = v
102
+
103
+
104
+ def run_git(
105
+ repo: Path,
106
+ *args: str,
107
+ timeout: int = 30,
108
+ env: dict[str, str] | None = None,
109
+ ) -> tuple[int, str, str]:
110
+ """Run a git command in `repo`, return (returncode, stdout, stderr)."""
111
+ try:
112
+ r = subprocess.run(
113
+ ["git", *args],
114
+ cwd=str(repo),
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=timeout,
118
+ env=env if env is not None else git_env(),
119
+ )
120
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
121
+ except subprocess.TimeoutExpired:
122
+ return 128, "", f"git timed out after {timeout}s"
123
+ except FileNotFoundError:
124
+ return 127, "", "git executable not found"
125
+ except OSError as e: # e.g. cwd vanished between discovery and now
126
+ return 128, "", str(e)
127
+
128
+
129
+ def _git_one(repo: Path, *args: str, env: dict[str, str] | None = None) -> str:
130
+ """Run git, return stdout (stripped) or '' on failure."""
131
+ rc, out, _ = run_git(repo, *args, env=env)
132
+ return out if rc == 0 else ""
133
+
134
+
135
+ # ── remote urls ──────────────────────────────────────────────────────────
136
+
137
+
138
+ def _remote_url(repo: Path, env: dict[str, str] | None = None) -> str:
139
+ """URL of the preferred remote, or '' when there is none."""
140
+ url = _git_one(repo, "remote", "get-url", "origin", env=env)
141
+ if url:
142
+ return url
143
+ # No `origin` (fresh `git init`, a renamed remote, a submodule whose
144
+ # remote is only recorded in .gitmodules). Fall back to the first
145
+ # configured remote so the tree is still informative.
146
+ out = _git_one(repo, "remote", env=env)
147
+ for name in out.splitlines():
148
+ name = name.strip()
149
+ if not name:
150
+ continue
151
+ url = _git_one(repo, "remote", "get-url", name, env=env)
152
+ if url:
153
+ return url
154
+ return ""
155
+
156
+
157
+ # ── fetch ────────────────────────────────────────────────────────────────
158
+
159
+
160
+ def _classify_fetch(rc: int, err: str) -> str | None:
161
+ """Return None if the fetch genuinely succeeded, else a short reason."""
162
+ if rc == 0:
163
+ return None
164
+ if rc == 128:
165
+ low = err.lower()
166
+ # A hung/failed remote is *not* a repo problem — the tree can still be
167
+ # built from local refs, and pulli stays useful offline.
168
+ if any(s in low for s in ("timed out", "timeout", "could not resolve",
169
+ "connection", "network", "unreachable",
170
+ "no route to host", "couldn't read",
171
+ "authentication", "permission denied (publickey)",
172
+ "could not read from remote", "early eof",
173
+ "the remote end hung up", "unable to access")):
174
+ return "unreachable"
175
+ if "not a git repository" in low or "does not appear" in low:
176
+ return "not a git repository"
177
+ return err.splitlines()[0] if err else "fetch failed"
178
+ if rc in _OFFLINE_FETCH_CODES:
179
+ return "unreachable"
180
+ return err.splitlines()[0] if err else f"fetch failed (exit {rc})"
181
+
182
+
183
+ def fetch_all(
184
+ nodes: list[RepoNode],
185
+ *,
186
+ timeout: float = 8.0,
187
+ quiet: bool = False,
188
+ workers: int = 8,
189
+ use_color: bool = True,
190
+ ) -> list[RepoNode]:
191
+ """Fetch every repo so ahead/behind reflects the remote, not the last
192
+ local fetch.
193
+
194
+ Fetches run in a bounded thread pool (the default spawns one OS thread
195
+ per repo, which is a lot for a 50-repo tree) and each one is bounded by
196
+ `timeout`. Returns the list of repos whose fetch failed; `node.error` is
197
+ filled in with a short reason, so callers can tell "offline" (harmless)
198
+ from "broken repo" (worth reporting).
199
+
200
+ When stderr is a TTY, a spinner animates on stderr with live progress
201
+ ("Fetching remotes… 3/8"), so a multi-second fetch never looks frozen.
202
+ Off a TTY it is a no-op, so piped/captured output stays byte-clean.
203
+ """
204
+ if not nodes:
205
+ return []
206
+
207
+ candidates = [n for n in nodes if not n.error]
208
+ failed: list[RepoNode] = []
209
+ lock = threading.Lock()
210
+ env = git_env()
211
+
212
+ spinner = Spinner(f"Fetching remotes… 0/{len(candidates)}", use_color=use_color)
213
+ done = 0
214
+
215
+ def _do(node: RepoNode) -> None:
216
+ nonlocal done
217
+ rc, _, err = run_git(node.path, "fetch", "--quiet", "--prune",
218
+ timeout=timeout, env=env)
219
+ reason = _classify_fetch(rc, err)
220
+ if reason is None:
221
+ pass
222
+ else:
223
+ if not quiet:
224
+ print(f" fetch failed: {node.rel} — {reason}", file=sys.stderr)
225
+ with lock:
226
+ # An unreachable remote is expected (offline, VPN, credentials)
227
+ # and is NOT a broken repo. Recording it as `error` would make
228
+ # pulli report a perfectly healthy repo as broken and exit
229
+ # non-zero, which is exactly wrong on a train.
230
+ node.fetch_failed = True
231
+ node.fetch_reason = reason
232
+ failed.append(node)
233
+ with lock:
234
+ done += 1
235
+ spinner.update(f"Fetching remotes… {done}/{len(candidates)}")
236
+
237
+ spinner.start()
238
+ try:
239
+ n_workers = max(1, min(workers, len(candidates)))
240
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
241
+ # pool.map re-raises; _do never raises (git failures are captured
242
+ # as return codes), so consume the iterator to completion.
243
+ for _ in pool.map(_do, candidates):
244
+ pass
245
+ finally:
246
+ spinner.stop()
247
+ return failed
248
+
249
+
250
+ # ── streaming status ────────────────────────────────────────────────────
251
+
252
+
253
+ def fetch_and_status(
254
+ nodes: list[RepoNode],
255
+ *,
256
+ timeout: float = 8.0,
257
+ quiet: bool = False,
258
+ workers: int = 8,
259
+ use_color: bool = True,
260
+ fetch: bool = True,
261
+ on_done=None,
262
+ ) -> list[RepoNode]:
263
+ """Fetch + collect status for every repo, streaming results as they land.
264
+
265
+ Each repo is processed by its own worker: fetch (unless `fetch=False`),
266
+ then collect status, then `on_done(node)` is called so a live renderer
267
+ can show the repo's line the moment it is ready — instead of waiting for
268
+ the slowest remote and printing everything at once.
269
+
270
+ Returns the list of repos whose fetch failed (unreachable / offline),
271
+ the same contract as `fetch_all`. `on_done` is called from worker
272
+ threads, so it must be thread-safe (the `LiveTree` locks internally).
273
+ """
274
+ if not nodes:
275
+ return []
276
+
277
+ candidates = [n for n in nodes if not n.error]
278
+ failed: list[RepoNode] = []
279
+ lock = threading.Lock()
280
+ env = git_env()
281
+
282
+ def _do(node: RepoNode) -> None:
283
+ if fetch:
284
+ rc, _, err = run_git(node.path, "fetch", "--quiet", "--prune",
285
+ timeout=timeout, env=env)
286
+ reason = _classify_fetch(rc, err)
287
+ if reason is not None:
288
+ if not quiet:
289
+ print(f" fetch failed: {node.rel} — {reason}", file=sys.stderr)
290
+ with lock:
291
+ node.fetch_failed = True
292
+ node.fetch_reason = reason
293
+ failed.append(node)
294
+ _collect_one(node)
295
+ if on_done is not None:
296
+ on_done(node)
297
+
298
+ n_workers = max(1, min(workers, len(candidates)))
299
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
300
+ for _ in pool.map(_do, candidates):
301
+ pass
302
+ return failed
303
+
304
+
305
+ # ── status collection ────────────────────────────────────────────────────
306
+
307
+
308
+ def collect_status(root: RepoNode, workers: int = 8) -> None:
309
+ """Fill in branch/ahead/behind/dirty/upstream/error on every repo below
310
+ `root` (and on `root` itself if it is one).
311
+
312
+ Repos are inspected concurrently: the work is a handful of small git
313
+ calls per repo, so it is entirely I/O- and process-bound, and doing it
314
+ sequentially made a 50-repo tree visibly sluggish. Each repo writes only
315
+ to its own node, so no locking is needed.
316
+ """
317
+ nodes = list(iter_repos(root))
318
+ if not nodes:
319
+ return
320
+ if len(nodes) == 1 or workers <= 1:
321
+ for n in nodes:
322
+ _collect_one(n)
323
+ return
324
+ n_workers = max(1, min(workers, len(nodes)))
325
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
326
+ for _ in pool.map(_collect_one, nodes):
327
+ pass
328
+
329
+
330
+ def _short_reason(err: str) -> str:
331
+ """First meaningful line of a git error, for a status line."""
332
+ for line in (err or "").splitlines():
333
+ s = line.strip()
334
+ if s and not s.lower().startswith("hint:"):
335
+ return s
336
+ return ""
337
+
338
+
339
+ def _collect_one(node: RepoNode) -> None:
340
+ """Inspect one repo and fill in its status fields."""
341
+ repo = node.path
342
+ # _push_env mutates os.environ, which is process-global, so it cannot be
343
+ # used from the thread pool. Pass the sanitized env explicitly instead.
344
+ _collect_one_locked(node, repo, env=git_env())
345
+
346
+
347
+ def _collect_one_locked(node: RepoNode, repo: Path, env: dict[str, str]) -> None:
348
+ rc, _, err = run_git(repo, "rev-parse", "--is-inside-work-tree", env=env)
349
+ if rc != 0:
350
+ node.error = _short_reason(err) or "not a git work tree"
351
+ return
352
+
353
+ node.url = _remote_url(repo, env)
354
+
355
+ # Branch / ref name. HEAD may be detached.
356
+ branch = _git_one(repo, "symbolic-ref", "--quiet", "--short", "HEAD", env=env)
357
+ if not branch:
358
+ # detached HEAD — show short sha
359
+ sha = _git_one(repo, "rev-parse", "--short", "HEAD", env=env)
360
+ node.branch = f"({sha})" if sha else "(detached)"
361
+ else:
362
+ node.branch = branch
363
+
364
+ # Upstream tracking ref, e.g. origin/main
365
+ upstream = _git_one(
366
+ repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", env=env
367
+ )
368
+ node.upstream = upstream or None
369
+
370
+ # ahead/behind vs upstream — ONE rev-list for both sides, so the two
371
+ # numbers can never describe different revisions.
372
+ if upstream:
373
+ counts = _git_one(repo, "rev-list", "--left-right", "--count",
374
+ f"{upstream}...HEAD", env=env)
375
+ if counts:
376
+ parts = counts.split()
377
+ if len(parts) == 2:
378
+ # `rev-list --left-right --count A...B` prints "<left>\t<right>"
379
+ # i.e. "behind\tahead" for A=upstream, B=HEAD.
380
+ try:
381
+ node.behind, node.ahead = int(parts[0]), int(parts[1])
382
+ except ValueError:
383
+ pass
384
+
385
+ # in-progress merge/rebase/bisect: pulling is at best rude, at worst
386
+ # corrupting. Surface it as dirty so every consumer skips the repo.
387
+ state = _git_one(repo, "rev-parse", "--git-path", "MERGE_HEAD", env=env)
388
+ if state and (repo / state).exists():
389
+ node.operation = "merge in progress"
390
+ else:
391
+ for marker, label in (
392
+ ("rebase-merge", "rebase in progress"),
393
+ ("rebase-apply", "rebase in progress"),
394
+ ("rebase-merge-interactive", "rebase in progress"),
395
+ ("BISECT_LOG", "bisect in progress"),
396
+ ("CHERRY_PICK_HEAD", "cherry-pick in progress"),
397
+ ("REVERT_HEAD", "revert in progress"),
398
+ ):
399
+ p = _git_one(repo, "rev-parse", "--git-path", marker, env=env)
400
+ if p and (repo / p).exists():
401
+ node.operation = label
402
+ break
403
+
404
+ # dirty? porcelain status; parse the file paths for display.
405
+ rc, out, _ = run_git(repo, "status", "--porcelain", env=env)
406
+ if rc == 0:
407
+ node.dirty_files = [_porcelain_path(l) for l in out.splitlines() if l.strip()]
408
+ node.dirty = bool(node.dirty_files)
409
+ else:
410
+ node.dirty = None
411
+ node.dirty_files = []
412
+
413
+
414
+ def _porcelain_path(line: str) -> str:
415
+ """Path from a `git status --porcelain` line (`XY PATH` or
416
+ `XY OLD -> NEW`); strips git's quoting and rename source. Works on
417
+ stripped lines (leading status-column space may already be gone)."""
418
+ s = line.strip()
419
+ path = s[2:].lstrip() if len(s) > 2 else s
420
+ if " -> " in path:
421
+ path = path.split(" -> ", 1)[1]
422
+ return _unquote(path.strip())
423
+
424
+
425
+ def _unquote(s: str) -> str:
426
+ """Decode git's C-style quoting (`"a b\\t.txt"`) used for odd paths."""
427
+ if len(s) < 2 or not s.startswith('"') or not s.endswith('"'):
428
+ return s
429
+ body = s[1:-1]
430
+ out: list[str] = []
431
+ i = 0
432
+ while i < len(body):
433
+ c = body[i]
434
+ if c == "\\" and i + 1 < len(body):
435
+ nxt = body[i + 1]
436
+ out.append({"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\"}.get(nxt, nxt))
437
+ i += 2
438
+ else:
439
+ out.append(c)
440
+ i += 1
441
+ return "".join(out)