git-muster 0.3.1__tar.gz → 0.4.1__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.1
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
@@ -13,19 +13,21 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Version Control :: Git
16
- Requires-Dist: rich>=14,<15
16
+ Requires-Dist: rich>=14,<16
17
17
  Requires-Dist: typer>=0.27,<0.28
18
18
  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
 
@@ -55,24 +57,26 @@ BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUES
55
57
  - Conditional linked-worktree names and checkout paths.
56
58
  - Draft, open, approved, changes-requested, merged, and closed GitHub pull requests.
57
59
  - Clickable, underlined PR numbers in supported interactive terminals.
58
- - Responsive terminal layout plus stable ASCII output for logs and pipes.
60
+ - Responsive terminal layout plus stable plain output with ASCII interface glyphs for logs and pipes.
59
61
  - Read-only operation with `--no-fetch`; no branch switching, deletion, merging, rebasing, or pushing.
60
62
 
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 uses ASCII interface glyphs without color or terminal hyperlinks. Repository-provided text, including branch, remote, and worktree names, is preserved and may contain Unicode. Git Muster also falls back to the ASCII glyph set on its own whenever the destination stream cannot encode its symbols, so its built-in decorations never cause an encoding failure.
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
 
@@ -32,24 +33,26 @@ BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUES
32
33
  - Conditional linked-worktree names and checkout paths.
33
34
  - Draft, open, approved, changes-requested, merged, and closed GitHub pull requests.
34
35
  - Clickable, underlined PR numbers in supported interactive terminals.
35
- - Responsive terminal layout plus stable ASCII output for logs and pipes.
36
+ - Responsive terminal layout plus stable plain output with ASCII interface glyphs for logs and pipes.
36
37
  - Read-only operation with `--no-fetch`; no branch switching, deletion, merging, rebasing, or pushing.
37
38
 
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 uses ASCII interface glyphs without color or terminal hyperlinks. Repository-provided text, including branch, remote, and worktree names, is preserved and may contain Unicode. Git Muster also falls back to the ASCII glyph set on its own whenever the destination stream cannot encode its symbols, so its built-in decorations never cause an encoding failure.
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.1"
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"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Topic :: Software Development :: Version Control :: Git",
28
28
  ]
29
29
  dependencies = [
30
- "rich>=14,<15",
30
+ "rich>=14,<16",
31
31
  "typer>=0.27,<0.28",
32
32
  ]
33
33
 
@@ -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.1"
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"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.14",
22
22
  "Topic :: Software Development :: Version Control :: Git",
23
23
  ]
24
- dependencies = ["rich>=14,<15", "typer>=0.27,<0.28"]
24
+ dependencies = ["rich>=14,<16", "typer>=0.27,<0.28"]
25
25
 
26
26
  [project.scripts]
27
27
  git-muster = "git_muster.cli:entrypoint"
@@ -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()
@@ -38,7 +38,7 @@ EPILOG = """
38
38
  Use the remote-tracking data already on disk.
39
39
 
40
40
  [cyan]git muster --plain[/cyan]
41
- Emit stable ASCII output without color for logs and pipes.
41
+ Emit stable output with ASCII decorations and no color for logs and pipes.
42
42
 
43
43
  [bold]Linked worktrees[/bold]
44
44
 
@@ -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,42 @@ 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
+ rows = [row for row in parsed if isinstance(row, dict)]
319
+ if parsed and not rows:
320
+ return [], "unusable"
321
+ return rows, None
322
+
323
+
191
324
  def parse_track(track: str) -> tuple[int, int] | None:
192
325
  """Return ahead/behind counts from Git's tracking decoration."""
193
326
  if "gone" in track:
@@ -244,14 +377,27 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
244
377
  print("git muster: current directory is not inside a Git repository", file=sys.stderr)
245
378
  return 2
