gitpulse-tui 1.2.0__tar.gz → 1.2.2__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.2}/PKG-INFO +2 -1
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/README.md +14 -3
- gitpulse_tui-1.2.2/gitpulse/config.py +169 -0
- gitpulse_tui-1.2.2/gitpulse/digest.py +165 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/git_ops.py +276 -8
- gitpulse_tui-1.2.2/gitpulse/main.py +560 -0
- gitpulse_tui-1.2.2/gitpulse/parallel.py +61 -0
- gitpulse_tui-1.2.2/gitpulse/stale.py +84 -0
- gitpulse_tui-1.2.2/gitpulse/ui/bulk_results.py +110 -0
- gitpulse_tui-1.2.2/gitpulse/ui/command_palette.py +134 -0
- gitpulse_tui-1.2.2/gitpulse/ui/digest_screen.py +222 -0
- gitpulse_tui-1.2.2/gitpulse/ui/fleet_status.py +119 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/sidebar.py +125 -31
- gitpulse_tui-1.2.2/gitpulse/ui/stale_screen.py +280 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/styles.tcss +139 -104
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/tabs.py +95 -86
- gitpulse_tui-1.2.2/gitpulse/utils.py +93 -0
- gitpulse_tui-1.2.2/gitpulse/watcher.py +62 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2/gitpulse_tui.egg-info}/PKG-INFO +2 -1
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/SOURCES.txt +10 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/requires.txt +3 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/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.2}/LICENSE +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/__init__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/__main__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/scanner.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/__init__.py +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/entry_points.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/top_level.txt +0 -0
- {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/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.2
|
|
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
|
|
@@ -14,15 +14,26 @@ Built with **Python**, **Textual**, **Rich**, and **GitPython**.
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
The easiest way to install GitPulse is via PyPI using `pip` or `pipx`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install gitpulse-tui
|
|
23
|
+
```
|
|
24
|
+
*(We recommend using `pipx install gitpulse-tui` to install it in an isolated environment)*
|
|
25
|
+
|
|
26
|
+
### Install from source
|
|
27
|
+
|
|
28
|
+
If you prefer to install from source or want to contribute to the project:
|
|
18
29
|
|
|
19
30
|
```bash
|
|
20
|
-
git clone https://github.com/
|
|
31
|
+
git clone https://github.com/lebiraja/git-tui.git
|
|
21
32
|
cd git-tui
|
|
22
33
|
./install.sh
|
|
23
34
|
```
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
The installer:
|
|
26
37
|
- Checks your Python version (3.10+ required)
|
|
27
38
|
- Creates a virtual environment automatically
|
|
28
39
|
- Installs all dependencies
|
|
@@ -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)
|