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.
@@ -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
+ [![CI](https://github.com/olearydj/git-muster/actions/workflows/ci.yml/badge.svg)](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
27
+ [![Python 3.13–3.14](https://img.shields.io/badge/python-3.13%E2%80%933.14-blue.svg)](https://www.python.org/)
28
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
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
+ [![CI](https://github.com/olearydj/git-muster/actions/workflows/ci.yml/badge.svg)](https://github.com/olearydj/git-muster/actions/workflows/ci.yml)
4
+ [![Python 3.13–3.14](https://img.shields.io/badge/python-3.13%E2%80%933.14-blue.svg)](https://www.python.org/)
5
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+
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,6 @@
1
+ """Run Git Muster as ``python -m git_muster``."""
2
+
3
+ from git_muster.cli import entrypoint
4
+
5
+ if __name__ == "__main__":
6
+ entrypoint()
@@ -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()