pulli 0.2.9__tar.gz → 0.2.11__tar.gz
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-0.2.9 → pulli-0.2.11}/PKG-INFO +1 -1
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/__init__.py +1 -1
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/cli.py +33 -31
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/discovery.py +1 -0
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/pull.py +43 -53
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/spinner.py +3 -6
- pulli-0.2.11/src/pulli/state.py +116 -0
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/status.py +50 -0
- pulli-0.2.11/src/pulli/style.py +93 -0
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/tree.py +64 -65
- {pulli-0.2.9 → pulli-0.2.11}/tests/test_pulli.py +19 -0
- {pulli-0.2.9 → pulli-0.2.11}/.gitignore +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/LICENSE +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/README.md +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/pyproject.toml +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/scripts/release.sh +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/src/pulli/update.py +0 -0
- {pulli-0.2.9 → pulli-0.2.11}/uv.lock +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""pulli — discover git repos under a root and report their status."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.11"
|
|
@@ -24,6 +24,7 @@ from .discovery import discover, iter_repos, set_rels
|
|
|
24
24
|
from .pull import pull
|
|
25
25
|
from .status import collect_status, fetch_all, fetch_and_status
|
|
26
26
|
from .tree import LiveTree, render, render_flat
|
|
27
|
+
from .style import BOLD, RESET
|
|
27
28
|
|
|
28
29
|
# Flags that belong to a subcommand. Used by _inject_tree to decide whether
|
|
29
30
|
# a leading argument is a subcommand or a path/flag for the default one.
|
|
@@ -154,6 +155,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
154
155
|
action="store_true",
|
|
155
156
|
help="Omit the one-line summary under the list.",
|
|
156
157
|
)
|
|
158
|
+
tree_p.add_argument(
|
|
159
|
+
"-v", "--verbose",
|
|
160
|
+
action="store_true",
|
|
161
|
+
help="List the incoming commits under each behind repo "
|
|
162
|
+
"(git log --oneline, HEAD..upstream).",
|
|
163
|
+
)
|
|
157
164
|
|
|
158
165
|
# `pulli pull` — pull repos that are behind.
|
|
159
166
|
pull_p = sub.add_parser("pull", help="Pull repos that are behind upstream.")
|
|
@@ -163,6 +170,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
163
170
|
action="store_true",
|
|
164
171
|
help="Show what would be pulled without pulling.",
|
|
165
172
|
)
|
|
173
|
+
pull_p.add_argument(
|
|
174
|
+
"-v", "--verbose",
|
|
175
|
+
action="store_true",
|
|
176
|
+
help="List the new commits under each pulled repo (git log --oneline).",
|
|
177
|
+
)
|
|
166
178
|
pull_p.add_argument(
|
|
167
179
|
"--no-fetch",
|
|
168
180
|
action="store_true",
|
|
@@ -308,46 +320,35 @@ def _check_root(root: Path) -> int | None:
|
|
|
308
320
|
|
|
309
321
|
|
|
310
322
|
def _needs_attention(node) -> bool:
|
|
311
|
-
"""A repo the user should look at
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if node.error or node.operation:
|
|
315
|
-
return True
|
|
316
|
-
if node.behind and node.ahead:
|
|
317
|
-
return True # diverged
|
|
318
|
-
if node.behind:
|
|
319
|
-
return True
|
|
320
|
-
return bool(node.dirty)
|
|
323
|
+
"""A repo the user should look at — anything that is not CURRENT."""
|
|
324
|
+
from .state import needs_attention
|
|
325
|
+
return needs_attention(node)
|
|
321
326
|
|
|
322
327
|
|
|
323
328
|
def _summary(repos, use_color: bool) -> str:
|
|
324
329
|
"""One line under the list: the answer to "how are my repos doing?",
|
|
325
|
-
without counting lines by hand.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
330
|
+
without counting lines by hand. Counts come from the one classifier,
|
|
331
|
+
so the summary can never disagree with the report or the tree."""
|
|
332
|
+
from .state import classify
|
|
333
|
+
|
|
334
|
+
counts: dict[str, int] = {}
|
|
335
|
+
for r in repos:
|
|
336
|
+
counts[classify(r).value] = counts.get(classify(r).value, 0) + 1
|
|
331
337
|
offline = sum(1 for r in repos if r.fetch_failed)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
parts.append(f"{ahead} ahead")
|
|
340
|
-
if dirty:
|
|
341
|
-
parts.append(f"{dirty} dirty")
|
|
338
|
+
parts = [f"{len(repos)} repos"]
|
|
339
|
+
for label in ("behind", "diverged", "ahead", "dirty", "broken"):
|
|
340
|
+
# "behind" in the summary means pullable; pull.py reports the same
|
|
341
|
+
# repos as "would pull".
|
|
342
|
+
key = "pullable" if label == "behind" else label
|
|
343
|
+
if counts.get(key):
|
|
344
|
+
parts.append(f"{counts[key]} {label}")
|
|
342
345
|
if offline:
|
|
343
346
|
parts.append(f"{offline} offline")
|
|
344
|
-
if broken:
|
|
345
|
-
parts.append(f"{broken} broken")
|
|
346
347
|
text = " · ".join(parts)
|
|
347
|
-
if
|
|
348
|
+
if counts.get("pullable") or counts.get("diverged") or counts.get("broken"):
|
|
348
349
|
# Something needs a human — make the line findable at a glance.
|
|
349
|
-
hint = f"{text} — pulli pull would update {
|
|
350
|
-
return
|
|
350
|
+
hint = f"{text} — pulli pull would update {counts.get('pullable', 0)} of them"
|
|
351
|
+
return BOLD + hint + RESET if use_color else hint
|
|
351
352
|
return text
|
|
352
353
|
|
|
353
354
|
|
|
@@ -448,6 +449,7 @@ def _run_pull(args) -> int:
|
|
|
448
449
|
follow_symlinks=not args.no_symlinks,
|
|
449
450
|
max_depth=args.max_depth,
|
|
450
451
|
link_root=getattr(args, "link_root", None),
|
|
452
|
+
verbose=getattr(args, "verbose", False),
|
|
451
453
|
)
|
|
452
454
|
|
|
453
455
|
|
|
@@ -52,6 +52,7 @@ class RepoNode:
|
|
|
52
52
|
untracked_only: bool = False # dirty, but only untracked files — safe to fast-forward
|
|
53
53
|
upstream: str | None = None
|
|
54
54
|
url: str = "" # remote url (credentials never stored — scrubbed at render)
|
|
55
|
+
is_fork: bool = False # origin is a fork: another remote points at a repo of the same name (likely the upstream parent)
|
|
55
56
|
operation: str | None = None # "merge in progress", "rebase in progress", …
|
|
56
57
|
fetch_failed: bool = False # fetch did not succeed (offline / unreachable)
|
|
57
58
|
fetch_reason: str | None = None # why the fetch failed
|
|
@@ -32,21 +32,13 @@ from pathlib import Path
|
|
|
32
32
|
|
|
33
33
|
from .discovery import RepoNode, discover, iter_repos, set_rels
|
|
34
34
|
from .status import collect_status, fetch_all, git_env, run_git
|
|
35
|
-
|
|
36
|
-
# ANSI colors (mirrors tree.py).
|
|
37
|
-
_RESET = "\x1b[0m"
|
|
38
|
-
_DIM = "\x1b[2m"
|
|
39
|
-
_BOLD = "\x1b[1m"
|
|
40
|
-
_GREEN = "\x1b[32m"
|
|
41
|
-
_YELLOW = "\x1b[33m"
|
|
42
|
-
_RED = "\x1b[31m"
|
|
43
|
-
_CYAN = "\x1b[36m"
|
|
35
|
+
from .style import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, Colorizer
|
|
44
36
|
|
|
45
37
|
_PULL_TIMEOUT = 120
|
|
46
38
|
|
|
47
39
|
|
|
48
40
|
def _color(s: str, code: str, use_color: bool) -> str:
|
|
49
|
-
return code + s +
|
|
41
|
+
return code + s + RESET if use_color else s
|
|
50
42
|
|
|
51
43
|
|
|
52
44
|
def _fmt_ahead_behind(node: RepoNode) -> str:
|
|
@@ -68,44 +60,27 @@ def _fmt_dirty(node: RepoNode, use_color: bool) -> str:
|
|
|
68
60
|
which is what the reader needs to know about them."""
|
|
69
61
|
files = node.dirty_files or []
|
|
70
62
|
if not files:
|
|
71
|
-
return _color("dirty",
|
|
63
|
+
return _color("dirty", YELLOW, use_color)
|
|
72
64
|
shown = files[:4]
|
|
73
65
|
more = len(files) - len(shown)
|
|
74
66
|
listing = ", ".join(shown) + (f", +{more} more" if more > 0 else "")
|
|
75
67
|
label = "untracked" if node.untracked_only else "dirty"
|
|
76
68
|
return (
|
|
77
|
-
f"{_color(label,
|
|
78
|
-
f"({_color(listing,
|
|
69
|
+
f"{_color(label, YELLOW, use_color)} {len(files)} "
|
|
70
|
+
f"({_color(listing, DIM, use_color)})"
|
|
79
71
|
)
|
|
80
72
|
|
|
81
73
|
|
|
82
74
|
def _classify(node: RepoNode) -> str:
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return "broken"
|
|
87
|
-
if node.operation:
|
|
88
|
-
return "busy"
|
|
89
|
-
behind, ahead = node.behind or 0, node.ahead or 0
|
|
90
|
-
if not node.upstream or (not behind and not ahead):
|
|
91
|
-
return "current"
|
|
92
|
-
if behind and ahead:
|
|
93
|
-
return "diverged"
|
|
94
|
-
if node.dirty and not node.untracked_only:
|
|
95
|
-
return "dirty"
|
|
96
|
-
if behind:
|
|
97
|
-
return "pullable"
|
|
98
|
-
return "ahead"
|
|
75
|
+
"""Shim over state.classify — kept for _pull_json and the tests."""
|
|
76
|
+
from .state import classify
|
|
77
|
+
return classify(node).value
|
|
99
78
|
|
|
100
79
|
|
|
101
80
|
def _sort_key(node: RepoNode) -> tuple[int, str]:
|
|
102
|
-
"""Report order: what needs attention first.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"broken": 0, "busy": 1, "diverged": 2,
|
|
106
|
-
"pullable": 3, "dirty": 4, "ahead": 5, "current": 6,
|
|
107
|
-
}[ _classify(node) ]
|
|
108
|
-
return (rank, node.rel)
|
|
81
|
+
"""Report order: what needs attention first."""
|
|
82
|
+
from .state import sort_key
|
|
83
|
+
return sort_key(node)
|
|
109
84
|
|
|
110
85
|
|
|
111
86
|
def _first_meaningful_line(text: str) -> str:
|
|
@@ -130,28 +105,28 @@ def _report(node: RepoNode, kind: str, *, use_color: bool, dry_run: bool) -> str
|
|
|
130
105
|
ab = _fmt_ahead_behind(node)
|
|
131
106
|
|
|
132
107
|
if kind == "broken":
|
|
133
|
-
line = f" {C('✗',
|
|
108
|
+
line = f" {C('✗', RED, use_color)} {rel} {C(node.error or 'error', RED, use_color)}"
|
|
134
109
|
elif kind == "busy":
|
|
135
|
-
line = f" {C('◐',
|
|
110
|
+
line = f" {C('◐', YELLOW, use_color)} {rel} {C(node.operation + ' — skipping pull', YELLOW, use_color)}"
|
|
136
111
|
elif kind == "diverged":
|
|
137
112
|
line = (
|
|
138
|
-
f" {C('↕',
|
|
139
|
-
f"{C('diverged — needs merge or rebase, skipping',
|
|
113
|
+
f" {C('↕', YELLOW, use_color)} {rel} {ab} "
|
|
114
|
+
f"{C('diverged — needs merge or rebase, skipping', YELLOW, use_color)}"
|
|
140
115
|
)
|
|
141
116
|
elif kind == "dirty":
|
|
142
117
|
line = (
|
|
143
|
-
f" {C('◐',
|
|
118
|
+
f" {C('◐', YELLOW, use_color)} {rel} {ab} "
|
|
144
119
|
f"{_fmt_dirty(node, use_color)}, skipping pull — "
|
|
145
|
-
f"{C('commit or stash, then pulli pull again',
|
|
120
|
+
f"{C('commit or stash, then pulli pull again', DIM, use_color)}"
|
|
146
121
|
)
|
|
147
122
|
elif kind == "ahead":
|
|
148
|
-
line = f" {C('↑',
|
|
123
|
+
line = f" {C('↑', CYAN, use_color)} {rel} {C(f'↑{node.ahead} ahead (not pushed)', CYAN, use_color)}"
|
|
149
124
|
elif kind == "current":
|
|
150
125
|
# Nothing to do. A dirty repo with nothing to pull is only worth
|
|
151
126
|
# mentioning in dry-run mode, where the point is a full inventory.
|
|
152
127
|
if not (dry_run and node.dirty):
|
|
153
128
|
return None
|
|
154
|
-
line = f" {C('◐',
|
|
129
|
+
line = f" {C('◐', YELLOW, use_color)} {rel} up to date {_fmt_dirty(node, use_color)}"
|
|
155
130
|
else: # pullable
|
|
156
131
|
return None # printed by the pull loop, which knows the outcome
|
|
157
132
|
print(line)
|
|
@@ -167,6 +142,7 @@ def pull(
|
|
|
167
142
|
follow_symlinks: bool = True,
|
|
168
143
|
max_depth: int = 50,
|
|
169
144
|
link_root: Path | None = None,
|
|
145
|
+
verbose: bool = False,
|
|
170
146
|
) -> int:
|
|
171
147
|
"""Pull every repo under `root` that is behind upstream.
|
|
172
148
|
|
|
@@ -215,17 +191,29 @@ def pull(
|
|
|
215
191
|
|
|
216
192
|
if dry_run:
|
|
217
193
|
print()
|
|
218
|
-
print(_color("Dry run — no pulls performed.",
|
|
194
|
+
print(_color("Dry run — no pulls performed.", BOLD, use_color))
|
|
219
195
|
for node in pullable:
|
|
220
196
|
note = ""
|
|
221
197
|
if node.dirty and node.untracked_only:
|
|
222
|
-
note = _color(" (untracked files only — safe)",
|
|
198
|
+
note = _color(" (untracked files only — safe)", DIM, use_color)
|
|
223
199
|
print(
|
|
224
|
-
f" {_color('↓',
|
|
225
|
-
f"{_fmt_ahead_behind(node)} {_color('would pull',
|
|
200
|
+
f" {_color('↓', GREEN, use_color)} {node.rel} "
|
|
201
|
+
f"{_fmt_ahead_behind(node)} {_color('would pull', GREEN, use_color)}{note}"
|
|
226
202
|
)
|
|
227
203
|
else:
|
|
228
204
|
for node in pullable:
|
|
205
|
+
# The commits this pull will bring in — captured *before* the
|
|
206
|
+
# pull, while the range HEAD..upstream still exists (after a
|
|
207
|
+
# fast-forward it is empty).
|
|
208
|
+
new_commits: list[str] = []
|
|
209
|
+
if verbose and node.upstream:
|
|
210
|
+
rc, out, _ = run_git(
|
|
211
|
+
node.path,
|
|
212
|
+
"log", "--oneline", f"HEAD..{node.upstream}",
|
|
213
|
+
timeout=_PULL_TIMEOUT,
|
|
214
|
+
)
|
|
215
|
+
if rc == 0:
|
|
216
|
+
new_commits = [l for l in out.splitlines() if l.strip()]
|
|
229
217
|
rc, stdout, stderr = run_git(
|
|
230
218
|
node.path, "pull", "--ff-only", "--quiet", timeout=_PULL_TIMEOUT
|
|
231
219
|
)
|
|
@@ -233,18 +221,20 @@ def pull(
|
|
|
233
221
|
pulled.append(node)
|
|
234
222
|
note = ""
|
|
235
223
|
if node.dirty and node.untracked_only:
|
|
236
|
-
note = _color(" (untracked files kept)",
|
|
224
|
+
note = _color(" (untracked files kept)", DIM, use_color)
|
|
237
225
|
print(
|
|
238
|
-
f" {_color('✓',
|
|
226
|
+
f" {_color('✓', GREEN, use_color)} {node.rel} "
|
|
239
227
|
f"{_fmt_ahead_behind(node)} pulled{note}"
|
|
240
228
|
)
|
|
229
|
+
for c in new_commits:
|
|
230
|
+
print(_color(f" {c}", DIM, use_color))
|
|
241
231
|
else:
|
|
242
232
|
reason = _first_meaningful_line(stderr or stdout) or f"exit {rc}"
|
|
243
233
|
failed.append((node, reason))
|
|
244
234
|
print(
|
|
245
|
-
f" {_color('✗',
|
|
235
|
+
f" {_color('✗', RED, use_color)} {node.rel} "
|
|
246
236
|
f"{_fmt_ahead_behind(node)} "
|
|
247
|
-
f"{_color('pull failed: ' + reason,
|
|
237
|
+
f"{_color('pull failed: ' + reason, RED, use_color)}"
|
|
248
238
|
)
|
|
249
239
|
|
|
250
240
|
# ── summary ──────────────────────────────────────────────────────────
|
|
@@ -257,7 +247,7 @@ def pull(
|
|
|
257
247
|
_color(
|
|
258
248
|
f"note: {len(offline)} repo(s) unreachable — their ↓↑ may be "
|
|
259
249
|
"stale (rerun when online).",
|
|
260
|
-
|
|
250
|
+
YELLOW,
|
|
261
251
|
use_color,
|
|
262
252
|
)
|
|
263
253
|
)
|
|
@@ -24,11 +24,8 @@ _INTERVAL = 0.08
|
|
|
24
24
|
# wipe it completely when we're done so it never trails into real output.
|
|
25
25
|
_CLEAR = "\r\x1b[2K"
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
_CYAN = "\x1b[36m"
|
|
30
|
-
_DIM = "\x1b[2m"
|
|
31
|
-
_RESET = "\x1b[0m"
|
|
27
|
+
# The palette lives in style.py; the spinner only picks two entries.
|
|
28
|
+
from .style import CYAN, DIM, RESET
|
|
32
29
|
|
|
33
30
|
|
|
34
31
|
class Spinner:
|
|
@@ -77,7 +74,7 @@ class Spinner:
|
|
|
77
74
|
self._frame += 1
|
|
78
75
|
with self._write_lock:
|
|
79
76
|
if self.use_color:
|
|
80
|
-
self.stream.write(f"\r{
|
|
77
|
+
self.stream.write(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}{RESET}")
|
|
81
78
|
else:
|
|
82
79
|
self.stream.write(f"\r{frame} {self.message}")
|
|
83
80
|
self.stream.flush()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""RepoState — one classifier, one precedence, every consumer a lookup.
|
|
2
|
+
|
|
3
|
+
"What state is this repo in?" used to be decided five times (pull's
|
|
4
|
+
_classify, cli's _needs_attention, tree's _line_color and _status_glyph,
|
|
5
|
+
cli's _summary counts), each with its own subtly different precedence —
|
|
6
|
+
and tree and pull could genuinely disagree about the same repo.
|
|
7
|
+
|
|
8
|
+
Now there is one precedence table, and it is pull.py's (the safety-
|
|
9
|
+
relevant one): a repo is classified by the first rule that matches.
|
|
10
|
+
|
|
11
|
+
error → BROKEN report, skip, exit non-zero
|
|
12
|
+
operation → BUSY report, skip
|
|
13
|
+
diverged → DIVERGED report, skip (ff impossible)
|
|
14
|
+
dirty-tracked→ DIRTY report, skip (commit or stash first)
|
|
15
|
+
behind → PULLABLE git pull --ff-only
|
|
16
|
+
(untracked-only dirty does not block a pull)
|
|
17
|
+
ahead → AHEAD note: push when you like
|
|
18
|
+
otherwise → CURRENT silent
|
|
19
|
+
|
|
20
|
+
Every derived view — glyph, line color, attention filter, report bucket,
|
|
21
|
+
summary count — is a lookup on this enum, so the views can no longer
|
|
22
|
+
disagree with each other or with what pull actually does.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from enum import Enum
|
|
27
|
+
|
|
28
|
+
from .discovery import RepoNode
|
|
29
|
+
from .style import CYAN, GREEN, RED, YELLOW
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RepoState(Enum):
|
|
33
|
+
BROKEN = "broken" # git itself failed on this repo
|
|
34
|
+
BUSY = "busy" # merge/rebase/bisect in progress
|
|
35
|
+
DIVERGED = "diverged" # ahead *and* behind — ff impossible
|
|
36
|
+
DIRTY = "dirty" # tracked changes — pull would clobber
|
|
37
|
+
PULLABLE = "pullable" # behind only — git pull --ff-only
|
|
38
|
+
AHEAD = "ahead" # commits not pushed
|
|
39
|
+
CURRENT = "current" # nothing to do
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify(node: RepoNode) -> RepoState:
|
|
43
|
+
"""The one precedence table. First match wins."""
|
|
44
|
+
if node.error:
|
|
45
|
+
return RepoState.BROKEN
|
|
46
|
+
if node.operation:
|
|
47
|
+
return RepoState.BUSY
|
|
48
|
+
behind, ahead = node.behind or 0, node.ahead or 0
|
|
49
|
+
if behind and ahead:
|
|
50
|
+
return RepoState.DIVERGED
|
|
51
|
+
if node.dirty and not node.untracked_only:
|
|
52
|
+
return RepoState.DIRTY
|
|
53
|
+
if behind:
|
|
54
|
+
return RepoState.PULLABLE
|
|
55
|
+
if ahead:
|
|
56
|
+
return RepoState.AHEAD
|
|
57
|
+
return RepoState.CURRENT
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── derived views: lookups, not logic ────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
#: Glyph shown in the tree's state column.
|
|
63
|
+
_GLYPHS: dict[RepoState, tuple[str, str]] = {
|
|
64
|
+
RepoState.BROKEN: ("✗", RED),
|
|
65
|
+
RepoState.BUSY: ("◐", YELLOW),
|
|
66
|
+
RepoState.DIVERGED: ("◐", YELLOW),
|
|
67
|
+
RepoState.DIRTY: ("◐", YELLOW),
|
|
68
|
+
RepoState.PULLABLE: ("●", GREEN),
|
|
69
|
+
RepoState.AHEAD: ("●", GREEN),
|
|
70
|
+
RepoState.CURRENT: ("●", GREEN),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def glyph(node: RepoNode) -> tuple[str, str]:
|
|
75
|
+
"""(symbol, color) for the state column."""
|
|
76
|
+
return _GLYPHS[classify(node)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: Whole-line highlight color for actionable rows, None for quiet rows.
|
|
80
|
+
#: Behind (pull me) and dirty-tracked (commit/stash me) are the two
|
|
81
|
+
#: states that ask for action; the eye scans rows before columns.
|
|
82
|
+
_LINE_COLORS: dict[RepoState, str | None] = {
|
|
83
|
+
RepoState.BROKEN: None,
|
|
84
|
+
RepoState.BUSY: None,
|
|
85
|
+
RepoState.DIVERGED: None,
|
|
86
|
+
RepoState.DIRTY: YELLOW,
|
|
87
|
+
RepoState.PULLABLE: CYAN,
|
|
88
|
+
RepoState.AHEAD: None,
|
|
89
|
+
RepoState.CURRENT: None,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def line_color(node: RepoNode) -> str | None:
|
|
94
|
+
"""Whole-line highlight color, or None for a quiet row."""
|
|
95
|
+
return _LINE_COLORS[classify(node)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def needs_attention(node: RepoNode) -> bool:
|
|
99
|
+
"""A repo the user should look at: anything that is not CURRENT."""
|
|
100
|
+
return classify(node) is not RepoState.CURRENT
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: Report order: what needs attention first. Tie-break on display path.
|
|
104
|
+
_RANKS: dict[RepoState, int] = {
|
|
105
|
+
RepoState.BROKEN: 0,
|
|
106
|
+
RepoState.BUSY: 1,
|
|
107
|
+
RepoState.DIVERGED: 2,
|
|
108
|
+
RepoState.PULLABLE: 3,
|
|
109
|
+
RepoState.DIRTY: 4,
|
|
110
|
+
RepoState.AHEAD: 5,
|
|
111
|
+
RepoState.CURRENT: 6,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sort_key(node: RepoNode) -> tuple[int, str]:
|
|
116
|
+
return (_RANKS[classify(node)], node.rel)
|
|
@@ -154,6 +154,55 @@ def _remote_url(repo: Path, env: dict[str, str] | None = None) -> str:
|
|
|
154
154
|
return ""
|
|
155
155
|
|
|
156
156
|
|
|
157
|
+
def _is_fork(repo: Path, env: dict[str, str] | None = None) -> bool:
|
|
158
|
+
"""True when the repo looks like a fork.
|
|
159
|
+
|
|
160
|
+
Two signals, either one is enough:
|
|
161
|
+
1. a remote named `upstream` — the documented GitHub convention for
|
|
162
|
+
"the repo I forked from";
|
|
163
|
+
2. remotes pointing at the same repo *name* under different *owners*
|
|
164
|
+
(`devskale/rodney` + `simonw/rodney`), compared case-insensitively.
|
|
165
|
+
|
|
166
|
+
Name comparison alone misses forks whose copy was renamed
|
|
167
|
+
(`devskale/handyST` forked from `cjpais/Handy`) — hence signal 1.
|
|
168
|
+
"""
|
|
169
|
+
remotes = _git_one(repo, "remote", env=env).split()
|
|
170
|
+
if len(remotes) < 2:
|
|
171
|
+
return False
|
|
172
|
+
if "upstream" in remotes:
|
|
173
|
+
return True
|
|
174
|
+
owners: set[str] = set()
|
|
175
|
+
names: set[str] = set()
|
|
176
|
+
for name in remotes:
|
|
177
|
+
url = _git_one(repo, "remote", "get-url", name, env=env)
|
|
178
|
+
owner, rname = _owner_repo(url)
|
|
179
|
+
if rname:
|
|
180
|
+
owners.add(owner.lower())
|
|
181
|
+
names.add(rname.lower())
|
|
182
|
+
# same repo name under more than one owner = a fork and its parent
|
|
183
|
+
return len(names) == 1 and len(owners) >= 2
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _owner_repo(url: str) -> tuple[str, str]:
|
|
187
|
+
"""(owner, repo) from a remote url; ('', '') when unparseable.
|
|
188
|
+
|
|
189
|
+
`git@h:owner/repo.git` and `https://h/owner/repo` -> ('owner', 'repo').
|
|
190
|
+
"""
|
|
191
|
+
if not url:
|
|
192
|
+
return "", ""
|
|
193
|
+
if "://" in url:
|
|
194
|
+
url = url.split("://", 1)[1]
|
|
195
|
+
if ":" in url: # scp-like git@host:owner/repo
|
|
196
|
+
url = url.split(":", 1)[1]
|
|
197
|
+
parts = [p for p in url.split("/") if p]
|
|
198
|
+
if len(parts) < 2:
|
|
199
|
+
return "", ""
|
|
200
|
+
repo = parts[-1]
|
|
201
|
+
if repo.endswith(".git"):
|
|
202
|
+
repo = repo[: -len(".git")]
|
|
203
|
+
return parts[-2], repo
|
|
204
|
+
|
|
205
|
+
|
|
157
206
|
# ── fetch ────────────────────────────────────────────────────────────────
|
|
158
207
|
|
|
159
208
|
|
|
@@ -351,6 +400,7 @@ def _collect_one_locked(node: RepoNode, repo: Path, env: dict[str, str]) -> None
|
|
|
351
400
|
return
|
|
352
401
|
|
|
353
402
|
node.url = _remote_url(repo, env)
|
|
403
|
+
node.is_fork = _is_fork(repo, env)
|
|
354
404
|
|
|
355
405
|
# Branch / ref name. HEAD may be detached.
|
|
356
406
|
branch = _git_one(repo, "symbolic-ref", "--quiet", "--short", "HEAD", env=env)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""ANSI styling — one palette, one gate, one place.
|
|
2
|
+
|
|
3
|
+
Every visual decision in pulli goes through this module: the palette
|
|
4
|
+
constants, the should-this-be-colored gate (TTY, --no-color, NO_COLOR,
|
|
5
|
+
TERM=dumb), and the whole-line highlight that survives nested SGR resets.
|
|
6
|
+
|
|
7
|
+
Before this module existed the palette was defined four times (tree.py,
|
|
8
|
+
pull.py, spinner.py, cli.py) with three different helper shapes, and the
|
|
9
|
+
NO_COLOR gate only lived in cli.py — so any new consumer could (and did)
|
|
10
|
+
forget it. Now the gate is part of the interface: a Colorizer is either
|
|
11
|
+
on or off, and everything rendered through it honors the same rules.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
# ── palette ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
RESET = "\x1b[0m"
|
|
21
|
+
DIM = "\x1b[2m"
|
|
22
|
+
BOLD = "\x1b[1m"
|
|
23
|
+
GREEN = "\x1b[32m"
|
|
24
|
+
YELLOW = "\x1b[33m"
|
|
25
|
+
RED = "\x1b[31m"
|
|
26
|
+
CYAN = "\x1b[36m"
|
|
27
|
+
MAGENTA = "\x1b[35m"
|
|
28
|
+
|
|
29
|
+
# Matches a single ANSI SGR escape sequence (color / style codes).
|
|
30
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def visible_width(s: str) -> int:
|
|
34
|
+
"""Visible column count, ignoring ANSI escape sequences."""
|
|
35
|
+
return len(_ANSI_RE.sub("", s))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def should_color(stream=None, *, override: bool | None = None) -> bool:
|
|
39
|
+
"""The one color gate: TTY + no NO_COLOR + not TERM=dumb.
|
|
40
|
+
|
|
41
|
+
`override` is the --no-color / forced-color decision from the CLI and
|
|
42
|
+
wins over everything. Kept separate from the Colorizer so callers can
|
|
43
|
+
ask "would color be on?" without constructing one.
|
|
44
|
+
"""
|
|
45
|
+
if override is False:
|
|
46
|
+
return False
|
|
47
|
+
stream = stream if stream is not None else sys.stdout
|
|
48
|
+
try:
|
|
49
|
+
if not stream.isatty():
|
|
50
|
+
return False
|
|
51
|
+
except (AttributeError, OSError):
|
|
52
|
+
return False
|
|
53
|
+
# no-color.org convention: NO_COLOR set to any non-empty value.
|
|
54
|
+
import os
|
|
55
|
+
if os.environ.get("NO_COLOR"):
|
|
56
|
+
return False
|
|
57
|
+
if os.environ.get("TERM") == "dumb":
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Colorizer:
|
|
63
|
+
"""Wrap text in ANSI codes — or don't, when color is off.
|
|
64
|
+
|
|
65
|
+
One callable: `C(text, *codes)`. When disabled it is the identity,
|
|
66
|
+
so render code has no `if use_color` branches — it always calls C.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, enabled: bool) -> None:
|
|
70
|
+
self.enabled = enabled
|
|
71
|
+
|
|
72
|
+
def __call__(self, s: str, *codes: str) -> str:
|
|
73
|
+
if not self.enabled or not codes:
|
|
74
|
+
return s
|
|
75
|
+
return "".join(codes) + s + RESET
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def highlight_line(line: str, color: str) -> str:
|
|
79
|
+
"""Color-wrap a whole line, surviving nested SGR resets.
|
|
80
|
+
|
|
81
|
+
Inner resets (e.g. the count's own cyan+bold) would end the outer
|
|
82
|
+
color mid-line and make the line render two-tone; re-apply the color
|
|
83
|
+
after each reset so the line stays one color throughout.
|
|
84
|
+
Returns the input unchanged when it carries no ANSI codes (color off)
|
|
85
|
+
— nothing to highlight then.
|
|
86
|
+
"""
|
|
87
|
+
if "\x1b[" not in line:
|
|
88
|
+
return line
|
|
89
|
+
# Drop dim segments: dim-color renders darker than the color, which
|
|
90
|
+
# would split the highlighted line into bright and dark halves. The
|
|
91
|
+
# point of the highlight is one uniform color.
|
|
92
|
+
line = line.replace(DIM, "")
|
|
93
|
+
return color + line.replace(RESET, RESET + color) + RESET
|
|
@@ -37,32 +37,31 @@ keeps the credential scrubbing in exactly one place.
|
|
|
37
37
|
"""
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
|
-
import re
|
|
41
40
|
import shutil
|
|
42
41
|
import sys
|
|
43
42
|
import threading
|
|
44
43
|
|
|
45
44
|
from .discovery import RepoNode, iter_repos
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
from .style import (
|
|
46
|
+
BOLD,
|
|
47
|
+
CYAN,
|
|
48
|
+
DIM,
|
|
49
|
+
GREEN,
|
|
50
|
+
MAGENTA,
|
|
51
|
+
RED,
|
|
52
|
+
RESET,
|
|
53
|
+
YELLOW,
|
|
54
|
+
Colorizer,
|
|
55
|
+
highlight_line,
|
|
56
|
+
visible_width,
|
|
57
|
+
)
|
|
56
58
|
|
|
57
59
|
_BARE_NOTE = "(bare repo — nothing to pull)"
|
|
58
60
|
|
|
59
|
-
# Matches a single ANSI SGR escape sequence (color / style codes).
|
|
60
|
-
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
61
|
-
|
|
62
61
|
|
|
63
62
|
def _visible_width(s: str) -> int:
|
|
64
63
|
"""Visible column count, ignoring ANSI escape sequences."""
|
|
65
|
-
return
|
|
64
|
+
return visible_width(s)
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
def _clip_ansi(s: str, width: int) -> str:
|
|
@@ -105,7 +104,7 @@ def _clip_ansi(s: str, width: int) -> str:
|
|
|
105
104
|
vis += 1
|
|
106
105
|
i += 1
|
|
107
106
|
if truncated:
|
|
108
|
-
out.append(
|
|
107
|
+
out.append(RESET)
|
|
109
108
|
return "".join(out)
|
|
110
109
|
|
|
111
110
|
|
|
@@ -145,12 +144,9 @@ def _shorten_url(url: str) -> str:
|
|
|
145
144
|
|
|
146
145
|
|
|
147
146
|
def _status_glyph(node: RepoNode) -> tuple[str, str]:
|
|
148
|
-
"""
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if node.dirty or node.operation:
|
|
152
|
-
return "◐", _YELLOW
|
|
153
|
-
return "●", _GREEN
|
|
147
|
+
"""(symbol, color) for the state column."""
|
|
148
|
+
from .state import glyph
|
|
149
|
+
return glyph(node)
|
|
154
150
|
|
|
155
151
|
|
|
156
152
|
def _ahead_behind(node: RepoNode, C=lambda s, *c: s) -> str:
|
|
@@ -166,23 +162,19 @@ def _ahead_behind(node: RepoNode, C=lambda s, *c: s) -> str:
|
|
|
166
162
|
if not node.upstream:
|
|
167
163
|
return "· ·"
|
|
168
164
|
behind, ahead = node.behind or 0, node.ahead or 0
|
|
169
|
-
b = C(f"↓{behind}",
|
|
170
|
-
a = C(f"↑{ahead}",
|
|
165
|
+
b = C(f"↓{behind}", CYAN, BOLD) if behind else f"↓{behind}"
|
|
166
|
+
a = C(f"↑{ahead}", CYAN, BOLD) if ahead else f"↑{ahead}"
|
|
171
167
|
return f"{b} {a}"
|
|
172
168
|
|
|
173
169
|
|
|
174
|
-
def _highlight_line(line: str) -> str:
|
|
175
|
-
|
|
170
|
+
def _highlight_line(line: str, color: str = CYAN) -> str:
|
|
171
|
+
return highlight_line(line, color)
|
|
176
172
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"""
|
|
183
|
-
if "\x1b[" not in line:
|
|
184
|
-
return line
|
|
185
|
-
return _CYAN + line.replace(_RESET, _RESET + _CYAN) + _RESET
|
|
173
|
+
|
|
174
|
+
def _line_color(node: RepoNode) -> str | None:
|
|
175
|
+
"""Whole-line highlight color for a repo, or None."""
|
|
176
|
+
from .state import line_color
|
|
177
|
+
return line_color(node)
|
|
186
178
|
|
|
187
179
|
|
|
188
180
|
def _repo_tail_parts(node: RepoNode, C) -> list[str]:
|
|
@@ -193,20 +185,25 @@ def _repo_tail_parts(node: RepoNode, C) -> list[str]:
|
|
|
193
185
|
joins them with two spaces.
|
|
194
186
|
"""
|
|
195
187
|
if node.error:
|
|
196
|
-
return [C(f"✗ {node.error}",
|
|
188
|
+
return [C(f"✗ {node.error}", RED)]
|
|
197
189
|
|
|
198
190
|
url = _shorten_url(node.url)
|
|
199
191
|
parts = [
|
|
200
|
-
C(url,
|
|
201
|
-
C(node.branch or "?",
|
|
192
|
+
C(url, DIM) if url else "",
|
|
193
|
+
C(node.branch or "?", BOLD),
|
|
202
194
|
_ahead_behind(node, C),
|
|
203
195
|
]
|
|
196
|
+
if node.is_fork:
|
|
197
|
+
# A fork pulls from its own copy but usually wants to track the
|
|
198
|
+
# parent — worth a marker so "behind 0" is not read as "caught up
|
|
199
|
+
# with the parent" when it only means "caught up with my fork".
|
|
200
|
+
parts.append(C("⑂ fork", MAGENTA))
|
|
204
201
|
|
|
205
202
|
if node.operation:
|
|
206
|
-
state = C(f"◐ {node.operation} — skipping",
|
|
203
|
+
state = C(f"◐ {node.operation} — skipping", YELLOW)
|
|
207
204
|
elif node.fetch_failed:
|
|
208
205
|
# Fetch didn't work, so ↓↑ may be stale. Say so rather than lie.
|
|
209
|
-
state = C(f"◐ offline — {node.fetch_reason or 'unreachable'}",
|
|
206
|
+
state = C(f"◐ offline — {node.fetch_reason or 'unreachable'}", YELLOW)
|
|
210
207
|
else:
|
|
211
208
|
glyph, gcolor = _status_glyph(node)
|
|
212
209
|
if node.dirty:
|
|
@@ -215,9 +212,9 @@ def _repo_tail_parts(node: RepoNode, C) -> list[str]:
|
|
|
215
212
|
# pull can't conflict with an untracked file. So it gets its own
|
|
216
213
|
# word and a softer color than tracked changes, which can.
|
|
217
214
|
if node.untracked_only:
|
|
218
|
-
state = C(f"◐ untracked {len(node.dirty_files)}",
|
|
215
|
+
state = C(f"◐ untracked {len(node.dirty_files)}", DIM)
|
|
219
216
|
else:
|
|
220
|
-
state = C(f"◐ dirty {len(node.dirty_files)}",
|
|
217
|
+
state = C(f"◐ dirty {len(node.dirty_files)}", YELLOW)
|
|
221
218
|
else:
|
|
222
219
|
state = C(glyph, gcolor)
|
|
223
220
|
|
|
@@ -233,14 +230,14 @@ def _repo_tail(node: RepoNode, C) -> str:
|
|
|
233
230
|
def _label(node: RepoNode, C) -> str:
|
|
234
231
|
name = node.name
|
|
235
232
|
if node.is_submodule:
|
|
236
|
-
return C(name,
|
|
233
|
+
return C(name, CYAN) + C(" (submodule)", DIM)
|
|
237
234
|
if node.is_symlink:
|
|
238
|
-
s = C(name,
|
|
235
|
+
s = C(name, MAGENTA) + C(" -> " + (node.symlink_target or ""), DIM)
|
|
239
236
|
# A link that is the only route to its target is just a
|
|
240
237
|
# symlink. One whose target is also listed under its real name
|
|
241
238
|
# is an alias: it shows a second name for a repo that appears
|
|
242
239
|
# elsewhere in the tree, and must not look like a second one.
|
|
243
|
-
return s + (C(" (alias)",
|
|
240
|
+
return s + (C(" (alias)", DIM) if node.is_alias else C(" (symlink)", DIM))
|
|
244
241
|
return name
|
|
245
242
|
|
|
246
243
|
|
|
@@ -265,14 +262,14 @@ def _build_lines(
|
|
|
265
262
|
|
|
266
263
|
# Root header — annotated too when the root is itself a repo, so the
|
|
267
264
|
# head of the tree isn't a bare path with no status next to it.
|
|
268
|
-
root_label = C(str(root.link_path or root.path),
|
|
265
|
+
root_label = C(str(root.link_path or root.path), BOLD)
|
|
269
266
|
header = root_label
|
|
270
267
|
if root.is_repo:
|
|
271
268
|
header += " " + tail(root)
|
|
272
269
|
repo_line[id(root)] = 0
|
|
273
270
|
repo_base[id(root)] = root_label
|
|
274
271
|
elif root.is_bare:
|
|
275
|
-
header += " " + C(_BARE_NOTE,
|
|
272
|
+
header += " " + C(_BARE_NOTE, DIM)
|
|
276
273
|
lines.append(header)
|
|
277
274
|
|
|
278
275
|
def render_node(node: RepoNode, prefix: str, is_last: bool) -> None:
|
|
@@ -290,14 +287,14 @@ def _build_lines(
|
|
|
290
287
|
repo_line[id(node)] = len(lines) - 1
|
|
291
288
|
repo_base[id(node)] = base
|
|
292
289
|
elif node.is_bare:
|
|
293
|
-
lines.append(f"{prefix}{connector}{name}/ {C(_BARE_NOTE,
|
|
290
|
+
lines.append(f"{prefix}{connector}{name}/ {C(_BARE_NOTE, DIM)}")
|
|
294
291
|
elif node.is_symlink:
|
|
295
292
|
# A link to a plain dir: the label already carries the marker,
|
|
296
293
|
# and a trailing / would land *after* it — "(symlink)/".
|
|
297
294
|
lines.append(f"{prefix}{connector}{name}")
|
|
298
295
|
else:
|
|
299
296
|
# plain directory — show with trailing /, no status
|
|
300
|
-
lines.append(f"{prefix}{connector}{C(name + '/',
|
|
297
|
+
lines.append(f"{prefix}{connector}{C(name + '/', DIM)}")
|
|
301
298
|
|
|
302
299
|
child_prefix = prefix + (" " if is_last else "│ ")
|
|
303
300
|
for i, child in enumerate(node.children):
|
|
@@ -333,11 +330,11 @@ def _build_flat_lines(
|
|
|
333
330
|
return lines, repo_line, repo_base
|
|
334
331
|
# Pad every path to the widest one so the status columns line up like a
|
|
335
332
|
# table instead of starting wherever the previous path happened to end.
|
|
336
|
-
path_width = max(_visible_width(C(n.rel,
|
|
333
|
+
path_width = max(_visible_width(C(n.rel, DIM)) for n in repos)
|
|
337
334
|
# Pad the url column too, so branch / ahead-behind / state also align.
|
|
338
335
|
url_width = max((_visible_width(_shorten_url(n.url)) for n in repos), default=0)
|
|
339
336
|
for n in repos:
|
|
340
|
-
base = C(n.rel,
|
|
337
|
+
base = C(n.rel, DIM)
|
|
341
338
|
padded = base + " " * (path_width - _visible_width(base))
|
|
342
339
|
# Rebuild the tail with a url column padded to a fixed width. Always
|
|
343
340
|
# emit the url cell (even when empty) so the branch column lines up
|
|
@@ -347,11 +344,12 @@ def _build_flat_lines(
|
|
|
347
344
|
cols[0] = cols[0] + " " * (url_width - _visible_width(cols[0]))
|
|
348
345
|
text = " ".join(c for c in cols if c)
|
|
349
346
|
line = padded + " " + text
|
|
350
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
353
|
-
|
|
354
|
-
|
|
347
|
+
# Actionable rows get the whole line (see _line_color): behind →
|
|
348
|
+
# cyan (pull), dirty tracked changes → yellow (commit/stash).
|
|
349
|
+
# Everything else stays quiet: no news takes no color.
|
|
350
|
+
color = _line_color(n)
|
|
351
|
+
if color:
|
|
352
|
+
line = _highlight_line(line, color)
|
|
355
353
|
lines.append(line)
|
|
356
354
|
repo_line[id(n)] = len(lines) - 1
|
|
357
355
|
repo_base[id(n)] = padded
|
|
@@ -364,7 +362,7 @@ def render(root: RepoNode, *, use_color: bool = True) -> str:
|
|
|
364
362
|
def C(s: str, *codes: str) -> str:
|
|
365
363
|
if not use_color:
|
|
366
364
|
return s
|
|
367
|
-
return "".join(codes) + s +
|
|
365
|
+
return "".join(codes) + s + RESET
|
|
368
366
|
|
|
369
367
|
lines, _, _ = _build_lines(root, C, lambda n: _repo_tail(n, C))
|
|
370
368
|
return "\n".join(lines)
|
|
@@ -379,7 +377,7 @@ def render_flat(root: RepoNode, *, use_color: bool = True, repos=None) -> str:
|
|
|
379
377
|
def C(s: str, *codes: str) -> str:
|
|
380
378
|
if not use_color:
|
|
381
379
|
return s
|
|
382
|
-
return "".join(codes) + s +
|
|
380
|
+
return "".join(codes) + s + RESET
|
|
383
381
|
|
|
384
382
|
lines, _, _ = _build_flat_lines(root, C, lambda n: _repo_tail(n, C), repos=repos)
|
|
385
383
|
return "\n".join(lines)
|
|
@@ -412,7 +410,7 @@ class LiveTree:
|
|
|
412
410
|
def C(s: str, *codes: str) -> str:
|
|
413
411
|
if not use_color:
|
|
414
412
|
return s
|
|
415
|
-
return "".join(codes) + s +
|
|
413
|
+
return "".join(codes) + s + RESET
|
|
416
414
|
|
|
417
415
|
self._C = C
|
|
418
416
|
self._flat = flat
|
|
@@ -430,7 +428,7 @@ class LiveTree:
|
|
|
430
428
|
# placeholders) once and leave update() inert. The CLI only uses
|
|
431
429
|
# LiveTree on a TTY, so this path is defensive.
|
|
432
430
|
self._active = False
|
|
433
|
-
lines, repo_line, repo_base = builder(root, C, lambda n: C("…",
|
|
431
|
+
lines, repo_line, repo_base = builder(root, C, lambda n: C("…", DIM))
|
|
434
432
|
self.lines = [_clip_ansi(line, self._width) for line in lines]
|
|
435
433
|
self.repo_line = repo_line
|
|
436
434
|
self.repo_base = repo_base
|
|
@@ -439,7 +437,7 @@ class LiveTree:
|
|
|
439
437
|
return
|
|
440
438
|
|
|
441
439
|
self._active = True
|
|
442
|
-
lines, repo_line, repo_base = builder(root, C, lambda n: C("…",
|
|
440
|
+
lines, repo_line, repo_base = builder(root, C, lambda n: C("…", DIM))
|
|
443
441
|
self.lines = [_clip_ansi(line, self._width) for line in lines]
|
|
444
442
|
self.repo_line = repo_line
|
|
445
443
|
self.repo_base = repo_base
|
|
@@ -476,10 +474,11 @@ class LiveTree:
|
|
|
476
474
|
if self._flat and len(cols) >= 4:
|
|
477
475
|
cols[0] = cols[0] + " " * (self._url_width - _visible_width(cols[0]))
|
|
478
476
|
text = self.repo_base[nid] + " " + " ".join(c for c in cols if c)
|
|
479
|
-
# Same highlight rule as _build_flat_lines
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
477
|
+
# Same highlight rule as _build_flat_lines (see _line_color).
|
|
478
|
+
if self._flat:
|
|
479
|
+
color = _line_color(node)
|
|
480
|
+
if color:
|
|
481
|
+
text = _highlight_line(text, color)
|
|
483
482
|
text = _clip_ansi(text, self._width)
|
|
484
483
|
with self._lock:
|
|
485
484
|
self._rewrite(i, text)
|
|
@@ -526,6 +526,25 @@ def test_render_untracked_only_is_not_dirty(lab):
|
|
|
526
526
|
assert "dirty" not in out
|
|
527
527
|
|
|
528
528
|
|
|
529
|
+
def test_fork_marker(lab):
|
|
530
|
+
"""A repo whose remotes point at the same name under different owners
|
|
531
|
+
is a fork and gets the ⑂ marker; a plain two-remote repo does not."""
|
|
532
|
+
# The lab clone's origin is <root>/origin.git -> repo name 'origin'.
|
|
533
|
+
r = lab.clone("rodney")
|
|
534
|
+
_git(r, "remote", "set-url", "origin", "https://github.com/simonw/rodney.git")
|
|
535
|
+
_git(r, "remote", "add", "fork", "https://github.com/devskale/rodney.git")
|
|
536
|
+
plain = lab.clone("plain")
|
|
537
|
+
_git(plain, "remote", "set-url", "origin", "https://github.com/simonw/rodney.git")
|
|
538
|
+
_git(plain, "remote", "add", "other", "https://github.com/devskale/other-repo.git")
|
|
539
|
+
tree = discover(lab.root)
|
|
540
|
+
set_rels(tree)
|
|
541
|
+
collect_status(tree)
|
|
542
|
+
out = render_flat(tree, use_color=False)
|
|
543
|
+
assert "⑂ fork" in out
|
|
544
|
+
assert out.count("⑂") == 1
|
|
545
|
+
assert "other-repo" not in out
|
|
546
|
+
|
|
547
|
+
|
|
529
548
|
def test_no_upstream_renders_placeholder(lab):
|
|
530
549
|
r = lab.clone("r")
|
|
531
550
|
_git(r, "remote", "remove", "origin")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|