git-muster 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-muster
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: See every local Git branch and what needs attention
5
5
  Keywords: branch,cli,git,github,worktree
6
6
  Author: Dan O'Leary
@@ -123,7 +123,7 @@ git muster --version
123
123
 
124
124
  ### Color, symbols, and width
125
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:
126
+ Color, Unicode symbols, clickable repository identities, and clickable pull-request numbers appear when the report is written to an interactive terminal that can render them. GitHub repositories appear as `owner / repository`, with the owner and repository linked separately. The title includes `[public]`, `[private]`, or `[internal]` when GitHub reports visibility, `[fork]` when the repository is a fork, and `[archived]` when it is archived. A fork label links to its parent repository. Git Muster resolves the repository from `origin`, or another GitHub remote when `origin` is not hosted there, and honors the usual overrides:
127
127
 
128
128
  | Variable | Effect |
129
129
  |---|---|
@@ -157,7 +157,7 @@ Publication is deliberately separate from Git's configured upstream. A branch is
157
157
 
158
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.
159
159
 
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.
160
+ GitHub integration uses one `gh repo view` query for canonical identity, visibility, fork parent, and archive state, plus one optional `gh pr list --state all --limit 100` query for pull requests. 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, falls back to identity parsed from the remote URL, omits metadata it cannot establish, and names the reason pull-request state is missing.
161
161
 
162
162
  ## Safety and scope
163
163
 
@@ -99,7 +99,7 @@ git muster --version
99
99
 
100
100
  ### Color, symbols, and width
101
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:
102
+ Color, Unicode symbols, clickable repository identities, and clickable pull-request numbers appear when the report is written to an interactive terminal that can render them. GitHub repositories appear as `owner / repository`, with the owner and repository linked separately. The title includes `[public]`, `[private]`, or `[internal]` when GitHub reports visibility, `[fork]` when the repository is a fork, and `[archived]` when it is archived. A fork label links to its parent repository. Git Muster resolves the repository from `origin`, or another GitHub remote when `origin` is not hosted there, and honors the usual overrides:
103
103
 
104
104
  | Variable | Effect |
105
105
  |---|---|
@@ -133,7 +133,7 @@ Publication is deliberately separate from Git's configured upstream. A branch is
133
133
 
134
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.
135
135
 
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.
136
+ GitHub integration uses one `gh repo view` query for canonical identity, visibility, fork parent, and archive state, plus one optional `gh pr list --state all --limit 100` query for pull requests. 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, falls back to identity parsed from the remote URL, omits metadata it cannot establish, and names the reason pull-request state is missing.
137
137
 
138
138
  ## Safety and scope
139
139
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "git-muster"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "See every local Git branch and what needs attention"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "git-muster"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "See every local Git branch and what needs attention"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -12,6 +12,7 @@ from collections.abc import Callable
12
12
  from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Annotated, Any
15
+ from urllib.parse import urlsplit
15
16
 
16
17
  import typer
17
18
  from rich.console import Console