246
379
 
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()
380
+ interactive = sys.stdout.isatty()
381
+ ansi_ready = enable_ansi_escapes()
382
+ forced_colour = bool(os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"))
383
+ use_colour = (
384
+ not plain
385
+ and ansi_ready
386
+ and not os.environ.get("NO_COLOR")
387
+ and (interactive or forced_colour)
388
+ )
389
+ use_links = not plain and ansi_ready and interactive
249
390
  modern_terminal = sys.platform != "win32" or bool(
250
391
  os.environ.get("WT_SESSION")
251
392
  or os.environ.get("TERM_PROGRAM")
252
393
  or os.environ.get("TERMINAL_EMULATOR")
253
394
  )
254
- use_symbols = not plain and modern_terminal
395
+ use_symbols = (
396
+ not plain
397
+ and modern_terminal
398
+ and stream_supports("".join(SYMBOL_GLYPHS.values()), sys.stdout)
399
+ )
400
+ glyph = SYMBOL_GLYPHS if use_symbols else ASCII_GLYPHS
255
401
 
256
402
  def paint(code: str) -> Callable[[str], str]:
257
403
  return lambda text: f"\033[{code}m{text}\033[0m" if use_colour else text
@@ -263,86 +409,56 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
263
409
  yellow = paint("33")
264
410
  blue = paint("34")
265
411
 
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
412
  terminal_width = max(60, min(shutil.get_terminal_size((100, 24)).columns, 160))
294
413
 
414
+ fetch_failed = False
295
415
  if not no_fetch:
296
- if sys.stdout.isatty():
416
+ if interactive:
297
417
  print(dim("fetching..."), end="", flush=True)
298
- command_output("git", "fetch", "--all", "--prune", "--quiet")
299
- if sys.stdout.isatty():
418
+ fetch_failed = command_output("git", "fetch", "--all", "--prune", "--quiet") is None
419
+ if interactive:
300
420
  print("\r \r", end="", flush=True)
301
421
 
302
422
  default_branch = command_output("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
303
423
  default_branch = default_branch.removeprefix("origin/") if default_branch else "main"
304
424
 
425
+ pr_rows, pr_reason = github_pull_requests()
305
426
  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
- )
427
+ pr_ranks: dict[str, tuple[bool, int]] = {}
428
+ usable_rows = 0
429
+ for row in pr_rows:
430
+ name = row.get("headRefName")
431
+ number = row.get("number")
432
+ # bool is a subclass of int, so the number's type is compared exactly.
433
+ if not isinstance(name, str) or not name or type(number) is not int or number <= 0:
434
+ continue
435
+ usable_rows += 1
436
+ if name == default_branch:
437
+ continue
438
+ state = pull_request_status(row)
439
+ rank = (state in ACTIVE_PR_STATES, number)
440
+ if name in pr_ranks and pr_ranks[name] >= rank:
441
+ continue
442
+ colour = (
443
+ green
444
+ if state in {"approved", "merged"}
445
+ else red
446
+ if state == "changes requested"
447
+ else yellow
448
+ if state == "draft"
449
+ else dim
450
+ if state == "closed"
451
+ else blue
452
+ )
453
+ pr_ranks[name] = rank
454
+ pull_requests[name] = PullRequest(
455
+ f"#{number} {state}",
456
+ colour,
457
+ number,
458
+ str(row.get("url", "")),
459
+ )
460
+ if pr_rows and not usable_rows:
461
+ pr_reason = "unusable"
346
462
 
347
463
  current = command_output("git", "branch", "--show-current") or ""
348
464
  dirty = git_output("status", "--short", "--untracked-files=all")
@@ -450,7 +566,7 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
450
566
  return max([len(heading), *(len(value) for value in values)]) + 2 + extra
451
567
 
452
568
  branch_heading = f"BRANCH ({len(branches)} local)"
453
- show_pr = pr_json is not None
569
+ show_pr = pr_reason is None
454
570
  show_worktree = any(branch.worktree for branch in branches)
455
571
  pr_width = (
456
572
  max([len("PULL REQUEST"), *(len(branch.pull_request.text) for branch in branches)])
@@ -509,8 +625,10 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
509
625
 
510
626
  print()
511
627
  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")
628
+ separator = glyph["separator"]
629
+ worktree = (
630
+ yellow(f"dirty: {f' {separator} '.join(worktree_parts)}") if dirty_lines else green("clean")
631
+ )
514
632
  print(dim("worktree, ") + worktree)
515
633
  print(dim(glyph["rule"] * total_width))
516
634
  heading = (
@@ -520,7 +638,10 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
520
638
  )
521
639
  if show_worktree:
522
640
  heading += pad("WORKTREE", columns["worktree"])
523
- heading += pad("UPDATED", columns["updated"]) + "PULL REQUEST" if show_pr else "UPDATED"
641
+ if show_pr:
642
+ heading += pad("UPDATED", columns["updated"]) + "PULL REQUEST"
643
+ else:
644
+ heading += "UPDATED"
524
645
  print(dim(heading))
525
646
 
526
647
  for branch in branches:
@@ -528,36 +649,38 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
528
649
  name = fit(branch.name, columns["name"] - 3, glyph["ellipsis"])
529
650
  name_padding = " " * max(0, columns["name"] - len(marker) - len(name))
530
651
  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(
652
+ row_text = marker + paint_branch(name, branch.is_current) + name_padding
653
+ row_text += dim(
533
654
  pad(
534
655
  fit(branch.remote, columns["remote"] - 2, glyph["ellipsis"]),
535
656
  columns["remote"],
536
657
  )
537
658
  )
538
- row += branch.state_colour(
659
+ row_text += branch.state_colour(
539
660
  pad(f"{branch.state_mark} {branch.state_text}", columns["state"])
540
661
  )
541
662
  if show_worktree:
542
663
  worktree_name = branch.worktree or "-"
543
- row += dim(
664
+ row_text += dim(
544
665
  pad(
545
666
  fit(worktree_name, columns["worktree"] - 2, glyph["ellipsis"]),
546
667
  columns["worktree"],
547
668
  )
548
669
  )
549
670
  if show_pr:
550
- row += dim(pad(updated, columns["updated"]))
671
+ row_text += dim(pad(updated, columns["updated"]))
551
672
  pr = branch.pull_request
552
673
  if pr.number is None:
553
674
  rendered_pr = pr.text
554
675
  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)
676
+ number_text = f"#{pr.number}"
677
+ rendered_pr = (
678
+ hyperlink(number_text, pr.url, enabled=use_links) + pr.text[len(number_text) :]
679
+ )
680
+ row_text += pr.colour(rendered_pr)
558
681
  else:
559
- row += dim(updated)
560
- print(row)
682
+ row_text += dim(updated)
683
+ print(row_text)
561
684
 
562
685
  print(dim(glyph["rule"] * total_width))
563
686
  linked_worktrees = [branch for branch in branches if branch.worktree_path]
@@ -587,16 +710,19 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
587
710
  if remote_gone:
588
711
  counts.append(f"{remote_gone} remote gone")
589
712
  if counts:
590
- print(dim(" · ".join(counts)))
713
+ print(dim(f" {separator} ".join(counts)))
591
714
 
592
- if not show_pr:
715
+ if fetch_failed:
593
716
  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"))
717
+ print(yellow(f"{glyph['notice']} Fetch failed - branch states may be out of date."))
718
+ print(yellow(" Check network access and remote configuration, or use --no-fetch."))
719
+
720
+ if pr_reason is not None:
721
+ notice_lines = PR_NOTICES[pr_reason]
722
+ print()
723
+ print(yellow(f"{glyph['notice']} {notice_lines[0]}"))
724
+ for extra_line in notice_lines[1:]:
725
+ print(yellow(extra_line))
600
726
  print()
601
727
  return 0
602
728
 
@@ -624,7 +750,10 @@ def main(
624
750
  bool,
625
751
  typer.Option(
626
752
  "--plain",
627
- help="Disable color and Unicode symbols for stable logs and pipes.",
753
+ help=(
754
+ "Disable color, hyperlinks, and Unicode interface symbols for stable logs and "
755
+ "pipes. Repository-provided text is preserved."
756
+ ),
628
757
  rich_help_panel="Report options",
629
758
  ),
630
759
  ] = False,
File without changes