git-muster 0.3.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/LICENSE +21 -0
- git_muster-0.3.1/PKG-INFO +169 -0
- git_muster-0.3.1/README.md +146 -0
- git_muster-0.3.1/pyproject.toml +84 -0
- git_muster-0.3.1/pyproject.toml.orig +61 -0
- git_muster-0.3.1/src/git_muster/__init__.py +10 -0
- git_muster-0.3.1/src/git_muster/__main__.py +6 -0
- git_muster-0.3.1/src/git_muster/cli.py +654 -0
git_muster-0.3.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan O'Leary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-muster
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: See every local Git branch and what needs attention
|
|
5
|
+
Keywords: branch,cli,git,github,worktree
|
|
6
|
+
Author: Dan O'Leary
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
16
|
+
Requires-Dist: rich>=14,<15
|
|
17
|
+
Requires-Dist: typer>=0.27,<0.28
|
|
18
|
+
Requires-Python: >=3.13, <3.15
|
|
19
|
+
Project-URL: Homepage, https://github.com/olearydj/git-muster
|
|
20
|
+
Project-URL: Repository, https://github.com/olearydj/git-muster
|
|
21
|
+
Project-URL: Issues, https://github.com/olearydj/git-muster/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# git-muster
|
|
25
|
+
|
|
26
|
+
[](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
|
|
27
|
+
[](https://www.python.org/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
Every branch, present and accounted for.
|
|
31
|
+
|
|
32
|
+
Git Muster is a compact command-line report for local Git branches. It separates worktree dirtiness from committed branch state, identifies what is published and what needs attention, shows linked worktrees, and adds pull-request status when the GitHub CLI is available.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
project :: feature/report
|
|
36
|
+
worktree, clean
|
|
37
|
+
--------------------------------------------------------------------------------
|
|
38
|
+
BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUEST
|
|
39
|
+
main origin ↓ behind 2 main-release 3h -
|
|
40
|
+
▸ feature/report origin ↑ ahead 1 - 12m #42 approved
|
|
41
|
+
fix/parser origin ✓ in sync - 2d #39 merged
|
|
42
|
+
experiment - · local only - 1w -
|
|
43
|
+
--------------------------------------------------------------------------------
|
|
44
|
+
1 other linked worktree:
|
|
45
|
+
main → /worktrees/project/main-release
|
|
46
|
+
✓ working tree clean
|
|
47
|
+
1 with unpushed commits · 1 behind · 1 local only
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- One scannable report covering every local branch.
|
|
53
|
+
- Publication state derived independently from the configured upstream.
|
|
54
|
+
- Ahead, behind, diverged, local-only, and remote-gone states.
|
|
55
|
+
- Conditional linked-worktree names and checkout paths.
|
|
56
|
+
- Draft, open, approved, changes-requested, merged, and closed GitHub pull requests.
|
|
57
|
+
- Clickable, underlined PR numbers in supported interactive terminals.
|
|
58
|
+
- Responsive terminal layout plus stable ASCII output for logs and pipes.
|
|
59
|
+
- Read-only operation with `--no-fetch`; no branch switching, deletion, merging, rebasing, or pushing.
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Python 3.13 or 3.14
|
|
64
|
+
- Git
|
|
65
|
+
- [uv](https://docs.astral.sh/uv/) for the recommended installation
|
|
66
|
+
- Optional: an authenticated [GitHub CLI](https://cli.github.com/) for pull-request information
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
Install the latest version directly from GitHub:
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
uv tool install git+https://github.com/olearydj/git-muster.git
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The installed commands are equivalent:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
git-muster
|
|
80
|
+
gm
|
|
81
|
+
git muster
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For development from a clone:
|
|
85
|
+
|
|
86
|
+
```console
|
|
87
|
+
git clone https://github.com/olearydj/git-muster.git
|
|
88
|
+
cd git-muster
|
|
89
|
+
uv sync --locked --all-groups
|
|
90
|
+
uv run git-muster --help
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
Run the report from anywhere inside a Git repository:
|
|
96
|
+
|
|
97
|
+
```console
|
|
98
|
+
git muster
|
|
99
|
+
```
|
|
100
|
+
|
|
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:
|
|
102
|
+
|
|
103
|
+
```console
|
|
104
|
+
git muster --no-fetch
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use ASCII without color or terminal hyperlinks for logs and pipes:
|
|
108
|
+
|
|
109
|
+
```console
|
|
110
|
+
git muster --plain
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The built-in help includes examples, effects, linked-worktree behavior, and a Rich branch-state reference:
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
git muster --help
|
|
117
|
+
git muster --version
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Reading the report
|
|
121
|
+
|
|
122
|
+
| Column | Meaning |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `BRANCH` | Local branch name; the leading marker identifies the current branch. |
|
|
125
|
+
| `REMOTE` | Push remote or matching remote branch; `-` means no current publication relationship. |
|
|
126
|
+
| `STATE` | Ahead/behind relationship between the local branch and its publication branch. |
|
|
127
|
+
| `WORKTREE` | Directory holding the branch when it is checked out elsewhere; omitted when none exist. |
|
|
128
|
+
| `UPDATED` | Relative date of the branch tip. |
|
|
129
|
+
| `PULL REQUEST` | Optional GitHub PR number and normalized state. |
|
|
130
|
+
|
|
131
|
+
Publication is deliberately separate from Git's configured upstream. A branch is recognized as published when it has a push destination or matching remote branch, even if it has no upstream or tracks a local parent branch. Git Muster retains both relationships internally but reports publication state by default.
|
|
132
|
+
|
|
133
|
+
| State | Meaning |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `in sync` | Local and remote branch tips agree. |
|
|
136
|
+
| `ahead N` | Local commits have not been pushed. |
|
|
137
|
+
| `behind N` | Remote commits are missing locally. |
|
|
138
|
+
| `ahead N, behind M` | Local and remote histories have diverged. |
|
|
139
|
+
| `remote gone` | A configured same-name push or upstream branch no longer exists remotely. |
|
|
140
|
+
| `local only` | No push destination or matching remote branch currently exists. This does not claim the branch was never published. |
|
|
141
|
+
|
|
142
|
+
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
|
+
|
|
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.
|
|
145
|
+
|
|
146
|
+
## Safety and scope
|
|
147
|
+
|
|
148
|
+
Git Muster reports; it does not manage branches or worktrees. It never switches, deletes, merges, rebases, or pushes branches. Its only default repository mutation is refreshing remote-tracking references with `git fetch --all --prune`; `--no-fetch` makes the entire run read-only.
|
|
149
|
+
|
|
150
|
+
Git Muster intentionally has no configuration file, interactive browser, branch tree, cleanup command, or plugin system.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
Runtime dependencies are limited to Typer and Rich. Repository inspection uses the standard library and Git CLI, while tests create disposable local repositories and stub GitHub responses.
|
|
155
|
+
|
|
156
|
+
```console
|
|
157
|
+
uv sync --locked --all-groups
|
|
158
|
+
uv run --locked pytest
|
|
159
|
+
uv run --locked ruff format --check .
|
|
160
|
+
uv run --locked ruff check .
|
|
161
|
+
uv run --locked ty check
|
|
162
|
+
uv build
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
Git Muster is available under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# git-muster
|
|
2
|
+
|
|
3
|
+
[](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Every branch, present and accounted for.
|
|
8
|
+
|
|
9
|
+
Git Muster is a compact command-line report for local Git branches. It separates worktree dirtiness from committed branch state, identifies what is published and what needs attention, shows linked worktrees, and adds pull-request status when the GitHub CLI is available.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
project :: feature/report
|
|
13
|
+
worktree, clean
|
|
14
|
+
--------------------------------------------------------------------------------
|
|
15
|
+
BRANCH (4 local) REMOTE STATE WORKTREE UPDATED PULL REQUEST
|
|
16
|
+
main origin ↓ behind 2 main-release 3h -
|
|
17
|
+
▸ feature/report origin ↑ ahead 1 - 12m #42 approved
|
|
18
|
+
fix/parser origin ✓ in sync - 2d #39 merged
|
|
19
|
+
experiment - · local only - 1w -
|
|
20
|
+
--------------------------------------------------------------------------------
|
|
21
|
+
1 other linked worktree:
|
|
22
|
+
main → /worktrees/project/main-release
|
|
23
|
+
✓ working tree clean
|
|
24
|
+
1 with unpushed commits · 1 behind · 1 local only
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- One scannable report covering every local branch.
|
|
30
|
+
- Publication state derived independently from the configured upstream.
|
|
31
|
+
- Ahead, behind, diverged, local-only, and remote-gone states.
|
|
32
|
+
- Conditional linked-worktree names and checkout paths.
|
|
33
|
+
- Draft, open, approved, changes-requested, merged, and closed GitHub pull requests.
|
|
34
|
+
- Clickable, underlined PR numbers in supported interactive terminals.
|
|
35
|
+
- Responsive terminal layout plus stable ASCII output for logs and pipes.
|
|
36
|
+
- Read-only operation with `--no-fetch`; no branch switching, deletion, merging, rebasing, or pushing.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python 3.13 or 3.14
|
|
41
|
+
- Git
|
|
42
|
+
- [uv](https://docs.astral.sh/uv/) for the recommended installation
|
|
43
|
+
- Optional: an authenticated [GitHub CLI](https://cli.github.com/) for pull-request information
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Install the latest version directly from GitHub:
|
|
48
|
+
|
|
49
|
+
```console
|
|
50
|
+
uv tool install git+https://github.com/olearydj/git-muster.git
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The installed commands are equivalent:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
git-muster
|
|
57
|
+
gm
|
|
58
|
+
git muster
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For development from a clone:
|
|
62
|
+
|
|
63
|
+
```console
|
|
64
|
+
git clone https://github.com/olearydj/git-muster.git
|
|
65
|
+
cd git-muster
|
|
66
|
+
uv sync --locked --all-groups
|
|
67
|
+
uv run git-muster --help
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
Run the report from anywhere inside a Git repository:
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
git muster
|
|
76
|
+
```
|
|
77
|
+
|
|
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:
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
git muster --no-fetch
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use ASCII without color or terminal hyperlinks for logs and pipes:
|
|
85
|
+
|
|
86
|
+
```console
|
|
87
|
+
git muster --plain
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The built-in help includes examples, effects, linked-worktree behavior, and a Rich branch-state reference:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
git muster --help
|
|
94
|
+
git muster --version
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Reading the report
|
|
98
|
+
|
|
99
|
+
| Column | Meaning |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `BRANCH` | Local branch name; the leading marker identifies the current branch. |
|
|
102
|
+
| `REMOTE` | Push remote or matching remote branch; `-` means no current publication relationship. |
|
|
103
|
+
| `STATE` | Ahead/behind relationship between the local branch and its publication branch. |
|
|
104
|
+
| `WORKTREE` | Directory holding the branch when it is checked out elsewhere; omitted when none exist. |
|
|
105
|
+
| `UPDATED` | Relative date of the branch tip. |
|
|
106
|
+
| `PULL REQUEST` | Optional GitHub PR number and normalized state. |
|
|
107
|
+
|
|
108
|
+
Publication is deliberately separate from Git's configured upstream. A branch is recognized as published when it has a push destination or matching remote branch, even if it has no upstream or tracks a local parent branch. Git Muster retains both relationships internally but reports publication state by default.
|
|
109
|
+
|
|
110
|
+
| State | Meaning |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `in sync` | Local and remote branch tips agree. |
|
|
113
|
+
| `ahead N` | Local commits have not been pushed. |
|
|
114
|
+
| `behind N` | Remote commits are missing locally. |
|
|
115
|
+
| `ahead N, behind M` | Local and remote histories have diverged. |
|
|
116
|
+
| `remote gone` | A configured same-name push or upstream branch no longer exists remotely. |
|
|
117
|
+
| `local only` | No push destination or matching remote branch currently exists. This does not claim the branch was never published. |
|
|
118
|
+
|
|
119
|
+
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
|
+
|
|
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.
|
|
122
|
+
|
|
123
|
+
## Safety and scope
|
|
124
|
+
|
|
125
|
+
Git Muster reports; it does not manage branches or worktrees. It never switches, deletes, merges, rebases, or pushes branches. Its only default repository mutation is refreshing remote-tracking references with `git fetch --all --prune`; `--no-fetch` makes the entire run read-only.
|
|
126
|
+
|
|
127
|
+
Git Muster intentionally has no configuration file, interactive browser, branch tree, cleanup command, or plugin system.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
Runtime dependencies are limited to Typer and Rich. Repository inspection uses the standard library and Git CLI, while tests create disposable local repositories and stub GitHub responses.
|
|
132
|
+
|
|
133
|
+
```console
|
|
134
|
+
uv sync --locked --all-groups
|
|
135
|
+
uv run --locked pytest
|
|
136
|
+
uv run --locked ruff format --check .
|
|
137
|
+
uv run --locked ruff check .
|
|
138
|
+
uv run --locked ty check
|
|
139
|
+
uv build
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. CI runs the same checks on Python 3.13 and 3.14.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
Git Muster is available under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.1,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "git-muster"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "See every local Git branch and what needs attention"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13,<3.15"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = [
|
|
14
|
+
"branch",
|
|
15
|
+
"cli",
|
|
16
|
+
"git",
|
|
17
|
+
"github",
|
|
18
|
+
"worktree",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"rich>=14,<15",
|
|
31
|
+
"typer>=0.27,<0.28",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "Dan O'Leary"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
git-muster = "git_muster.cli:entrypoint"
|
|
39
|
+
gm = "git_muster.cli:entrypoint"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/olearydj/git-muster"
|
|
43
|
+
Repository = "https://github.com/olearydj/git-muster"
|
|
44
|
+
Issues = "https://github.com/olearydj/git-muster/issues"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=8.4,<10",
|
|
49
|
+
"pytest-cov>=6,<8",
|
|
50
|
+
"ruff>=0.12,<1",
|
|
51
|
+
"ty>=0.0.1a20",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.uv]
|
|
55
|
+
exclude-newer = "3 days"
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
addopts = "-ra --strict-markers"
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
target-version = "py313"
|
|
63
|
+
line-length = 100
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = [
|
|
67
|
+
"E",
|
|
68
|
+
"F",
|
|
69
|
+
"I",
|
|
70
|
+
"UP",
|
|
71
|
+
"B",
|
|
72
|
+
"SIM",
|
|
73
|
+
"RUF",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.coverage.run]
|
|
77
|
+
branch = true
|
|
78
|
+
source = ["git_muster"]
|
|
79
|
+
|
|
80
|
+
[tool.coverage.report]
|
|
81
|
+
show_missing = true
|
|
82
|
+
|
|
83
|
+
[tool.ty.src]
|
|
84
|
+
include = ["src"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.1,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "git-muster"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "See every local Git branch and what needs attention"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13,<3.15"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Dan O'Leary" }]
|
|
14
|
+
keywords = ["branch", "cli", "git", "github", "worktree"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["rich>=14,<15", "typer>=0.27,<0.28"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
git-muster = "git_muster.cli:entrypoint"
|
|
28
|
+
gm = "git_muster.cli:entrypoint"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/olearydj/git-muster"
|
|
32
|
+
Repository = "https://github.com/olearydj/git-muster"
|
|
33
|
+
Issues = "https://github.com/olearydj/git-muster/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8.4,<10", "pytest-cov>=6,<8", "ruff>=0.12,<1", "ty>=0.0.1a20"]
|
|
37
|
+
|
|
38
|
+
[tool.uv]
|
|
39
|
+
# Keep the cooldown in the project because uv records it in uv.lock.
|
|
40
|
+
exclude-newer = "3 days"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
addopts = "-ra --strict-markers"
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
target-version = "py313"
|
|
48
|
+
line-length = 100
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
52
|
+
|
|
53
|
+
[tool.coverage.run]
|
|
54
|
+
branch = true
|
|
55
|
+
source = ["git_muster"]
|
|
56
|
+
|
|
57
|
+
[tool.coverage.report]
|
|
58
|
+
show_missing = true
|
|
59
|
+
|
|
60
|
+
[tool.ty.src]
|
|
61
|
+
include = ["src"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Git Muster public package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("git-muster")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - editable source without metadata
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Command-line branch status report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.padding import Padding
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from typer.core import TyperCommand
|
|
21
|
+
|
|
22
|
+
from git_muster import __version__
|
|
23
|
+
|
|
24
|
+
HELP = (
|
|
25
|
+
"Inspect every local branch in the current Git repository. Git Muster separates "
|
|
26
|
+
"uncommitted work from committed branch state, then shows which branches are "
|
|
27
|
+
"unpublished, ahead, behind, or diverged from their remote branch. When the "
|
|
28
|
+
"GitHub CLI is authenticated, pull-request state appears in the same report."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
EPILOG = """
|
|
32
|
+
[bold]Examples[/bold]
|
|
33
|
+
|
|
34
|
+
[cyan]git muster[/cyan]
|
|
35
|
+
Refresh remote-tracking refs, then report every local branch.
|
|
36
|
+
|
|
37
|
+
[cyan]git muster --no-fetch[/cyan]
|
|
38
|
+
Use the remote-tracking data already on disk.
|
|
39
|
+
|
|
40
|
+
[cyan]git muster --plain[/cyan]
|
|
41
|
+
Emit stable ASCII output without color for logs and pipes.
|
|
42
|
+
|
|
43
|
+
[bold]Linked worktrees[/bold]
|
|
44
|
+
|
|
45
|
+
The [cyan]WORKTREE[/cyan] column appears only when another checkout holds a branch. Full
|
|
46
|
+
checkout paths follow the branch table; other worktrees are not inspected or changed.
|
|
47
|
+
|
|
48
|
+
[bold]Effects[/bold]
|
|
49
|
+
|
|
50
|
+
The default run executes [cyan]git fetch --all --prune[/cyan]. It never switches, deletes,
|
|
51
|
+
merges, rebases, or pushes branches. Use [cyan]--no-fetch[/cyan] for a fully read-only run.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def branch_states_table() -> Table:
|
|
56
|
+
"""Build the branch-state reference shown in command help."""
|
|
57
|
+
table = Table(
|
|
58
|
+
title="Branch states",
|
|
59
|
+
title_justify="left",
|
|
60
|
+
title_style="bold",
|
|
61
|
+
border_style="dim",
|
|
62
|
+
)
|
|
63
|
+
table.add_column("State", no_wrap=True)
|
|
64
|
+
table.add_column("Meaning")
|
|
65
|
+
table.add_row("[green]in sync[/green]", "Local and remote branch tips agree.")
|
|
66
|
+
table.add_row("[yellow]ahead N[/yellow]", "N local commits have not been pushed.")
|
|
67
|
+
table.add_row("[blue]behind N[/blue]", "N remote commits are missing locally.")
|
|
68
|
+
table.add_row("[red]ahead N, behind M[/red]", "The histories have diverged.")
|
|
69
|
+
table.add_row("[red]remote gone[/red]", "The configured push branch no longer exists.")
|
|
70
|
+
table.add_row("[dim]local only[/dim]", "No push destination or matching remote branch exists.")
|
|
71
|
+
return table
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MusterCommand(TyperCommand):
|
|
75
|
+
"""Typer command that appends Git Muster's state reference table."""
|
|
76
|
+
|
|
77
|
+
def format_help(self, ctx: Any, formatter: Any) -> None:
|
|
78
|
+
super().format_help(ctx, formatter)
|
|
79
|
+
Console().print(Padding(branch_states_table(), (0, 1, 1, 1)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
app = typer.Typer(
|
|
83
|
+
name="git muster",
|
|
84
|
+
help=HELP,
|
|
85
|
+
epilog=EPILOG,
|
|
86
|
+
add_completion=False,
|
|
87
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
88
|
+
rich_markup_mode="rich",
|
|
89
|
+
pretty_exceptions_enable=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def command_output(command: str, *args: str) -> str | None:
|
|
94
|
+
"""Run a command quietly, returning ``None`` when it is unavailable or fails."""
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
[command, *args],
|
|
98
|
+
stdin=subprocess.DEVNULL,
|
|
99
|
+
stdout=subprocess.PIPE,
|
|
100
|
+
stderr=subprocess.DEVNULL,
|
|
101
|
+
text=True,
|
|
102
|
+
check=False,
|
|
103
|
+
)
|
|
104
|
+
except OSError:
|
|
105
|
+
return None
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
return None
|
|
108
|
+
return result.stdout.rstrip("\r\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def git_output(*args: str) -> str:
|
|
112
|
+
"""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
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def compact_age(relative: str) -> str:
|
|
120
|
+
"""Shorten a Git relative date when terminal space is constrained."""
|
|
121
|
+
match = re.match(r"^(\d+)\s+(\w)", relative)
|
|
122
|
+
if match:
|
|
123
|
+
return "".join(match.groups())
|
|
124
|
+
return relative.removesuffix(" ago")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fit(value: str, limit: int, ellipsis: str) -> str:
|
|
128
|
+
"""Middle-truncate text while retaining its recognizable ending."""
|
|
129
|
+
if len(value) <= limit:
|
|
130
|
+
return value
|
|
131
|
+
if limit <= 3:
|
|
132
|
+
return value[:limit]
|
|
133
|
+
keep_end = max(4, int((limit - 1) * 0.6))
|
|
134
|
+
keep_start = limit - 1 - keep_end
|
|
135
|
+
return value[:keep_start] + ellipsis + value[-keep_end:]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class PullRequest:
|
|
140
|
+
"""Pull-request presentation data for one branch."""
|
|
141
|
+
|
|
142
|
+
text: str
|
|
143
|
+
colour: Callable[[str], str]
|
|
144
|
+
number: int | None = None
|
|
145
|
+
url: str = ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Branch:
|
|
150
|
+
"""Collected status for one local branch."""
|
|
151
|
+
|
|
152
|
+
name: str
|
|
153
|
+
upstream: str | None
|
|
154
|
+
publication: str | None
|
|
155
|
+
remote: str
|
|
156
|
+
state_text: str
|
|
157
|
+
state_mark: str
|
|
158
|
+
state_colour: Callable[[str], str]
|
|
159
|
+
updated: str
|
|
160
|
+
pull_request: PullRequest
|
|
161
|
+
is_current: bool
|
|
162
|
+
worktree: str
|
|
163
|
+
worktree_path: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def hyperlink(text: str, url: str, *, enabled: bool) -> str:
|
|
167
|
+
"""Make text clickable with an OSC 8 link on a capable interactive terminal."""
|
|
168
|
+
if not enabled or not url:
|
|
169
|
+
return text
|
|
170
|
+
underlined = f"\033[4m{text}\033[24m"
|
|
171
|
+
return f"\033]8;;{url}\033\\{underlined}\033]8;;\033\\"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pull_request_status(row: dict[str, object]) -> str:
|
|
175
|
+
"""Normalize GitHub's PR and review fields into a compact status."""
|
|
176
|
+
if row.get("isDraft"):
|
|
177
|
+
return "draft"
|
|
178
|
+
state = str(row.get("state", "")).upper()
|
|
179
|
+
if state == "MERGED":
|
|
180
|
+
return "merged"
|
|
181
|
+
if state == "CLOSED":
|
|
182
|
+
return "closed"
|
|
183
|
+
decision = str(row.get("reviewDecision", "")).upper()
|
|
184
|
+
if decision == "APPROVED":
|
|
185
|
+
return "approved"
|
|
186
|
+
if decision == "CHANGES_REQUESTED":
|
|
187
|
+
return "changes requested"
|
|
188
|
+
return "open"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_track(track: str) -> tuple[int, int] | None:
|
|
192
|
+
"""Return ahead/behind counts from Git's tracking decoration."""
|
|
193
|
+
if "gone" in track:
|
|
194
|
+
return None
|
|
195
|
+
ahead_match = re.search(r"ahead (\d+)", track)
|
|
196
|
+
behind_match = re.search(r"behind (\d+)", track)
|
|
197
|
+
return (
|
|
198
|
+
int(ahead_match.group(1)) if ahead_match else 0,
|
|
199
|
+
int(behind_match.group(1)) if behind_match else 0,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def publication_for(
|
|
204
|
+
name: str,
|
|
205
|
+
upstream: str,
|
|
206
|
+
upstream_track: str,
|
|
207
|
+
push: str,
|
|
208
|
+
push_track: str,
|
|
209
|
+
remote_refs: set[str],
|
|
210
|
+
remotes: set[str],
|
|
211
|
+
) -> tuple[str | None, tuple[int, int] | None, bool]:
|
|
212
|
+
"""Find the branch's push/remote counterpart without conflating its upstream."""
|
|
213
|
+
if push:
|
|
214
|
+
gone = "gone" in push_track and push not in remote_refs
|
|
215
|
+
return push, parse_track(push_track), gone
|
|
216
|
+
|
|
217
|
+
matching = sorted(ref for ref in remote_refs if ref.partition("/")[2] == name)
|
|
218
|
+
if matching:
|
|
219
|
+
publication = (
|
|
220
|
+
upstream
|
|
221
|
+
if upstream in matching
|
|
222
|
+
else f"origin/{name}"
|
|
223
|
+
if f"origin/{name}" in matching
|
|
224
|
+
else matching[0]
|
|
225
|
+
)
|
|
226
|
+
track = upstream_track if publication == upstream else ""
|
|
227
|
+
return publication, parse_track(track), False
|
|
228
|
+
|
|
229
|
+
upstream_remote, separator, upstream_branch = upstream.partition("/")
|
|
230
|
+
if (
|
|
231
|
+
separator
|
|
232
|
+
and upstream_remote in remotes
|
|
233
|
+
and upstream_branch == name
|
|
234
|
+
and "gone" in upstream_track
|
|
235
|
+
):
|
|
236
|
+
return upstream, None, True
|
|
237
|
+
return None, (0, 0), False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
|
|
241
|
+
"""Run the report and return a process exit status."""
|
|
242
|
+
repo_root = command_output("git", "rev-parse", "--show-toplevel")
|
|
243
|
+
if repo_root is None:
|
|
244
|
+
print("git muster: current directory is not inside a Git repository", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
|
|
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()
|
|
249
|
+
modern_terminal = sys.platform != "win32" or bool(
|
|
250
|
+
os.environ.get("WT_SESSION")
|
|
251
|
+
or os.environ.get("TERM_PROGRAM")
|
|
252
|
+
or os.environ.get("TERMINAL_EMULATOR")
|
|
253
|
+
)
|
|
254
|
+
use_symbols = not plain and modern_terminal
|
|
255
|
+
|
|
256
|
+
def paint(code: str) -> Callable[[str], str]:
|
|
257
|
+
return lambda text: f"\033[{code}m{text}\033[0m" if use_colour else text
|
|
258
|
+
|
|
259
|
+
bold = paint("1")
|
|
260
|
+
dim = paint("2")
|
|
261
|
+
red = paint("31")
|
|
262
|
+
green = paint("32")
|
|
263
|
+
yellow = paint("33")
|
|
264
|
+
blue = paint("34")
|
|
265
|
+
|
|
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
|
+
terminal_width = max(60, min(shutil.get_terminal_size((100, 24)).columns, 160))
|
|
294
|
+
|
|
295
|
+
if not no_fetch:
|
|
296
|
+
if sys.stdout.isatty():
|
|
297
|
+
print(dim("fetching..."), end="", flush=True)
|
|
298
|
+
command_output("git", "fetch", "--all", "--prune", "--quiet")
|
|
299
|
+
if sys.stdout.isatty():
|
|
300
|
+
print("\r \r", end="", flush=True)
|
|
301
|
+
|
|
302
|
+
default_branch = command_output("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
|
303
|
+
default_branch = default_branch.removeprefix("origin/") if default_branch else "main"
|
|
304
|
+
|
|
305
|
+
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
|
+
)
|
|
346
|
+
|
|
347
|
+
current = command_output("git", "branch", "--show-current") or ""
|
|
348
|
+
dirty = git_output("status", "--short", "--untracked-files=all")
|
|
349
|
+
dirty_lines = dirty.splitlines() if dirty else []
|
|
350
|
+
worktree_counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicted": 0}
|
|
351
|
+
conflict_codes = {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}
|
|
352
|
+
for line in dirty_lines:
|
|
353
|
+
code = line[:2]
|
|
354
|
+
if code == "??":
|
|
355
|
+
worktree_counts["untracked"] += 1
|
|
356
|
+
elif code in conflict_codes:
|
|
357
|
+
worktree_counts["conflicted"] += 1
|
|
358
|
+
else:
|
|
359
|
+
if code[0] != " ":
|
|
360
|
+
worktree_counts["staged"] += 1
|
|
361
|
+
if code[1] != " ":
|
|
362
|
+
worktree_counts["modified"] += 1
|
|
363
|
+
|
|
364
|
+
worktree_parts = [f"{count} {kind}" for kind, count in worktree_counts.items() if count]
|
|
365
|
+
|
|
366
|
+
remote_rows = git_output(
|
|
367
|
+
"for-each-ref",
|
|
368
|
+
"refs/remotes",
|
|
369
|
+
"--format=%(refname:short)\t%(symref)",
|
|
370
|
+
)
|
|
371
|
+
remote_refs = {
|
|
372
|
+
ref
|
|
373
|
+
for row in remote_rows.splitlines()
|
|
374
|
+
for ref, symref in [row.split("\t", 1)]
|
|
375
|
+
if not symref
|
|
376
|
+
}
|
|
377
|
+
remotes = set(git_output("remote").splitlines())
|
|
378
|
+
|
|
379
|
+
raw_branches = git_output(
|
|
380
|
+
"for-each-ref",
|
|
381
|
+
"--sort=-committerdate",
|
|
382
|
+
"refs/heads",
|
|
383
|
+
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)\t%(push:short)\t%(push:track)\t%(committerdate:relative)\t%(worktreepath)",
|
|
384
|
+
)
|
|
385
|
+
branches: list[Branch] = []
|
|
386
|
+
for line in raw_branches.splitlines():
|
|
387
|
+
name, upstream, upstream_track, push, push_track, updated, worktree_path = line.split(
|
|
388
|
+
"\t", 6
|
|
389
|
+
)
|
|
390
|
+
publication, counts, gone = publication_for(
|
|
391
|
+
name,
|
|
392
|
+
upstream,
|
|
393
|
+
upstream_track,
|
|
394
|
+
push,
|
|
395
|
+
push_track,
|
|
396
|
+
remote_refs,
|
|
397
|
+
remotes,
|
|
398
|
+
)
|
|
399
|
+
if publication and counts == (0, 0) and publication != upstream and publication != push:
|
|
400
|
+
raw_counts = git_output(
|
|
401
|
+
"rev-list", "--left-right", "--count", f"{publication}...{name}"
|
|
402
|
+
)
|
|
403
|
+
behind_count, ahead_count = (int(value) for value in raw_counts.split())
|
|
404
|
+
counts = ahead_count, behind_count
|
|
405
|
+
|
|
406
|
+
ahead_count, behind_count = counts or (0, 0)
|
|
407
|
+
if gone:
|
|
408
|
+
state_text, state_colour, state_mark = "remote gone", red, glyph["gone"]
|
|
409
|
+
elif not publication:
|
|
410
|
+
state_text, state_colour, state_mark = "local only", dim, glyph["local"]
|
|
411
|
+
elif ahead_count and behind_count:
|
|
412
|
+
state_text = f"ahead {ahead_count}, behind {behind_count}"
|
|
413
|
+
state_colour, state_mark = red, glyph["diverged"]
|
|
414
|
+
elif ahead_count:
|
|
415
|
+
state_text = f"ahead {ahead_count}"
|
|
416
|
+
state_colour, state_mark = yellow, glyph["ahead"]
|
|
417
|
+
elif behind_count:
|
|
418
|
+
state_text = f"behind {behind_count}"
|
|
419
|
+
state_colour, state_mark = blue, glyph["behind"]
|
|
420
|
+
else:
|
|
421
|
+
state_text, state_colour, state_mark = "in sync", green, glyph["synced"]
|
|
422
|
+
|
|
423
|
+
remote = "-"
|
|
424
|
+
if publication:
|
|
425
|
+
remote_name, _, remote_branch = publication.partition("/")
|
|
426
|
+
remote = remote_name if remote_branch == name else publication
|
|
427
|
+
|
|
428
|
+
linked_worktree = ""
|
|
429
|
+
if worktree_path and Path(worktree_path).resolve() != Path(repo_root).resolve():
|
|
430
|
+
linked_worktree = Path(worktree_path).name
|
|
431
|
+
|
|
432
|
+
branches.append(
|
|
433
|
+
Branch(
|
|
434
|
+
name=name,
|
|
435
|
+
upstream=upstream or None,
|
|
436
|
+
publication=publication,
|
|
437
|
+
remote=remote,
|
|
438
|
+
state_text=state_text,
|
|
439
|
+
state_mark=state_mark,
|
|
440
|
+
state_colour=state_colour,
|
|
441
|
+
updated=updated,
|
|
442
|
+
pull_request=pull_requests.get(name, PullRequest("-", dim)),
|
|
443
|
+
is_current=name == current,
|
|
444
|
+
worktree=linked_worktree,
|
|
445
|
+
worktree_path=worktree_path if linked_worktree else "",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def natural(values: list[str], heading: str, extra: int = 0) -> int:
|
|
450
|
+
return max([len(heading), *(len(value) for value in values)]) + 2 + extra
|
|
451
|
+
|
|
452
|
+
branch_heading = f"BRANCH ({len(branches)} local)"
|
|
453
|
+
show_pr = pr_json is not None
|
|
454
|
+
show_worktree = any(branch.worktree for branch in branches)
|
|
455
|
+
pr_width = (
|
|
456
|
+
max([len("PULL REQUEST"), *(len(branch.pull_request.text) for branch in branches)])
|
|
457
|
+
if show_pr
|
|
458
|
+
else 0
|
|
459
|
+
)
|
|
460
|
+
columns = {
|
|
461
|
+
"name": natural([branch.name for branch in branches], branch_heading, 2),
|
|
462
|
+
"remote": natural([branch.remote for branch in branches], "REMOTE"),
|
|
463
|
+
"state": natural(
|
|
464
|
+
[f"{branch.state_mark} {branch.state_text}" for branch in branches], "STATE"
|
|
465
|
+
),
|
|
466
|
+
"updated": natural([branch.updated for branch in branches], "UPDATED"),
|
|
467
|
+
}
|
|
468
|
+
if show_worktree:
|
|
469
|
+
columns["worktree"] = natural([branch.worktree or "-" for branch in branches], "WORKTREE")
|
|
470
|
+
|
|
471
|
+
overflow = sum(columns.values()) + pr_width - terminal_width
|
|
472
|
+
compact_dates = False
|
|
473
|
+
if overflow > 0:
|
|
474
|
+
compact_dates = True
|
|
475
|
+
compact_width = natural([compact_age(branch.updated) for branch in branches], "UPDATED")
|
|
476
|
+
overflow -= columns["updated"] - compact_width
|
|
477
|
+
columns["updated"] = compact_width
|
|
478
|
+
if overflow > 0:
|
|
479
|
+
floor = len("WORKTREE") + 2
|
|
480
|
+
shed = min(overflow, max(0, columns.get("worktree", floor) - floor))
|
|
481
|
+
if show_worktree:
|
|
482
|
+
columns["worktree"] -= shed
|
|
483
|
+
overflow -= shed
|
|
484
|
+
if overflow > 0:
|
|
485
|
+
floor = len("REMOTE") + 2
|
|
486
|
+
shed = min(overflow, max(0, columns["remote"] - floor))
|
|
487
|
+
columns["remote"] -= shed
|
|
488
|
+
overflow -= shed
|
|
489
|
+
if overflow > 0:
|
|
490
|
+
columns["name"] = max(18, columns["name"] - overflow)
|
|
491
|
+
|
|
492
|
+
def pad(value: str, width: int) -> str:
|
|
493
|
+
return value.ljust(width)
|
|
494
|
+
|
|
495
|
+
def paint_branch(value: str, is_current: bool) -> str:
|
|
496
|
+
slash = value.rfind("/")
|
|
497
|
+
if slash == -1 or not use_colour:
|
|
498
|
+
return bold(value) if is_current else value
|
|
499
|
+
head = dim(value[: slash + 1])
|
|
500
|
+
tail = bold(value[slash + 1 :]) if is_current else value[slash + 1 :]
|
|
501
|
+
return head + tail
|
|
502
|
+
|
|
503
|
+
total_width = sum(columns.values()) + pr_width
|
|
504
|
+
repo_name = Path(repo_root).name
|
|
505
|
+
unpushed = sum("ahead" in branch.state_text for branch in branches)
|
|
506
|
+
unpublished = sum(branch.publication is None for branch in branches)
|
|
507
|
+
remote_gone = sum(branch.state_text == "remote gone" for branch in branches)
|
|
508
|
+
behind_count = sum("behind" in branch.state_text for branch in branches)
|
|
509
|
+
|
|
510
|
+
print()
|
|
511
|
+
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")
|
|
514
|
+
print(dim("worktree, ") + worktree)
|
|
515
|
+
print(dim(glyph["rule"] * total_width))
|
|
516
|
+
heading = (
|
|
517
|
+
pad(branch_heading, columns["name"])
|
|
518
|
+
+ pad("REMOTE", columns["remote"])
|
|
519
|
+
+ pad("STATE", columns["state"])
|
|
520
|
+
)
|
|
521
|
+
if show_worktree:
|
|
522
|
+
heading += pad("WORKTREE", columns["worktree"])
|
|
523
|
+
heading += pad("UPDATED", columns["updated"]) + "PULL REQUEST" if show_pr else "UPDATED"
|
|
524
|
+
print(dim(heading))
|
|
525
|
+
|
|
526
|
+
for branch in branches:
|
|
527
|
+
marker = f"{glyph['current']} " if branch.is_current else " "
|
|
528
|
+
name = fit(branch.name, columns["name"] - 3, glyph["ellipsis"])
|
|
529
|
+
name_padding = " " * max(0, columns["name"] - len(marker) - len(name))
|
|
530
|
+
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(
|
|
533
|
+
pad(
|
|
534
|
+
fit(branch.remote, columns["remote"] - 2, glyph["ellipsis"]),
|
|
535
|
+
columns["remote"],
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
row += branch.state_colour(
|
|
539
|
+
pad(f"{branch.state_mark} {branch.state_text}", columns["state"])
|
|
540
|
+
)
|
|
541
|
+
if show_worktree:
|
|
542
|
+
worktree_name = branch.worktree or "-"
|
|
543
|
+
row += dim(
|
|
544
|
+
pad(
|
|
545
|
+
fit(worktree_name, columns["worktree"] - 2, glyph["ellipsis"]),
|
|
546
|
+
columns["worktree"],
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
if show_pr:
|
|
550
|
+
row += dim(pad(updated, columns["updated"]))
|
|
551
|
+
pr = branch.pull_request
|
|
552
|
+
if pr.number is None:
|
|
553
|
+
rendered_pr = pr.text
|
|
554
|
+
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)
|
|
558
|
+
else:
|
|
559
|
+
row += dim(updated)
|
|
560
|
+
print(row)
|
|
561
|
+
|
|
562
|
+
print(dim(glyph["rule"] * total_width))
|
|
563
|
+
linked_worktrees = [branch for branch in branches if branch.worktree_path]
|
|
564
|
+
if linked_worktrees:
|
|
565
|
+
suffix = "" if len(linked_worktrees) == 1 else "s"
|
|
566
|
+
print(bold(f"{len(linked_worktrees)} other linked worktree{suffix}:"))
|
|
567
|
+
for branch in linked_worktrees:
|
|
568
|
+
print(f" {branch.name} {dim('->')} {dim(branch.worktree_path)}")
|
|
569
|
+
|
|
570
|
+
if dirty_lines:
|
|
571
|
+
suffix = "" if len(dirty_lines) == 1 else "s"
|
|
572
|
+
print(yellow(f"{len(dirty_lines)} uncommitted path{suffix}:"))
|
|
573
|
+
for line in dirty_lines[:8]:
|
|
574
|
+
print(" " + dim(line))
|
|
575
|
+
if len(dirty_lines) > 8:
|
|
576
|
+
print(dim(f" ...and {len(dirty_lines) - 8} more"))
|
|
577
|
+
else:
|
|
578
|
+
print(green(f"{glyph['synced']} working tree clean"))
|
|
579
|
+
|
|
580
|
+
counts: list[str] = []
|
|
581
|
+
if unpushed:
|
|
582
|
+
counts.append(f"{unpushed} with unpushed commits")
|
|
583
|
+
if behind_count:
|
|
584
|
+
counts.append(f"{behind_count} behind")
|
|
585
|
+
if unpublished:
|
|
586
|
+
counts.append(f"{unpublished} local only")
|
|
587
|
+
if remote_gone:
|
|
588
|
+
counts.append(f"{remote_gone} remote gone")
|
|
589
|
+
if counts:
|
|
590
|
+
print(dim(" · ".join(counts)))
|
|
591
|
+
|
|
592
|
+
if not show_pr:
|
|
593
|
+
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"))
|
|
600
|
+
print()
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _version_callback(value: bool) -> None:
|
|
605
|
+
if value:
|
|
606
|
+
typer.echo(f"git-muster {__version__}")
|
|
607
|
+
raise typer.Exit()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@app.command(cls=MusterCommand, help=HELP, epilog=EPILOG)
|
|
611
|
+
def main(
|
|
612
|
+
no_fetch: Annotated[
|
|
613
|
+
bool,
|
|
614
|
+
typer.Option(
|
|
615
|
+
"--no-fetch",
|
|
616
|
+
help=(
|
|
617
|
+
"Skip git fetch and report against remote-tracking refs already on disk. "
|
|
618
|
+
"Useful offline and for a fully read-only run."
|
|
619
|
+
),
|
|
620
|
+
rich_help_panel="Report options",
|
|
621
|
+
),
|
|
622
|
+
] = False,
|
|
623
|
+
plain: Annotated[
|
|
624
|
+
bool,
|
|
625
|
+
typer.Option(
|
|
626
|
+
"--plain",
|
|
627
|
+
help="Disable color and Unicode symbols for stable logs and pipes.",
|
|
628
|
+
rich_help_panel="Report options",
|
|
629
|
+
),
|
|
630
|
+
] = False,
|
|
631
|
+
version: Annotated[
|
|
632
|
+
bool,
|
|
633
|
+
typer.Option(
|
|
634
|
+
"--version",
|
|
635
|
+
callback=_version_callback,
|
|
636
|
+
is_eager=True,
|
|
637
|
+
help="Show the installed version and exit.",
|
|
638
|
+
),
|
|
639
|
+
] = False,
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Show every local Git branch and what needs attention."""
|
|
642
|
+
del version
|
|
643
|
+
try:
|
|
644
|
+
status = run_report(no_fetch=no_fetch, plain=plain)
|
|
645
|
+
except RuntimeError as error:
|
|
646
|
+
typer.echo(f"git muster: {error}", err=True)
|
|
647
|
+
raise typer.Exit(2) from None
|
|
648
|
+
if status:
|
|
649
|
+
raise typer.Exit(status)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def entrypoint() -> None:
|
|
653
|
+
"""Run the Typer application."""
|
|
654
|
+
app()
|