@@ -81,6 +82,7 @@ ASCII_GLYPHS = {
81
82
 
82
83
  PR_LIMIT = 100
83
84
  PR_FIELDS = "headRefName,number,state,isDraft,reviewDecision,url"
85
+ REPOSITORY_FIELDS = "nameWithOwner,url,visibility,isFork,parent,isArchived"
84
86
  ACTIVE_PR_STATES = {"draft", "open", "approved", "changes requested"}
85
87
 
86
88
  PR_NOTICES: dict[str, tuple[str, ...]] = {
@@ -242,6 +244,19 @@ class PullRequest:
242
244
  url: str = ""
243
245
 
244
246
 
247
+ @dataclass(frozen=True)
248
+ class GitHubRepository:
249
+ """GitHub identity and presentation metadata for the current repository."""
250
+
251
+ owner: str
252
+ name: str
253
+ url: str
254
+ visibility: str = ""
255
+ is_fork: bool = False
256
+ parent_url: str = ""
257
+ is_archived: bool = False
258
+
259
+
245
260
  @dataclass(frozen=True)
246
261
  class Branch:
247
262
  """Collected status for one local branch."""
@@ -268,6 +283,90 @@ def hyperlink(text: str, url: str, *, enabled: bool) -> str:
268
283
  return f"\033]8;;{url}\033\\{underlined}\033]8;;\033\\"
269
284
 
270
285
 
286
+ def github_repository_url(remote_url: str) -> str:
287
+ """Turn a GitHub clone URL into its canonical HTTPS repository page."""
288
+ scp_match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", remote_url)
289
+ if scp_match and "://" not in remote_url:
290
+ host, path = scp_match.groups()
291
+ else:
292
+ parsed = urlsplit(remote_url)
293
+ if parsed.scheme not in {"git", "http", "https", "ssh"}:
294
+ return ""
295
+ host, path = parsed.hostname or "", parsed.path
296
+
297
+ if host.lower() != "github.com":
298
+ return ""
299
+ path = path.strip("/")
300
+ if path.endswith(".git"):
301
+ path = path[:-4]
302
+ if not re.fullmatch(r"[-A-Za-z0-9_.]+/[-A-Za-z0-9_.]+", path):
303
+ return ""
304
+ return f"https://github.com/{path}"
305
+
306
+
307
+ def repository_github_url(remotes: set[str]) -> str:
308
+ """Find the repository page, preferring the conventional origin remote."""
309
+ for remote in sorted(remotes, key=lambda name: (name != "origin", name)):
310
+ remote_url = command_output("git", "remote", "get-url", remote) or ""
311
+ if url := github_repository_url(remote_url):
312
+ return url
313
+ return ""
314
+
315
+
316
+ def github_repository(repository_url: str) -> GitHubRepository:
317
+ """Read repository metadata from GitHub, retaining URL-derived identity on failure."""
318
+ owner, name = urlsplit(repository_url).path.strip("/").split("/", 1)
319
+ fallback = GitHubRepository(owner, name, repository_url)
320
+ result = command_result("gh", "repo", "view", f"{owner}/{name}", "--json", REPOSITORY_FIELDS)
321
+ if result is None or result[0] != 0:
322
+ return fallback
323
+ try:
324
+ payload = json.loads(result[1])
325
+ except (json.JSONDecodeError, TypeError, ValueError):
326
+ return fallback
327
+ if not isinstance(payload, dict):
328
+ return fallback
329
+
330
+ canonical_url = github_repository_url(str(payload.get("url", ""))) or repository_url
331
+ canonical_name = payload.get("nameWithOwner")
332
+ if isinstance(canonical_name, str) and re.fullmatch(
333
+ r"[-A-Za-z0-9_.]+/[-A-Za-z0-9_.]+", canonical_name
334
+ ):
335
+ owner, name = canonical_name.split("/", 1)
336
+ canonical_url = f"https://github.com/{canonical_name}"
337
+ else:
338
+ owner, name = urlsplit(canonical_url).path.strip("/").split("/", 1)
339
+
340
+ visibility = payload.get("visibility")
341
+ visibility = (
342
+ visibility.lower()
343
+ if isinstance(visibility, str) and visibility.upper() in {"PUBLIC", "PRIVATE", "INTERNAL"}
344
+ else ""
345
+ )
346
+
347
+ parent_url = ""
348
+ parent = payload.get("parent")
349
+ if isinstance(parent, dict):
350
+ parent_url = github_repository_url(str(parent.get("url", "")))
351
+ parent_name = parent.get("nameWithOwner")
352
+ if (
353
+ not parent_url
354
+ and isinstance(parent_name, str)
355
+ and re.fullmatch(r"[-A-Za-z0-9_.]+/[-A-Za-z0-9_.]+", parent_name)
356
+ ):
357
+ parent_url = f"https://github.com/{parent_name}"
358
+
359
+ return GitHubRepository(
360
+ owner=owner,
361
+ name=name,
362
+ url=canonical_url,
363
+ visibility=visibility,
364
+ is_fork=payload.get("isFork") is True,
365
+ parent_url=parent_url,
366
+ is_archived=payload.get("isArchived") is True,
367
+ )
368
+
369
+
271
370
  def pull_request_status(row: dict[str, object]) -> str:
272
371
  """Normalize GitHub's PR and review fields into a compact status."""
273
372
  state = str(row.get("state", "")).upper()
@@ -491,6 +590,8 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
491
590
  if not symref
492
591
  }
493
592
  remotes = set(git_output("remote").splitlines())
593
+ repo_url = repository_github_url(remotes)
594
+ github_repo = github_repository(repo_url) if repo_url else None
494
595
 
495
596
  raw_branches = git_output(
496
597
  "for-each-ref",
@@ -624,7 +725,23 @@ def run_report(*, no_fetch: bool = False, plain: bool = False) -> int:
624
725
  behind_count = sum("behind" in branch.state_text for branch in branches)
625
726
 
626
727
  print()
627
- print(bold(repo_name) + dim(" :: ") + bold(current or "detached HEAD"))
728
+ if github_repo:
729
+ owner_url = f"https://github.com/{github_repo.owner}"
730
+ rendered_repo = (
731
+ bold(hyperlink(github_repo.owner, owner_url, enabled=use_links))
732
+ + dim(" / ")
733
+ + bold(hyperlink(github_repo.name, github_repo.url, enabled=use_links))
734
+ )
735
+ if github_repo.visibility:
736
+ rendered_repo += dim(f" [{github_repo.visibility}]")
737
+ if github_repo.is_fork:
738
+ fork_label = hyperlink("[fork]", github_repo.parent_url, enabled=use_links)
739
+ rendered_repo += " " + dim(fork_label)
740
+ if github_repo.is_archived:
741
+ rendered_repo += " " + yellow("[archived]")
742
+ else:
743
+ rendered_repo = bold(repo_name)
744
+ print(rendered_repo + dim(" :: ") + bold(current or "detached HEAD"))
628
745
  separator = glyph["separator"]
629
746
  worktree = (
630
747
  yellow(f"dirty: {f' {separator} '.join(worktree_parts)}") if dirty_lines else green("clean")
File without changes