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.
Files changed (33) hide show
  1. {gitpulse_tui-1.2.0/gitpulse_tui.egg-info → gitpulse_tui-1.2.1}/PKG-INFO +2 -1
  2. gitpulse_tui-1.2.1/gitpulse/config.py +169 -0
  3. gitpulse_tui-1.2.1/gitpulse/digest.py +165 -0
  4. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/git_ops.py +268 -0
  5. gitpulse_tui-1.2.1/gitpulse/main.py +560 -0
  6. gitpulse_tui-1.2.1/gitpulse/parallel.py +61 -0
  7. gitpulse_tui-1.2.1/gitpulse/stale.py +84 -0
  8. gitpulse_tui-1.2.1/gitpulse/ui/bulk_results.py +110 -0
  9. gitpulse_tui-1.2.1/gitpulse/ui/command_palette.py +135 -0
  10. gitpulse_tui-1.2.1/gitpulse/ui/digest_screen.py +222 -0
  11. gitpulse_tui-1.2.1/gitpulse/ui/fleet_status.py +115 -0
  12. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/sidebar.py +118 -17
  13. gitpulse_tui-1.2.1/gitpulse/ui/stale_screen.py +282 -0
  14. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/styles.tcss +7 -1
  15. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/tabs.py +22 -13
  16. gitpulse_tui-1.2.1/gitpulse/utils.py +93 -0
  17. gitpulse_tui-1.2.1/gitpulse/watcher.py +62 -0
  18. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1/gitpulse_tui.egg-info}/PKG-INFO +2 -1
  19. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/SOURCES.txt +10 -0
  20. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/requires.txt +3 -0
  21. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/pyproject.toml +2 -1
  22. gitpulse_tui-1.2.0/gitpulse/main.py +0 -271
  23. gitpulse_tui-1.2.0/gitpulse/utils.py +0 -51
  24. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/LICENSE +0 -0
  25. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/README.md +0 -0
  26. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/__init__.py +0 -0
  27. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/__main__.py +0 -0
  28. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/scanner.py +0 -0
  29. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse/ui/__init__.py +0 -0
  30. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
  31. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/entry_points.txt +0 -0
  32. {gitpulse_tui-1.2.0 → gitpulse_tui-1.2.1}/gitpulse_tui.egg-info/top_level.txt +0 -0
  33. {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.0
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