gitpulse-tui 1.2.4__tar.gz → 1.2.6__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.4/gitpulse_tui.egg-info → gitpulse_tui-1.2.6}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/README.md +2 -2
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/digest.py +4 -3
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/git_ops.py +41 -26
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/main.py +54 -31
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/scanner.py +2 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/digest_screen.py +4 -8
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/fleet_status.py +19 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/stale_screen.py +1 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/tabs.py +54 -26
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/utils.py +1 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/watcher.py +7 -5
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6/gitpulse_tui.egg-info}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/pyproject.toml +1 -1
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/LICENSE +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/__init__.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/__main__.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/config.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/parallel.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/stale.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/__init__.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/bulk_results.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/command_palette.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/sidebar.py +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/styles.tcss +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/SOURCES.txt +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/entry_points.txt +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/requires.txt +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/top_level.txt +0 -0
- {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/setup.cfg +0 -0
|
@@ -47,9 +47,9 @@ source ~/.zshrc # or source ~/.bashrc
|
|
|
47
47
|
## Usage
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
gitpulse # scans
|
|
50
|
+
gitpulse # scans current directory (or scan.roots from config)
|
|
51
51
|
gitpulse --root /path/to/repos # scans a custom directory
|
|
52
|
-
gitpulse --root . # scans current directory
|
|
52
|
+
gitpulse --root . # scans current directory explicitly
|
|
53
53
|
gitpulse --commits 20 # show 20 commits per repo (default: 10)
|
|
54
54
|
gitpulse --version # print version
|
|
55
55
|
```
|
|
@@ -77,12 +77,13 @@ def _collect_for_repo(
|
|
|
77
77
|
if not all_commits:
|
|
78
78
|
return None
|
|
79
79
|
|
|
80
|
-
# De-duplicate by hash
|
|
80
|
+
# De-duplicate by full hash (not short hash, which can collide in large repos)
|
|
81
81
|
seen: set[str] = set()
|
|
82
82
|
unique: list[AuthorCommit] = []
|
|
83
83
|
for c in all_commits:
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
key = c.full_hash or c.short_hash # full_hash preferred
|
|
85
|
+
if key not in seen:
|
|
86
|
+
seen.add(key)
|
|
86
87
|
unique.append(c)
|
|
87
88
|
|
|
88
89
|
unique.sort(key=lambda c: c.ts, reverse=True)
|
|
@@ -82,6 +82,7 @@ class AuthorCommit:
|
|
|
82
82
|
insertions: int = 0
|
|
83
83
|
deletions: int = 0
|
|
84
84
|
files_changed: int = 0
|
|
85
|
+
full_hash: str = "" # full 40-char SHA used for deduplication
|
|
85
86
|
|
|
86
87
|
|
|
87
88
|
@dataclass
|
|
@@ -201,14 +202,23 @@ def get_repo_info(path: Path) -> RepoInfo:
|
|
|
201
202
|
except Exception:
|
|
202
203
|
contributor_count = 0
|
|
203
204
|
|
|
204
|
-
# Commit activity: commits per week for the last 7 weeks (sparkline
|
|
205
|
+
# Commit activity: commits per week for the last 7 weeks (sparkline).
|
|
206
|
+
# Use a fast plumbing command (timestamps only) instead of loading
|
|
207
|
+
# full commit objects, which is ~10x faster on large repos.
|
|
205
208
|
try:
|
|
206
209
|
now_ts = time.time()
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
cutoff = int(now_ts - 7 * 7 * 86400)
|
|
211
|
+
log_out = repo.git.log(
|
|
212
|
+
"--format=%ct", f"--after={cutoff}", "HEAD",
|
|
213
|
+
)
|
|
214
|
+
for ts_str in log_out.splitlines():
|
|
215
|
+
try:
|
|
216
|
+
age_days = (now_ts - float(ts_str.strip())) / 86400
|
|
217
|
+
week_idx = int(age_days // 7)
|
|
218
|
+
if 0 <= week_idx < 7:
|
|
219
|
+
activity[week_idx] += 1
|
|
220
|
+
except ValueError:
|
|
221
|
+
pass
|
|
212
222
|
activity.reverse() # oldest week first
|
|
213
223
|
except Exception:
|
|
214
224
|
activity = [0] * 7
|
|
@@ -257,7 +267,7 @@ def get_repo_info(path: Path) -> RepoInfo:
|
|
|
257
267
|
except Exception:
|
|
258
268
|
pass
|
|
259
269
|
|
|
260
|
-
except
|
|
270
|
+
except Exception: # InvalidGitRepositoryError, PermissionError, etc.
|
|
261
271
|
branch = "unknown"
|
|
262
272
|
status = RepoStatus.CLEAN
|
|
263
273
|
mod_count = 0
|
|
@@ -355,18 +365,20 @@ def get_commits(path: Path, n: int = 10) -> list[CommitInfo]:
|
|
|
355
365
|
|
|
356
366
|
def get_diff(path: Path) -> str:
|
|
357
367
|
"""
|
|
358
|
-
Return the uncommitted diff
|
|
368
|
+
Return the uncommitted diff as a string.
|
|
369
|
+
|
|
370
|
+
Combines staged (cached) and unstaged changes so both are visible when
|
|
371
|
+
a file has entries in both states. Staged changes are shown first.
|
|
359
372
|
"""
|
|
360
373
|
repo = _open_repo(path)
|
|
361
374
|
|
|
362
375
|
try:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return staged_diff
|
|
376
|
+
staged_diff = repo.git.diff("--cached")
|
|
377
|
+
unstaged_diff = repo.git.diff()
|
|
378
|
+
parts = [p for p in (staged_diff, unstaged_diff) if p]
|
|
379
|
+
if not parts:
|
|
368
380
|
return "No uncommitted changes."
|
|
369
|
-
return
|
|
381
|
+
return "\n".join(parts)
|
|
370
382
|
except Exception as exc:
|
|
371
383
|
return f"Error getting diff: {exc}"
|
|
372
384
|
|
|
@@ -471,40 +483,40 @@ def get_tags(path: Path, n: int = 15) -> list[TagInfo]:
|
|
|
471
483
|
Return the most recent `n` tags, sorted by date descending.
|
|
472
484
|
"""
|
|
473
485
|
repo = _open_repo(path)
|
|
474
|
-
|
|
486
|
+
# Store (timestamp, TagInfo) so we sort by the raw float, not the
|
|
487
|
+
# formatted string (which would put empty-date error entries at the top).
|
|
488
|
+
tagged: list[tuple[float, TagInfo]] = []
|
|
475
489
|
|
|
476
490
|
try:
|
|
477
491
|
for tag_ref in repo.tags:
|
|
478
492
|
try:
|
|
479
|
-
# Annotated tag
|
|
480
493
|
tag_obj = tag_ref.tag
|
|
481
494
|
if tag_obj:
|
|
482
|
-
ts = tag_obj.tagged_date
|
|
495
|
+
ts = float(tag_obj.tagged_date)
|
|
483
496
|
date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
484
497
|
message = (tag_obj.message or "").strip().split("\n")[0]
|
|
485
498
|
tagger = str(tag_obj.tagger) if tag_obj.tagger else ""
|
|
486
499
|
else:
|
|
487
500
|
# Lightweight tag — use commit date
|
|
488
501
|
commit = tag_ref.commit
|
|
489
|
-
ts = commit.committed_date
|
|
502
|
+
ts = float(commit.committed_date)
|
|
490
503
|
date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
491
504
|
message = commit.message.strip().split("\n")[0]
|
|
492
505
|
tagger = str(commit.author)
|
|
493
506
|
|
|
494
|
-
|
|
507
|
+
tagged.append((ts, TagInfo(
|
|
495
508
|
name=tag_ref.name,
|
|
496
509
|
date=date_str,
|
|
497
510
|
message=message,
|
|
498
511
|
tagger=tagger,
|
|
499
|
-
))
|
|
512
|
+
)))
|
|
500
513
|
except Exception:
|
|
501
|
-
|
|
514
|
+
tagged.append((0.0, TagInfo(name=tag_ref.name, date="", message="", tagger="")))
|
|
502
515
|
except Exception:
|
|
503
516
|
pass
|
|
504
517
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return tags[:n]
|
|
518
|
+
tagged.sort(key=lambda x: x[0], reverse=True)
|
|
519
|
+
return [t for _, t in tagged[:n]]
|
|
508
520
|
|
|
509
521
|
|
|
510
522
|
def stage_files(path: Path, files: list[str]) -> str:
|
|
@@ -562,11 +574,13 @@ def commit_changes(path: Path, message: str) -> str:
|
|
|
562
574
|
try:
|
|
563
575
|
if not message.strip():
|
|
564
576
|
return "Error: commit message cannot be empty"
|
|
565
|
-
# Check there is something staged
|
|
577
|
+
# Check there is something staged.
|
|
578
|
+
# On a repo with no commits yet, diff("HEAD") raises — fall back to
|
|
579
|
+
# checking the raw index entries so the very first commit is not blocked.
|
|
566
580
|
try:
|
|
567
581
|
staged = list(repo.index.diff("HEAD"))
|
|
568
582
|
except Exception:
|
|
569
|
-
staged =
|
|
583
|
+
staged = list(repo.index.entries.keys())
|
|
570
584
|
if not staged:
|
|
571
585
|
return "Nothing staged to commit. Stage files first."
|
|
572
586
|
commit_obj = repo.index.commit(message.strip())
|
|
@@ -1049,6 +1063,7 @@ def get_author_commits(
|
|
|
1049
1063
|
|
|
1050
1064
|
commits.append(AuthorCommit(
|
|
1051
1065
|
short_hash=full_hash[:7],
|
|
1066
|
+
full_hash=full_hash,
|
|
1052
1067
|
ts=ts,
|
|
1053
1068
|
message=subject.strip(),
|
|
1054
1069
|
insertions=insertions,
|
|
@@ -97,7 +97,8 @@ class GitPulseApp(App):
|
|
|
97
97
|
self._scanning = False # Guard against concurrent scans
|
|
98
98
|
self._watch_enabled = watch # Whether watch mode is on
|
|
99
99
|
self._watch_paused = False # Toggled by 'w' key
|
|
100
|
-
self._signatures: dict = {} # path → (HEAD mtime, index mtime, refs mtime)
|
|
100
|
+
self._signatures: dict = {} # path → (HEAD mtime, index mtime, refs mtime, packed-refs mtime)
|
|
101
|
+
self._fleet_category: str = "" # Active fleet-filter category ("" = none)
|
|
101
102
|
|
|
102
103
|
# -----------------------------------------------------------------
|
|
103
104
|
# Layout
|
|
@@ -195,8 +196,12 @@ class GitPulseApp(App):
|
|
|
195
196
|
|
|
196
197
|
def _dispatch_bulk(self, action_key: str, repos: list) -> None:
|
|
197
198
|
"""Fan out a bulk git operation over repos using a thread pool worker."""
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
try:
|
|
200
|
+
from gitpulse.git_ops import git_fetch, git_pull, git_push, git_gc, git_remote_prune, git_clean_dry, get_repo_info
|
|
201
|
+
from gitpulse.parallel import run_parallel
|
|
202
|
+
except ImportError:
|
|
203
|
+
from git_ops import git_fetch, git_pull, git_push, git_gc, git_remote_prune, git_clean_dry, get_repo_info # type: ignore[no-redef]
|
|
204
|
+
from parallel import run_parallel # type: ignore[no-redef]
|
|
200
205
|
|
|
201
206
|
_ops = {
|
|
202
207
|
"fetch": lambda r: git_fetch(r.path),
|
|
@@ -287,6 +292,18 @@ class GitPulseApp(App):
|
|
|
287
292
|
self._refresh_single_repo(updated)
|
|
288
293
|
return
|
|
289
294
|
|
|
295
|
+
if group == "branch_switch":
|
|
296
|
+
# Result is (switch_message, updated_RepoInfo)
|
|
297
|
+
switch_msg, updated_info = event.worker.result
|
|
298
|
+
self.notify(switch_msg, timeout=3)
|
|
299
|
+
self._refresh_single_repo(updated_info)
|
|
300
|
+
self._start_scan()
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if group not in (None, "scan"):
|
|
304
|
+
# Unknown group (e.g. git_op owned by MainPanel) — ignore here.
|
|
305
|
+
return
|
|
306
|
+
|
|
290
307
|
# Full scan result
|
|
291
308
|
self._scanning = False
|
|
292
309
|
infos: list[RepoInfo] = event.worker.result
|
|
@@ -366,20 +383,18 @@ class GitPulseApp(App):
|
|
|
366
383
|
main.load_repo(repo_info.path, repo_info)
|
|
367
384
|
|
|
368
385
|
def _apply_filter(self, query: str) -> None:
|
|
369
|
-
"""Filter the repo list by name,
|
|
386
|
+
"""Filter the repo list by name, preserving any active fleet filter."""
|
|
370
387
|
q = query.strip().lower()
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
else:
|
|
374
|
-
self.repos = list(self._all_repos)
|
|
388
|
+
base = self._fleet_filtered_repos()
|
|
389
|
+
self.repos = [r for r in base if q in r.name.lower()] if q else list(base)
|
|
375
390
|
|
|
376
391
|
sidebar: RepoSidebar = self.query_one("#sidebar-container", RepoSidebar)
|
|
377
392
|
sidebar.populate(self.repos)
|
|
378
393
|
if self.repos:
|
|
379
394
|
self._select_repo(self.repos[0])
|
|
380
395
|
|
|
381
|
-
def
|
|
382
|
-
"""
|
|
396
|
+
def _fleet_filtered_repos(self) -> list[RepoInfo]:
|
|
397
|
+
"""Return _all_repos filtered by the current fleet category (if any)."""
|
|
383
398
|
from gitpulse.git_ops import RepoStatus # avoid circular at module level
|
|
384
399
|
_predicates = {
|
|
385
400
|
"dirty": lambda r: r.status != RepoStatus.CLEAN,
|
|
@@ -388,15 +403,22 @@ class GitPulseApp(App):
|
|
|
388
403
|
"stashes": lambda r: r.stash_count > 0,
|
|
389
404
|
"stale": lambda r: r.has_stale_branches,
|
|
390
405
|
}
|
|
391
|
-
pred = _predicates.get(
|
|
406
|
+
pred = _predicates.get(self._fleet_category)
|
|
392
407
|
if pred is None:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
408
|
+
return list(self._all_repos)
|
|
409
|
+
return [r for r in self._all_repos if pred(r)]
|
|
410
|
+
|
|
411
|
+
def _apply_fleet_filter(self, category: str) -> None:
|
|
412
|
+
"""Filter sidebar to repos matching a fleet-status category and highlight chip."""
|
|
413
|
+
self._fleet_category = category
|
|
414
|
+
self.repos = self._fleet_filtered_repos()
|
|
396
415
|
|
|
397
416
|
sidebar: RepoSidebar = self.query_one("#sidebar-container", RepoSidebar)
|
|
398
417
|
sidebar.populate(self.repos)
|
|
399
418
|
|
|
419
|
+
fleet: FleetStatus = self.query_one("#fleet-status", FleetStatus)
|
|
420
|
+
fleet.set_active_filter(category)
|
|
421
|
+
|
|
400
422
|
if self.repos:
|
|
401
423
|
self._select_repo(self.repos[0])
|
|
402
424
|
|
|
@@ -429,23 +451,20 @@ class GitPulseApp(App):
|
|
|
429
451
|
if self._selected_repo is None:
|
|
430
452
|
return
|
|
431
453
|
|
|
432
|
-
|
|
433
|
-
|
|
454
|
+
path = self._selected_repo.path
|
|
455
|
+
branch_name = message.branch_name
|
|
434
456
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
457
|
+
def _do_switch() -> tuple[str, RepoInfo]:
|
|
458
|
+
msg = switch_branch(path, branch_name)
|
|
459
|
+
info = get_repo_info(path)
|
|
460
|
+
return msg, info
|
|
438
461
|
|
|
439
|
-
|
|
440
|
-
self._start_scan()
|
|
462
|
+
self.run_worker(_do_switch, thread=True, group="branch_switch", exclusive=False)
|
|
441
463
|
|
|
442
464
|
def on_main_panel_reload_requested(self, message: MainPanel.ReloadRequested) -> None:
|
|
443
|
-
"""Fired after a commit or branch operation —
|
|
465
|
+
"""Fired after a commit or branch operation — rescan to update sidebar."""
|
|
444
466
|
if self._selected_repo is None:
|
|
445
467
|
return
|
|
446
|
-
updated_info = get_repo_info(self._selected_repo.path)
|
|
447
|
-
self._selected_repo = updated_info
|
|
448
|
-
# Rescan to update sidebar badges/timestamps
|
|
449
468
|
self._start_scan()
|
|
450
469
|
|
|
451
470
|
|
|
@@ -462,8 +481,8 @@ def parse_args() -> argparse.Namespace:
|
|
|
462
481
|
parser.add_argument(
|
|
463
482
|
"--root",
|
|
464
483
|
type=str,
|
|
465
|
-
default=
|
|
466
|
-
help="Root directory to scan for git repos (default: current directory)",
|
|
484
|
+
default=None,
|
|
485
|
+
help="Root directory to scan for git repos (default: first entry in config scan.roots, or current directory)",
|
|
467
486
|
)
|
|
468
487
|
parser.add_argument(
|
|
469
488
|
"--commits",
|
|
@@ -518,18 +537,22 @@ def main() -> None:
|
|
|
518
537
|
"""Entry point — called by both `python main.py` and the `gitpulse` command."""
|
|
519
538
|
args = parse_args()
|
|
520
539
|
|
|
521
|
-
# Load config
|
|
540
|
+
# Load config first so scan.roots can influence the default root.
|
|
522
541
|
if args.config:
|
|
523
542
|
_config.load(Path(args.config))
|
|
543
|
+
cfg = _config.get()
|
|
524
544
|
|
|
525
|
-
|
|
545
|
+
if args.root is not None:
|
|
546
|
+
root = Path(args.root).expanduser().resolve()
|
|
547
|
+
elif cfg.scan.roots:
|
|
548
|
+
root = Path(cfg.scan.roots[0]).expanduser().resolve()
|
|
549
|
+
else:
|
|
550
|
+
root = Path(".").resolve()
|
|
526
551
|
|
|
527
552
|
if not root.is_dir():
|
|
528
553
|
print(f"Error: '{root}' is not a valid directory.", file=sys.stderr)
|
|
529
554
|
sys.exit(1)
|
|
530
555
|
|
|
531
|
-
cfg = _config.get()
|
|
532
|
-
|
|
533
556
|
if args.digest:
|
|
534
557
|
# CLI digest mode — no TUI
|
|
535
558
|
from gitpulse.scanner import scan_repos as _scan
|
|
@@ -44,7 +44,8 @@ def scan_repos(root: Path) -> list[Path]:
|
|
|
44
44
|
|
|
45
45
|
repos: list[Path] = []
|
|
46
46
|
_walk(root, repos)
|
|
47
|
-
|
|
47
|
+
# No alphabetical sort here — the caller (_scan_worker) re-sorts by
|
|
48
|
+
# commit timestamp, making a pre-sort wasted work.
|
|
48
49
|
return repos
|
|
49
50
|
|
|
50
51
|
|
|
@@ -26,13 +26,6 @@ except ImportError:
|
|
|
26
26
|
from utils import parse_since, relative_time # type: ignore
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
_WINDOWS = {
|
|
30
|
-
"1": ("1d", "Today"),
|
|
31
|
-
"7": ("7d", "7 days"),
|
|
32
|
-
"3": ("30d", "30 days"),
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
29
|
class DigestScreen(ModalScreen):
|
|
37
30
|
"""Full-screen activity digest modal."""
|
|
38
31
|
|
|
@@ -134,11 +127,14 @@ class DigestScreen(ModalScreen):
|
|
|
134
127
|
body.update(f"[bold #ff5252]Error: {e}[/]")
|
|
135
128
|
return
|
|
136
129
|
|
|
137
|
-
# Run in a worker so we don't block the UI
|
|
130
|
+
# Run in a worker so we don't block the UI.
|
|
131
|
+
# exclusive=True cancels any in-flight digest worker before starting
|
|
132
|
+
# a new one, preventing a race when the user switches windows rapidly.
|
|
138
133
|
self.run_worker(
|
|
139
134
|
lambda: build_digest(self._repos, since_ts, self._author_patterns),
|
|
140
135
|
thread=True,
|
|
141
136
|
group="digest",
|
|
137
|
+
exclusive=True,
|
|
142
138
|
)
|
|
143
139
|
|
|
144
140
|
def on_worker_state_changed(self, event) -> None:
|
|
@@ -79,6 +79,10 @@ class FleetStatus(Widget):
|
|
|
79
79
|
super().__init__()
|
|
80
80
|
self.category = category
|
|
81
81
|
|
|
82
|
+
def __init__(self, **kwargs) -> None:
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self._active_filter: str = ""
|
|
85
|
+
|
|
82
86
|
def compose(self) -> ComposeResult:
|
|
83
87
|
yield Static("fleet:", id="fleet-label", markup=False)
|
|
84
88
|
yield FleetChip("dirty", id="chip-dirty")
|
|
@@ -111,6 +115,21 @@ class FleetStatus(Widget):
|
|
|
111
115
|
self._set_chip("chip-stashes", "⊞ stash", total_stashes, "#4dd0e1")
|
|
112
116
|
self._set_chip("chip-stale", "☠ stale", n_stale, "#e040fb")
|
|
113
117
|
|
|
118
|
+
def set_active_filter(self, category: str) -> None:
|
|
119
|
+
"""Highlight the active filter chip and dim the rest."""
|
|
120
|
+
self._active_filter = category
|
|
121
|
+
chip_map = {
|
|
122
|
+
"dirty": "chip-dirty", "behind": "chip-behind",
|
|
123
|
+
"ahead": "chip-ahead", "stashes": "chip-stashes",
|
|
124
|
+
"stale": "chip-stale", "all": "chip-all",
|
|
125
|
+
}
|
|
126
|
+
for cat, cid in chip_map.items():
|
|
127
|
+
chip: FleetChip = self.query_one(f"#{cid}", FleetChip)
|
|
128
|
+
if cat == category and category not in ("all", ""):
|
|
129
|
+
chip.add_class("-active-filter")
|
|
130
|
+
else:
|
|
131
|
+
chip.remove_class("-active-filter")
|
|
132
|
+
|
|
114
133
|
def _set_chip(self, widget_id: str, label: str, count: int, color: str) -> None:
|
|
115
134
|
chip: FleetChip = self.query_one(f"#{widget_id}", FleetChip)
|
|
116
135
|
if count == 0:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
stale_screen.py — Stale-branch cleanup modal for GitPulse.
|
|
3
3
|
|
|
4
|
-
Opened with '
|
|
4
|
+
Opened with 'b' to show branches across all repos matching stale/merged/WIP
|
|
5
5
|
criteria. Supports multi-select and bulk delete with a typed confirmation.
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -74,6 +74,19 @@ _ICON_UNSTAGED = "✏️ "
|
|
|
74
74
|
_ICON_UNTRACKED = "❓"
|
|
75
75
|
_ICON_STASH = "📦"
|
|
76
76
|
|
|
77
|
+
# Module-level constant — avoids rebuilding this dict on every recursive
|
|
78
|
+
# _build() call during Tree tab loading.
|
|
79
|
+
_FILE_ICONS: dict[str, str] = {
|
|
80
|
+
"py": "🐍", "js": "🟨", "ts": "🟦", "go": "🐹",
|
|
81
|
+
"rs": "⚙️", "c": "🔧", "cpp": "🔧", "java": "☕",
|
|
82
|
+
"md": "📝", "rst": "📝", "txt": "📝",
|
|
83
|
+
"json": "📋", "yaml": "📋", "yml": "📋",
|
|
84
|
+
"toml": "📋", "ini": "📋", "cfg": "📋", "env": "🔒",
|
|
85
|
+
"sh": "📜", "bash": "📜", "zsh": "📜",
|
|
86
|
+
"html": "🌐", "css": "🎨", "tcss": "🎨",
|
|
87
|
+
"sql": "🗄️",
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
|
|
78
91
|
# ===================================================================
|
|
79
92
|
# Modal: Commit dialog
|
|
@@ -1008,17 +1021,6 @@ class MainPanel(Widget):
|
|
|
1008
1021
|
_build(child, d[name], f"{prefix}{name}/")
|
|
1009
1022
|
for name in files:
|
|
1010
1023
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
1011
|
-
# Language-specific file icons for visual identification
|
|
1012
|
-
_FILE_ICONS = {
|
|
1013
|
-
"py": "🐍", "js": "🟨", "ts": "🟦", "go": "🐹",
|
|
1014
|
-
"rs": "⚙️", "c": "🔧", "cpp": "🔧", "java": "☕",
|
|
1015
|
-
"md": "📝", "rst": "📝", "txt": "📝",
|
|
1016
|
-
"json": "📋", "yaml": "📋", "yml": "📋",
|
|
1017
|
-
"toml": "📋", "ini": "📋", "cfg": "📋", "env": "🔒",
|
|
1018
|
-
"sh": "📜", "bash": "📜", "zsh": "📜",
|
|
1019
|
-
"html": "🌐", "css": "🎨", "tcss": "🎨",
|
|
1020
|
-
"sql": "🗄️",
|
|
1021
|
-
}
|
|
1022
1024
|
icon = _FILE_ICONS.get(ext, "📄")
|
|
1023
1025
|
if ext in ("py", "js", "ts", "go", "rs", "c", "cpp", "java"):
|
|
1024
1026
|
label = f"[#3ddc84]{icon} {name}[/]"
|
|
@@ -1182,34 +1184,60 @@ class MainPanel(Widget):
|
|
|
1182
1184
|
self.post_message(self.ReloadRequested())
|
|
1183
1185
|
|
|
1184
1186
|
def action_fetch(self) -> None:
|
|
1185
|
-
"""Fetch from all remotes (f key in Remotes tab)."""
|
|
1187
|
+
"""Fetch from all remotes (f key in Remotes tab) — runs in background."""
|
|
1186
1188
|
if self._current_repo is None:
|
|
1187
1189
|
return
|
|
1188
1190
|
self.app.notify("Fetching…", timeout=2)
|
|
1189
|
-
|
|
1190
|
-
self.
|
|
1191
|
-
|
|
1191
|
+
path = self._current_repo
|
|
1192
|
+
self.run_worker(
|
|
1193
|
+
lambda: ("fetch", git_fetch(path)),
|
|
1194
|
+
thread=True, group="git_op", exclusive=False,
|
|
1195
|
+
)
|
|
1192
1196
|
|
|
1193
1197
|
def action_pull(self) -> None:
|
|
1194
|
-
"""Pull from the tracking branch (p key in Remotes tab)."""
|
|
1198
|
+
"""Pull from the tracking branch (p key in Remotes tab) — runs in background."""
|
|
1195
1199
|
if self._current_repo is None:
|
|
1196
1200
|
return
|
|
1197
1201
|
self.app.notify("Pulling…", timeout=2)
|
|
1198
|
-
|
|
1199
|
-
self.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
self.post_message(self.ReloadRequested())
|
|
1202
|
+
path = self._current_repo
|
|
1203
|
+
self.run_worker(
|
|
1204
|
+
lambda: ("pull", git_pull(path)),
|
|
1205
|
+
thread=True, group="git_op", exclusive=False,
|
|
1206
|
+
)
|
|
1204
1207
|
|
|
1205
1208
|
def action_push(self) -> None:
|
|
1206
|
-
"""Push to the tracking branch (P key in Remotes tab)."""
|
|
1209
|
+
"""Push to the tracking branch (P key in Remotes tab) — runs in background."""
|
|
1207
1210
|
if self._current_repo is None:
|
|
1208
1211
|
return
|
|
1209
1212
|
self.app.notify("Pushing…", timeout=2)
|
|
1210
|
-
|
|
1211
|
-
self.
|
|
1212
|
-
|
|
1213
|
+
path = self._current_repo
|
|
1214
|
+
self.run_worker(
|
|
1215
|
+
lambda: ("push", git_push(path)),
|
|
1216
|
+
thread=True, group="git_op", exclusive=False,
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
def on_worker_state_changed(self, event) -> None:
|
|
1220
|
+
"""Handle results from background git_op workers (fetch/pull/push)."""
|
|
1221
|
+
from textual.worker import WorkerState
|
|
1222
|
+
group = getattr(event.worker, "group", None)
|
|
1223
|
+
if group != "git_op":
|
|
1224
|
+
return
|
|
1225
|
+
event.stop() # Don't let it bubble to the App's handler
|
|
1226
|
+
if event.state == WorkerState.SUCCESS and event.worker.result is not None:
|
|
1227
|
+
op, result_msg = event.worker.result
|
|
1228
|
+
self.app.notify(result_msg, timeout=5)
|
|
1229
|
+
if op in ("pull", "fetch"):
|
|
1230
|
+
for tab in ("tab-status", "tab-commits", "tab-remotes"):
|
|
1231
|
+
self._loaded_tabs.discard(tab)
|
|
1232
|
+
else:
|
|
1233
|
+
self._loaded_tabs.discard("tab-remotes")
|
|
1234
|
+
self._load_tab(self._active_tab())
|
|
1235
|
+
self.post_message(self.ReloadRequested())
|
|
1236
|
+
elif event.state == WorkerState.ERROR:
|
|
1237
|
+
self.app.notify(
|
|
1238
|
+
f"Git operation failed: {event.worker.error}",
|
|
1239
|
+
severity="error", timeout=5,
|
|
1240
|
+
)
|
|
1213
1241
|
|
|
1214
1242
|
|
|
1215
1243
|
def _delete_selected_branch(self) -> None:
|
|
@@ -14,7 +14,7 @@ from datetime import datetime, timezone, timedelta
|
|
|
14
14
|
# Package version — single source of truth
|
|
15
15
|
# ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
|
-
__version__ = "1.2.
|
|
17
|
+
__version__ = "1.2.6"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
# ---------------------------------------------------------------------------
|
|
@@ -20,11 +20,12 @@ except ImportError:
|
|
|
20
20
|
from git_ops import RepoInfo # type: ignore[no-redef]
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def repo_signature(repo_path: Path) -> tuple[float, float, float]:
|
|
24
|
-
"""Return (HEAD mtime, index mtime, refs/heads mtime
|
|
23
|
+
def repo_signature(repo_path: Path) -> tuple[float, float, float, float]:
|
|
24
|
+
"""Return (HEAD mtime, index mtime, refs/heads mtime, packed-refs mtime).
|
|
25
25
|
|
|
26
26
|
Any unreadable path contributes 0.0 so the tuple is always well-typed.
|
|
27
|
-
|
|
27
|
+
packed-refs is checked alongside refs/heads because cloned or gc'd repos
|
|
28
|
+
store references there rather than as individual files under refs/heads/.
|
|
28
29
|
"""
|
|
29
30
|
def _mtime(p: Path) -> float:
|
|
30
31
|
try:
|
|
@@ -37,17 +38,18 @@ def repo_signature(repo_path: Path) -> tuple[float, float, float]:
|
|
|
37
38
|
_mtime(git_dir / "HEAD"),
|
|
38
39
|
_mtime(git_dir / "index"),
|
|
39
40
|
_mtime(git_dir / "refs" / "heads"),
|
|
41
|
+
_mtime(git_dir / "packed-refs"),
|
|
40
42
|
)
|
|
41
43
|
|
|
42
44
|
|
|
43
|
-
def snapshot(repos: list[RepoInfo]) -> dict[Path, tuple[float, float, float]]:
|
|
45
|
+
def snapshot(repos: list[RepoInfo]) -> dict[Path, tuple[float, float, float, float]]:
|
|
44
46
|
"""Build a path → signature map for all repos. O(N) stat calls."""
|
|
45
47
|
return {r.path: repo_signature(r.path) for r in repos}
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
def changed_repos(
|
|
49
51
|
repos: list[RepoInfo],
|
|
50
|
-
previous: dict[Path, tuple[float, float, float]],
|
|
52
|
+
previous: dict[Path, tuple[float, float, float, float]],
|
|
51
53
|
) -> list[RepoInfo]:
|
|
52
54
|
"""Return repos whose signature differs from *previous*.
|
|
53
55
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitpulse-tui"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.6"
|
|
8
8
|
description = "Git Repo Dashboard TUI — live status, commits, diffs, and branches in your terminal"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|