gitpulse-tui 1.2.0__tar.gz → 1.2.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.
- {gitpulse_tui-1.2.0/gitpulse_tui.egg-info → gitpulse_tui-1.2.1}/PKG-INFO +2 -1
- gitpulse_tui-1.2.1/gitpulse/config.py +169 -0
- gitpulse_tui-1.2.1/gitpulse/digest.py +165 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/git_ops.py +268 -0
- gitpulse_tui-1.2.1/gitpulse/main.py +560 -0
- gitpulse_tui-1.2.1/gitpulse/parallel.py +61 -0
- gitpulse_tui-1.2.1/gitpulse/stale.py +84 -0
- gitpulse_tui-1.2.1/gitpulse/ui/bulk_results.py +110 -0
- gitpulse_tui-1.2.1/gitpulse/ui/command_palette.py +135 -0
- gitpulse_tui-1.2.1/gitpulse/ui/digest_screen.py +222 -0
- gitpulse_tui-1.2.1/gitpulse/ui/fleet_status.py +115 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/sidebar.py +118 -17
- gitpulse_tui-1.2.1/gitpulse/ui/stale_screen.py +282 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/styles.tcss +7 -1
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/tabs.py +22 -13
- gitpulse_tui-1.2.1/gitpulse/utils.py +93 -0
- gitpulse_tui-1.2.1/gitpulse/watcher.py +62 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1/gitpulse_tui.egg-info}/PKG-INFO +2 -1
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/SOURCES.txt +10 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/requires.txt +3 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/pyproject.toml +2 -1
- gitpulse_tui-1.2.0/gitpulse/main.py +0 -271
- gitpulse_tui-1.2.0/gitpulse/utils.py +0 -51
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/LICENSE +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/README.md +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/__init__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/__main__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/scanner.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/__init__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/entry_points.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/top_level.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/setup.cfg +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpulse-tui
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Git Repo Dashboard TUI — live status, commits, diffs, and branches in your terminal
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Dist: textual>=0.50.0
|
|
8
8
|
Requires-Dist: rich>=13.0.0
|
|
9
9
|
Requires-Dist: gitpython>=3.1.0
|
|
10
|
+
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
10
11
|
Dynamic: license-file
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config.py — TOML configuration loader for GitPulse.
|
|
3
|
+
|
|
4
|
+
Reads ~/.config/gitpulse/config.toml (or a path supplied via --config).
|
|
5
|
+
Uses tomllib (stdlib, Python 3.11+) or tomli (backport, 3.10).
|
|
6
|
+
Missing file → silent fallback to built-in defaults.
|
|
7
|
+
CLI flags always take precedence over config values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# tomllib is stdlib on 3.11+; tomli is the backport for 3.10
|
|
17
|
+
if sys.version_info >= (3, 11):
|
|
18
|
+
import tomllib
|
|
19
|
+
else:
|
|
20
|
+
try:
|
|
21
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
22
|
+
except ImportError:
|
|
23
|
+
tomllib = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
DEFAULT_CONFIG_PATH = Path("~/.config/gitpulse/config.toml")
|
|
26
|
+
EXAMPLE_CONFIG_PATH = Path("~/.config/gitpulse/config.toml.example")
|
|
27
|
+
|
|
28
|
+
_EXAMPLE_CONTENT = """\
|
|
29
|
+
# GitPulse configuration — copy to config.toml and edit as needed.
|
|
30
|
+
|
|
31
|
+
[scan]
|
|
32
|
+
# roots = ["~/projects", "~/work"] # override --root; list of directories to scan
|
|
33
|
+
|
|
34
|
+
[author]
|
|
35
|
+
# emails = ["you@example.com"] # used by digest mode; defaults to git config user.email
|
|
36
|
+
|
|
37
|
+
[watch]
|
|
38
|
+
enabled = true
|
|
39
|
+
interval_seconds = 5
|
|
40
|
+
|
|
41
|
+
[stale]
|
|
42
|
+
weeks = 8
|
|
43
|
+
default_branches = ["main", "master", "develop", "trunk"]
|
|
44
|
+
|
|
45
|
+
[bulk]
|
|
46
|
+
max_workers = 8
|
|
47
|
+
|
|
48
|
+
[digest]
|
|
49
|
+
default_window = "1d"
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ScanConfig:
|
|
55
|
+
roots: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AuthorConfig:
|
|
60
|
+
emails: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class WatchConfig:
|
|
65
|
+
enabled: bool = True
|
|
66
|
+
interval_seconds: int = 5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class StaleConfig:
|
|
71
|
+
weeks: int = 8
|
|
72
|
+
default_branches: list[str] = field(
|
|
73
|
+
default_factory=lambda: ["main", "master", "develop", "trunk"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class BulkConfig:
|
|
79
|
+
max_workers: int = 8
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class DigestConfig:
|
|
84
|
+
default_window: str = "1d"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GitPulseConfig:
|
|
89
|
+
scan: ScanConfig = field(default_factory=ScanConfig)
|
|
90
|
+
author: AuthorConfig = field(default_factory=AuthorConfig)
|
|
91
|
+
watch: WatchConfig = field(default_factory=WatchConfig)
|
|
92
|
+
stale: StaleConfig = field(default_factory=StaleConfig)
|
|
93
|
+
bulk: BulkConfig = field(default_factory=BulkConfig)
|
|
94
|
+
digest: DigestConfig = field(default_factory=DigestConfig)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _write_example_if_missing() -> None:
|
|
98
|
+
example = EXAMPLE_CONFIG_PATH.expanduser()
|
|
99
|
+
if not example.exists():
|
|
100
|
+
try:
|
|
101
|
+
example.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
example.write_text(_EXAMPLE_CONTENT)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load(path: Path | None = None) -> GitPulseConfig:
|
|
108
|
+
"""Load and return the GitPulse configuration.
|
|
109
|
+
|
|
110
|
+
Falls back to defaults silently if the file is absent or tomli is
|
|
111
|
+
unavailable. Writes a .example file on first run.
|
|
112
|
+
"""
|
|
113
|
+
cfg = GitPulseConfig()
|
|
114
|
+
_write_example_if_missing()
|
|
115
|
+
|
|
116
|
+
config_path = (path or DEFAULT_CONFIG_PATH).expanduser()
|
|
117
|
+
if not config_path.exists():
|
|
118
|
+
return cfg
|
|
119
|
+
|
|
120
|
+
if tomllib is None:
|
|
121
|
+
# Python 3.10 without tomli installed — use defaults
|
|
122
|
+
return cfg
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with open(config_path, "rb") as fh:
|
|
126
|
+
raw = tomllib.load(fh)
|
|
127
|
+
except Exception:
|
|
128
|
+
return cfg
|
|
129
|
+
|
|
130
|
+
if "scan" in raw:
|
|
131
|
+
s = raw["scan"]
|
|
132
|
+
cfg.scan.roots = s.get("roots", [])
|
|
133
|
+
|
|
134
|
+
if "author" in raw:
|
|
135
|
+
a = raw["author"]
|
|
136
|
+
cfg.author.emails = a.get("emails", [])
|
|
137
|
+
|
|
138
|
+
if "watch" in raw:
|
|
139
|
+
w = raw["watch"]
|
|
140
|
+
cfg.watch.enabled = bool(w.get("enabled", True))
|
|
141
|
+
cfg.watch.interval_seconds = int(w.get("interval_seconds", 5))
|
|
142
|
+
|
|
143
|
+
if "stale" in raw:
|
|
144
|
+
st = raw["stale"]
|
|
145
|
+
cfg.stale.weeks = int(st.get("weeks", 8))
|
|
146
|
+
cfg.stale.default_branches = list(
|
|
147
|
+
st.get("default_branches", ["main", "master", "develop", "trunk"])
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if "bulk" in raw:
|
|
151
|
+
cfg.bulk.max_workers = int(raw["bulk"].get("max_workers", 8))
|
|
152
|
+
|
|
153
|
+
if "digest" in raw:
|
|
154
|
+
cfg.digest.default_window = str(raw["digest"].get("default_window", "1d"))
|
|
155
|
+
|
|
156
|
+
return cfg
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Module-level singleton — loaded once, reused everywhere.
|
|
160
|
+
# Call load() explicitly if you need a custom path.
|
|
161
|
+
_default: GitPulseConfig | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get() -> GitPulseConfig:
|
|
165
|
+
"""Return the cached default config (loaded once on first call)."""
|
|
166
|
+
global _default
|
|
167
|
+
if _default is None:
|
|
168
|
+
_default = load()
|
|
169
|
+
return _default
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
digest.py — Activity digest aggregation for GitPulse.
|
|
3
|
+
|
|
4
|
+
Collects commits authored by a set of email patterns across all scanned repos
|
|
5
|
+
within a time window, groups them by repo, and produces a Digest object.
|
|
6
|
+
Renders as markdown for stdout/clipboard use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from gitpulse.git_ops import RepoInfo, AuthorCommit, get_author_commits, get_author_email
|
|
17
|
+
from gitpulse.parallel import run_parallel
|
|
18
|
+
from gitpulse.utils import relative_time
|
|
19
|
+
except ImportError:
|
|
20
|
+
from git_ops import RepoInfo, AuthorCommit, get_author_commits, get_author_email # type: ignore
|
|
21
|
+
from parallel import run_parallel # type: ignore
|
|
22
|
+
from utils import relative_time # type: ignore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RepoDigest:
|
|
27
|
+
repo: RepoInfo
|
|
28
|
+
commits: list[AuthorCommit]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def insertions(self) -> int:
|
|
32
|
+
return sum(c.insertions for c in self.commits)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def deletions(self) -> int:
|
|
36
|
+
return sum(c.deletions for c in self.commits)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def files_changed(self) -> int:
|
|
40
|
+
return sum(c.files_changed for c in self.commits)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Digest:
|
|
45
|
+
since_ts: float
|
|
46
|
+
until_ts: float
|
|
47
|
+
author_patterns: list[str]
|
|
48
|
+
by_repo: list[RepoDigest] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def total_commits(self) -> int:
|
|
52
|
+
return sum(len(rd.commits) for rd in self.by_repo)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total_insertions(self) -> int:
|
|
56
|
+
return sum(rd.insertions for rd in self.by_repo)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total_deletions(self) -> int:
|
|
60
|
+
return sum(rd.deletions for rd in self.by_repo)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def repos_active(self) -> int:
|
|
64
|
+
return len(self.by_repo)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _collect_for_repo(
|
|
68
|
+
args: tuple[RepoInfo, float, list[str]],
|
|
69
|
+
) -> RepoDigest | None:
|
|
70
|
+
"""Worker target: fetch commits for one repo. Returns None if no commits."""
|
|
71
|
+
repo, since_ts, author_patterns = args
|
|
72
|
+
all_commits: list[AuthorCommit] = []
|
|
73
|
+
for pattern in author_patterns:
|
|
74
|
+
commits = get_author_commits(repo.path, since_ts, pattern)
|
|
75
|
+
all_commits.extend(commits)
|
|
76
|
+
|
|
77
|
+
if not all_commits:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# De-duplicate by hash in case multiple patterns matched same commit
|
|
81
|
+
seen: set[str] = set()
|
|
82
|
+
unique: list[AuthorCommit] = []
|
|
83
|
+
for c in all_commits:
|
|
84
|
+
if c.short_hash not in seen:
|
|
85
|
+
seen.add(c.short_hash)
|
|
86
|
+
unique.append(c)
|
|
87
|
+
|
|
88
|
+
unique.sort(key=lambda c: c.ts, reverse=True)
|
|
89
|
+
return RepoDigest(repo=repo, commits=unique)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_author_patterns(
|
|
93
|
+
repos: list[RepoInfo],
|
|
94
|
+
explicit_patterns: list[str],
|
|
95
|
+
) -> list[str]:
|
|
96
|
+
"""If no explicit patterns given, try to read user.email from the first N repos."""
|
|
97
|
+
if explicit_patterns:
|
|
98
|
+
return explicit_patterns
|
|
99
|
+
emails: set[str] = set()
|
|
100
|
+
for repo in repos[:5]:
|
|
101
|
+
email = get_author_email(repo.path)
|
|
102
|
+
if email:
|
|
103
|
+
emails.add(email)
|
|
104
|
+
return list(emails) if emails else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_digest(
|
|
108
|
+
repos: list[RepoInfo],
|
|
109
|
+
since_ts: float,
|
|
110
|
+
author_patterns: list[str] | None = None,
|
|
111
|
+
max_workers: int = 8,
|
|
112
|
+
) -> Digest:
|
|
113
|
+
"""Build a Digest aggregating all matching commits across *repos*."""
|
|
114
|
+
patterns = _resolve_author_patterns(repos, author_patterns or [])
|
|
115
|
+
until_ts = time.time()
|
|
116
|
+
|
|
117
|
+
if not patterns:
|
|
118
|
+
return Digest(since_ts=since_ts, until_ts=until_ts, author_patterns=[])
|
|
119
|
+
|
|
120
|
+
args_list = [(repo, since_ts, patterns) for repo in repos]
|
|
121
|
+
results = run_parallel(_collect_for_repo, args_list, max_workers=max_workers)
|
|
122
|
+
|
|
123
|
+
by_repo = [
|
|
124
|
+
result
|
|
125
|
+
for _, result in results
|
|
126
|
+
if result is not None and not isinstance(result, Exception)
|
|
127
|
+
]
|
|
128
|
+
by_repo.sort(key=lambda rd: len(rd.commits), reverse=True)
|
|
129
|
+
|
|
130
|
+
return Digest(
|
|
131
|
+
since_ts=since_ts,
|
|
132
|
+
until_ts=until_ts,
|
|
133
|
+
author_patterns=patterns,
|
|
134
|
+
by_repo=by_repo,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_markdown(d: Digest) -> str:
|
|
139
|
+
"""Render a Digest as a markdown standup summary."""
|
|
140
|
+
from datetime import datetime, timezone
|
|
141
|
+
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
since_str = datetime.fromtimestamp(d.since_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
144
|
+
until_str = datetime.fromtimestamp(d.until_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
145
|
+
authors_str = ", ".join(d.author_patterns) if d.author_patterns else "all authors"
|
|
146
|
+
|
|
147
|
+
lines.append(f"# Activity digest — {since_str} → {until_str}")
|
|
148
|
+
lines.append(f"**Author(s):** {authors_str} ")
|
|
149
|
+
lines.append(
|
|
150
|
+
f"**Summary:** {d.total_commits} commit{'s' if d.total_commits != 1 else ''} "
|
|
151
|
+
f"across {d.repos_active} repo{'s' if d.repos_active != 1 else ''} "
|
|
152
|
+
f"· +{d.total_insertions} -{d.total_deletions} lines"
|
|
153
|
+
)
|
|
154
|
+
lines.append("")
|
|
155
|
+
|
|
156
|
+
for rd in d.by_repo:
|
|
157
|
+
lines.append(f"## {rd.repo.name} ({len(rd.commits)} commits · +{rd.insertions} -{rd.deletions})")
|
|
158
|
+
for c in rd.commits:
|
|
159
|
+
rel = relative_time(c.ts)
|
|
160
|
+
stats = f"+{c.insertions}/-{c.deletions}" if c.insertions or c.deletions else ""
|
|
161
|
+
stats_str = f" `{stats}`" if stats else ""
|
|
162
|
+
lines.append(f"- `{c.short_hash}` {c.message}{stats_str} _{rel}_")
|
|
163
|
+
lines.append("")
|
|
164
|
+
|
|
165
|
+
return "\n".join(lines)
|
|
@@ -9,6 +9,7 @@ branches. Uses GitPython for interfacing with local git repositories.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import enum
|
|
12
|
+
import re
|
|
12
13
|
import time
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from datetime import datetime
|
|
@@ -46,6 +47,10 @@ class RepoInfo:
|
|
|
46
47
|
total_commits: int = 0 # Total commit count on current branch
|
|
47
48
|
contributor_count: int = 0 # Unique author count
|
|
48
49
|
commit_activity: list[int] = field(default_factory=list) # Commits per week, 7 weeks oldest→newest
|
|
50
|
+
ahead: int = 0 # Commits ahead of upstream
|
|
51
|
+
behind: int = 0 # Commits behind upstream
|
|
52
|
+
stash_count: int = 0 # Number of stash entries
|
|
53
|
+
has_stale_branches: bool = False # True if any branch is older than stale threshold
|
|
49
54
|
|
|
50
55
|
|
|
51
56
|
@dataclass
|
|
@@ -68,6 +73,17 @@ class CommitInfo:
|
|
|
68
73
|
deletions: int = 0
|
|
69
74
|
|
|
70
75
|
|
|
76
|
+
@dataclass
|
|
77
|
+
class AuthorCommit:
|
|
78
|
+
"""A single commit attributed to a specific author, used by digest mode."""
|
|
79
|
+
short_hash: str
|
|
80
|
+
ts: float
|
|
81
|
+
message: str
|
|
82
|
+
insertions: int = 0
|
|
83
|
+
deletions: int = 0
|
|
84
|
+
files_changed: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
71
87
|
@dataclass
|
|
72
88
|
class BranchInfo:
|
|
73
89
|
"""Information about a local branch."""
|
|
@@ -75,6 +91,21 @@ class BranchInfo:
|
|
|
75
91
|
is_current: bool
|
|
76
92
|
|
|
77
93
|
|
|
94
|
+
@dataclass
|
|
95
|
+
class BranchDetail:
|
|
96
|
+
"""Rich branch information used by stale-branch analysis."""
|
|
97
|
+
repo_path: Path
|
|
98
|
+
repo_name: str
|
|
99
|
+
name: str
|
|
100
|
+
last_commit_ts: float
|
|
101
|
+
last_commit_msg: str
|
|
102
|
+
is_current: bool
|
|
103
|
+
has_upstream: bool
|
|
104
|
+
is_merged_into_default: bool
|
|
105
|
+
is_wip: bool # subject matches ^(wip|fixup!|squash!|tmp)\b
|
|
106
|
+
age_days: int
|
|
107
|
+
|
|
108
|
+
|
|
78
109
|
@dataclass
|
|
79
110
|
class StashEntry:
|
|
80
111
|
"""A single stash entry."""
|
|
@@ -184,6 +215,48 @@ def get_repo_info(path: Path) -> RepoInfo:
|
|
|
184
215
|
except Exception:
|
|
185
216
|
pass
|
|
186
217
|
|
|
218
|
+
# Ahead/behind relative to upstream (skip for detached HEAD)
|
|
219
|
+
ahead = 0
|
|
220
|
+
behind = 0
|
|
221
|
+
try:
|
|
222
|
+
if branch != "(detached)":
|
|
223
|
+
_br = repo.active_branch
|
|
224
|
+
tracking = _br.tracking_branch()
|
|
225
|
+
if tracking:
|
|
226
|
+
ahead = len(list(repo.iter_commits(f"{tracking.name}..{_br.name}")))
|
|
227
|
+
behind = len(list(repo.iter_commits(f"{_br.name}..{tracking.name}")))
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
# Stash count
|
|
232
|
+
stash_count = 0
|
|
233
|
+
try:
|
|
234
|
+
sl = repo.git.stash("list")
|
|
235
|
+
stash_count = len(sl.strip().splitlines()) if sl.strip() else 0
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# Stale-branch quick check (any local branch older than 8 weeks, not current)
|
|
240
|
+
has_stale = False
|
|
241
|
+
try:
|
|
242
|
+
_stale_cutoff = time.time() - (8 * 7 * 86400)
|
|
243
|
+
ref_out = repo.git.for_each_ref(
|
|
244
|
+
"refs/heads/",
|
|
245
|
+
format="%(refname:short) %(committerdate:unix)",
|
|
246
|
+
)
|
|
247
|
+
for _line in ref_out.strip().splitlines():
|
|
248
|
+
_parts = _line.rsplit(" ", 1)
|
|
249
|
+
if len(_parts) == 2:
|
|
250
|
+
_bname, _ts_str = _parts
|
|
251
|
+
try:
|
|
252
|
+
if float(_ts_str) < _stale_cutoff and _bname != branch:
|
|
253
|
+
has_stale = True
|
|
254
|
+
break
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
187
260
|
except (InvalidGitRepositoryError, Exception):
|
|
188
261
|
branch = "unknown"
|
|
189
262
|
status = RepoStatus.CLEAN
|
|
@@ -193,6 +266,10 @@ def get_repo_info(path: Path) -> RepoInfo:
|
|
|
193
266
|
total = 0
|
|
194
267
|
contributor_count = 0
|
|
195
268
|
activity = []
|
|
269
|
+
ahead = 0
|
|
270
|
+
behind = 0
|
|
271
|
+
stash_count = 0
|
|
272
|
+
has_stale = False
|
|
196
273
|
|
|
197
274
|
return RepoInfo(
|
|
198
275
|
name=path.name,
|
|
@@ -205,6 +282,10 @@ def get_repo_info(path: Path) -> RepoInfo:
|
|
|
205
282
|
total_commits=total,
|
|
206
283
|
contributor_count=contributor_count,
|
|
207
284
|
commit_activity=activity,
|
|
285
|
+
ahead=ahead,
|
|
286
|
+
behind=behind,
|
|
287
|
+
stash_count=stash_count,
|
|
288
|
+
has_stale_branches=has_stale,
|
|
208
289
|
)
|
|
209
290
|
|
|
210
291
|
|
|
@@ -709,6 +790,36 @@ def git_push(path: Path) -> str:
|
|
|
709
790
|
return f"Error pushing: {exc}"
|
|
710
791
|
|
|
711
792
|
|
|
793
|
+
def git_gc(path: Path) -> str:
|
|
794
|
+
"""Run git gc --auto to prune loose objects."""
|
|
795
|
+
repo = _open_repo(path)
|
|
796
|
+
try:
|
|
797
|
+
repo.git.gc("--auto")
|
|
798
|
+
return "gc --auto completed ✓"
|
|
799
|
+
except Exception as exc:
|
|
800
|
+
return f"Error running gc: {exc}"
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def git_remote_prune(path: Path) -> str:
|
|
804
|
+
"""Run git remote prune origin to remove stale remote-tracking refs."""
|
|
805
|
+
repo = _open_repo(path)
|
|
806
|
+
try:
|
|
807
|
+
result = repo.git.remote("prune", "origin")
|
|
808
|
+
return result.strip() if result.strip() else "remote prune origin completed ✓"
|
|
809
|
+
except Exception as exc:
|
|
810
|
+
return f"Error pruning: {exc}"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def git_clean_dry(path: Path) -> str:
|
|
814
|
+
"""Run git clean -nd (dry run) and return untracked file list."""
|
|
815
|
+
repo = _open_repo(path)
|
|
816
|
+
try:
|
|
817
|
+
result = repo.git.clean("-nd")
|
|
818
|
+
return result.strip() if result.strip() else "Nothing to clean ✓"
|
|
819
|
+
except Exception as exc:
|
|
820
|
+
return f"Error running clean: {exc}"
|
|
821
|
+
|
|
822
|
+
|
|
712
823
|
# ===================================================================
|
|
713
824
|
# Stash operations
|
|
714
825
|
# ===================================================================
|
|
@@ -790,3 +901,160 @@ def get_tracked_files(path: Path) -> list[str]:
|
|
|
790
901
|
return sorted(p for p in ls_output.splitlines() if p.strip())
|
|
791
902
|
except Exception:
|
|
792
903
|
return []
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# ===================================================================
|
|
907
|
+
# Author / Digest operations
|
|
908
|
+
# ===================================================================
|
|
909
|
+
|
|
910
|
+
def default_branch_for(repo, candidates: list[str] | None = None) -> str | None:
|
|
911
|
+
"""Return the first candidate branch that exists locally, else None."""
|
|
912
|
+
_candidates = candidates or ["main", "master", "develop", "trunk"]
|
|
913
|
+
local_names = {b.name for b in repo.branches}
|
|
914
|
+
for name in _candidates:
|
|
915
|
+
if name in local_names:
|
|
916
|
+
return name
|
|
917
|
+
return None
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def get_branch_details(
|
|
921
|
+
path: Path,
|
|
922
|
+
default_branches: list[str] | None = None,
|
|
923
|
+
) -> list[BranchDetail]:
|
|
924
|
+
"""Return rich details for every local branch in the repo."""
|
|
925
|
+
import time as _time
|
|
926
|
+
repo = _open_repo(path)
|
|
927
|
+
details: list[BranchDetail] = []
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
current = repo.active_branch.name if not repo.head.is_detached else None
|
|
931
|
+
except Exception:
|
|
932
|
+
current = None
|
|
933
|
+
|
|
934
|
+
default = default_branch_for(repo, default_branches)
|
|
935
|
+
|
|
936
|
+
# Collect merged branches (names only)
|
|
937
|
+
merged: set[str] = set()
|
|
938
|
+
if default:
|
|
939
|
+
try:
|
|
940
|
+
merged_out = repo.git.branch("--merged", default)
|
|
941
|
+
for line in merged_out.splitlines():
|
|
942
|
+
merged.add(line.strip().lstrip("* "))
|
|
943
|
+
except Exception:
|
|
944
|
+
pass
|
|
945
|
+
|
|
946
|
+
_wip_re = re.compile(r"^(wip|fixup!|squash!|tmp)\b", re.IGNORECASE)
|
|
947
|
+
now = _time.time()
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
raw = repo.git.for_each_ref(
|
|
951
|
+
"refs/heads/",
|
|
952
|
+
format="%(refname:short)%00%(committerdate:unix)%00%(contents:subject)%00%(upstream:short)%00%(HEAD)",
|
|
953
|
+
)
|
|
954
|
+
except Exception:
|
|
955
|
+
return details
|
|
956
|
+
|
|
957
|
+
for line in raw.strip().splitlines():
|
|
958
|
+
parts = line.split("\x00")
|
|
959
|
+
if len(parts) < 5:
|
|
960
|
+
continue
|
|
961
|
+
bname, ts_str, subject, upstream, head_marker = parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
962
|
+
|
|
963
|
+
try:
|
|
964
|
+
ts = float(ts_str)
|
|
965
|
+
except ValueError:
|
|
966
|
+
ts = 0.0
|
|
967
|
+
|
|
968
|
+
age_days = int((now - ts) / 86400) if ts else 0
|
|
969
|
+
|
|
970
|
+
details.append(BranchDetail(
|
|
971
|
+
repo_path=path,
|
|
972
|
+
repo_name=path.name,
|
|
973
|
+
name=bname,
|
|
974
|
+
last_commit_ts=ts,
|
|
975
|
+
last_commit_msg=subject.strip()[:80],
|
|
976
|
+
is_current=(head_marker.strip() == "*" or bname == current),
|
|
977
|
+
has_upstream=bool(upstream.strip()),
|
|
978
|
+
is_merged_into_default=(bname in merged),
|
|
979
|
+
is_wip=bool(_wip_re.match(subject.strip())),
|
|
980
|
+
age_days=age_days,
|
|
981
|
+
))
|
|
982
|
+
|
|
983
|
+
return details
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def get_author_email(path: Path) -> str:
|
|
987
|
+
"""Return the configured git user.email for this repo, or empty string."""
|
|
988
|
+
repo = _open_repo(path)
|
|
989
|
+
try:
|
|
990
|
+
return repo.git.config("user.email").strip()
|
|
991
|
+
except Exception:
|
|
992
|
+
return ""
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def get_author_commits(
|
|
996
|
+
path: Path,
|
|
997
|
+
since_ts: float,
|
|
998
|
+
author_pattern: str,
|
|
999
|
+
) -> list[AuthorCommit]:
|
|
1000
|
+
"""Return commits by *author_pattern* in *path* since *since_ts*.
|
|
1001
|
+
|
|
1002
|
+
Uses ``--all`` so commits on any branch are credited. Parses shortstat
|
|
1003
|
+
blocks to extract insertions/deletions without iterating commit objects.
|
|
1004
|
+
"""
|
|
1005
|
+
repo = _open_repo(path)
|
|
1006
|
+
commits: list[AuthorCommit] = []
|
|
1007
|
+
try:
|
|
1008
|
+
# Request log with NUL-separated records: hash\x1fts\x1fsubject
|
|
1009
|
+
# then a blank line and the shortstat
|
|
1010
|
+
raw = repo.git.log(
|
|
1011
|
+
"--all",
|
|
1012
|
+
"--no-merges",
|
|
1013
|
+
f"--since=@{int(since_ts)}",
|
|
1014
|
+
f"--author={author_pattern}",
|
|
1015
|
+
"--pretty=format:\x1e%H\x1f%ct\x1f%s",
|
|
1016
|
+
"--shortstat",
|
|
1017
|
+
)
|
|
1018
|
+
if not raw.strip():
|
|
1019
|
+
return []
|
|
1020
|
+
|
|
1021
|
+
# Split on the record separator \x1e (prepended to each commit header)
|
|
1022
|
+
records = [r for r in raw.split("\x1e") if r.strip()]
|
|
1023
|
+
for record in records:
|
|
1024
|
+
lines = record.strip().splitlines()
|
|
1025
|
+
if not lines:
|
|
1026
|
+
continue
|
|
1027
|
+
header_line = lines[0]
|
|
1028
|
+
parts = header_line.split("\x1f", 2)
|
|
1029
|
+
if len(parts) < 3:
|
|
1030
|
+
continue
|
|
1031
|
+
full_hash, ts_str, subject = parts[0].strip(), parts[1], parts[2]
|
|
1032
|
+
try:
|
|
1033
|
+
ts = float(ts_str)
|
|
1034
|
+
except ValueError:
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
insertions = 0
|
|
1038
|
+
deletions = 0
|
|
1039
|
+
files_changed = 0
|
|
1040
|
+
# shortstat line looks like: "3 files changed, 42 insertions(+), 5 deletions(-)"
|
|
1041
|
+
stat_lines = [l for l in lines[1:] if "changed" in l or "insertion" in l or "deletion" in l]
|
|
1042
|
+
for stat in stat_lines:
|
|
1043
|
+
m_files = re.search(r"(\d+) file", stat)
|
|
1044
|
+
m_ins = re.search(r"(\d+) insertion", stat)
|
|
1045
|
+
m_del = re.search(r"(\d+) deletion", stat)
|
|
1046
|
+
if m_files: files_changed = int(m_files.group(1))
|
|
1047
|
+
if m_ins: insertions = int(m_ins.group(1))
|
|
1048
|
+
if m_del: deletions = int(m_del.group(1))
|
|
1049
|
+
|
|
1050
|
+
commits.append(AuthorCommit(
|
|
1051
|
+
short_hash=full_hash[:7],
|
|
1052
|
+
ts=ts,
|
|
1053
|
+
message=subject.strip(),
|
|
1054
|
+
insertions=insertions,
|
|
1055
|
+
deletions=deletions,
|
|
1056
|
+
files_changed=files_changed,
|
|
1057
|
+
))
|
|
1058
|
+
except Exception:
|
|
1059
|
+
pass
|
|
1060
|
+
return commits
|