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.
Files changed (31) hide show
  1. {gitpulse_tui-1.2.4/gitpulse_tui.egg-info → gitpulse_tui-1.2.6}/PKG-INFO +1 -1
  2. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/README.md +2 -2
  3. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/digest.py +4 -3
  4. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/git_ops.py +41 -26
  5. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/main.py +54 -31
  6. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/scanner.py +2 -1
  7. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/digest_screen.py +4 -8
  8. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/fleet_status.py +19 -0
  9. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/stale_screen.py +1 -1
  10. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/tabs.py +54 -26
  11. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/utils.py +1 -1
  12. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/watcher.py +7 -5
  13. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6/gitpulse_tui.egg-info}/PKG-INFO +1 -1
  14. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/pyproject.toml +1 -1
  15. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/LICENSE +0 -0
  16. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/__init__.py +0 -0
  17. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/__main__.py +0 -0
  18. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/config.py +0 -0
  19. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/parallel.py +0 -0
  20. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/stale.py +0 -0
  21. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/__init__.py +0 -0
  22. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/bulk_results.py +0 -0
  23. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/command_palette.py +0 -0
  24. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/sidebar.py +0 -0
  25. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse/ui/styles.tcss +0 -0
  26. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/SOURCES.txt +0 -0
  27. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
  28. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/entry_points.txt +0 -0
  29. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/requires.txt +0 -0
  30. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/gitpulse_tui.egg-info/top_level.txt +0 -0
  31. {gitpulse_tui-1.2.4 → gitpulse_tui-1.2.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpulse-tui
3
- Version: 1.2.4
3
+ Version: 1.2.6
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
@@ -47,9 +47,9 @@ source ~/.zshrc # or source ~/.bashrc
47
47
  ## Usage
48
48
 
49
49
  ```bash
50
- gitpulse # scans ~/projects (default)
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 in case multiple patterns matched same commit
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
- if c.short_hash not in seen:
85
- seen.add(c.short_hash)
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 buckets)
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
- for c in repo.iter_commits(max_count=200):
208
- age_days = (now_ts - float(c.committed_date)) / 86400
209
- week_idx = int(age_days // 7)
210
- if 0 <= week_idx < 7:
211
- activity[week_idx] += 1
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 (InvalidGitRepositoryError, Exception):
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 (working tree vs index) as a string.
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
- diff_text = repo.git.diff()
364
- if not diff_text:
365
- staged_diff = repo.git.diff("--cached")
366
- if staged_diff:
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 diff_text
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
- tags: list[TagInfo] = []
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
- tags.append(TagInfo(
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
- tags.append(TagInfo(name=tag_ref.name, date="", message="", tagger=""))
514
+ tagged.append((0.0, TagInfo(name=tag_ref.name, date="", message="", tagger="")))
502
515
  except Exception:
503
516
  pass
504
517
 
505
- # Sort by date descending, take first n
506
- tags.sort(key=lambda t: t.date, reverse=True)
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
- from gitpulse.git_ops import git_fetch, git_pull, git_push, git_gc, git_remote_prune, git_clean_dry, get_repo_info
199
- from gitpulse.parallel import run_parallel
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, re-populate sidebar."""
386
+ """Filter the repo list by name, preserving any active fleet filter."""
370
387
  q = query.strip().lower()
371
- if q:
372
- self.repos = [r for r in self._all_repos if q in r.name.lower()]
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 _apply_fleet_filter(self, category: str) -> None:
382
- """Filter sidebar to repos matching a fleet-status category."""
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(category)
406
+ pred = _predicates.get(self._fleet_category)
392
407
  if pred is None:
393
- self.repos = list(self._all_repos)
394
- else:
395
- self.repos = [r for r in self._all_repos if pred(r)]
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
- result = switch_branch(self._selected_repo.path, message.branch_name)
433
- self.notify(result, timeout=3)
454
+ path = self._selected_repo.path
455
+ branch_name = message.branch_name
434
456
 
435
- # Refresh the selected repo's data
436
- updated_info = get_repo_info(self._selected_repo.path)
437
- self._select_repo(updated_info)
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
- # Also kick off a background rescan to update the sidebar
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 — refresh sidebar entry."""
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 (custom path takes precedence)
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
- root = Path(args.root).expanduser().resolve()
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
- repos.sort(key=lambda p: p.name.lower())
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 'B' to show branches across all repos matching stale/merged/WIP
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
- result = git_fetch(self._current_repo)
1190
- self.app.notify(result, timeout=4)
1191
- self._reload_tab("tab-remotes")
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
- result = git_pull(self._current_repo)
1199
- self.app.notify(result, timeout=5)
1200
- for tab in ("tab-status", "tab-commits", "tab-remotes"):
1201
- self._loaded_tabs.discard(tab)
1202
- self._load_tab(self._active_tab())
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
- result = git_push(self._current_repo)
1211
- self.app.notify(result, timeout=5)
1212
- self._reload_tab("tab-remotes")
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.4"
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) for *repo_path*.
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
- Missing files won't cause false positives after the first stable snapshot.
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpulse-tui
3
- Version: 1.2.4
3
+ Version: 1.2.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitpulse-tui"
7
- version = "1.2.4"
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