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.
Files changed (33) hide show
  1. {gitpulse_tui-1.2.0/gitpulse_tui.egg-info → gitpulse_tui-1.2.2}/PKG-INFO +2 -1
  2. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/README.md +14 -3
  3. gitpulse_tui-1.2.2/gitpulse/config.py +169 -0
  4. gitpulse_tui-1.2.2/gitpulse/digest.py +165 -0
  5. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/git_ops.py +276 -8
  6. gitpulse_tui-1.2.2/gitpulse/main.py +560 -0
  7. gitpulse_tui-1.2.2/gitpulse/parallel.py +61 -0
  8. gitpulse_tui-1.2.2/gitpulse/stale.py +84 -0
  9. gitpulse_tui-1.2.2/gitpulse/ui/bulk_results.py +110 -0
  10. gitpulse_tui-1.2.2/gitpulse/ui/command_palette.py +134 -0
  11. gitpulse_tui-1.2.2/gitpulse/ui/digest_screen.py +222 -0
  12. gitpulse_tui-1.2.2/gitpulse/ui/fleet_status.py +119 -0
  13. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/sidebar.py +125 -31
  14. gitpulse_tui-1.2.2/gitpulse/ui/stale_screen.py +280 -0
  15. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/styles.tcss +139 -104
  16. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/tabs.py +95 -86
  17. gitpulse_tui-1.2.2/gitpulse/utils.py +93 -0
  18. gitpulse_tui-1.2.2/gitpulse/watcher.py +62 -0
  19. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2/gitpulse_tui.egg-info}/PKG-INFO +2 -1
  20. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/SOURCES.txt +10 -0
  21. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/requires.txt +3 -0
  22. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/pyproject.toml +2 -1
  23. gitpulse_tui-1.2.0/gitpulse/main.py +0 -271
  24. gitpulse_tui-1.2.0/gitpulse/utils.py +0 -51
  25. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/LICENSE +0 -0
  26. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/__init__.py +0 -0
  27. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/__main__.py +0 -0
  28. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/scanner.py +0 -0
  29. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse/ui/__init__.py +0 -0
  30. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
  31. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/entry_points.txt +0 -0
  32. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/top_level.txt +0 -0
  33. {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.0
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
  ![Commits tab](ss/commits.png)
16
16
 
17
- ## Install (one command)
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/yourname/git-tui.git
31
+ git clone https://github.com/lebiraja/git-tui.git
21
32
  cd git-tui
22
33
  ./install.sh
23
34
  ```
24
35
 
25
- That's it. The installer:
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)