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.
- {git_muster-0.3.1 → git_muster-0.4.0}/PKG-INFO +31 -9
- {git_muster-0.3.1 → git_muster-0.4.0}/README.md +29 -8
- {git_muster-0.3.1 → git_muster-0.4.0}/pyproject.toml +3 -1
- {git_muster-0.3.1 → git_muster-0.4.0}/pyproject.toml.orig +4 -1
- {git_muster-0.3.1 → git_muster-0.4.0}/src/git_muster/__main__.py +1 -1
- {git_muster-0.3.1 → git_muster-0.4.0}/src/git_muster/cli.py +235 -112
- {git_muster-0.3.1 → git_muster-0.4.0}/LICENSE +0 -0
- {git_muster-0.3.1 → git_muster-0.4.0}/src/git_muster/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-muster
|
|
3
|
-
Version: 0.
|
|
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
|
[](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/git-muster/)
|
|
27
29
|
[](https://www.python.org/)
|
|
28
|
-
[](LICENSE)
|
|
30
|
+
[](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
|
|
72
|
+
Install the latest release from PyPI:
|
|
71
73
|
|
|
72
74
|
```console
|
|
73
|
-
uv tool install 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
|
|
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
|
[](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/git-muster/)
|
|
4
5
|
[](https://www.python.org/)
|
|
5
|
-
[](LICENSE)
|
|
6
|
+
[](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
|
|
48
|
+
Install the latest release from PyPI:
|
|
48
49
|
|
|
49
50
|
```console
|
|
50
|
-
uv tool install 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
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
94
|
-
"""Run a command
|
|
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
|
-
|
|
148
|
+
completed = subprocess.run(
|
|
97
149
|
[command, *args],
|
|
98
150
|
stdin=subprocess.DEVNULL,
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
if
|
|
115
|
-
raise RuntimeError(f"git {' '.join(args)} failed")
|
|
116
|
-
|
|
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
|
|
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 <=
|
|
225
|
+
if limit <= len(ellipsis):
|
|
132
226
|
return value[:limit]
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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 =
|
|
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
|
|
413
|
+
if interactive:
|
|
297
414
|
print(dim("fetching..."), end="", flush=True)
|
|
298
|
-
command_output("git", "fetch", "--all", "--prune", "--quiet")
|
|
299
|
-
if
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
"
|
|
310
|
-
"
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 =
|
|
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
|
-
|
|
513
|
-
worktree =
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
rendered_pr =
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
print(
|
|
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("
|
|
710
|
+
print(dim(f" {separator} ".join(counts)))
|
|
591
711
|
|
|
592
|
-
if
|
|
712
|
+
if fetch_failed:
|
|
593
713
|
print()
|
|
594
|
-
notice
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
print(
|
|
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
|
|
File without changes
|