AllGitStatus 0.14.9__py3-none-any.whl
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.
- AllGitStatus/MainApp.py +442 -0
- AllGitStatus/MainApp.tcss +36 -0
- AllGitStatus/Repository.py +91 -0
- AllGitStatus/Sources/GitHubSource.py +563 -0
- AllGitStatus/Sources/LocalGitSource.py +306 -0
- AllGitStatus/Sources/Source.py +51 -0
- AllGitStatus/Sources/__init__.py +1 -0
- AllGitStatus/__init__.py +6 -0
- AllGitStatus/__main__.py +92 -0
- AllGitStatus/py.typed +0 -0
- allgitstatus-0.14.9.dist-info/METADATA +149 -0
- allgitstatus-0.14.9.dist-info/RECORD +14 -0
- allgitstatus-0.14.9.dist-info/WHEEL +4 -0
- allgitstatus-0.14.9.dist-info/entry_points.txt +3 -0
AllGitStatus/MainApp.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# noqa: D100
|
|
2
|
+
import contextlib
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from rich.traceback import Traceback
|
|
11
|
+
from textual.app import App, ComposeResult, ScreenStackError
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.coordinate import Coordinate
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import DataTable, Footer, Header, Label, RichLog
|
|
16
|
+
|
|
17
|
+
from AllGitStatus import __version__
|
|
18
|
+
from AllGitStatus.Repository import EnumerateRepositories, Repository
|
|
19
|
+
from AllGitStatus.Sources.GitHubSource import GitHubSource
|
|
20
|
+
from AllGitStatus.Sources.LocalGitSource import LocalGitSource
|
|
21
|
+
from AllGitStatus.Sources.Source import ErrorInfo, ResultInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ----------------------------------------------------------------------
|
|
25
|
+
# ----------------------------------------------------------------------
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Column:
|
|
28
|
+
"""Column displayed within a data table."""
|
|
29
|
+
|
|
30
|
+
value: int
|
|
31
|
+
name: str
|
|
32
|
+
justify: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ----------------------------------------------------------------------
|
|
36
|
+
NameColumn = Column(0, "Name", "left")
|
|
37
|
+
BranchColumn = Column(1, "Branch", "center")
|
|
38
|
+
LocalColumn = Column(2, "Local", "center")
|
|
39
|
+
StashesColumn = Column(3, "Stashes", "center")
|
|
40
|
+
RemoteColumn = Column(4, "Remote", "center")
|
|
41
|
+
StarsColumn = Column(5, "Stars", "center")
|
|
42
|
+
ForksColumn = Column(6, "Forks", "center")
|
|
43
|
+
WatchersColumn = Column(7, "Watchers", "center")
|
|
44
|
+
IssuesColumn = Column(8, "Issues", "center")
|
|
45
|
+
PullRequestsColumn = Column(9, "PRs", "center")
|
|
46
|
+
SecurityAlertsColumn = Column(10, "Security", "center")
|
|
47
|
+
CICDStatusColumn = Column(11, "CI/CD", "center")
|
|
48
|
+
ReleaseColumn = Column(12, "Release", "center")
|
|
49
|
+
ArchivedColumn = Column(13, "Archived", "center")
|
|
50
|
+
|
|
51
|
+
COLUMN_MAP: dict[
|
|
52
|
+
tuple[
|
|
53
|
+
str, # source class name
|
|
54
|
+
str | None, # source key value
|
|
55
|
+
],
|
|
56
|
+
Column,
|
|
57
|
+
] = {
|
|
58
|
+
("", ""): NameColumn,
|
|
59
|
+
(LocalGitSource.__name__, "current_branch"): BranchColumn,
|
|
60
|
+
(LocalGitSource.__name__, "local_status"): LocalColumn,
|
|
61
|
+
(LocalGitSource.__name__, "stashes"): StashesColumn,
|
|
62
|
+
(LocalGitSource.__name__, "remote_status"): RemoteColumn,
|
|
63
|
+
(GitHubSource.__name__, "stars"): StarsColumn,
|
|
64
|
+
(GitHubSource.__name__, "forks"): ForksColumn,
|
|
65
|
+
(GitHubSource.__name__, "watchers"): WatchersColumn,
|
|
66
|
+
(GitHubSource.__name__, "issues"): IssuesColumn,
|
|
67
|
+
(GitHubSource.__name__, "pull_requests"): PullRequestsColumn,
|
|
68
|
+
(GitHubSource.__name__, "security_alerts"): SecurityAlertsColumn,
|
|
69
|
+
(GitHubSource.__name__, "cicd_status"): CICDStatusColumn,
|
|
70
|
+
(GitHubSource.__name__, "release"): ReleaseColumn,
|
|
71
|
+
(GitHubSource.__name__, "archived"): ArchivedColumn,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ----------------------------------------------------------------------
|
|
76
|
+
class MainApp(App):
|
|
77
|
+
"""Main application."""
|
|
78
|
+
|
|
79
|
+
CSS_PATH = Path(__file__).with_suffix(".tcss")
|
|
80
|
+
|
|
81
|
+
BINDINGS = [ # noqa: RUF012
|
|
82
|
+
("R", "RefreshAll", "Refresh All"),
|
|
83
|
+
("r", "RefreshSelected", "Refresh"),
|
|
84
|
+
("p", "PullSelected", "Pull"),
|
|
85
|
+
("P", "PushSelected", "Push"),
|
|
86
|
+
("q", "quit", "Quit"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# ----------------------------------------------------------------------
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
working_dir: Path,
|
|
93
|
+
github_pat: str | None,
|
|
94
|
+
*args,
|
|
95
|
+
debug: bool = False,
|
|
96
|
+
**kwargs,
|
|
97
|
+
) -> None:
|
|
98
|
+
super().__init__(*args, **kwargs)
|
|
99
|
+
|
|
100
|
+
self._working_dir = working_dir
|
|
101
|
+
self._github_pat = github_pat
|
|
102
|
+
self._debug = debug
|
|
103
|
+
|
|
104
|
+
self.title = "AllGitStatus{}".format(" [DEBUG]" if debug else "")
|
|
105
|
+
|
|
106
|
+
self._data_table: DataTable = DataTable(
|
|
107
|
+
cursor_type="cell",
|
|
108
|
+
zebra_stripes=True,
|
|
109
|
+
id="data_table",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self._data_table.border_title = "[1] Repositories"
|
|
113
|
+
|
|
114
|
+
self._additional_info = RichLog(id="additional_info", auto_scroll=False)
|
|
115
|
+
self._additional_info.border_title = "[2] Additional Info"
|
|
116
|
+
|
|
117
|
+
self._repositories: list[Repository] | None = None
|
|
118
|
+
|
|
119
|
+
self._additional_info_data: dict[
|
|
120
|
+
int, # row_index
|
|
121
|
+
dict[
|
|
122
|
+
int, # column_index
|
|
123
|
+
object,
|
|
124
|
+
],
|
|
125
|
+
] = {}
|
|
126
|
+
|
|
127
|
+
self._state_data: dict[
|
|
128
|
+
int, # row_index
|
|
129
|
+
dict[
|
|
130
|
+
int, # column_index
|
|
131
|
+
object,
|
|
132
|
+
],
|
|
133
|
+
] = {}
|
|
134
|
+
|
|
135
|
+
# The lifetime of this object is defined by `on_mount` and `on_unmount` as aiohttp.ClientSession
|
|
136
|
+
# requires an active event loop
|
|
137
|
+
self._github_session: aiohttp.ClientSession | None = None
|
|
138
|
+
|
|
139
|
+
# ----------------------------------------------------------------------
|
|
140
|
+
def compose(self) -> ComposeResult: # noqa: D102
|
|
141
|
+
yield Header()
|
|
142
|
+
yield Vertical(
|
|
143
|
+
self._data_table,
|
|
144
|
+
self._additional_info,
|
|
145
|
+
id="vertical_group",
|
|
146
|
+
)
|
|
147
|
+
yield Horizontal(Footer(), Label(__version__), id="footer")
|
|
148
|
+
|
|
149
|
+
# ----------------------------------------------------------------------
|
|
150
|
+
async def on_mount(self) -> None: # noqa: D102
|
|
151
|
+
assert self._github_session is None
|
|
152
|
+
self._github_session = aiohttp.ClientSession(
|
|
153
|
+
headers=GitHubSource.CreateGitHubHttpHeaders(self._github_pat)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for column in COLUMN_MAP.values():
|
|
157
|
+
self._data_table.add_column(Text(column.name, justify=column.justify)) # ty: ignore[invalid-argument-type]
|
|
158
|
+
|
|
159
|
+
await self._ResetAllRepositories()
|
|
160
|
+
|
|
161
|
+
# ----------------------------------------------------------------------
|
|
162
|
+
async def on_unmount(self) -> None: # noqa: D102
|
|
163
|
+
# Close the shared session when the app exits
|
|
164
|
+
if self._github_session is not None:
|
|
165
|
+
await self._github_session.close()
|
|
166
|
+
self._github_session = None
|
|
167
|
+
|
|
168
|
+
# ----------------------------------------------------------------------
|
|
169
|
+
async def on_data_table_cell_highlighted(self, message: DataTable.ColumnSelected) -> None: # noqa: ARG002, D102
|
|
170
|
+
await self._OnSelectionChanged()
|
|
171
|
+
|
|
172
|
+
# ----------------------------------------------------------------------
|
|
173
|
+
async def action_RefreshAll(self) -> None: # noqa: D102
|
|
174
|
+
await self._ResetAllRepositories()
|
|
175
|
+
|
|
176
|
+
# ----------------------------------------------------------------------
|
|
177
|
+
async def action_RefreshSelected(self) -> None: # noqa: D102
|
|
178
|
+
assert self._repositories is not None
|
|
179
|
+
|
|
180
|
+
await self._ResetRepository(
|
|
181
|
+
self._repositories[self._data_table.cursor_coordinate.row],
|
|
182
|
+
self._data_table.cursor_coordinate.row,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ----------------------------------------------------------------------
|
|
186
|
+
async def action_PullSelected(self) -> None: # noqa: D102
|
|
187
|
+
assert self._repositories is not None
|
|
188
|
+
|
|
189
|
+
repository = self._repositories[self._data_table.cursor_coordinate.row]
|
|
190
|
+
|
|
191
|
+
await LocalGitSource.Pull(repository)
|
|
192
|
+
await self._ResetRepository(repository, self._data_table.cursor_coordinate.row)
|
|
193
|
+
|
|
194
|
+
# ----------------------------------------------------------------------
|
|
195
|
+
async def action_PushSelected(self) -> None: # noqa: D102
|
|
196
|
+
assert self._repositories is not None
|
|
197
|
+
|
|
198
|
+
repository = self._repositories[self._data_table.cursor_coordinate.row]
|
|
199
|
+
|
|
200
|
+
await LocalGitSource.Push(repository)
|
|
201
|
+
await self._ResetRepository(repository, self._data_table.cursor_coordinate.row)
|
|
202
|
+
|
|
203
|
+
# ----------------------------------------------------------------------
|
|
204
|
+
def key_1(self) -> None: # noqa: D102
|
|
205
|
+
self._data_table.focus()
|
|
206
|
+
|
|
207
|
+
# ----------------------------------------------------------------------
|
|
208
|
+
def key_2(self) -> None: # noqa: D102
|
|
209
|
+
self._additional_info.focus()
|
|
210
|
+
|
|
211
|
+
# ----------------------------------------------------------------------
|
|
212
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: # noqa: ARG002, D102
|
|
213
|
+
if action == "RefreshAll":
|
|
214
|
+
if self._repositories is not None:
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
if action == "RefreshSelected":
|
|
220
|
+
if self._repositories is not None:
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
if action == "PullSelected":
|
|
226
|
+
if self._repositories is not None:
|
|
227
|
+
state_data = self._state_data.get(self._data_table.cursor_coordinate.row, {}).get(
|
|
228
|
+
RemoteColumn.value, {}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if state_data and state_data["has_remote_changes"]: # ty: ignore[not-subscriptable]
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
if action == "PushSelected":
|
|
237
|
+
if self._repositories is not None:
|
|
238
|
+
state_data = self._state_data.get(self._data_table.cursor_coordinate.row, {}).get(
|
|
239
|
+
RemoteColumn.value, {}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if state_data and state_data["has_local_changes"]: # ty: ignore[not-subscriptable]
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
# ----------------------------------------------------------------------
|
|
250
|
+
# |
|
|
251
|
+
# | Private Methods
|
|
252
|
+
# |
|
|
253
|
+
# ----------------------------------------------------------------------
|
|
254
|
+
async def _ResetAllRepositories(self) -> None:
|
|
255
|
+
self._repositories = None
|
|
256
|
+
self._additional_info_data.clear()
|
|
257
|
+
self._state_data.clear()
|
|
258
|
+
self._data_table.clear()
|
|
259
|
+
|
|
260
|
+
await self._OnSelectionChanged()
|
|
261
|
+
|
|
262
|
+
# Get the repositories
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------------
|
|
265
|
+
async def OnRepositoriesComplete(repositories: list[Repository] | None) -> None:
|
|
266
|
+
if repositories is None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
assert self._repositories is None
|
|
270
|
+
self._repositories = repositories
|
|
271
|
+
|
|
272
|
+
for repository_index, repository in enumerate(repositories):
|
|
273
|
+
self._data_table.add_row()
|
|
274
|
+
|
|
275
|
+
await self._ResetRepository(repository, repository_index)
|
|
276
|
+
|
|
277
|
+
# ----------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
self.push_screen(_GetRepositoriesModal(self._working_dir), OnRepositoriesComplete)
|
|
280
|
+
|
|
281
|
+
# ----------------------------------------------------------------------
|
|
282
|
+
async def _ResetRepository(self, repository: Repository, repository_index: int) -> None:
|
|
283
|
+
self._additional_info_data.pop(repository_index, None)
|
|
284
|
+
self._state_data.pop(repository_index, None)
|
|
285
|
+
|
|
286
|
+
for column in COLUMN_MAP.values():
|
|
287
|
+
self._data_table.update_cell_at(
|
|
288
|
+
Coordinate(repository_index, column.value),
|
|
289
|
+
"",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if repository_index == self._data_table.cursor_coordinate.row:
|
|
293
|
+
await self._OnSelectionChanged()
|
|
294
|
+
self._RefreshBindings()
|
|
295
|
+
|
|
296
|
+
# Create the repo name
|
|
297
|
+
if repository.path == self._working_dir:
|
|
298
|
+
repo_name = repository.path.name
|
|
299
|
+
else:
|
|
300
|
+
repo_name = str(repository.path.relative_to(self._working_dir))
|
|
301
|
+
|
|
302
|
+
self._data_table.update_cell_at(
|
|
303
|
+
Coordinate(repository_index, NameColumn.value),
|
|
304
|
+
Text(f"📂 {repo_name}", justify=NameColumn.justify), # ty: ignore[invalid-argument-type]
|
|
305
|
+
update_width=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self._additional_info_data.setdefault(repository_index, {})[NameColumn.value] = textwrap.dedent(
|
|
309
|
+
f"""\
|
|
310
|
+
Local: {repository.path}
|
|
311
|
+
Origin: {repository.remote_url or ""}
|
|
312
|
+
""",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Load the content
|
|
316
|
+
|
|
317
|
+
# ----------------------------------------------------------------------
|
|
318
|
+
async def LoadCells() -> None:
|
|
319
|
+
assert self._github_session is not None
|
|
320
|
+
|
|
321
|
+
sources = [
|
|
322
|
+
LocalGitSource(),
|
|
323
|
+
GitHubSource(self._github_session),
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
# Set all of the column values to pending
|
|
327
|
+
for source in sources:
|
|
328
|
+
if not source.Applies(repository):
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
for column_key, column in COLUMN_MAP.items():
|
|
332
|
+
if not column_key[0] and not column_key[1]:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
if column_key[0] != source.__class__.__name__:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
self._data_table.update_cell_at(
|
|
339
|
+
Coordinate(repository_index, column.value),
|
|
340
|
+
Text("⏳", justify=column.justify), # ty: ignore[invalid-argument-type]
|
|
341
|
+
update_width=True,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Get the actual values
|
|
345
|
+
for source in sources:
|
|
346
|
+
if not source.Applies(repository):
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
async for info in source.Query(repository):
|
|
350
|
+
await self._PopulateCell(repository_index, info)
|
|
351
|
+
|
|
352
|
+
# ----------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
self.run_worker(LoadCells())
|
|
355
|
+
|
|
356
|
+
# ----------------------------------------------------------------------
|
|
357
|
+
async def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) -> None:
|
|
358
|
+
column = COLUMN_MAP[info.key]
|
|
359
|
+
|
|
360
|
+
if isinstance(info, ErrorInfo):
|
|
361
|
+
display_value = "💥"
|
|
362
|
+
additional_info = Traceback.from_exception(
|
|
363
|
+
type(info.error),
|
|
364
|
+
info.error,
|
|
365
|
+
info.error.__traceback__ if self._debug else None,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
elif isinstance(info, ResultInfo):
|
|
369
|
+
display_value = info.display_value
|
|
370
|
+
additional_info = info.additional_info
|
|
371
|
+
|
|
372
|
+
if info.state_data is not None:
|
|
373
|
+
self._state_data.setdefault(repository_index, {})[column.value] = info.state_data
|
|
374
|
+
|
|
375
|
+
if repository_index == self._data_table.cursor_coordinate.row:
|
|
376
|
+
self._RefreshBindings()
|
|
377
|
+
|
|
378
|
+
else:
|
|
379
|
+
assert False, info # noqa: B011, PT015 # pragma: no cover
|
|
380
|
+
|
|
381
|
+
self._data_table.update_cell_at(
|
|
382
|
+
Coordinate(repository_index, column.value),
|
|
383
|
+
Text(display_value, justify=column.justify), # ty: ignore[invalid-argument-type]
|
|
384
|
+
update_width=True,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
self._additional_info_data.setdefault(repository_index, {})[column.value] = additional_info
|
|
388
|
+
|
|
389
|
+
if self._data_table.cursor_row == repository_index and self._data_table.cursor_column == column.value:
|
|
390
|
+
await self._OnSelectionChanged()
|
|
391
|
+
|
|
392
|
+
# ----------------------------------------------------------------------
|
|
393
|
+
async def _OnSelectionChanged(self) -> None:
|
|
394
|
+
self._additional_info.clear()
|
|
395
|
+
self._RefreshBindings()
|
|
396
|
+
|
|
397
|
+
row_index = self._data_table.cursor_coordinate.row
|
|
398
|
+
col_index = self._data_table.cursor_coordinate.column
|
|
399
|
+
|
|
400
|
+
additional_info = self._additional_info_data.get(row_index, {}).get(col_index)
|
|
401
|
+
|
|
402
|
+
if additional_info:
|
|
403
|
+
self._additional_info.write(additional_info)
|
|
404
|
+
self._additional_info.scroll_home()
|
|
405
|
+
|
|
406
|
+
# ----------------------------------------------------------------------
|
|
407
|
+
def _RefreshBindings(self) -> None:
|
|
408
|
+
# ScreenStackErrors are occasionally raised when testing
|
|
409
|
+
with contextlib.suppress(ScreenStackError):
|
|
410
|
+
self.refresh_bindings()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ----------------------------------------------------------------------
|
|
414
|
+
# ----------------------------------------------------------------------
|
|
415
|
+
# ----------------------------------------------------------------------
|
|
416
|
+
class _GetRepositoriesModal(ModalScreen[list[Repository]]):
|
|
417
|
+
CSS = """
|
|
418
|
+
#GetRepositoriesModal {
|
|
419
|
+
background: $panel;
|
|
420
|
+
padding: 1;
|
|
421
|
+
}
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
# ----------------------------------------------------------------------
|
|
425
|
+
def __init__(self, working_dir: Path, *args, **kwargs) -> None:
|
|
426
|
+
self._working_dir = working_dir
|
|
427
|
+
super().__init__(*args, **kwargs)
|
|
428
|
+
|
|
429
|
+
# ----------------------------------------------------------------------
|
|
430
|
+
def compose(self) -> ComposeResult:
|
|
431
|
+
yield Label(f"Searching for repositories in '{self._working_dir}'...", id="GetRepositoriesModal")
|
|
432
|
+
|
|
433
|
+
# ----------------------------------------------------------------------
|
|
434
|
+
async def on_mount(self) -> None:
|
|
435
|
+
# ----------------------------------------------------------------------
|
|
436
|
+
async def Execute() -> None:
|
|
437
|
+
repos = [repository async for repository in EnumerateRepositories(self._working_dir)]
|
|
438
|
+
self.dismiss(repos)
|
|
439
|
+
|
|
440
|
+
# ----------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
self.run_worker(Execute())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
_GetRepositoriesModal {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
#vertical_group {
|
|
6
|
+
height: 100%;
|
|
7
|
+
width: 100%;
|
|
8
|
+
|
|
9
|
+
#data_table {
|
|
10
|
+
border: solid $primary-muted;
|
|
11
|
+
height: 2fr;
|
|
12
|
+
width: 100%;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#additional_info {
|
|
16
|
+
border: solid $primary-muted;
|
|
17
|
+
height: 1fr;
|
|
18
|
+
width: 100%;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#footer {
|
|
23
|
+
dock: bottom;
|
|
24
|
+
height: 1;
|
|
25
|
+
|
|
26
|
+
Footer {
|
|
27
|
+
width: 75%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Label {
|
|
31
|
+
dock: right;
|
|
32
|
+
background: $footer-background;
|
|
33
|
+
text-align: center;
|
|
34
|
+
width: 25%;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# noqa: D100
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ----------------------------------------------------------------------
|
|
12
|
+
# |
|
|
13
|
+
# | Public Types
|
|
14
|
+
# |
|
|
15
|
+
# ----------------------------------------------------------------------
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Repository:
|
|
18
|
+
"""Represents a local git repository."""
|
|
19
|
+
|
|
20
|
+
# ----------------------------------------------------------------------
|
|
21
|
+
path: Path
|
|
22
|
+
remote_url: str | None = None
|
|
23
|
+
github_owner: str | None = None
|
|
24
|
+
github_repo: str | None = None
|
|
25
|
+
|
|
26
|
+
# ----------------------------------------------------------------------
|
|
27
|
+
@classmethod
|
|
28
|
+
async def FromDirectory(cls, path: Path) -> "Repository":
|
|
29
|
+
"""Create a Repository object from a local directory containing a git repository."""
|
|
30
|
+
|
|
31
|
+
remote_url: str | None = None
|
|
32
|
+
github_owner: str | None = None
|
|
33
|
+
github_repo: str | None = None
|
|
34
|
+
|
|
35
|
+
# Get the remote url (if possible)
|
|
36
|
+
try:
|
|
37
|
+
proc = await asyncio.create_subprocess_exec(
|
|
38
|
+
"git",
|
|
39
|
+
"-C",
|
|
40
|
+
str(path),
|
|
41
|
+
"remote",
|
|
42
|
+
"get-url",
|
|
43
|
+
"origin",
|
|
44
|
+
stdout=asyncio.subprocess.PIPE,
|
|
45
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
content, _ = await proc.communicate()
|
|
49
|
+
content = content.decode().strip()
|
|
50
|
+
|
|
51
|
+
if proc.returncode == 0:
|
|
52
|
+
remote_url = content
|
|
53
|
+
|
|
54
|
+
except Exception: # noqa: S110
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
# Extract the owner and repo from the remote url
|
|
58
|
+
if remote_url is not None:
|
|
59
|
+
for pattern in [
|
|
60
|
+
r"github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$",
|
|
61
|
+
r"github\.com/([^/]+)/([^/]+?)/?$",
|
|
62
|
+
]:
|
|
63
|
+
match = re.search(pattern, remote_url)
|
|
64
|
+
if match is not None:
|
|
65
|
+
github_owner = match.group(1)
|
|
66
|
+
github_repo = match.group(2)
|
|
67
|
+
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
return cls(path, remote_url, github_owner, github_repo)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ----------------------------------------------------------------------
|
|
74
|
+
# |
|
|
75
|
+
# | Public Functions
|
|
76
|
+
# |
|
|
77
|
+
# ----------------------------------------------------------------------
|
|
78
|
+
async def EnumerateRepositories(root_path: Path) -> AsyncGenerator[Repository]:
|
|
79
|
+
"""Recursively enumerate all git repositories under the specified root path."""
|
|
80
|
+
|
|
81
|
+
if not root_path.is_dir(): # noqa: ASYNC240
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
for this_root_str, directories, _ in os.walk(root_path):
|
|
85
|
+
this_root = Path(this_root_str)
|
|
86
|
+
|
|
87
|
+
if ".git" in directories:
|
|
88
|
+
yield await Repository.FromDirectory(this_root)
|
|
89
|
+
|
|
90
|
+
# Do not spend any more time searching this directory and its descendants
|
|
91
|
+
directories[:] = []
|