git-muster 0.3.1__tar.gz → 0.4.0__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.4
2
2
  Name: git-muster
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: See every local Git branch and what needs attention
5
5
  Keywords: branch,cli,git,github,worktree
6
6
  Author: Dan O'Leary
@@ -19,13 +19,15 @@ Requires-Python: >=3.13, <3.15
19
19
  Project-URL: Homepage, https://github.com/olearydj/git-muster
20
20
  Project-URL: Repository, https://github.com/olearydj/git-muster
21
21
  Project-URL: Issues, https://github.com/olearydj/git-muster/issues
22
+ Project-URL: Changelog, https://github.com/olearydj/git-muster/blob/main/CHANGELOG.md
22
23
  Description-Content-Type: text/markdown
23
24
 
24
25
  # git-muster
25
26
 
26
27
  [![CI](https://github.com/olearydj/git-muster/actions/workflows/ci.yml/badge.svg)](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/git-muster?logo=pypi&logoColor=white)](https://pypi.org/project/git-muster/)
27
29
  [![Python 3.13–3.14](https://img.shields.io/badge/python-3.13%E2%80%933.14-blue.svg)](https://www.python.org/)
28
- [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
30
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/olearydj/git-muster/blob/main/LICENSE)
29
31
 
30
32
  Every branch, present and accounted for.
31
33
 
@@ -61,18 +63,20 @@ BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUES
61
63
  ## Requirements
62
64
 
63
65
  - Python 3.13 or 3.14
64
- - Git
66
+ - Git 2.23 or newer, for `git branch --show-current` and `for-each-ref`'s `%(worktreepath)`
65
67
  - [uv](https://docs.astral.sh/uv/) for the recommended installation
66
68
  - Optional: an authenticated [GitHub CLI](https://cli.github.com/) for pull-request information
67
69
 
68
70
  ## Install
69
71
 
70
- Install the latest version directly from GitHub:
72
+ Install the latest release from PyPI:
71
73
 
72
74
  ```console
73
- uv tool install git+https://github.com/olearydj/git-muster.git
75
+ uv tool install git-muster
74
76
  ```
75
77
 
78
+ Use `uv tool upgrade git-muster` to update an existing installation.
79
+
76
80
  The installed commands are equivalent:
77
81
 
78
82
  ```console
@@ -98,7 +102,7 @@ Run the report from anywhere inside a Git repository:
98
102
  git muster
99
103
  ```
100
104
 
101
- By default, Git Muster runs `git fetch --all --prune --quiet` before reporting so remote-tracking references are current. Skip all network activity and repository mutation with:
105
+ By default, Git Muster runs `git fetch --all --prune --quiet` before reporting so remote-tracking references are current. When that fetch fails, because the machine is offline or a remote no longer answers, the report still renders from the references already on disk and says that branch states may be out of date. Skip all network activity and repository mutation with:
102
106
 
103
107
  ```console
104
108
  git muster --no-fetch
@@ -117,6 +121,18 @@ git muster --help
117
121
  git muster --version
118
122
  ```
119
123
 
124
+ ### Color, symbols, and width
125
+
126
+ Color, Unicode symbols, and clickable pull-request numbers appear when the report is written to an interactive terminal that can render them. Git Muster honors the usual overrides:
127
+
128
+ | Variable | Effect |
129
+ |---|---|
130
+ | `NO_COLOR` | Disables color; symbols and layout are unchanged. |
131
+ | `FORCE_COLOR`, `CLICOLOR_FORCE` | Keep color when output is redirected to a file or pipe. |
132
+ | `COLUMNS` | Overrides the detected terminal width used to fit columns. |
133
+
134
+ `--plain` overrides all of them and emits pure ASCII, without color or terminal hyperlinks. Git Muster also falls back to those ASCII glyphs on its own whenever the destination stream cannot encode its symbols, so redirected output never fails to encode.
135
+
120
136
  ## Reading the report
121
137
 
122
138
  | Column | Meaning |
@@ -141,7 +157,7 @@ Publication is deliberately separate from Git's configured upstream. A branch is
141
157
 
142
158
  When another linked worktree holds a branch, Git Muster shows its directory in the table and lists the full checkout path below it. It does not scan those other worktrees for dirtiness or change them.
143
159
 
144
- GitHub integration uses one optional `gh pr list` query. Without an installed and authenticated `gh`, the complete Git report still works and explains why PR status is unavailable.
160
+ GitHub integration uses one optional `gh pr list --state all --limit 100` query, so a branch whose pull request falls outside the hundred most recent is reported without pull-request state. When several pull requests share a head branch, the newest open, draft, approved, or changes-requested one wins over closed and merged ones. Without an installed and authenticated `gh`, or when no remote points at a known GitHub host, the complete Git report still works and names the reason pull-request state is missing.
145
161
 
146
162
  ## Safety and scope
147
163
 
@@ -162,8 +178,14 @@ uv run --locked ty check
162
178
  uv build
163
179
  ```
164
180
 
165
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
181
+ See [CONTRIBUTING.md](https://github.com/olearydj/git-muster/blob/main/CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
182
+
183
+ ## Project documents
184
+
185
+ - [CHANGELOG.md](https://github.com/olearydj/git-muster/blob/main/CHANGELOG.md) for release history.
186
+ - [CONTRIBUTING.md](https://github.com/olearydj/git-muster/blob/main/CONTRIBUTING.md) for development and release steps.
187
+ - [SECURITY.md](https://github.com/olearydj/git-muster/blob/main/SECURITY.md) for reporting a vulnerability.
166
188
 
167
189
  ## License
168
190
 
169
- Git Muster is available under the [MIT License](LICENSE).
191
+ Git Muster is available under the [MIT License](https://github.com/olearydj/git-muster/blob/main/LICENSE).
@@ -1,8 +1,9 @@
1
1
  # git-muster
2
2
 
3
3
  [![CI](https://github.com/olearydj/git-muster/actions/workflows/ci.yml/badge.svg)](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/git-muster?logo=pypi&logoColor=white)](https://pypi.org/project/git-muster/)
4
5
  [![Python 3.13–3.14](https://img.shields.io/badge/python-3.13%E2%80%933.14-blue.svg)](https://www.python.org/)
5
- [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/olearydj/git-muster/blob/main/LICENSE)
6
7
 
7
8
  Every branch, present and accounted for.
8
9
 
@@ -38,18 +39,20 @@ BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUES
38
39
  ## Requirements
39
40
 
40
41
  - Python 3.13 or 3.14
41
- - Git
42
+ - Git 2.23 or newer, for `git branch --show-current` and `for-each-ref`'s `%(worktreepath)`
42
43
  - [uv](https://docs.astral.sh/uv/) for the recommended installation
43
44
  - Optional: an authenticated [GitHub CLI](https://cli.github.com/) for pull-request information
44
45
 
45
46
  ## Install
46
47
 
47
- Install the latest version directly from GitHub:
48
+ Install the latest release from PyPI:
48
49
 
49
50
  ```console
50
- uv tool install git+https://github.com/olearydj/git-muster.git
51
+ uv tool install git-muster
51
52
  ```
52
53
 
54
+ Use `uv tool upgrade git-muster` to update an existing installation.
55
+
53
56
  The installed commands are equivalent:
54
57
 
55
58
  ```console
@@ -75,7 +78,7 @@ Run the report from anywhere inside a Git repository:
75
78
  git muster
76
79
  ```
77
80
 
78
- By default, Git Muster runs `git fetch --all --prune --quiet` before reporting so remote-tracking references are current. Skip all network activity and repository mutation with:
81
+ By default, Git Muster runs `git fetch --all --prune --quiet` before reporting so remote-tracking references are current. When that fetch fails, because the machine is offline or a remote no longer answers, the report still renders from the references already on disk and says that branch states may be out of date. Skip all network activity and repository mutation with:
79
82
 
80
83
  ```console
81
84
  git muster --no-fetch
@@ -94,6 +97,18 @@ git muster --help
94
97
  git muster --version
95
98
  ```
96
99
 
100
+ ### Color, symbols, and width
101
+
102
+ Color, Unicode symbols, and clickable pull-request numbers appear when the report is written to an interactive terminal that can render them. Git Muster honors the usual overrides:
103
+
104
+ | Variable | Effect |
105
+ |---|---|
106
+ | `NO_COLOR` | Disables color; symbols and layout are unchanged. |
107
+ | `FORCE_COLOR`, `CLICOLOR_FORCE` | Keep color when output is redirected to a file or pipe. |
108
+ | `COLUMNS` | Overrides the detected terminal width used to fit columns. |
109
+
110
+ `--plain` overrides all of them and emits pure ASCII, without color or terminal hyperlinks. Git Muster also falls back to those ASCII glyphs on its own whenever the destination stream cannot encode its symbols, so redirected output never fails to encode.
111
+
97
112
  ## Reading the report
98
113
 
99
114
  | Column | Meaning |
@@ -118,7 +133,7 @@ Publication is deliberately separate from Git's configured upstream. A branch is
118
133
 
119
134
  When another linked worktree holds a branch, Git Muster shows its directory in the table and lists the full checkout path below it. It does not scan those other worktrees for dirtiness or change them.
120
135
 
121
- GitHub integration uses one optional `gh pr list` query. Without an installed and authenticated `gh`, the complete Git report still works and explains why PR status is unavailable.
136
+ GitHub integration uses one optional `gh pr list --state all --limit 100` query, so a branch whose pull request falls outside the hundred most recent is reported without pull-request state. When several pull requests share a head branch, the newest open, draft, approved, or changes-requested one wins over closed and merged ones. Without an installed and authenticated `gh`, or when no remote points at a known GitHub host, the complete Git report still works and names the reason pull-request state is missing.
122
137
 
123
138
  ## Safety and scope
124
139
 
@@ -139,8 +154,14 @@ uv run --locked ty check
139
154
  uv build
140
155
  ```
141
156
 
142
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
157
+ See [CONTRIBUTING.md](https://github.com/olearydj/git-muster/blob/main/CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
158
+
159
+ ## Project documents
160
+
161
+ - [CHANGELOG.md](https://github.com/olearydj/git-muster/blob/main/CHANGELOG.md) for release history.
162
+ - [CONTRIBUTING.md](https://github.com/olearydj/git-muster/blob/main/CONTRIBUTING.md) for development and release steps.
163
+ - [SECURITY.md](https://github.com/olearydj/git-muster/blob/main/SECURITY.md) for reporting a vulnerability.
143
164
 
144
165
  ## License
145
166
 
146
- Git Muster is available under the [MIT License](LICENSE).
167
+ Git Muster is available under the [MIT License](https://github.com/olearydj/git-muster/blob/main/LICENSE).
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "git-muster"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "See every local Git branch and what needs attention"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -42,6 +42,7 @@ gm = "git_muster.cli:entrypoint"
42
42
  Homepage = "https://github.com/olearydj/git-muster"
43
43
  Repository = "https://github.com/olearydj/git-muster"
44
44
  Issues = "https://github.com/olearydj/git-muster/issues"
45
+ Changelog = "https://github.com/olearydj/git-muster/blob/main/CHANGELOG.md"
45
46
 
46
47
  [dependency-groups]
47
48
  dev = [
@@ -52,6 +53,7 @@ dev = [
52
53
  ]
53
54
 
54
55
  [tool.uv]
56
+ required-version = ">=0.12.1,<0.13"
55
57
  exclude-newer = "3 days"
56
58
 
57
59
  [tool.pytest.ini_options]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "git-muster"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "See every local Git branch and what needs attention"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -31,11 +31,14 @@ gm = "git_muster.cli:entrypoint"
31
31
  Homepage = "https://github.com/olearydj/git-muster"
32
32
  Repository = "https://github.com/olearydj/git-muster"
33
33
  Issues = "https://github.com/olearydj/git-muster/issues"
34
+ Changelog = "https://github.com/olearydj/git-muster/blob/main/CHANGELOG.md"
34
35
 
35
36
  [dependency-groups]
36
37
  dev = ["pytest>=8.4,<10", "pytest-cov>=6,<8", "ruff>=0.12,<1", "ty>=0.0.1a20"]
37
38
 
38
39
  [tool.uv]
40
+ # Bound the front end as well as the build backend so local and CI resolution match.
41
+ required-version = ">=0.12.1,<0.13"
39
42
  # Keep the cooldown in the project because uv records it in uv.lock.
40
43
  exclude-newer = "3 days"
41
44
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  from git_muster.cli import entrypoint
4
4
 
5
- if __name__ == "__main__":
5
+ if __name__ == "__main__": # pragma: no cover - exercised through a subprocess
6
6
  entrypoint()
@@ -51,6 +51,58 @@ The default run executes [cyan]git fetch --all --prune[/cyan]. It never switches
51
51
  merges, rebases, or pushes branches. Use [cyan]--no-fetch[/cyan] for a fully read-only run.
52
52
  """
53
53
 
54
+ SYMBOL_GLYPHS = {
55
+ "current": "▸",
56
+ "synced": "✓",
57
+ "ahead": "↑",
58
+ "behind": "↓",
59
+ "diverged": "⇅",
60
+ "gone": "✕",
61
+ "local": "·",
62
+ "rule": "─",
63
+ "ellipsis": "…",
64
+ "notice": "▲",
65
+ "separator": "·",
66
+ }
67
+
68
+ ASCII_GLYPHS = {
69
+ "current": ">",
70
+ "synced": "=",
71
+ "ahead": "^",
72
+ "behind": "v",
73
+ "diverged": "x",
74
+ "gone": "!",
75
+ "local": ".",
76
+ "rule": "-",
77
+ "ellipsis": "..",
78
+ "notice": "!",
79
+ "separator": "|",
80
+ }
81
+
82
+ PR_LIMIT = 100
83
+ PR_FIELDS = "headRefName,number,state,isDraft,reviewDecision,url"
84
+ ACTIVE_PR_STATES = {"draft", "open", "approved", "changes requested"}
85
+
86
+ PR_NOTICES: dict[str, tuple[str, ...]] = {
87
+ "missing": (
88
+ "Pull request state unknown - the GitHub CLI is not installed.",
89
+ " Install it from https://cli.github.com, then run: gh auth login",
90
+ ),
91
+ "unauthenticated": (
92
+ "Pull request state unknown - the GitHub CLI is not logged in.",
93
+ " Run: gh auth login",
94
+ ),
95
+ "not-github": ("Pull request state unknown - no remote points at a known GitHub host.",),
96
+ "unreachable": (
97
+ "Pull request state unknown - the GitHub CLI could not reach GitHub.",
98
+ " Run: gh pr list --state all --limit 1",
99
+ ),
100
+ "unusable": (
101
+ "Pull request state unknown - the GitHub CLI returned unexpected data.",
102
+ " Run: gh pr list --state all --limit 1",
103
+ ),
104
+ }
105
+
54
106
 
55
107
  def branch_states_table() -> Table:
56
108
  """Build the branch-state reference shown in command help."""
@@ -90,30 +142,72 @@ app = typer.Typer(
90
142
  )
91
143
 
92
144
 
93
- def command_output(command: str, *args: str) -> str | None:
94
- """Run a command quietly, returning ``None`` when it is unavailable or fails."""
145
+ def command_result(command: str, *args: str) -> tuple[int, str, str] | None:
146
+ """Run a command, returning its status and streams, or ``None`` when it is unavailable."""
95
147
  try:
96
- result = subprocess.run(
148
+ completed = subprocess.run(
97
149
  [command, *args],
98
150
  stdin=subprocess.DEVNULL,
99
- stdout=subprocess.PIPE,
100
- stderr=subprocess.DEVNULL,
151
+ capture_output=True,
101
152
  text=True,
102
153
  check=False,
103
154
  )
104
155
  except OSError:
105
156
  return None
106
- if result.returncode != 0:
157
+ return completed.returncode, completed.stdout.rstrip("\r\n"), completed.stderr.strip()
158
+
159
+
160
+ def command_output(command: str, *args: str) -> str | None:
161
+ """Run a command quietly, returning ``None`` when it is unavailable or fails."""
162
+ result = command_result(command, *args)
163
+ if result is None or result[0] != 0:
107
164
  return None
108
- return result.stdout.rstrip("\r\n")
165
+ return result[1]
109
166
 
110
167
 
111
168
  def git_output(*args: str) -> str:
112
169
  """Run a required Git command or raise a concise operational error."""
113
- output = command_output("git", *args)
114
- if output is None:
115
- raise RuntimeError(f"git {' '.join(args)} failed")
116
- return output
170
+ result = command_result("git", *args)
171
+ if result is None:
172
+ raise RuntimeError(f"git {' '.join(args)} failed: git is not installed")
173
+ code, stdout, stderr = result
174
+ if code != 0:
175
+ detail = stderr.splitlines()[0] if stderr else f"exit status {code}"
176
+ raise RuntimeError(f"git {' '.join(args)} failed: {detail}")
177
+ return stdout
178
+
179
+
180
+ def stream_supports(text: str, stream: Any) -> bool:
181
+ """Report whether a text stream can encode every character in ``text``."""
182
+ encoding = getattr(stream, "encoding", None)
183
+ if not encoding:
184
+ return True
185
+ try:
186
+ text.encode(encoding)
187
+ except (LookupError, UnicodeEncodeError):
188
+ return False
189
+ return True
190
+
191
+
192
+ def enable_ansi_escapes() -> bool:
193
+ """Turn on virtual-terminal processing where needed and report ANSI support."""
194
+ if sys.platform != "win32":
195
+ return True
196
+ if not sys.stdout.isatty():
197
+ # Redirected output has no console mode to configure, so leave the
198
+ # decision to --plain, NO_COLOR, and FORCE_COLOR.
199
+ return True
200
+ try: # pragma: no cover - Windows-only console setup
201
+ import ctypes
202
+
203
+ kernel32 = ctypes.windll.kernel32
204
+ handle = kernel32.GetStdHandle(-11)
205
+ mode = ctypes.c_uint32()
206
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
207
+ return False
208
+ return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
209
+ except (AttributeError, OSError, ValueError): # pragma: no cover - defensive
210
+ return False
117
211
 
118
212
 
119
213
  def compact_age(relative: str) -> str:
@@ -125,13 +219,16 @@ def compact_age(relative: str) -> str:
125
219
 
126
220
 
127
221
  def fit(value: str, limit: int, ellipsis: str) -> str:
128
- """Middle-truncate text while retaining its recognizable ending."""
222
+ """Middle-truncate text to exactly ``limit`` cells, retaining its recognizable ending."""
129
223
  if len(value) <= limit:
130
224
  return value
131
- if limit <= 3:
225
+ if limit <= len(ellipsis):
132
226
  return value[:limit]
133
- keep_end = max(4, int((limit - 1) * 0.6))
134
- keep_start = limit - 1 - keep_end
227
+ budget = limit - len(ellipsis)
228
+ keep_end = max(1, int(budget * 0.6))
229
+ keep_start = budget - keep_end
230
+ if keep_start <= 0:
231
+ return ellipsis + value[-budget:]
135
232
  return value[:keep_start] + ellipsis + value[-keep_end:]
136
233
 
137
234
 
@@ -173,13 +270,13 @@ def hyperlink(text: str, url: str, *, enabled: bool) -> str:
173
270
 
174
271
  def pull_request_status(row: dict[str, object]) -> str:
175
272
  """Normalize GitHub's PR and review fields into a compact status."""
176
- if row.get("isDraft"):
177
- return "draft"
178
273
  state = str(row.get("state", "")).upper()
179
274
  if state == "MERGED":
180
275
  return "merged"
181
276
  if state == "CLOSED":
182
277
  return "closed"
278
+ if row.get("isDraft"):
279
+ return "draft"
183
280
  decision = str(row.get("reviewDecision", "")).upper()
184
281
  if decision == "APPROVED":
185
282
  return "approved"
@@ -188,6 +285,39 @@ def pull_request_status(row: dict[str, object]) -> str:
188
285
  return "open"
189
286
 
190
287
 
288
+ def github_failure(code: int, stderr: str) -> str:
289
+ """Classify why the GitHub CLI refused to report pull requests."""
290
+ message = stderr.lower()
291
+ if (
292
+ "no git remotes" in message
293
+ or "known github host" in message
294
+ or "not a git repository" in message
295
+ ):
296
+ return "not-github"
297
+ if code == 4 or "gh auth login" in message or "authentication" in message:
298
+ return "unauthenticated"
299
+ return "unreachable"
300
+
301
+
302
+ def github_pull_requests() -> tuple[list[dict[str, object]], str | None]:
303
+ """Read pull requests through the GitHub CLI, explaining any failure."""
304
+ result = command_result(
305
+ "gh", "pr", "list", "--state", "all", "--limit", str(PR_LIMIT), "--json", PR_FIELDS
306
+ )
307
+ if result is None:
308
+ return [], "missing"
309
+ code, stdout, stderr = result
310
+ if code != 0:
311
+ return [], github_failure(code, stderr)
312
+ try:
313
+ parsed = json.loads(stdout)
314
+ except (json.JSONDecodeError, TypeError, ValueError):
315
+ return [], "unusable"
316
+ if not isinstance(parsed, list):
317
+ return [], "unusable"
318
+ return [row for row in parsed if isinstance(row, dict)], None
319
+
320
+
191
321
  def parse_track(track: str) -> tuple[int, int] | None:
192
322
  """Return ahead/behind counts from Git's tracking decoration."""
193
323
  if "gone" in track:
@@ -244,14 +374,27 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
244
374
  print("git muster: current directory is not inside a Git repository", file=sys.stderr)
245
375
  return 2
246
376
 
247
- use_colour = not plain and sys.stdout.isatty() and not bool(os.environ.get("NO_COLOR"))
248
- use_links = not plain and sys.stdout.isatty()
377
+ interactive = sys.stdout.isatty()
378
+ ansi_ready = enable_ansi_escapes()
379
+ forced_colour = bool(os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"))
380
+ use_colour = (
381
+ not plain
382
+ and ansi_ready
383
+ and not os.environ.get("NO_COLOR")
384
+ and (interactive or forced_colour)
385
+ )
386
+ use_links = not plain and ansi_ready and interactive
249
387
  modern_terminal = sys.platform != "win32" or bool(
250
388
  os.environ.get("WT_SESSION")
251
389
  or os.environ.get("TERM_PROGRAM")
252
390
  or os.environ.get("TERMINAL_EMULATOR")
253
391
  )
254
- use_symbols = not plain and modern_terminal
392
+ use_symbols = (
393
+ not plain
394
+ and modern_terminal
395
+ and stream_supports("".join(SYMBOL_GLYPHS.values()), sys.stdout)
396
+ )
397
+ glyph = SYMBOL_GLYPHS if use_symbols else ASCII_GLYPHS
255
398
 
256
399
  def paint(code: str) -> Callable[[str], str]:
257
400
  return lambda text: f"\033[{code}m{text}\033[0m" if use_colour else text
@@ -263,86 +406,56 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
263
406
  yellow = paint("33")
264
407
  blue = paint("34")
265
408
 
266
- if use_symbols:
267
- glyph = {
268
- "current": "▸",
269
- "synced": "✓",
270
- "ahead": "↑",
271
- "behind": "↓",
272
- "diverged": "⇅",
273
- "gone": "✕",
274
- "local": "·",
275
- "rule": "─",
276
- "ellipsis": "…",
277
- "notice": "▲",
278
- }
279
- else:
280
- glyph = {
281
- "current": ">",
282
- "synced": "=",
283
- "ahead": "^",
284
- "behind": "v",
285
- "diverged": "x",
286
- "gone": "!",
287
- "local": ".",
288
- "rule": "-",
289
- "ellipsis": "..",
290
- "notice": "!",
291
- }
292
-
293
409
  terminal_width = max(60, min(shutil.get_terminal_size((100, 24)).columns, 160))
294
410
 
411
+ fetch_failed = False
295
412
  if not no_fetch:
296
- if sys.stdout.isatty():
413
+ if interactive:
297
414
  print(dim("fetching..."), end="", flush=True)
298
- command_output("git", "fetch", "--all", "--prune", "--quiet")
299
- if sys.stdout.isatty():
415
+ fetch_failed = command_output("git", "fetch", "--all", "--prune", "--quiet") is None
416
+ if interactive:
300
417
  print("\r \r", end="", flush=True)
301
418
 
302
419
  default_branch = command_output("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
303
420
  default_branch = default_branch.removeprefix("origin/") if default_branch else "main"
304
421
 
422
+ pr_rows, pr_reason = github_pull_requests()
305
423
  pull_requests: dict[str, PullRequest] = {}
306
- pr_json = command_output(
307
- "gh",
308
- "pr",
309
- "list",
310
- "--state",
311
- "all",
312
- "--limit",
313
- "100",
314
- "--json",
315
- "headRefName,number,state,isDraft,reviewDecision,url",
316
- )
317
- if pr_json is not None:
318
- try:
319
- pr_rows = json.loads(pr_json)
320
- except (json.JSONDecodeError, TypeError):
321
- pr_json = None
322
- else:
323
- for pr in pr_rows:
324
- name = pr.get("headRefName")
325
- if not name or name == default_branch or name in pull_requests:
326
- continue
327
- state = pull_request_status(pr)
328
- colour = (
329
- green
330
- if state in {"approved", "merged"}
331
- else red
332
- if state == "changes requested"
333
- else yellow
334
- if state == "draft"
335
- else dim
336
- if state == "closed"
337
- else blue
338
- )
339
- number = pr.get("number")
340
- pull_requests[name] = PullRequest(
341
- f"#{number} {state}",
342
- colour,
343
- number if isinstance(number, int) else None,
344
- str(pr.get("url", "")),
345
- )
424
+ pr_ranks: dict[str, tuple[bool, int]] = {}
425
+ usable_rows = 0
426
+ for row in pr_rows:
427
+ name = row.get("headRefName")
428
+ number = row.get("number")
429
+ # bool is a subclass of int, so the number's type is compared exactly.
430
+ if not isinstance(name, str) or not name or type(number) is not int or number <= 0:
431
+ continue
432
+ usable_rows += 1
433
+ if name == default_branch:
434
+ continue
435
+ state = pull_request_status(row)
436
+ rank = (state in ACTIVE_PR_STATES, number)
437
+ if name in pr_ranks and pr_ranks[name] >= rank:
438
+ continue
439
+ colour = (
440
+ green
441
+ if state in {"approved", "merged"}
442
+ else red
443
+ if state == "changes requested"
444
+ else yellow
445
+ if state == "draft"
446
+ else dim
447
+ if state == "closed"
448
+ else blue
449
+ )
450
+ pr_ranks[name] = rank
451
+ pull_requests[name] = PullRequest(
452
+ f"#{number} {state}",
453
+ colour,
454
+ number,
455
+ str(row.get("url", "")),
456
+ )
457
+ if pr_rows and not usable_rows:
458
+ pr_reason = "unusable"
346
459
 
347
460
  current = command_output("git", "branch", "--show-current") or ""
348
461
  dirty = git_output("status", "--short", "--untracked-files=all")
@@ -450,7 +563,7 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
450
563
  return max([len(heading), *(len(value) for value in values)]) + 2 + extra
451
564
 
452
565
  branch_heading = f"BRANCH ({len(branches)} local)"
453
- show_pr = pr_json is not None
566
+ show_pr = pr_reason is None
454
567
  show_worktree = any(branch.worktree for branch in branches)
455
568
  pr_width = (
456
569
  max([len("PULL REQUEST"), *(len(branch.pull_request.text) for branch in branches)])
@@ -509,8 +622,10 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
509
622
 
510
623
  print()
511
624
  print(bold(repo_name) + dim(" :: ") + bold(current or "detached HEAD"))
512
- joiner = " · " if use_symbols else " | "
513
- worktree = yellow(f"dirty: {joiner.join(worktree_parts)}") if dirty_lines else green("clean")
625
+ separator = glyph["separator"]
626
+ worktree = (
627
+ yellow(f"dirty: {f' {separator} '.join(worktree_parts)}") if dirty_lines else green("clean")
628
+ )
514
629
  print(dim("worktree, ") + worktree)
515
630
  print(dim(glyph["rule"] * total_width))
516
631
  heading = (
@@ -520,7 +635,10 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
520
635
  )
521
636
  if show_worktree:
522
637
  heading += pad("WORKTREE", columns["worktree"])
523
- heading += pad("UPDATED", columns["updated"]) + "PULL REQUEST" if show_pr else "UPDATED"
638
+ if show_pr:
639
+ heading += pad("UPDATED", columns["updated"]) + "PULL REQUEST"
640
+ else:
641
+ heading += "UPDATED"
524
642
  print(dim(heading))
525
643
 
526
644
  for branch in branches:
@@ -528,36 +646,38 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
528
646
  name = fit(branch.name, columns["name"] - 3, glyph["ellipsis"])
529
647
  name_padding = " " * max(0, columns["name"] - len(marker) - len(name))
530
648
  updated = compact_age(branch.updated) if compact_dates else branch.updated
531
- row = marker + paint_branch(name, branch.is_current) + name_padding
532
- row += dim(
649
+ row_text = marker + paint_branch(name, branch.is_current) + name_padding
650
+ row_text += dim(
533
651
  pad(
534
652
  fit(branch.remote, columns["remote"] - 2, glyph["ellipsis"]),
535
653
  columns["remote"],
536
654
  )
537
655
  )
538
- row += branch.state_colour(
656
+ row_text += branch.state_colour(
539
657
  pad(f"{branch.state_mark} {branch.state_text}", columns["state"])
540
658
  )
541
659
  if show_worktree:
542
660
  worktree_name = branch.worktree or "-"
543
- row += dim(
661
+ row_text += dim(
544
662
  pad(
545
663
  fit(worktree_name, columns["worktree"] - 2, glyph["ellipsis"]),
546
664
  columns["worktree"],
547
665
  )
548
666
  )
549
667
  if show_pr:
550
- row += dim(pad(updated, columns["updated"]))
668
+ row_text += dim(pad(updated, columns["updated"]))
551
669
  pr = branch.pull_request
552
670
  if pr.number is None:
553
671
  rendered_pr = pr.text
554
672
  else:
555
- number = f"#{pr.number}"
556
- rendered_pr = hyperlink(number, pr.url, enabled=use_links) + pr.text[len(number) :]
557
- row += pr.colour(rendered_pr)
673
+ number_text = f"#{pr.number}"
674
+ rendered_pr = (
675
+ hyperlink(number_text, pr.url, enabled=use_links) + pr.text[len(number_text) :]
676
+ )
677
+ row_text += pr.colour(rendered_pr)
558
678
  else:
559
- row += dim(updated)
560
- print(row)
679
+ row_text += dim(updated)
680
+ print(row_text)
561
681
 
562
682
  print(dim(glyph["rule"] * total_width))
563
683
  linked_worktrees = [branch for branch in branches if branch.worktree_path]
@@ -587,16 +707,19 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
587
707
  if remote_gone:
588
708
  counts.append(f"{remote_gone} remote gone")
589
709
  if counts:
590
- print(dim(" · ".join(counts)))
710
+ print(dim(f" {separator} ".join(counts)))
591
711
 
592
- if not show_pr:
712
+ if fetch_failed:
593
713
  print()
594
- notice = (
595
- f"{glyph['notice']} Pull request state unknown - "
596
- "the GitHub CLI is missing or not logged in."
597
- )
598
- print(yellow(notice))
599
- print(yellow(" Install it from https://cli.github.com, then run: gh auth login"))
714
+ print(yellow(f"{glyph['notice']} Fetch failed - branch states may be out of date."))
715
+ print(yellow(" Check network access and remote configuration, or use --no-fetch."))
716
+
717
+ if pr_reason is not None:
718
+ notice_lines = PR_NOTICES[pr_reason]
719
+ print()
720
+ print(yellow(f"{glyph['notice']} {notice_lines[0]}"))
721
+ for extra_line in notice_lines[1:]:
722
+ print(yellow(extra_line))
600
723
  print()
601
724
  return 0
602
725
 
File without changes