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.
- {git_muster-0.3.1 → git_muster-0.4.1}/PKG-INFO +33 -11
- {git_muster-0.3.1 → git_muster-0.4.1}/README.md +30 -9
- {git_muster-0.3.1 → git_muster-0.4.1}/pyproject.toml +4 -2
- {git_muster-0.3.1 → git_muster-0.4.1}/pyproject.toml.orig +5 -2
- {git_muster-0.3.1 → git_muster-0.4.1}/src/git_muster/__main__.py +1 -1
- {git_muster-0.3.1 → git_muster-0.4.1}/src/git_muster/cli.py +243 -114
- {git_muster-0.3.1 → git_muster-0.4.1}/LICENSE +0 -0
- {git_muster-0.3.1 → git_muster-0.4.1}/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.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,<
|
|
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
|
[](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
|
|
|
@@ -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
|
|
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
|
|
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 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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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 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
|
|
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.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,<
|
|
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.
|
|
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,<
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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,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
|
-
|
|
248
|
-
|
|
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 =
|
|
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
|
|
416
|
+
if interactive:
|
|
297
417
|
print(dim("fetching..."), end="", flush=True)
|
|
298
|
-
command_output("git", "fetch", "--all", "--prune", "--quiet")
|
|
299
|
-
if
|
|
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
|
-
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
|
|
513
|
-
worktree =
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
rendered_pr =
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
print(
|
|
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("
|
|
713
|
+
print(dim(f" {separator} ".join(counts)))
|
|
591
714
|
|
|
592
|
-
if
|
|
715
|
+
if fetch_failed:
|
|
593
716
|
print()
|
|
594
|
-
notice
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
print(
|
|
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=
|
|
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
|
|
File without changes
|