gitux 0.1.0__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.
- gitux/__init__.py +3 -0
- gitux/__main__.py +6 -0
- gitux/cli.py +35 -0
- gitux/domain/__init__.py +27 -0
- gitux/domain/models.py +180 -0
- gitux/git/__init__.py +57 -0
- gitux/git/commands.py +392 -0
- gitux/git/exceptions.py +9 -0
- gitux/git/parser.py +83 -0
- gitux/presenter/__init__.py +6 -0
- gitux/presenter/commit_presenter.py +107 -0
- gitux/presenter/repo_presenter.py +119 -0
- gitux/ui/__init__.py +1 -0
- gitux/ui/app.py +452 -0
- gitux/ui/gitux.tcss +323 -0
- gitux/ui/widgets/__init__.py +24 -0
- gitux/ui/widgets/block_container.py +61 -0
- gitux/ui/widgets/branch_screen.py +54 -0
- gitux/ui/widgets/changed_files.py +187 -0
- gitux/ui/widgets/commit_log.py +197 -0
- gitux/ui/widgets/commit_screen.py +110 -0
- gitux/ui/widgets/diff_viewer.py +149 -0
- gitux/ui/widgets/gitux_footer.py +44 -0
- gitux/ui/widgets/header.py +81 -0
- gitux/ui/widgets/help_screen.py +74 -0
- gitux/ui/widgets/repo_stats_bar.py +212 -0
- gitux/utils/__init__.py +1 -0
- gitux-0.1.0.dist-info/METADATA +174 -0
- gitux-0.1.0.dist-info/RECORD +32 -0
- gitux-0.1.0.dist-info/WHEEL +4 -0
- gitux-0.1.0.dist-info/entry_points.txt +2 -0
- gitux-0.1.0.dist-info/licenses/LICENSE +21 -0
gitux/ui/app.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Main application — bento-grid TUI for Git operations.
|
|
2
|
+
|
|
3
|
+
Composes the full UI layout, manages block activation (files/commits/diff),
|
|
4
|
+
routes keyboard events, and orchestrates data flow between presenters and widgets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from textual import work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.events import Key
|
|
14
|
+
|
|
15
|
+
from gitux.domain import (
|
|
16
|
+
FileCounts,
|
|
17
|
+
FileStatus,
|
|
18
|
+
HeadSummary,
|
|
19
|
+
OperationState,
|
|
20
|
+
PushResult,
|
|
21
|
+
RemoteStatus,
|
|
22
|
+
RepoInfo,
|
|
23
|
+
derive_wip_state,
|
|
24
|
+
)
|
|
25
|
+
from gitux.presenter.commit_presenter import CommitPresenter
|
|
26
|
+
from gitux.presenter.repo_presenter import RepoPresenter
|
|
27
|
+
from gitux.ui.widgets import (
|
|
28
|
+
BlockContainer,
|
|
29
|
+
BranchScreen,
|
|
30
|
+
ChangedFilesPanel,
|
|
31
|
+
CommitLogWidget,
|
|
32
|
+
CommitScreen,
|
|
33
|
+
DiffViewerWidget,
|
|
34
|
+
GituxFooter,
|
|
35
|
+
HelpScreen,
|
|
36
|
+
RepoStatsBar,
|
|
37
|
+
TopAppBar,
|
|
38
|
+
extract_commit_hash,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_CSS_PATH: str = str(Path(__file__).parent / "gitux.tcss")
|
|
42
|
+
|
|
43
|
+
_BLOCK_MAP: dict[str, dict[str, str]] = {
|
|
44
|
+
"files": {"block": "#files-block", "content": "#changed-files"},
|
|
45
|
+
"commits": {"block": "#commits-block", "content": "#commit-log"},
|
|
46
|
+
"diff": {"block": "#diff-block", "content": "#diff-viewer"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_BLOCK_ORDER: tuple[str, ...] = ("files", "commits", "diff")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GituxApp(App[None]):
|
|
53
|
+
"""Main Textual application for GITUX.
|
|
54
|
+
|
|
55
|
+
Composes a bento-grid layout with:
|
|
56
|
+
- TopAppBar header (repo name, branch)
|
|
57
|
+
- RepoStatsBar (repo stats)
|
|
58
|
+
- Three BlockContainers: Changed Files, Commit Log, File Diff
|
|
59
|
+
- GituxFooter (shortcuts, version, sync status)
|
|
60
|
+
|
|
61
|
+
Owns :class:`RepoPresenter` and :class:`CommitPresenter` and coordinates
|
|
62
|
+
data flow between them and the widget layer.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
CSS_PATH = _CSS_PATH
|
|
66
|
+
|
|
67
|
+
BINDINGS = [
|
|
68
|
+
Binding("ctrl+p", "push", "Push", show=True),
|
|
69
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
70
|
+
Binding("c", "open_commit", "Commit", show=True),
|
|
71
|
+
Binding("b", "open_branches", "Branches", show=True),
|
|
72
|
+
Binding("q", "quit", "Quit", show=True),
|
|
73
|
+
Binding("question_mark", "help", "Help", show=True),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._active_block: str = "files"
|
|
79
|
+
self._block_index: int = 0
|
|
80
|
+
self.repo = RepoPresenter()
|
|
81
|
+
self.presenter = CommitPresenter()
|
|
82
|
+
|
|
83
|
+
def compose(self) -> ComposeResult:
|
|
84
|
+
"""Build the widget tree: header, stats bar, bento grid, footer."""
|
|
85
|
+
yield TopAppBar(id="top-bar")
|
|
86
|
+
yield RepoStatsBar(id="stats-bar")
|
|
87
|
+
with Vertical(id="main-content"):
|
|
88
|
+
with Horizontal(id="bento-top"):
|
|
89
|
+
yield BlockContainer(
|
|
90
|
+
id="files-block",
|
|
91
|
+
title="Changed Files",
|
|
92
|
+
subtitle="loading...",
|
|
93
|
+
content_widget=ChangedFilesPanel(id="changed-files"),
|
|
94
|
+
)
|
|
95
|
+
yield BlockContainer(
|
|
96
|
+
id="commits-block",
|
|
97
|
+
title="Commit Log",
|
|
98
|
+
subtitle="active: ",
|
|
99
|
+
content_widget=CommitLogWidget(id="commit-log"),
|
|
100
|
+
)
|
|
101
|
+
yield BlockContainer(
|
|
102
|
+
id="diff-block",
|
|
103
|
+
title="File Diff",
|
|
104
|
+
subtitle="staged diff",
|
|
105
|
+
content_widget=DiffViewerWidget(id="diff-viewer"),
|
|
106
|
+
)
|
|
107
|
+
yield GituxFooter()
|
|
108
|
+
|
|
109
|
+
def on_mount(self) -> None:
|
|
110
|
+
"""Initialise data and activate the files block on startup."""
|
|
111
|
+
self._refresh_all()
|
|
112
|
+
self._activate_block("files")
|
|
113
|
+
|
|
114
|
+
def action_open_commit(self) -> None:
|
|
115
|
+
"""Open the commit message modal (bound to ``c``)."""
|
|
116
|
+
self.push_screen(
|
|
117
|
+
CommitScreen(self.presenter),
|
|
118
|
+
self._handle_commit_result,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _handle_commit_result(self, commit_hash: str | None) -> None:
|
|
122
|
+
"""Callback after commit modal closes. Refresh on success."""
|
|
123
|
+
if commit_hash:
|
|
124
|
+
self.notify(f"Committed: {commit_hash}", severity="information")
|
|
125
|
+
self._refresh_all()
|
|
126
|
+
|
|
127
|
+
def action_push(self) -> None:
|
|
128
|
+
"""Push commits to remote (bound to ``ctrl+p``)."""
|
|
129
|
+
self._run_push_worker()
|
|
130
|
+
|
|
131
|
+
def action_refresh(self) -> None:
|
|
132
|
+
"""Refresh all panels from git (bound to ``r``)."""
|
|
133
|
+
self._refresh_all()
|
|
134
|
+
|
|
135
|
+
def action_help(self) -> None:
|
|
136
|
+
"""Show the keyboard-shortcuts help modal (bound to ``?``)."""
|
|
137
|
+
self.push_screen(HelpScreen())
|
|
138
|
+
|
|
139
|
+
def action_open_branches(self) -> None:
|
|
140
|
+
"""Open the branch-switch modal (bound to ``b``)."""
|
|
141
|
+
self.push_screen(
|
|
142
|
+
BranchScreen(self.repo),
|
|
143
|
+
self._handle_branch_selected,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _handle_branch_selected(self, branch: str | None) -> None:
|
|
147
|
+
"""Callback after the branch modal closes; refresh on success."""
|
|
148
|
+
if branch:
|
|
149
|
+
self.notify(f"Switched to {branch}", severity="information")
|
|
150
|
+
self._refresh_all()
|
|
151
|
+
|
|
152
|
+
def on_key(self, event: Key) -> None:
|
|
153
|
+
"""Route keys: Tab cycles+activates blocks, all others go to active panel."""
|
|
154
|
+
if self.screen.is_modal:
|
|
155
|
+
return
|
|
156
|
+
key = event.key
|
|
157
|
+
|
|
158
|
+
# ── Tab: cycle blocks and activate block immediately ──
|
|
159
|
+
if key == "tab":
|
|
160
|
+
event.stop()
|
|
161
|
+
self._block_index = (self._block_index + 1) % len(_BLOCK_ORDER)
|
|
162
|
+
self._activate_block(_BLOCK_ORDER[self._block_index])
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# ── All other navigational keys: route to active content panel ──
|
|
166
|
+
if key in ("up", "down", "enter", "escape", "s", "a", "A",
|
|
167
|
+
"page_up", "page_down", "home", "end"):
|
|
168
|
+
event.stop()
|
|
169
|
+
self._route_key_to_panel(key)
|
|
170
|
+
|
|
171
|
+
def _route_key_to_panel(self, key: str) -> None:
|
|
172
|
+
"""Send a key event to the currently active content panel."""
|
|
173
|
+
if self._active_block == "files":
|
|
174
|
+
self._route_to_files(key)
|
|
175
|
+
elif self._active_block == "commits":
|
|
176
|
+
self._route_to_commits(key)
|
|
177
|
+
elif self._active_block == "diff":
|
|
178
|
+
self._route_to_diff(key)
|
|
179
|
+
|
|
180
|
+
def _route_to_files(self, key: str) -> None:
|
|
181
|
+
"""Route a key press to the changed-files panel."""
|
|
182
|
+
panel = self.query_one("#changed-files", ChangedFilesPanel)
|
|
183
|
+
if key == "down":
|
|
184
|
+
panel.action_cursor_down()
|
|
185
|
+
elif key == "up":
|
|
186
|
+
panel.action_cursor_up()
|
|
187
|
+
elif key in ("enter", "s"):
|
|
188
|
+
panel.action_toggle_file()
|
|
189
|
+
elif key == "a":
|
|
190
|
+
panel.action_stage_all()
|
|
191
|
+
elif key == "A":
|
|
192
|
+
panel.action_unstage_all()
|
|
193
|
+
|
|
194
|
+
def _route_to_commits(self, key: str) -> None:
|
|
195
|
+
"""Route a key press to the commit-log panel (log or detail mode)."""
|
|
196
|
+
panel = self.query_one("#commit-log", CommitLogWidget)
|
|
197
|
+
if key == "down":
|
|
198
|
+
if panel.is_detail_mode:
|
|
199
|
+
panel.scroll_down(animate=False)
|
|
200
|
+
else:
|
|
201
|
+
panel.action_cursor_down()
|
|
202
|
+
elif key == "up":
|
|
203
|
+
if panel.is_detail_mode:
|
|
204
|
+
panel.scroll_up(animate=False)
|
|
205
|
+
else:
|
|
206
|
+
panel.action_cursor_up()
|
|
207
|
+
elif key == "enter":
|
|
208
|
+
if panel.is_detail_mode:
|
|
209
|
+
return
|
|
210
|
+
line = panel.cursor_line
|
|
211
|
+
if not line:
|
|
212
|
+
return
|
|
213
|
+
commit_hash = extract_commit_hash(line)
|
|
214
|
+
if commit_hash is None:
|
|
215
|
+
return
|
|
216
|
+
details = self.repo.get_commit_details(commit_hash)
|
|
217
|
+
if not details:
|
|
218
|
+
return
|
|
219
|
+
panel.show_details(commit_hash, details)
|
|
220
|
+
elif key == "escape":
|
|
221
|
+
if panel.is_detail_mode:
|
|
222
|
+
panel.exit_details()
|
|
223
|
+
elif key == "page_down":
|
|
224
|
+
panel.scroll_page_down(animate=False)
|
|
225
|
+
elif key == "page_up":
|
|
226
|
+
panel.scroll_page_up(animate=False)
|
|
227
|
+
elif key == "home":
|
|
228
|
+
panel.scroll_home(animate=False)
|
|
229
|
+
elif key == "end":
|
|
230
|
+
panel.scroll_end(animate=False)
|
|
231
|
+
|
|
232
|
+
def _route_to_diff(self, key: str) -> None:
|
|
233
|
+
"""Route a key press to the diff-viewer panel."""
|
|
234
|
+
panel = self.query_one("#diff-viewer", DiffViewerWidget)
|
|
235
|
+
if key == "down":
|
|
236
|
+
panel.scroll_down(animate=False)
|
|
237
|
+
elif key == "up":
|
|
238
|
+
panel.scroll_up(animate=False)
|
|
239
|
+
elif key == "page_down":
|
|
240
|
+
panel.scroll_page_down(animate=False)
|
|
241
|
+
elif key == "page_up":
|
|
242
|
+
panel.scroll_page_up(animate=False)
|
|
243
|
+
elif key == "home":
|
|
244
|
+
panel.scroll_home(animate=False)
|
|
245
|
+
elif key == "end":
|
|
246
|
+
panel.scroll_end(animate=False)
|
|
247
|
+
|
|
248
|
+
def _activate_block(self, block_name: str) -> None:
|
|
249
|
+
"""Activate a bento block and deactivate the current one.
|
|
250
|
+
|
|
251
|
+
Updates block border styles and blurs keyboard focus so subsequent
|
|
252
|
+
key events go through :meth:`on_key`.
|
|
253
|
+
"""
|
|
254
|
+
info = _BLOCK_MAP[block_name]
|
|
255
|
+
|
|
256
|
+
if self._active_block:
|
|
257
|
+
current_info = _BLOCK_MAP[self._active_block]
|
|
258
|
+
current_block = self.query_one(current_info["block"], BlockContainer)
|
|
259
|
+
current_block.set_active(False)
|
|
260
|
+
|
|
261
|
+
self._active_block = block_name
|
|
262
|
+
self._block_index = _BLOCK_ORDER.index(block_name)
|
|
263
|
+
|
|
264
|
+
new_block = self.query_one(info["block"], BlockContainer)
|
|
265
|
+
new_block.set_active(True)
|
|
266
|
+
|
|
267
|
+
# Clear keyboard focus so no widget can intercept subsequent keys
|
|
268
|
+
if self.screen.focused is not None:
|
|
269
|
+
self.screen.focused.blur()
|
|
270
|
+
|
|
271
|
+
@work(thread=True, exclusive=True)
|
|
272
|
+
async def _run_push_worker(self) -> None:
|
|
273
|
+
"""Run push in a background thread to avoid blocking the UI."""
|
|
274
|
+
result = self.presenter.push()
|
|
275
|
+
self.call_from_thread(self._handle_push_result, result)
|
|
276
|
+
|
|
277
|
+
def _handle_push_result(self, result: PushResult) -> None:
|
|
278
|
+
"""Notify the user whether push succeeded or failed, then refresh."""
|
|
279
|
+
if result.success:
|
|
280
|
+
self.notify("Pushed successfully", severity="information")
|
|
281
|
+
else:
|
|
282
|
+
self.notify(result.error or "Push failed", severity="error")
|
|
283
|
+
self._refresh_all()
|
|
284
|
+
|
|
285
|
+
def _refresh_all(self) -> None:
|
|
286
|
+
"""Refresh every widget from git state: files, header, stats bar, log, diff, footer."""
|
|
287
|
+
try:
|
|
288
|
+
files = self.presenter.load_status()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
self.notify(f"Error loading status: {exc}", severity="error")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
branch = self._get_branch_safe()
|
|
294
|
+
remote_status = self._get_remote_status_safe()
|
|
295
|
+
repo_info = self._get_repo_info_safe()
|
|
296
|
+
is_detached = self._get_detached_safe()
|
|
297
|
+
|
|
298
|
+
user = self._get_user_safe()
|
|
299
|
+
head_summary = self._get_head_summary_safe()
|
|
300
|
+
default_branch = self._get_default_branch_safe()
|
|
301
|
+
operation = self._get_operation_state_safe()
|
|
302
|
+
|
|
303
|
+
file_counts = FileCounts.from_status(files)
|
|
304
|
+
wip = derive_wip_state(
|
|
305
|
+
file_counts,
|
|
306
|
+
operation,
|
|
307
|
+
has_commits=head_summary is not None, # empty repo → UNKNOWN
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
header = self.query_one("#top-bar", TopAppBar)
|
|
311
|
+
header.update_display(
|
|
312
|
+
repo_name=repo_info.name if repo_info else "",
|
|
313
|
+
owner=repo_info.owner if repo_info else "",
|
|
314
|
+
branch=branch,
|
|
315
|
+
is_detached=is_detached,
|
|
316
|
+
default_branch=default_branch,
|
|
317
|
+
wip_state=wip,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
stats_bar = self.query_one("#stats-bar", RepoStatsBar)
|
|
321
|
+
stats_bar.update_stats(
|
|
322
|
+
user=user,
|
|
323
|
+
head_summary=head_summary,
|
|
324
|
+
file_counts=file_counts,
|
|
325
|
+
ahead=remote_status.ahead if remote_status else 0,
|
|
326
|
+
behind=remote_status.behind if remote_status else 0,
|
|
327
|
+
remote=remote_status.remote if remote_status else "(local)",
|
|
328
|
+
remote_branch=remote_status.branch if remote_status else "",
|
|
329
|
+
repo_name=repo_info.name if repo_info else "",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
changed_files = self.query_one("#changed-files", ChangedFilesPanel)
|
|
333
|
+
changed_files.set_files(files)
|
|
334
|
+
changed_files.set_on_toggle(self._on_file_toggle)
|
|
335
|
+
changed_files.set_on_selection_change(self._on_file_selected)
|
|
336
|
+
changed_files.set_on_stage_all(self._on_stage_all)
|
|
337
|
+
changed_files.set_on_unstage_all(self._on_unstage_all)
|
|
338
|
+
|
|
339
|
+
self.query_one("#files-block", BlockContainer).set_header_subtitle(
|
|
340
|
+
f"{len(files)} items"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
commit_log = self.query_one("#commit-log", CommitLogWidget)
|
|
344
|
+
try:
|
|
345
|
+
log_text = self._get_commit_log_safe(max_count=30)
|
|
346
|
+
commit_log.show_log(log_text)
|
|
347
|
+
except Exception:
|
|
348
|
+
commit_log.clear()
|
|
349
|
+
|
|
350
|
+
self.query_one("#commits-block", BlockContainer).set_header_subtitle(
|
|
351
|
+
f"active: {branch}" if branch else "active: "
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
self._on_file_selected()
|
|
355
|
+
|
|
356
|
+
footer = self.query_one(GituxFooter)
|
|
357
|
+
sync_ok = remote_status is not None and remote_status.behind == 0
|
|
358
|
+
footer.update_sync(ok=sync_ok)
|
|
359
|
+
|
|
360
|
+
def _on_file_selected(self) -> None:
|
|
361
|
+
"""Update the diff viewer when the cursor moves to a different file."""
|
|
362
|
+
changed_files = self.query_one("#changed-files", ChangedFilesPanel)
|
|
363
|
+
diff_viewer = self.query_one("#diff-viewer", DiffViewerWidget)
|
|
364
|
+
file = changed_files.selected_file
|
|
365
|
+
|
|
366
|
+
if file is None:
|
|
367
|
+
preview = self.presenter.get_staged_preview()
|
|
368
|
+
if preview:
|
|
369
|
+
diff_viewer.show_diff(preview)
|
|
370
|
+
else:
|
|
371
|
+
diff_viewer.clear()
|
|
372
|
+
self.query_one("#diff-block", BlockContainer).set_header_subtitle("staged diff")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
diff_text = self.presenter.get_diff(file)
|
|
376
|
+
if not diff_text:
|
|
377
|
+
diff_viewer.show_binary_placeholder()
|
|
378
|
+
else:
|
|
379
|
+
diff_viewer.show_diff(diff_text)
|
|
380
|
+
self.query_one("#diff-block", BlockContainer).set_header_subtitle(file.path)
|
|
381
|
+
|
|
382
|
+
def _on_file_toggle(self, file: FileStatus) -> None:
|
|
383
|
+
"""Stage or unstage a single file, then refresh all panels."""
|
|
384
|
+
try:
|
|
385
|
+
if file.is_staged:
|
|
386
|
+
self.presenter.unstage_files([file.path])
|
|
387
|
+
else:
|
|
388
|
+
self.presenter.stage_files([file.path])
|
|
389
|
+
self._refresh_all()
|
|
390
|
+
except Exception as exc:
|
|
391
|
+
self.notify(f"Error: {exc}", severity="error")
|
|
392
|
+
|
|
393
|
+
def _on_stage_all(self) -> None:
|
|
394
|
+
"""Stage all unstaged files, then refresh."""
|
|
395
|
+
try:
|
|
396
|
+
unstaged = self.presenter.unstaged_files
|
|
397
|
+
if unstaged:
|
|
398
|
+
self.presenter.stage_files([f.path for f in unstaged])
|
|
399
|
+
self._refresh_all()
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
self.notify(f"Error staging all: {exc}", severity="error")
|
|
402
|
+
|
|
403
|
+
def _on_unstage_all(self) -> None:
|
|
404
|
+
"""Unstage all staged files, then refresh."""
|
|
405
|
+
try:
|
|
406
|
+
staged = self.presenter.staged_files
|
|
407
|
+
if staged:
|
|
408
|
+
self.presenter.unstage_files([f.path for f in staged])
|
|
409
|
+
self._refresh_all()
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
self.notify(f"Error unstaging all: {exc}", severity="error")
|
|
412
|
+
|
|
413
|
+
def _get_commit_log_safe(self, max_count: int = 30) -> str:
|
|
414
|
+
"""Return commit log text, or empty string on error."""
|
|
415
|
+
return self.repo.get_commit_log(max_count)
|
|
416
|
+
|
|
417
|
+
def _get_branch_safe(self) -> str:
|
|
418
|
+
"""Return current branch name, or empty string on error."""
|
|
419
|
+
return self.repo.get_current_branch()
|
|
420
|
+
|
|
421
|
+
def _get_user_safe(self) -> str:
|
|
422
|
+
"""Return the configured git user, or empty string on error."""
|
|
423
|
+
return self.repo.get_user()
|
|
424
|
+
|
|
425
|
+
def _get_head_summary_safe(self) -> HeadSummary | None:
|
|
426
|
+
"""Return the HEAD commit summary, or None on error."""
|
|
427
|
+
return self.repo.get_head_summary()
|
|
428
|
+
|
|
429
|
+
def _get_default_branch_safe(self) -> str:
|
|
430
|
+
"""Return the default branch name, or empty string on error."""
|
|
431
|
+
return self.repo.get_default_branch()
|
|
432
|
+
|
|
433
|
+
def _get_operation_state_safe(self) -> OperationState | None:
|
|
434
|
+
"""Return the merge/rebase operation state, or None on error."""
|
|
435
|
+
return self.repo.get_operation_state()
|
|
436
|
+
|
|
437
|
+
def _get_remote_status_safe(self) -> RemoteStatus | None:
|
|
438
|
+
"""Return remote status, or ``None`` on error."""
|
|
439
|
+
return self.repo.get_remote_status()
|
|
440
|
+
|
|
441
|
+
def _get_repo_info_safe(self) -> RepoInfo | None:
|
|
442
|
+
"""Return repo info, or ``None`` on error."""
|
|
443
|
+
return self.repo.get_repo_info()
|
|
444
|
+
|
|
445
|
+
def _get_detached_safe(self) -> bool:
|
|
446
|
+
"""Return whether HEAD is detached (``False`` on error)."""
|
|
447
|
+
return self.repo.is_detached_head()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
app = GituxApp()
|
|
452
|
+
app.run()
|