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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pulli
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Walk a directory tree, show git repos with status, and fast-forward the ones that are behind upstream.
5
5
  Author: Stefan Waldherr
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """pulli — discover git repos under a root and report their status."""
2
- __version__ = "0.2.9"
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: broken, busy, diverged, behind, or
312
- dirty. Mirrors pull.py's bucket order so tree and pull never disagree
313
- about what "needs attention" means."""
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. Same buckets as pull.py's summary."""
326
- n = len(repos)
327
- behind = sum(1 for r in repos if r.behind and not r.ahead)
328
- diverged = sum(1 for r in repos if r.behind and r.ahead)
329
- ahead = sum(1 for r in repos if r.ahead and not r.behind)
330
- dirty = sum(1 for r in repos if r.dirty and not (r.behind or r.ahead))
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
- broken = sum(1 for r in repos if r.error)
333
- parts = [f"{n} repos"]
334
- if behind:
335
- parts.append(f"{behind} behind")
336
- if diverged:
337
- parts.append(f"{diverged} diverged")
338
- if ahead:
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 behind or diverged or broken:
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 {behind} of them"
350
- return f"\x1b[1m{hint}\x1b[0m" if use_color else hint
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 + _RESET if use_color else 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", _YELLOW, use_color)
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, _YELLOW, use_color)} {len(files)} "
78
- f"({_color(listing, _DIM, use_color)})"
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
- """Which bucket a repo falls into. One place, so the report, the
84
- counters and the summary can never disagree with each other."""
85
- if node.error:
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. Tie-break on the display
103
- path so two identical runs always print in the same order."""
104
- rank = {
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('✗', _RED, use_color)} {rel} {C(node.error or 'error', _RED, use_color)}"
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('◐', _YELLOW, use_color)} {rel} {C(node.operation + ' — skipping pull', _YELLOW, use_color)}"
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('↕', _YELLOW, use_color)} {rel} {ab} "
139
- f"{C('diverged — needs merge or rebase, skipping', _YELLOW, use_color)}"
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('◐', _YELLOW, use_color)} {rel} {ab} "
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', _DIM, use_color)}"
120
+ f"{C('commit or stash, then pulli pull again', DIM, use_color)}"
146
121
  )
147
122
  elif kind == "ahead":
148
- line = f" {C('↑', _CYAN, use_color)} {rel} {C(f'↑{node.ahead} ahead (not pushed)', _CYAN, use_color)}"
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('◐', _YELLOW, use_color)} {rel} up to date {_fmt_dirty(node, use_color)}"
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.", _BOLD, use_color))
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)", _DIM, use_color)
198
+ note = _color(" (untracked files only — safe)", DIM, use_color)
223
199
  print(
224
- f" {_color('↓', _GREEN, use_color)} {node.rel} "
225
- f"{_fmt_ahead_behind(node)} {_color('would pull', _GREEN, use_color)}{note}"
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)", _DIM, use_color)
224
+ note = _color(" (untracked files kept)", DIM, use_color)
237
225
  print(
238
- f" {_color('✓', _GREEN, use_color)} {node.rel} "
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('✗', _RED, use_color)} {node.rel} "
235
+ f" {_color('✗', RED, use_color)} {node.rel} "
246
236
  f"{_fmt_ahead_behind(node)} "
247
- f"{_color('pull failed: ' + reason, _RED, use_color)}"
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
- _YELLOW,
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
- # ANSI colors, matching pulli's palette (tree.py / pull.py). The glyph is
28
- # cyan (accent-ish), the message is dim (muted) — like pi's loader.
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{_CYAN}{frame}{_RESET} {_DIM}{self.message}{_RESET}")
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
- # ANSI colors — kept minimal; degrade gracefully on non-TTY (we strip them).
48
- _RESET = "\x1b[0m"
49
- _DIM = "\x1b[2m"
50
- _BOLD = "\x1b[1m"
51
- _GREEN = "\x1b[32m"
52
- _YELLOW = "\x1b[33m"
53
- _RED = "\x1b[31m"
54
- _CYAN = "\x1b[36m"
55
- _MAGENTA = "\x1b[35m"
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 len(_ANSI_RE.sub("", s))
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(_RESET)
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
- """Return (glyph, color) for the ok/dirty/error state."""
149
- if node.error:
150
- return "✗", _RED
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}", _CYAN, _BOLD) if behind else f"↓{behind}"
170
- a = C(f"↑{ahead}", _CYAN, _BOLD) if ahead else 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
- """Cyan-wrap a whole line, surviving nested SGR resets.
170
+ def _highlight_line(line: str, color: str = CYAN) -> str:
171
+ return highlight_line(line, color)
176
172
 
177
- Inner resets (e.g. the count's own cyan+bold) would end the outer cyan
178
- mid-line and make the line render two-tone; re-apply the cyan after
179
- each reset so the line stays one color throughout. Returns the input
180
- unchanged when it carries no ANSI codes (color off) — nothing to
181
- highlight then.
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}", _RED)]
188
+ return [C(f"✗ {node.error}", RED)]
197
189
 
198
190
  url = _shorten_url(node.url)
199
191
  parts = [
200
- C(url, _DIM) if url else "",
201
- C(node.branch or "?", _BOLD),
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", _YELLOW)
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'}", _YELLOW)
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)}", _DIM)
215
+ state = C(f"◐ untracked {len(node.dirty_files)}", DIM)
219
216
  else:
220
- state = C(f"◐ dirty {len(node.dirty_files)}", _YELLOW)
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, _CYAN) + C(" (submodule)", _DIM)
233
+ return C(name, CYAN) + C(" (submodule)", DIM)
237
234
  if node.is_symlink:
238
- s = C(name, _MAGENTA) + C(" -> " + (node.symlink_target or ""), _DIM)
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)", _DIM) if node.is_alias else C(" (symlink)", _DIM))
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), _BOLD)
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, _DIM)
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, _DIM)}")
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 + '/', _DIM)}")
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, _DIM)) for n in repos)
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, _DIM)
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
- # A repo that is behind is the actionable one — `pulli pull` acts on
351
- # it — so the whole line is highlighted (cyan) instead of just the
352
- # count. Everything up to date stays quiet: no news takes no color.
353
- if n.behind and not n.error:
354
- line = _highlight_line(line)
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 + _RESET
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 + _RESET
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 + _RESET
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("…", _DIM))
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("…", _DIM))
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: the whole line goes
480
- # cyan when the repo is behind — that is the actionable state.
481
- if self._flat and node.behind and not node.error:
482
- text = _highlight_line(text)
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