gitdirector 1.4.0__tar.gz → 1.4.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 (38) hide show
  1. {gitdirector-1.4.0 → gitdirector-1.4.2}/PKG-INFO +1 -1
  2. {gitdirector-1.4.0 → gitdirector-1.4.2}/pyproject.toml +1 -1
  3. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/cli.py +2 -0
  4. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/__init__.py +20 -3
  5. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/help.py +2 -1
  6. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app.py +21 -4
  7. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_repos.py +36 -4
  8. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_sessions.py +47 -9
  9. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_ui.py +22 -1
  10. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/constants.py +2 -0
  11. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/screens.py +5 -3
  12. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/monitor.py +3 -3
  13. gitdirector-1.4.2/src/gitdirector/version_check.py +170 -0
  14. {gitdirector-1.4.0 → gitdirector-1.4.2}/README.md +0 -0
  15. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/__init__.py +0 -0
  16. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/autoclean.py +0 -0
  17. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/cd.py +0 -0
  18. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/info.py +0 -0
  19. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/link.py +0 -0
  20. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/listt.py +0 -0
  21. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/pull.py +0 -0
  22. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/status.py +0 -0
  23. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/__init__.py +0 -0
  24. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_panels.py +0 -0
  25. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/panel_view.py +0 -0
  26. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/panels.py +0 -0
  27. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/terminal_widget.py +0 -0
  28. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/unlink.py +0 -0
  29. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/config.py +0 -0
  30. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/info.py +0 -0
  31. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/__init__.py +0 -0
  32. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/__init__.py +0 -0
  33. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/core.py +0 -0
  34. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/panels.py +0 -0
  35. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/manager.py +0 -0
  36. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/repo.py +0 -0
  37. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/storage.py +0 -0
  38. {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/ui_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gitdirector
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place.
5
5
  Keywords: git,repository,manager,cli,synchronization,batch
6
6
  Author: Anito Anto
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gitdirector"
7
- version = "1.4.0"
7
+ version = "1.4.2"
8
8
  description = "A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -14,6 +14,7 @@ from .commands import (
14
14
  info,
15
15
  link,
16
16
  listt,
17
+ print_update_notice,
17
18
  pull,
18
19
  status,
19
20
  tui,
@@ -39,6 +40,7 @@ class _HelpGroup(click.Group):
39
40
  @click.group(cls=_HelpGroup, invoke_without_command=True)
40
41
  @click.pass_context
41
42
  def cli(ctx):
43
+ print_update_notice()
42
44
  if ctx.invoked_subcommand is None:
43
45
  show_help()
44
46
 
@@ -1,17 +1,17 @@
1
1
  from typing import Optional
2
2
 
3
+ import click
3
4
  from rich import box
4
5
  from rich.console import Console
5
6
  from rich.table import Table
6
7
  from rich.text import Text
7
8
 
9
+ from .. import version_check
8
10
  from ..repo import RepoStatus
9
11
 
10
12
 
11
13
  def _get_version() -> str:
12
- from importlib.metadata import version
13
-
14
- return version("gitdirector")
14
+ return version_check.get_installed_version()
15
15
 
16
16
 
17
17
  __version__: Optional[str] = None
@@ -24,6 +24,23 @@ def get_version() -> str:
24
24
  return __version__
25
25
 
26
26
 
27
+ def print_update_notice() -> None:
28
+ ctx = click.get_current_context(silent=True)
29
+ if ctx is not None:
30
+ root_ctx = ctx.find_root()
31
+ if root_ctx.meta.get("update_notice_printed"):
32
+ return
33
+ root_ctx.meta["update_notice_printed"] = True
34
+
35
+ notice = version_check.get_update_notice()
36
+ if notice is None:
37
+ return
38
+
39
+ console.print()
40
+ console.print(f" [yellow]{notice}[/yellow]")
41
+ console.print()
42
+
43
+
27
44
  console = Console(highlight=False)
28
45
 
29
46
  _STATUS_COLOR = {
@@ -1,10 +1,11 @@
1
1
  import click
2
2
  from rich.table import Table
3
3
 
4
- from . import console, get_version
4
+ from . import console, get_version, print_update_notice
5
5
 
6
6
 
7
7
  def show_help():
8
+ print_update_notice()
8
9
  console.print()
9
10
  console.print(
10
11
  f" [bold white]GITDIRECTOR[/bold white] "
@@ -13,6 +13,7 @@ from textual.binding import Binding
13
13
  from textual.containers import Horizontal
14
14
  from textual.widgets import DataTable, Footer, Header, Input, Static, TabbedContent, TabPane
15
15
 
16
+ from ... import version_check
16
17
  from ...manager import RepositoryManager
17
18
  from ...repo import Repository, RepositoryInfo
18
19
  from .. import get_version
@@ -25,6 +26,7 @@ from .constants import (
25
26
  _DEFAULT_PANELS_SORT_COLUMN,
26
27
  _DEFAULT_SESSIONS_SORT_COLUMN,
27
28
  _DEFAULT_SORT_COLUMN,
29
+ _SESSION_STATUS_POLL_INTERVAL_SECS,
28
30
  )
29
31
  from .panels import Panel, PanelStore
30
32
  from .screens import (
@@ -205,7 +207,10 @@ class GitDirectorConsole(
205
207
  self._resume_selection_row: int | None = None
206
208
  self._resume_new_panel_guard_until: float = 0.0
207
209
  self._panel_store = PanelStore()
210
+ self._status_message = "Loading repositories…"
211
+ self._update_notice = version_check.get_cached_update_notice()
208
212
  self._session_status_tracking_paused = False
213
+ self._session_status_tracking_running = False
209
214
 
210
215
  def compose(self) -> ComposeResult:
211
216
  yield Header(show_clock=True)
@@ -236,7 +241,7 @@ class GitDirectorConsole(
236
241
  with Horizontal(id="search-container"):
237
242
  yield Static("/ search:", id="search-label")
238
243
  yield Input(placeholder="type to filter…", id="search-bar")
239
- yield Static("Loading repositories…", id="status-bar")
244
+ yield Static(self._compose_status_message(self._status_message), id="status-bar")
240
245
  yield Footer()
241
246
 
242
247
  def on_mount(self) -> None:
@@ -254,10 +259,22 @@ class GitDirectorConsole(
254
259
  )
255
260
  self.app_resume_signal.subscribe(self, self._handle_app_resume)
256
261
  self._sync_tmux_theme_config(self.theme)
257
- self._poll_timer = self.set_interval(3, self._trigger_status_poll)
258
- self._monitor.start()
262
+ self._poll_timer = self.set_interval(
263
+ _SESSION_STATUS_POLL_INTERVAL_SECS,
264
+ self._trigger_status_poll,
265
+ )
266
+ self._set_session_status_tracking_running(False)
267
+ self._load_update_notice()
259
268
  self._load_repos()
260
- self._trigger_status_poll()
269
+
270
+ @work(thread=True)
271
+ def _load_update_notice(self) -> None:
272
+ notice = version_check.format_update_notice(version_check.get_update_status())
273
+ self.call_from_thread(self._set_update_notice, notice)
274
+
275
+ def _set_update_notice(self, notice: str | None) -> None:
276
+ self._update_notice = notice
277
+ self._refresh_status_bar()
261
278
 
262
279
  def _sync_tmux_theme_config(self, theme_name: str | None = None) -> None:
263
280
  from ...integrations.tmux import sync_panel_tmux_config
@@ -87,6 +87,13 @@ class ConsoleReposMixin:
87
87
 
88
88
  def _populate_initial_rows(self) -> None:
89
89
  table = self.query_one("#repo-table", DataTable)
90
+ preserved_row_key = None
91
+ preserved_row_index = None
92
+ restore_focus = False
93
+ if self._resume_selection_tab != "repos":
94
+ preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
95
+ table
96
+ )
90
97
  table.clear()
91
98
  for path in self._repo_paths:
92
99
  table.add_row(
@@ -99,7 +106,16 @@ class ConsoleReposMixin:
99
106
  str(path),
100
107
  key=str(path),
101
108
  )
109
+
110
+ if self._resume_selection_tab == "repos":
102
111
  self._restore_resume_selection("repos")
112
+ else:
113
+ self._restore_table_selection(
114
+ table,
115
+ preserved_row_key,
116
+ preserved_row_index,
117
+ restore_focus=restore_focus,
118
+ )
103
119
 
104
120
  def _update_row(self, info: RepositoryInfo, sessions: int = 0) -> None:
105
121
  self._sessions_cache[str(info.path)] = sessions
@@ -138,6 +154,13 @@ class ConsoleReposMixin:
138
154
 
139
155
  def _apply_filter_and_sort(self) -> None:
140
156
  table = self.query_one("#repo-table", DataTable)
157
+ preserved_row_key = None
158
+ preserved_row_index = None
159
+ restore_focus = False
160
+ if self._resume_selection_tab != "repos":
161
+ preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
162
+ table
163
+ )
141
164
  table.clear()
142
165
 
143
166
  infos = list(self._results.values())
@@ -168,7 +191,15 @@ class ConsoleReposMixin:
168
191
  key=str(info.path),
169
192
  )
170
193
 
171
- self._restore_resume_selection("repos")
194
+ if self._resume_selection_tab == "repos":
195
+ self._restore_resume_selection("repos")
196
+ else:
197
+ self._restore_table_selection(
198
+ table,
199
+ preserved_row_key,
200
+ preserved_row_index,
201
+ restore_focus=restore_focus,
202
+ )
172
203
  self._update_status(self._build_loaded_status(len(infos), total))
173
204
 
174
205
  def _build_loaded_status(self, shown: int, total: int) -> str:
@@ -202,10 +233,11 @@ class ConsoleReposMixin:
202
233
  return msg
203
234
 
204
235
  def action_refresh(self) -> None:
205
- self._results.clear()
206
- self._sessions_cache.clear()
207
- self._load_repos()
208
236
  if self._active_tab == "sessions":
209
237
  self._load_sessions()
210
238
  elif self._active_tab == "panels":
211
239
  self._load_panels()
240
+ else:
241
+ self._results.clear()
242
+ self._sessions_cache.clear()
243
+ self._load_repos()
@@ -39,6 +39,13 @@ class ConsoleSessionsMixin:
39
39
  table = self.query_one("#sessions-table", DataTable)
40
40
  except NoMatches:
41
41
  return
42
+ preserved_row_key = None
43
+ preserved_row_index = None
44
+ restore_focus = False
45
+ if self._resume_selection_tab != "sessions":
46
+ preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
47
+ table
48
+ )
42
49
  table.clear()
43
50
  no_msg = self.query_one("#no-sessions-message", Static)
44
51
 
@@ -86,7 +93,15 @@ class ConsoleSessionsMixin:
86
93
  key=entry["session_name"],
87
94
  )
88
95
 
89
- self._restore_resume_selection("sessions")
96
+ if self._resume_selection_tab == "sessions":
97
+ self._restore_resume_selection("sessions")
98
+ else:
99
+ self._restore_table_selection(
100
+ table,
101
+ preserved_row_key,
102
+ preserved_row_index,
103
+ restore_focus=restore_focus,
104
+ )
90
105
  self._update_status(self._build_sessions_loaded_status(len(entries), total))
91
106
 
92
107
  def _build_sessions_loaded_status(self, shown: int, total: int) -> str:
@@ -121,31 +136,54 @@ class ConsoleSessionsMixin:
121
136
  msg += " [esc] clear search"
122
137
  return msg
123
138
 
139
+ def _should_run_session_status_tracking(self) -> bool:
140
+ return self._active_tab == "sessions" and not self._session_status_tracking_paused
141
+
142
+ def _set_session_status_tracking_running(self, running: bool) -> None:
143
+ poll_timer = getattr(self, "_poll_timer", None)
144
+
145
+ if running:
146
+ if self._session_status_tracking_running:
147
+ return
148
+ self._monitor.start()
149
+ if poll_timer is not None:
150
+ poll_timer.resume()
151
+ self._session_status_tracking_running = True
152
+ return
153
+
154
+ if poll_timer is not None:
155
+ poll_timer.pause()
156
+ if self._session_status_tracking_running:
157
+ self._monitor.stop()
158
+ self._session_status_tracking_running = False
159
+
160
+ def _sync_session_status_tracking(self) -> None:
161
+ self._set_session_status_tracking_running(self._should_run_session_status_tracking())
162
+
124
163
  def _pause_session_status_tracking(self) -> None:
125
164
  if self._session_status_tracking_paused:
126
165
  return
127
166
  self._session_status_tracking_paused = True
128
- poll_timer = getattr(self, "_poll_timer", None)
129
- if poll_timer is not None:
130
- poll_timer.pause()
131
- self._monitor.stop()
167
+ self._set_session_status_tracking_running(False)
132
168
 
133
169
  def _resume_session_status_tracking(self) -> None:
134
170
  if not self._session_status_tracking_paused:
135
171
  return
136
172
  self._session_status_tracking_paused = False
137
- self._monitor.start()
138
- poll_timer = getattr(self, "_poll_timer", None)
139
- if poll_timer is not None:
140
- poll_timer.resume()
173
+ self._sync_session_status_tracking()
141
174
 
142
175
  def _trigger_status_poll(self) -> None:
176
+ if not self._should_run_session_status_tracking():
177
+ return
143
178
  self._poll_session_statuses()
144
179
 
145
180
  @work(thread=True, exclusive=True, group="status_poll")
146
181
  def _poll_session_statuses(self) -> None:
147
182
  from ...integrations.tmux import get_all_session_statuses, list_all_gd_sessions
148
183
 
184
+ if not self._should_run_session_status_tracking():
185
+ return
186
+
149
187
  entries = list_all_gd_sessions()
150
188
  statuses = get_all_session_statuses()
151
189
  self._session_statuses = statuses
@@ -24,6 +24,22 @@ logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
26
  class ConsoleUIHelpersMixin:
27
+ def _compose_status_message(self, message: str) -> str:
28
+ notice = getattr(self, "_update_notice", None)
29
+ if not notice:
30
+ return message
31
+ if not message:
32
+ return notice
33
+ return f"{message} | {notice}"
34
+
35
+ def _refresh_status_bar(self) -> None:
36
+ try:
37
+ self.query_one("#status-bar", Static).update(
38
+ self._compose_status_message(self._status_message)
39
+ )
40
+ except NoMatches:
41
+ return
42
+
27
43
  def action_tab_repos(self) -> None:
28
44
  if self._resume_target_tab is not None and self._resume_target_tab != "repos":
29
45
  return
@@ -66,6 +82,7 @@ class ConsoleUIHelpersMixin:
66
82
  self._resume_tab_activation_guard = None
67
83
  self._active_tab = tab_id
68
84
  self.refresh_bindings()
85
+ self._sync_session_status_tracking()
69
86
  return
70
87
  if self._resume_target_tab is not None:
71
88
  if tab_id != self._resume_target_tab:
@@ -73,9 +90,11 @@ class ConsoleUIHelpersMixin:
73
90
  return
74
91
  self._active_tab = tab_id
75
92
  self.refresh_bindings()
93
+ self._sync_session_status_tracking()
76
94
  return
77
95
  self._active_tab = tab_id
78
96
  self.refresh_bindings()
97
+ self._sync_session_status_tracking()
79
98
  if tab_id == "sessions":
80
99
  self._load_sessions()
81
100
  elif tab_id == "panels":
@@ -123,6 +142,7 @@ class ConsoleUIHelpersMixin:
123
142
  tabs.active = restore_tab
124
143
  self._active_tab = restore_tab
125
144
  self.refresh_bindings()
145
+ self._sync_session_status_tracking()
126
146
 
127
147
  if restore_tab == "sessions":
128
148
  self._load_sessions()
@@ -144,7 +164,8 @@ class ConsoleUIHelpersMixin:
144
164
  self._refresh_repo_for_path(restore_path)
145
165
 
146
166
  def _update_status(self, message: str) -> None:
147
- self.query_one("#status-bar", Static).update(message)
167
+ self._status_message = message
168
+ self._refresh_status_bar()
148
169
 
149
170
  def _update_search_indicator(self) -> None:
150
171
  repo_ind = self.query_one("#repo-search-indicator", Static)
@@ -54,6 +54,8 @@ _SESSIONS_SORT_COLUMN_NAMES = {
54
54
 
55
55
  _DEFAULT_SESSIONS_SORT_COLUMN = 3
56
56
 
57
+ _SESSION_STATUS_POLL_INTERVAL_SECS = 10
58
+
57
59
  _PANELS_SORT_COLUMN_NAMES = {
58
60
  0: "Name",
59
61
  1: "TMUX",
@@ -98,7 +98,9 @@ class ActionMenuScreen(ModalScreen[str]):
98
98
  items.extend(
99
99
  [
100
100
  Option("", disabled=True),
101
- Option("[white]✕[/white] [dim]Remove Session…[/dim]", id="remove_session"),
101
+ Option(
102
+ "[white]✕[/white] [dim]Remove Session...[/dim]", id="remove_session"
103
+ ),
102
104
  ]
103
105
  )
104
106
  yield OptionList(*items, id="action-menu")
@@ -537,7 +539,7 @@ class PullLoadingScreen(ModalScreen[None]):
537
539
  yield LoadingIndicator()
538
540
  yield Static(f"Pulling [bold]{escape(self.repo_name)}[/bold]", id="pull-loading-title")
539
541
  yield Static(f"[dim]{escape(self.command)}[/dim]", id="pull-loading-command")
540
- yield Static("please wait", id="pull-loading-hint")
542
+ yield Static("please wait...", id="pull-loading-hint")
541
543
 
542
544
 
543
545
  class SortMenuScreen(ModalScreen[tuple | None]):
@@ -998,7 +1000,7 @@ class CreatePanelScreen(ModalScreen[tuple[str, str, dict[int, str | None]] | Non
998
1000
  )
999
1001
  else:
1000
1002
  yield Static("[dim]Name[/dim]", id="panel-name-label")
1001
- yield Input(placeholder="panel name", id="panel-name-input")
1003
+ yield Input(placeholder="panel name...", id="panel-name-input")
1002
1004
  with Horizontal(id="step-1-columns"):
1003
1005
  with Vertical(id="step-1-left"):
1004
1006
  yield Static("[dim]Layout[/dim]", classes="section-label")
@@ -69,9 +69,9 @@ _SHELL_COMMANDS = frozenset(
69
69
 
70
70
  _AGENT_PURPOSES = frozenset({"opencode", "claude", "copilot", "codex"})
71
71
 
72
- _SILENCE_THRESHOLD_SECS = 8
72
+ _SILENCE_THRESHOLD_SECS = 11
73
73
  _BELL_GRACE_SECS = 1.0
74
- _CONTENT_POLL_SECS = 2
74
+ _CONTENT_POLL_SECS = 10
75
75
 
76
76
 
77
77
  def _normalize_process_command(raw_args: str) -> str:
@@ -440,7 +440,7 @@ class TmuxMonitor:
440
440
  except Exception:
441
441
  pass
442
442
 
443
- for _ in range(20):
443
+ for _ in range(_CONTENT_POLL_SECS * 10):
444
444
  if not self._running:
445
445
  return
446
446
  time.sleep(0.1)
@@ -0,0 +1,170 @@
1
+ """PyPI release checking with a short local cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from urllib.request import urlopen
12
+
13
+ from .storage import advisory_file_lock, load_yaml_mapping, write_yaml_atomic
14
+
15
+ try:
16
+ from packaging.version import InvalidVersion, Version
17
+ except ImportError: # pragma: no cover - optional dependency
18
+ InvalidVersion = ValueError
19
+ Version = None
20
+
21
+ _PACKAGE_NAME = "gitdirector"
22
+ _PYPI_JSON_URL = f"https://pypi.org/pypi/{_PACKAGE_NAME}/json"
23
+ _VERSION_CACHE_TTL = timedelta(hours=6)
24
+ _VERSION_CHECK_TIMEOUT_SECS = 1.0
25
+ _VERSION_RE = re.compile(r"^v?(?P<release>\d+(?:\.\d+)*)(?P<suffix>.*)$", re.IGNORECASE)
26
+ _SUFFIX_RANK = {
27
+ "dev": 0,
28
+ "a": 1,
29
+ "alpha": 1,
30
+ "b": 2,
31
+ "beta": 2,
32
+ "rc": 3,
33
+ "c": 3,
34
+ "": 4,
35
+ "post": 5,
36
+ "rev": 5,
37
+ "r": 5,
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class UpdateStatus:
43
+ current_version: str
44
+ latest_version: str | None
45
+
46
+ @property
47
+ def update_available(self) -> bool:
48
+ if not self.latest_version:
49
+ return False
50
+ return _is_version_newer(self.latest_version, self.current_version)
51
+
52
+
53
+ def _utcnow() -> datetime:
54
+ return datetime.now(timezone.utc)
55
+
56
+
57
+ def _cache_paths() -> tuple[Path, Path]:
58
+ cache_dir = Path.home() / ".gitdirector"
59
+ return cache_dir / "version_check.yaml", cache_dir / "version_check.lock"
60
+
61
+
62
+ def _parse_checked_at(raw_value: object) -> datetime | None:
63
+ if not isinstance(raw_value, str) or not raw_value.strip():
64
+ return None
65
+ try:
66
+ parsed = datetime.fromisoformat(raw_value)
67
+ except ValueError:
68
+ return None
69
+ if parsed.tzinfo is None:
70
+ return parsed.replace(tzinfo=timezone.utc)
71
+ return parsed.astimezone(timezone.utc)
72
+
73
+
74
+ def _read_cache() -> tuple[datetime | None, str | None]:
75
+ cache_path, lock_path = _cache_paths()
76
+ with advisory_file_lock(lock_path):
77
+ data = load_yaml_mapping(cache_path, description="GitDirector version cache")
78
+ checked_at = _parse_checked_at(data.get("checked_at"))
79
+ latest_version = data.get("latest_version")
80
+ if not isinstance(latest_version, str) or not latest_version.strip():
81
+ latest_version = None
82
+ return checked_at, latest_version
83
+
84
+
85
+ def _write_cache(checked_at: datetime, latest_version: str | None) -> None:
86
+ cache_path, lock_path = _cache_paths()
87
+ data: dict[str, object] = {"checked_at": checked_at.isoformat()}
88
+ if latest_version:
89
+ data["latest_version"] = latest_version
90
+ with advisory_file_lock(lock_path):
91
+ write_yaml_atomic(cache_path, data)
92
+
93
+
94
+ def _fetch_latest_version() -> str | None:
95
+ with urlopen(_PYPI_JSON_URL, timeout=_VERSION_CHECK_TIMEOUT_SECS) as response:
96
+ payload = json.load(response)
97
+ latest_version = payload.get("info", {}).get("version")
98
+ if not isinstance(latest_version, str) or not latest_version.strip():
99
+ return None
100
+ return latest_version.strip()
101
+
102
+
103
+ def _fallback_version_key(version: str) -> tuple[tuple[int, ...], int, tuple[int, ...]]:
104
+ normalized = version.strip().lower()
105
+ match = _VERSION_RE.match(normalized)
106
+ if match is None:
107
+ return (0,), 0, ()
108
+
109
+ release = tuple(int(part) for part in match.group("release").split("."))
110
+ suffix = match.group("suffix").strip(".-+_").lower()
111
+ if not suffix:
112
+ return release, _SUFFIX_RANK[""], ()
113
+
114
+ tokens = re.findall(r"[a-z]+|\d+", suffix)
115
+ label = next((token for token in tokens if token.isalpha()), "")
116
+ numbers = tuple(int(token) for token in tokens if token.isdigit())
117
+ return release, _SUFFIX_RANK.get(label, 0), numbers
118
+
119
+
120
+ def _is_version_newer(latest_version: str, current_version: str) -> bool:
121
+ if Version is not None:
122
+ try:
123
+ return Version(latest_version) > Version(current_version)
124
+ except InvalidVersion:
125
+ pass
126
+ return _fallback_version_key(latest_version) > _fallback_version_key(current_version)
127
+
128
+
129
+ @lru_cache(maxsize=1)
130
+ def get_installed_version() -> str:
131
+ from importlib.metadata import version
132
+
133
+ return version(_PACKAGE_NAME)
134
+
135
+
136
+ def get_cached_update_status() -> UpdateStatus | None:
137
+ _, latest_version = _read_cache()
138
+ return UpdateStatus(get_installed_version(), latest_version)
139
+
140
+
141
+ def get_update_status() -> UpdateStatus | None:
142
+ current_version = get_installed_version()
143
+ now = _utcnow()
144
+ checked_at, cached_latest_version = _read_cache()
145
+
146
+ if checked_at is not None and now - checked_at <= _VERSION_CACHE_TTL:
147
+ return UpdateStatus(current_version, cached_latest_version)
148
+
149
+ latest_version = cached_latest_version
150
+ try:
151
+ latest_version = _fetch_latest_version()
152
+ except Exception:
153
+ pass
154
+
155
+ _write_cache(now, latest_version)
156
+ return UpdateStatus(current_version, latest_version)
157
+
158
+
159
+ def format_update_notice(status: UpdateStatus | None) -> str | None:
160
+ if status is None or not status.update_available or not status.latest_version:
161
+ return None
162
+ return f"Update available: v{status.latest_version} (current v{status.current_version})"
163
+
164
+
165
+ def get_cached_update_notice() -> str | None:
166
+ return format_update_notice(get_cached_update_status())
167
+
168
+
169
+ def get_update_notice() -> str | None:
170
+ return format_update_notice(get_update_status())
File without changes