gitnc 0.1.2__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.
- gitnc/__init__.py +3 -0
- gitnc/__main__.py +15 -0
- gitnc/app.py +4699 -0
- gitnc/git.py +1052 -0
- gitnc/terminal.py +230 -0
- gitnc/themes/__init__.py +25 -0
- gitnc/themes/atom-one-dark.yaml +100 -0
- gitnc/themes/atom-one-light.yaml +100 -0
- gitnc/themes/catppuccin-frappe.yaml +108 -0
- gitnc/themes/catppuccin-latte.yaml +101 -0
- gitnc/themes/catppuccin-macchiato.yaml +108 -0
- gitnc/themes/catppuccin-mocha.yaml +108 -0
- gitnc/themes/dracula.yaml +101 -0
- gitnc/themes/flexoki.yaml +103 -0
- gitnc/themes/gruvbox.yaml +102 -0
- gitnc/themes/midnight-commander.yaml +104 -0
- gitnc/themes/monokai.yaml +102 -0
- gitnc/themes/nord.yaml +106 -0
- gitnc/themes/rose-pine-dawn.yaml +107 -0
- gitnc/themes/rose-pine-moon.yaml +107 -0
- gitnc/themes/rose-pine.yaml +107 -0
- gitnc/themes/solarized-dark.yaml +104 -0
- gitnc/themes/solarized-light.yaml +104 -0
- gitnc/themes/textual-ansi.yaml +110 -0
- gitnc/themes/textual-dark.yaml +100 -0
- gitnc/themes/textual-light.yaml +101 -0
- gitnc/themes/tokyo-night.yaml +101 -0
- gitnc/ui_theme.py +268 -0
- gitnc-0.1.2.dist-info/METADATA +14 -0
- gitnc-0.1.2.dist-info/RECORD +33 -0
- gitnc-0.1.2.dist-info/WHEEL +4 -0
- gitnc-0.1.2.dist-info/entry_points.txt +2 -0
- gitnc-0.1.2.dist-info/licenses/LICENSE +232 -0
gitnc/git.py
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""Git data types and command wrappers used by the application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import overload
|
|
15
|
+
|
|
16
|
+
# Colour git writes when the user's config forces it on. `-c color.ui=false`
|
|
17
|
+
# (see `_run_in`) turns the default off, but per-command settings such as
|
|
18
|
+
# `color.diff = always` still win over it, so whatever escapes come through
|
|
19
|
+
# are stripped here: the UI renders the strings it gets literally, and raw
|
|
20
|
+
# SGR sequences would show up as garbage in the diff and commit panes.
|
|
21
|
+
ANSI_SGR_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Commit:
|
|
26
|
+
hash: str
|
|
27
|
+
author: str
|
|
28
|
+
date: str
|
|
29
|
+
subject: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GitStatus(str, Enum):
|
|
33
|
+
UNMODIFIED = " "
|
|
34
|
+
MODIFIED = "M"
|
|
35
|
+
TYPE_CHANGED = "T"
|
|
36
|
+
ADDED = "A"
|
|
37
|
+
DELETED = "D"
|
|
38
|
+
RENAMED = "R"
|
|
39
|
+
COPIED = "C"
|
|
40
|
+
UPDATED_UNMERGED = "U"
|
|
41
|
+
UNTRACKED = "?"
|
|
42
|
+
IGNORED = "!"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
LONG_STATUS_DESCRIPTIONS: dict[GitStatus, str] = {
|
|
46
|
+
GitStatus.MODIFIED: "modified",
|
|
47
|
+
GitStatus.TYPE_CHANGED: "typechange",
|
|
48
|
+
GitStatus.ADDED: "new file",
|
|
49
|
+
GitStatus.DELETED: "deleted",
|
|
50
|
+
GitStatus.RENAMED: "renamed",
|
|
51
|
+
GitStatus.COPIED: "copied",
|
|
52
|
+
GitStatus.UPDATED_UNMERGED: "unmerged",
|
|
53
|
+
GitStatus.UNTRACKED: "untracked",
|
|
54
|
+
GitStatus.IGNORED: "ignored",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GitCode(str, Enum):
|
|
59
|
+
"""Two-character porcelain status codes (staged + unstaged).
|
|
60
|
+
|
|
61
|
+
Members enumerate every ``GitStatus`` × ``GitStatus`` combination, named
|
|
62
|
+
``<staged>_<unstaged>`` with the raw two-character value.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
UNMODIFIED_UNMODIFIED = " "
|
|
66
|
+
UNMODIFIED_MODIFIED = " M"
|
|
67
|
+
UNMODIFIED_TYPE_CHANGED = " T"
|
|
68
|
+
UNMODIFIED_ADDED = " A"
|
|
69
|
+
UNMODIFIED_DELETED = " D"
|
|
70
|
+
UNMODIFIED_RENAMED = " R"
|
|
71
|
+
UNMODIFIED_COPIED = " C"
|
|
72
|
+
UNMODIFIED_UPDATED_UNMERGED = " U"
|
|
73
|
+
UNMODIFIED_UNTRACKED = " ?"
|
|
74
|
+
UNMODIFIED_IGNORED = " !"
|
|
75
|
+
MODIFIED_UNMODIFIED = "M "
|
|
76
|
+
MODIFIED_MODIFIED = "MM"
|
|
77
|
+
MODIFIED_TYPE_CHANGED = "MT"
|
|
78
|
+
MODIFIED_ADDED = "MA"
|
|
79
|
+
MODIFIED_DELETED = "MD"
|
|
80
|
+
MODIFIED_RENAMED = "MR"
|
|
81
|
+
MODIFIED_COPIED = "MC"
|
|
82
|
+
MODIFIED_UPDATED_UNMERGED = "MU"
|
|
83
|
+
MODIFIED_UNTRACKED = "M?"
|
|
84
|
+
MODIFIED_IGNORED = "M!"
|
|
85
|
+
TYPE_CHANGED_UNMODIFIED = "T "
|
|
86
|
+
TYPE_CHANGED_MODIFIED = "TM"
|
|
87
|
+
TYPE_CHANGED_TYPE_CHANGED = "TT"
|
|
88
|
+
TYPE_CHANGED_ADDED = "TA"
|
|
89
|
+
TYPE_CHANGED_DELETED = "TD"
|
|
90
|
+
TYPE_CHANGED_RENAMED = "TR"
|
|
91
|
+
TYPE_CHANGED_COPIED = "TC"
|
|
92
|
+
TYPE_CHANGED_UPDATED_UNMERGED = "TU"
|
|
93
|
+
TYPE_CHANGED_UNTRACKED = "T?"
|
|
94
|
+
TYPE_CHANGED_IGNORED = "T!"
|
|
95
|
+
ADDED_UNMODIFIED = "A "
|
|
96
|
+
ADDED_MODIFIED = "AM"
|
|
97
|
+
ADDED_TYPE_CHANGED = "AT"
|
|
98
|
+
ADDED_ADDED = "AA"
|
|
99
|
+
ADDED_DELETED = "AD"
|
|
100
|
+
ADDED_RENAMED = "AR"
|
|
101
|
+
ADDED_COPIED = "AC"
|
|
102
|
+
ADDED_UPDATED_UNMERGED = "AU"
|
|
103
|
+
ADDED_UNTRACKED = "A?"
|
|
104
|
+
ADDED_IGNORED = "A!"
|
|
105
|
+
DELETED_UNMODIFIED = "D "
|
|
106
|
+
DELETED_MODIFIED = "DM"
|
|
107
|
+
DELETED_TYPE_CHANGED = "DT"
|
|
108
|
+
DELETED_ADDED = "DA"
|
|
109
|
+
DELETED_DELETED = "DD"
|
|
110
|
+
DELETED_RENAMED = "DR"
|
|
111
|
+
DELETED_COPIED = "DC"
|
|
112
|
+
DELETED_UPDATED_UNMERGED = "DU"
|
|
113
|
+
DELETED_UNTRACKED = "D?"
|
|
114
|
+
DELETED_IGNORED = "D!"
|
|
115
|
+
RENAMED_UNMODIFIED = "R "
|
|
116
|
+
RENAMED_MODIFIED = "RM"
|
|
117
|
+
RENAMED_TYPE_CHANGED = "RT"
|
|
118
|
+
RENAMED_ADDED = "RA"
|
|
119
|
+
RENAMED_DELETED = "RD"
|
|
120
|
+
RENAMED_RENAMED = "RR"
|
|
121
|
+
RENAMED_COPIED = "RC"
|
|
122
|
+
RENAMED_UPDATED_UNMERGED = "RU"
|
|
123
|
+
RENAMED_UNTRACKED = "R?"
|
|
124
|
+
RENAMED_IGNORED = "R!"
|
|
125
|
+
COPIED_UNMODIFIED = "C "
|
|
126
|
+
COPIED_MODIFIED = "CM"
|
|
127
|
+
COPIED_TYPE_CHANGED = "CT"
|
|
128
|
+
COPIED_ADDED = "CA"
|
|
129
|
+
COPIED_DELETED = "CD"
|
|
130
|
+
COPIED_RENAMED = "CR"
|
|
131
|
+
COPIED_COPIED = "CC"
|
|
132
|
+
COPIED_UPDATED_UNMERGED = "CU"
|
|
133
|
+
COPIED_UNTRACKED = "C?"
|
|
134
|
+
COPIED_IGNORED = "C!"
|
|
135
|
+
UPDATED_UNMERGED_UNMODIFIED = "U "
|
|
136
|
+
UPDATED_UNMERGED_MODIFIED = "UM"
|
|
137
|
+
UPDATED_UNMERGED_TYPE_CHANGED = "UT"
|
|
138
|
+
UPDATED_UNMERGED_ADDED = "UA"
|
|
139
|
+
UPDATED_UNMERGED_DELETED = "UD"
|
|
140
|
+
UPDATED_UNMERGED_RENAMED = "UR"
|
|
141
|
+
UPDATED_UNMERGED_COPIED = "UC"
|
|
142
|
+
UPDATED_UNMERGED_UPDATED_UNMERGED = "UU"
|
|
143
|
+
UPDATED_UNMERGED_UNTRACKED = "U?"
|
|
144
|
+
UPDATED_UNMERGED_IGNORED = "U!"
|
|
145
|
+
UNTRACKED_UNMODIFIED = "? "
|
|
146
|
+
UNTRACKED_MODIFIED = "?M"
|
|
147
|
+
UNTRACKED_TYPE_CHANGED = "?T"
|
|
148
|
+
UNTRACKED_ADDED = "?A"
|
|
149
|
+
UNTRACKED_DELETED = "?D"
|
|
150
|
+
UNTRACKED_RENAMED = "?R"
|
|
151
|
+
UNTRACKED_COPIED = "?C"
|
|
152
|
+
UNTRACKED_UPDATED_UNMERGED = "?U"
|
|
153
|
+
UNTRACKED_UNTRACKED = "??"
|
|
154
|
+
UNTRACKED_IGNORED = "?!"
|
|
155
|
+
IGNORED_UNMODIFIED = "! "
|
|
156
|
+
IGNORED_MODIFIED = "!M"
|
|
157
|
+
IGNORED_TYPE_CHANGED = "!T"
|
|
158
|
+
IGNORED_ADDED = "!A"
|
|
159
|
+
IGNORED_DELETED = "!D"
|
|
160
|
+
IGNORED_RENAMED = "!R"
|
|
161
|
+
IGNORED_COPIED = "!C"
|
|
162
|
+
IGNORED_UPDATED_UNMERGED = "!U"
|
|
163
|
+
IGNORED_UNTRACKED = "!?"
|
|
164
|
+
IGNORED_IGNORED = "!!"
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def staged(self) -> GitStatus:
|
|
168
|
+
return GitStatus(self.value[0])
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def unstaged(self) -> GitStatus:
|
|
172
|
+
return GitStatus(self.value[1])
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_untracked(self) -> bool:
|
|
176
|
+
return self.value == "??"
|
|
177
|
+
|
|
178
|
+
def contains(self, status: GitStatus) -> bool:
|
|
179
|
+
return self.staged is status or self.unstaged is status
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def long_description(self) -> str:
|
|
183
|
+
"""Human-readable status mirroring `git status` long-format labels."""
|
|
184
|
+
if self.is_untracked:
|
|
185
|
+
return "untracked"
|
|
186
|
+
if self.value == "!!":
|
|
187
|
+
return "ignored"
|
|
188
|
+
parts: list[str] = []
|
|
189
|
+
if self.staged != GitStatus.UNMODIFIED:
|
|
190
|
+
parts.append(f"{LONG_STATUS_DESCRIPTIONS[self.staged]} (staged)")
|
|
191
|
+
if self.unstaged != GitStatus.UNMODIFIED:
|
|
192
|
+
parts.append(f"{LONG_STATUS_DESCRIPTIONS[self.unstaged]} (not staged)")
|
|
193
|
+
return ", ".join(parts) or "unmodified"
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def has_staged_changes(self) -> bool:
|
|
197
|
+
"""True when `git restore --staged <file>` would do something."""
|
|
198
|
+
return self.staged != GitStatus.UNMODIFIED and not self.is_untracked
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def has_unstaged_changes(self) -> bool:
|
|
202
|
+
"""True when `git add <file>` would do something (includes untracked)."""
|
|
203
|
+
return self.unstaged != GitStatus.UNMODIFIED
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_restorable(self) -> bool:
|
|
207
|
+
"""True when the file can be reset to the state it had at HEAD.
|
|
208
|
+
|
|
209
|
+
Ignored and unmerged entries are skipped: ignored files aren't a
|
|
210
|
+
user concern, and unmerged (conflict) states need manual resolution
|
|
211
|
+
rather than a blanket restore.
|
|
212
|
+
"""
|
|
213
|
+
if self.value == "!!":
|
|
214
|
+
return False
|
|
215
|
+
if self.contains(GitStatus.UPDATED_UNMERGED):
|
|
216
|
+
return False
|
|
217
|
+
if self.is_untracked:
|
|
218
|
+
return True
|
|
219
|
+
return self.staged != GitStatus.UNMODIFIED or self.unstaged != GitStatus.UNMODIFIED
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def is_deletable(self) -> bool:
|
|
223
|
+
"""True when DELETE has a well-defined operation for the file.
|
|
224
|
+
|
|
225
|
+
Skips ignored, unmerged, and entries whose worktree copy is already
|
|
226
|
+
gone with no further index work to do (`D ` and `DD`). The remaining
|
|
227
|
+
` D` (unstaged delete) is deletable so the user can stage that
|
|
228
|
+
removal via DELETE.
|
|
229
|
+
"""
|
|
230
|
+
if self.value == "!!":
|
|
231
|
+
return False
|
|
232
|
+
if self.contains(GitStatus.UPDATED_UNMERGED):
|
|
233
|
+
return False
|
|
234
|
+
worktree_copy_already_gone = self.staged == GitStatus.DELETED and self.unstaged in (
|
|
235
|
+
GitStatus.UNMODIFIED,
|
|
236
|
+
GitStatus.DELETED,
|
|
237
|
+
)
|
|
238
|
+
return not worktree_copy_already_gone
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class GitEntry:
|
|
243
|
+
code: GitCode
|
|
244
|
+
filename: str
|
|
245
|
+
submodule: str | None = None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
DEFAULT_MTIME_FORMAT = "%Y-%m-%d %H:%M"
|
|
249
|
+
DEFAULT_STATUS_WIDTH = len("Status")
|
|
250
|
+
DIRECTORY_SIZE_LABEL = "<DIR>"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _format_size(size: int) -> str:
|
|
254
|
+
value = float(size)
|
|
255
|
+
for unit in ("B", "K", "M", "G"):
|
|
256
|
+
if value < 1024 or unit == "G":
|
|
257
|
+
if unit == "B":
|
|
258
|
+
return f"{int(value)}B"
|
|
259
|
+
return f"{value:.1f}{unit}"
|
|
260
|
+
value /= 1024
|
|
261
|
+
return f"{value:.1f}T"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _entry_stat(repo_path: str, filename: str, mtime_format: str) -> tuple[str, str]:
|
|
265
|
+
candidate = filename.split(" -> ")[-1]
|
|
266
|
+
try:
|
|
267
|
+
st = os.stat(os.path.join(repo_path, candidate))
|
|
268
|
+
except OSError:
|
|
269
|
+
return "-", "-"
|
|
270
|
+
# A collapsed untracked directory: its inode size says nothing about what
|
|
271
|
+
# is inside it, so show a marker the way a file manager does.
|
|
272
|
+
size = DIRECTORY_SIZE_LABEL if stat.S_ISDIR(st.st_mode) else _format_size(st.st_size)
|
|
273
|
+
# Read as UTC then convert back to the local zone, so the displayed wall
|
|
274
|
+
# clock matches the user's timezone without relying on a naive datetime.
|
|
275
|
+
local_mtime = datetime.fromtimestamp(st.st_mtime, tz=UTC).astimezone()
|
|
276
|
+
return size, local_mtime.strftime(mtime_format)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
STATUS_COLORS: dict[GitStatus, str] = {
|
|
280
|
+
GitStatus.UNMODIFIED: "",
|
|
281
|
+
GitStatus.MODIFIED: "bold yellow",
|
|
282
|
+
GitStatus.TYPE_CHANGED: "bold #ff8800",
|
|
283
|
+
GitStatus.ADDED: "bold green",
|
|
284
|
+
GitStatus.DELETED: "bold red",
|
|
285
|
+
GitStatus.RENAMED: "bold cyan",
|
|
286
|
+
GitStatus.COPIED: "bold blue",
|
|
287
|
+
GitStatus.UPDATED_UNMERGED: "bold #ff5555",
|
|
288
|
+
GitStatus.UNTRACKED: "bold white",
|
|
289
|
+
GitStatus.IGNORED: "#888888",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
SUBMODULE_COLOR = "bold magenta"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _styled(text: str, color: str) -> str:
|
|
296
|
+
if not color:
|
|
297
|
+
return text
|
|
298
|
+
return f"[{color}]{text}[/{color}]"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _render_status_code(
|
|
302
|
+
entry: GitEntry,
|
|
303
|
+
status_width: int,
|
|
304
|
+
status_colors: Mapping[GitStatus, str],
|
|
305
|
+
submodule_color: str,
|
|
306
|
+
) -> str:
|
|
307
|
+
value = entry.code.value
|
|
308
|
+
padding = " " * max(0, status_width - 2)
|
|
309
|
+
if entry.submodule is not None:
|
|
310
|
+
return _styled(value, submodule_color) + padding
|
|
311
|
+
staged = _styled(value[0], status_colors[entry.code.staged])
|
|
312
|
+
unstaged = _styled(value[1], status_colors[entry.code.unstaged])
|
|
313
|
+
return f"{staged}{unstaged}{padding}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _truncate(text: str, width: int) -> str:
|
|
317
|
+
if len(text) <= width:
|
|
318
|
+
return text.ljust(width)
|
|
319
|
+
return text[: max(0, width - 1)] + "…"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _entry_sort_key(entry: GitEntry) -> tuple[str, str]:
|
|
323
|
+
return (entry.filename.casefold(), entry.filename)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class GitFilelist(Sequence[GitEntry]):
|
|
328
|
+
"""View model backing the short and long file-list views."""
|
|
329
|
+
|
|
330
|
+
entries: list[GitEntry] = field(default_factory=list)
|
|
331
|
+
highlighted_entry: GitEntry | None = None
|
|
332
|
+
selected_filenames: set[str] = field(default_factory=set)
|
|
333
|
+
|
|
334
|
+
def __post_init__(self) -> None:
|
|
335
|
+
self.entries = sorted(self.entries, key=_entry_sort_key)
|
|
336
|
+
if self.highlighted_entry is not None:
|
|
337
|
+
index = self.index_of(self.highlighted_entry)
|
|
338
|
+
self.highlighted_entry = self.entry_at(index)
|
|
339
|
+
self.selected_filenames = set(self.selected_filenames) & self._filenames()
|
|
340
|
+
|
|
341
|
+
def _filenames(self) -> set[str]:
|
|
342
|
+
return {entry.filename for entry in self.entries}
|
|
343
|
+
|
|
344
|
+
@overload
|
|
345
|
+
def __getitem__(self, index: int) -> GitEntry: ...
|
|
346
|
+
@overload
|
|
347
|
+
def __getitem__(self, index: slice) -> list[GitEntry]: ...
|
|
348
|
+
def __getitem__(self, index: int | slice) -> GitEntry | list[GitEntry]:
|
|
349
|
+
return self.entries[index]
|
|
350
|
+
|
|
351
|
+
def __len__(self) -> int:
|
|
352
|
+
return len(self.entries)
|
|
353
|
+
|
|
354
|
+
def __iter__(self) -> Iterator[GitEntry]:
|
|
355
|
+
return iter(self.entries)
|
|
356
|
+
|
|
357
|
+
def entry_at(self, index: int | None) -> GitEntry | None:
|
|
358
|
+
if not isinstance(index, int) or not 0 <= index < len(self.entries):
|
|
359
|
+
return None
|
|
360
|
+
return self.entries[index]
|
|
361
|
+
|
|
362
|
+
def index_of(self, entry: GitEntry) -> int | None:
|
|
363
|
+
for index, candidate in enumerate(self.entries):
|
|
364
|
+
if candidate.code == entry.code and candidate.filename == entry.filename:
|
|
365
|
+
return index
|
|
366
|
+
for index, candidate in enumerate(self.entries):
|
|
367
|
+
if candidate.filename == entry.filename:
|
|
368
|
+
return index
|
|
369
|
+
# A file the list now shows folded into its untracked directory's
|
|
370
|
+
# `dir/` row: that row is where the file went.
|
|
371
|
+
for index, candidate in enumerate(self.entries):
|
|
372
|
+
if candidate.filename.endswith("/") and entry.filename.startswith(candidate.filename):
|
|
373
|
+
return index
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def set_highlighted_index(self, index: int | None) -> GitEntry | None:
|
|
377
|
+
self.highlighted_entry = self.entry_at(index)
|
|
378
|
+
return self.highlighted_entry
|
|
379
|
+
|
|
380
|
+
def is_selected(self, index: int | None) -> bool:
|
|
381
|
+
entry = self.entry_at(index)
|
|
382
|
+
return entry is not None and entry.filename in self.selected_filenames
|
|
383
|
+
|
|
384
|
+
def toggle_selection(self, index: int | None) -> GitEntry | None:
|
|
385
|
+
entry = self.entry_at(index)
|
|
386
|
+
if entry is None:
|
|
387
|
+
return None
|
|
388
|
+
if entry.filename in self.selected_filenames:
|
|
389
|
+
self.selected_filenames.discard(entry.filename)
|
|
390
|
+
else:
|
|
391
|
+
self.selected_filenames.add(entry.filename)
|
|
392
|
+
return entry
|
|
393
|
+
|
|
394
|
+
def clear_selections(self) -> None:
|
|
395
|
+
self.selected_filenames.clear()
|
|
396
|
+
|
|
397
|
+
def restore_selections(self, filenames: set[str]) -> None:
|
|
398
|
+
self.selected_filenames = set(filenames) & self._filenames()
|
|
399
|
+
|
|
400
|
+
def selected_entries(self) -> list[GitEntry]:
|
|
401
|
+
return [e for e in self.entries if e.filename in self.selected_filenames]
|
|
402
|
+
|
|
403
|
+
def status_message(self) -> str:
|
|
404
|
+
if self.entries:
|
|
405
|
+
return f"{len(self.entries)} file(s) changed"
|
|
406
|
+
return "Nothing to commit, working tree clean"
|
|
407
|
+
|
|
408
|
+
def row_text(
|
|
409
|
+
self,
|
|
410
|
+
entry: GitEntry,
|
|
411
|
+
repo_path: str,
|
|
412
|
+
*,
|
|
413
|
+
status_width: int = DEFAULT_STATUS_WIDTH,
|
|
414
|
+
name_width: int,
|
|
415
|
+
size_width: int,
|
|
416
|
+
mtime_width: int,
|
|
417
|
+
mtime_format: str = DEFAULT_MTIME_FORMAT,
|
|
418
|
+
status_colors: Mapping[GitStatus, str] = STATUS_COLORS,
|
|
419
|
+
submodule_color: str = SUBMODULE_COLOR,
|
|
420
|
+
) -> str:
|
|
421
|
+
code = _render_status_code(entry, status_width, status_colors, submodule_color)
|
|
422
|
+
name = f"{_truncate(entry.filename, name_width):<{name_width}}"
|
|
423
|
+
size, mtime = _entry_stat(repo_path, entry.filename, mtime_format)
|
|
424
|
+
return f"{code} {name} {size:>{size_width}} {mtime:>{mtime_width}}"
|
|
425
|
+
|
|
426
|
+
def row_texts(
|
|
427
|
+
self,
|
|
428
|
+
repo_path: str,
|
|
429
|
+
*,
|
|
430
|
+
status_width: int = DEFAULT_STATUS_WIDTH,
|
|
431
|
+
name_width: int,
|
|
432
|
+
size_width: int,
|
|
433
|
+
mtime_width: int,
|
|
434
|
+
mtime_format: str = DEFAULT_MTIME_FORMAT,
|
|
435
|
+
status_colors: Mapping[GitStatus, str] = STATUS_COLORS,
|
|
436
|
+
submodule_color: str = SUBMODULE_COLOR,
|
|
437
|
+
) -> list[str]:
|
|
438
|
+
return [
|
|
439
|
+
self.row_text(
|
|
440
|
+
entry,
|
|
441
|
+
repo_path,
|
|
442
|
+
status_width=status_width,
|
|
443
|
+
name_width=name_width,
|
|
444
|
+
size_width=size_width,
|
|
445
|
+
mtime_width=mtime_width,
|
|
446
|
+
mtime_format=mtime_format,
|
|
447
|
+
status_colors=status_colors,
|
|
448
|
+
submodule_color=submodule_color,
|
|
449
|
+
)
|
|
450
|
+
for entry in self.entries
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _untracked_directory_listing(path: str, display_name: str) -> str:
|
|
455
|
+
"""The files under a collapsed untracked directory, one relative path a line.
|
|
456
|
+
|
|
457
|
+
Stands in for file content when the file list shows a whole untracked
|
|
458
|
+
directory as one entry, so opening it still shows what staging or
|
|
459
|
+
cleaning it would take along.
|
|
460
|
+
"""
|
|
461
|
+
files: list[str] = []
|
|
462
|
+
for root, dirs, names in os.walk(path):
|
|
463
|
+
# An untracked nested repository collapses to `dir/` as well; its
|
|
464
|
+
# object store is not what anyone opening the entry wants to read.
|
|
465
|
+
dirs[:] = sorted(d for d in dirs if d != ".git")
|
|
466
|
+
files.extend(os.path.relpath(os.path.join(root, n), path) for n in sorted(names))
|
|
467
|
+
header = f"Untracked directory: {display_name} ({len(files)} file(s))"
|
|
468
|
+
return "\n".join([header, "", *files])
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class GIT:
|
|
472
|
+
"""Encapsulate all git commands used by the application."""
|
|
473
|
+
|
|
474
|
+
def __init__(self, path: str = ".") -> None:
|
|
475
|
+
self.path = path
|
|
476
|
+
# Every git process that is running right now, so `shutdown()` can end
|
|
477
|
+
# the ones a quit would otherwise have to wait out. The reads run on
|
|
478
|
+
# worker threads, so both the set and its lock are touched from more
|
|
479
|
+
# than one thread.
|
|
480
|
+
self._running: set[subprocess.Popen[str]] = set()
|
|
481
|
+
self._running_lock = threading.Lock()
|
|
482
|
+
self._closed = False
|
|
483
|
+
|
|
484
|
+
def _run(self, *args: str) -> subprocess.CompletedProcess[str]:
|
|
485
|
+
return self._run_in(self.path, *args)
|
|
486
|
+
|
|
487
|
+
def _run_in(self, cwd: str, *args: str) -> subprocess.CompletedProcess[str]:
|
|
488
|
+
argv = ["git", "-c", "color.ui=false", "-C", cwd, *args]
|
|
489
|
+
# Spawning under the lock is what closes the window between the two
|
|
490
|
+
# halves of `shutdown()`: started outside it, a process could be born
|
|
491
|
+
# after the flag went up and before the set was read, and so run on
|
|
492
|
+
# unwatched while the app was already on its way out.
|
|
493
|
+
with self._running_lock:
|
|
494
|
+
if self._closed:
|
|
495
|
+
return subprocess.CompletedProcess(args=argv, returncode=1, stdout="", stderr="")
|
|
496
|
+
process = subprocess.Popen(
|
|
497
|
+
argv,
|
|
498
|
+
stdout=subprocess.PIPE,
|
|
499
|
+
stderr=subprocess.PIPE,
|
|
500
|
+
text=True,
|
|
501
|
+
)
|
|
502
|
+
self._running.add(process)
|
|
503
|
+
try:
|
|
504
|
+
stdout, stderr = process.communicate()
|
|
505
|
+
finally:
|
|
506
|
+
with self._running_lock:
|
|
507
|
+
self._running.discard(process)
|
|
508
|
+
return subprocess.CompletedProcess(
|
|
509
|
+
args=argv,
|
|
510
|
+
returncode=process.returncode,
|
|
511
|
+
stdout=ANSI_SGR_PATTERN.sub("", stdout or ""),
|
|
512
|
+
stderr=stderr,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def shutdown(self) -> int:
|
|
516
|
+
"""Kill every git process still running, and run no more. One-way.
|
|
517
|
+
|
|
518
|
+
`Popen` plus this method is what `subprocess.run()` used to be here.
|
|
519
|
+
The cost of `run()` was that a call in flight could only be waited
|
|
520
|
+
out: a read on a thread worker holds the process until git returns,
|
|
521
|
+
and Textual joins its worker threads on shutdown, so a quit pressed
|
|
522
|
+
during the startup `git status` sat there until it finished.
|
|
523
|
+
|
|
524
|
+
Refusing the *next* call is half of it, and not the optional half: a
|
|
525
|
+
read is several git commands one after another (`load_status` walks
|
|
526
|
+
the submodules), so killing the one that happens to be running just
|
|
527
|
+
hands the worker on to the next one. Every call after this returns
|
|
528
|
+
the failure shape — `returncode` 1 and no output — which the readers
|
|
529
|
+
already have to handle, because a git command can always fail.
|
|
530
|
+
|
|
531
|
+
Returns how many processes were signalled.
|
|
532
|
+
"""
|
|
533
|
+
with self._running_lock:
|
|
534
|
+
self._closed = True
|
|
535
|
+
processes = list(self._running)
|
|
536
|
+
terminated = 0
|
|
537
|
+
for process in processes:
|
|
538
|
+
try:
|
|
539
|
+
process.terminate()
|
|
540
|
+
except (OSError, ValueError): # already gone, or already reaped
|
|
541
|
+
continue
|
|
542
|
+
terminated += 1
|
|
543
|
+
return terminated
|
|
544
|
+
|
|
545
|
+
def _submodule_paths(self, abs_root: str) -> list[str]:
|
|
546
|
+
"""Submodule paths declared in <abs_root>/.gitmodules (relative to abs_root)."""
|
|
547
|
+
gitmodules = os.path.join(abs_root, ".gitmodules")
|
|
548
|
+
if not os.path.exists(gitmodules):
|
|
549
|
+
return []
|
|
550
|
+
result = self._run_in(
|
|
551
|
+
abs_root,
|
|
552
|
+
"config",
|
|
553
|
+
"--file",
|
|
554
|
+
".gitmodules",
|
|
555
|
+
"--get-regexp",
|
|
556
|
+
r"^submodule\..*\.path$",
|
|
557
|
+
)
|
|
558
|
+
paths: list[str] = []
|
|
559
|
+
for line in (result.stdout or "").splitlines():
|
|
560
|
+
_, _, value = line.partition(" ")
|
|
561
|
+
value = value.strip()
|
|
562
|
+
if value:
|
|
563
|
+
paths.append(value)
|
|
564
|
+
return paths
|
|
565
|
+
|
|
566
|
+
def _is_initialized_submodule(self, abs_path: str) -> bool:
|
|
567
|
+
return os.path.exists(os.path.join(abs_path, ".git"))
|
|
568
|
+
|
|
569
|
+
def _entry_target(self, entry: GitEntry) -> tuple[str, str]:
|
|
570
|
+
"""(cwd, inner_filename) for routing per-file commands at the right repo."""
|
|
571
|
+
if entry.submodule:
|
|
572
|
+
cwd = os.path.join(self.path, entry.submodule)
|
|
573
|
+
return cwd, os.path.relpath(entry.filename, entry.submodule)
|
|
574
|
+
return self.path, entry.filename
|
|
575
|
+
|
|
576
|
+
def entry_command_filename(self, entry: GitEntry) -> str:
|
|
577
|
+
"""The filename as it appears in argv for this entry (inner for submodule)."""
|
|
578
|
+
return self._entry_target(entry)[1]
|
|
579
|
+
|
|
580
|
+
def load_commits(self) -> list[Commit]:
|
|
581
|
+
"""Run git log and parse the output into Commit objects."""
|
|
582
|
+
result = self._run(
|
|
583
|
+
"log",
|
|
584
|
+
"--pretty=format:%H\x1f%an\x1f%ad\x1f%s",
|
|
585
|
+
"--date=short",
|
|
586
|
+
"-n",
|
|
587
|
+
"200",
|
|
588
|
+
)
|
|
589
|
+
commits = []
|
|
590
|
+
for line in result.stdout.splitlines():
|
|
591
|
+
parts = line.split("\x1f", 3)
|
|
592
|
+
if len(parts) == 4:
|
|
593
|
+
commits.append(Commit(*parts))
|
|
594
|
+
return commits
|
|
595
|
+
|
|
596
|
+
def load_file_diff(self, entry: GitEntry) -> str:
|
|
597
|
+
"""Return the diff text for a status entry (staged + unstaged combined)."""
|
|
598
|
+
cwd, name = self._entry_target(entry)
|
|
599
|
+
staged, unstaged = entry.code.staged, entry.code.unstaged
|
|
600
|
+
parts = []
|
|
601
|
+
if staged not in (GitStatus.UNMODIFIED, GitStatus.UNTRACKED):
|
|
602
|
+
result = self._run_in(cwd, "diff", "--cached", "--", name)
|
|
603
|
+
if result.stdout:
|
|
604
|
+
parts.append(result.stdout)
|
|
605
|
+
if unstaged not in (GitStatus.UNMODIFIED, GitStatus.UNTRACKED):
|
|
606
|
+
result = self._run_in(cwd, "diff", "--", name)
|
|
607
|
+
if result.stdout:
|
|
608
|
+
parts.append(result.stdout)
|
|
609
|
+
if not parts:
|
|
610
|
+
if entry.code.is_untracked:
|
|
611
|
+
return f"Untracked file: {entry.filename}"
|
|
612
|
+
return f"No diff available for {entry.filename}"
|
|
613
|
+
return "\n".join(parts)
|
|
614
|
+
|
|
615
|
+
def load_file_content(self, entry: GitEntry) -> str:
|
|
616
|
+
"""Return the raw text of an untracked entry's file on disk.
|
|
617
|
+
|
|
618
|
+
Returns an informative message instead of raising if the file is
|
|
619
|
+
unreadable (missing, permission denied) or binary.
|
|
620
|
+
"""
|
|
621
|
+
cwd, name = self._entry_target(entry)
|
|
622
|
+
path = os.path.join(cwd, name)
|
|
623
|
+
if os.path.isdir(path):
|
|
624
|
+
return _untracked_directory_listing(path, entry.filename)
|
|
625
|
+
try:
|
|
626
|
+
with open(path, "rb") as fh:
|
|
627
|
+
data = fh.read()
|
|
628
|
+
except OSError as exc:
|
|
629
|
+
reason = exc.strerror or str(exc)
|
|
630
|
+
return f"Cannot read file: {entry.filename} ({reason})"
|
|
631
|
+
if b"\x00" in data:
|
|
632
|
+
return f"Binary file: {entry.filename}"
|
|
633
|
+
return data.decode("utf-8", errors="replace")
|
|
634
|
+
|
|
635
|
+
def load_status(self, *, collapse_untracked_dirs: bool = False) -> GitFilelist:
|
|
636
|
+
"""Run git status --short and parse the output into a GitFilelist.
|
|
637
|
+
|
|
638
|
+
`--untracked-files=all` expands untracked directories to the
|
|
639
|
+
individual files inside them; gitignored files are excluded by
|
|
640
|
+
default, so the listing already respects `.gitignore`.
|
|
641
|
+
|
|
642
|
+
*collapse_untracked_dirs* switches that to `--untracked-files=normal`,
|
|
643
|
+
which lists a directory holding nothing git has ever tracked as one
|
|
644
|
+
`dir/` entry instead of every file below it. Git makes the call, so a
|
|
645
|
+
directory with a tracked file anywhere inside still lists its
|
|
646
|
+
untracked files one by one.
|
|
647
|
+
|
|
648
|
+
Initialized submodules are recursively expanded: their parent rows
|
|
649
|
+
are replaced with rows for the inner files (filenames prefixed with
|
|
650
|
+
the submodule path; ``submodule`` set on each inner entry).
|
|
651
|
+
"""
|
|
652
|
+
untracked_files = "normal" if collapse_untracked_dirs else "all"
|
|
653
|
+
return GitFilelist(self._load_status_at("", untracked_files=untracked_files))
|
|
654
|
+
|
|
655
|
+
def _load_status_at(self, rel_root: str, *, untracked_files: str) -> list[GitEntry]:
|
|
656
|
+
abs_root = os.path.join(self.path, rel_root) if rel_root else self.path
|
|
657
|
+
result = self._run_in(abs_root, "status", "--short", f"--untracked-files={untracked_files}")
|
|
658
|
+
submodules = set(self._submodule_paths(abs_root))
|
|
659
|
+
entries: list[GitEntry] = []
|
|
660
|
+
for line in result.stdout.splitlines():
|
|
661
|
+
if len(line) < 4:
|
|
662
|
+
continue
|
|
663
|
+
name = line[3:]
|
|
664
|
+
# Submodule lines: recurse before parsing the code, since git
|
|
665
|
+
# emits lowercase letters (e.g. " m") for submodule content
|
|
666
|
+
# changes that aren't valid GitCode values. If the inner
|
|
667
|
+
# working tree is clean (pointer-only change), we keep the
|
|
668
|
+
# parent's row so the gitlink bump can still be staged and
|
|
669
|
+
# committed; lowercase markers fall back to ` M`.
|
|
670
|
+
if name in submodules:
|
|
671
|
+
sub_abs = os.path.join(abs_root, name)
|
|
672
|
+
if self._is_initialized_submodule(sub_abs):
|
|
673
|
+
sub_rel = os.path.join(rel_root, name) if rel_root else name
|
|
674
|
+
inner = self._load_status_at(sub_rel, untracked_files=untracked_files)
|
|
675
|
+
if inner:
|
|
676
|
+
entries.extend(inner)
|
|
677
|
+
continue
|
|
678
|
+
raw = line[:2]
|
|
679
|
+
try:
|
|
680
|
+
code = GitCode(raw)
|
|
681
|
+
except ValueError:
|
|
682
|
+
normalised = raw.upper().replace("?", " ")
|
|
683
|
+
try:
|
|
684
|
+
code = GitCode(normalised)
|
|
685
|
+
except ValueError:
|
|
686
|
+
code = GitCode(" M")
|
|
687
|
+
prefixed = os.path.join(rel_root, name) if rel_root else name
|
|
688
|
+
entries.append(GitEntry(code, prefixed, submodule=(rel_root or None)))
|
|
689
|
+
continue
|
|
690
|
+
try:
|
|
691
|
+
code = GitCode(line[:2])
|
|
692
|
+
except ValueError:
|
|
693
|
+
continue
|
|
694
|
+
prefixed = os.path.join(rel_root, name) if rel_root else name
|
|
695
|
+
entries.append(GitEntry(code, prefixed, submodule=(rel_root or None)))
|
|
696
|
+
return entries
|
|
697
|
+
|
|
698
|
+
def show_commit(self, commit_hash: str) -> str:
|
|
699
|
+
"""Return git show --stat output for a commit."""
|
|
700
|
+
return self._run("show", "--stat", commit_hash).stdout
|
|
701
|
+
|
|
702
|
+
def branch_name(self) -> str | None:
|
|
703
|
+
"""Return the current branch name, or None when detached / unknown."""
|
|
704
|
+
result = self._run("branch", "--show-current")
|
|
705
|
+
name = (result.stdout or "").strip()
|
|
706
|
+
return name or None
|
|
707
|
+
|
|
708
|
+
def _branch_name_in(self, cwd: str) -> str | None:
|
|
709
|
+
result = self._run_in(cwd, "branch", "--show-current")
|
|
710
|
+
name = (result.stdout or "").strip()
|
|
711
|
+
return name or None
|
|
712
|
+
|
|
713
|
+
def submodule_branch_mismatches(self) -> list[tuple[str, str | None, str | None]]:
|
|
714
|
+
"""Return mismatches between submodule branches and the parent's branch.
|
|
715
|
+
|
|
716
|
+
Each tuple is ``(submodule_path, submodule_branch, parent_branch)`` for
|
|
717
|
+
every initialized submodule (recursively) whose current branch differs
|
|
718
|
+
from the root repo's current branch. ``None`` represents a detached
|
|
719
|
+
HEAD on either side. Uninitialized submodules are skipped.
|
|
720
|
+
"""
|
|
721
|
+
parent_branch = self.branch_name()
|
|
722
|
+
mismatches: list[tuple[str, str | None, str | None]] = []
|
|
723
|
+
self._collect_submodule_mismatches(self.path, "", parent_branch, mismatches)
|
|
724
|
+
return mismatches
|
|
725
|
+
|
|
726
|
+
def _collect_submodule_mismatches(
|
|
727
|
+
self,
|
|
728
|
+
abs_root: str,
|
|
729
|
+
rel_root: str,
|
|
730
|
+
parent_branch: str | None,
|
|
731
|
+
mismatches: list[tuple[str, str | None, str | None]],
|
|
732
|
+
) -> None:
|
|
733
|
+
for sub_path in self._submodule_paths(abs_root):
|
|
734
|
+
sub_abs = os.path.join(abs_root, sub_path)
|
|
735
|
+
if not self._is_initialized_submodule(sub_abs):
|
|
736
|
+
continue
|
|
737
|
+
sub_branch = self._branch_name_in(sub_abs)
|
|
738
|
+
rel_path = os.path.join(rel_root, sub_path) if rel_root else sub_path
|
|
739
|
+
if sub_branch != parent_branch:
|
|
740
|
+
mismatches.append((rel_path, sub_branch, parent_branch))
|
|
741
|
+
self._collect_submodule_mismatches(sub_abs, rel_path, parent_branch, mismatches)
|
|
742
|
+
|
|
743
|
+
def staged_filenames(self) -> set[str]:
|
|
744
|
+
"""Filenames with staged changes (`git diff --cached --name-only`)."""
|
|
745
|
+
result = self._run("diff", "--cached", "--name-only")
|
|
746
|
+
return {line for line in (result.stdout or "").splitlines() if line}
|
|
747
|
+
|
|
748
|
+
def commit_command(self, message: str) -> list[str]:
|
|
749
|
+
"""Return the argv that `commit` will run (for display/confirmation)."""
|
|
750
|
+
return ["git", "commit", "-m", message]
|
|
751
|
+
|
|
752
|
+
def commit(self, message: str) -> subprocess.CompletedProcess[str]:
|
|
753
|
+
"""Run `git commit -m <message>`."""
|
|
754
|
+
return self._run("commit", "-m", message)
|
|
755
|
+
|
|
756
|
+
def stage_command(self, filename: str) -> list[str]:
|
|
757
|
+
"""Return the argv that `stage_file` will run (for display/confirmation)."""
|
|
758
|
+
return ["git", "add", "--", filename]
|
|
759
|
+
|
|
760
|
+
def unstage_command(self, filename: str) -> list[str]:
|
|
761
|
+
"""Return the argv that `unstage_file` will run (for display/confirmation)."""
|
|
762
|
+
return ["git", "restore", "--staged", "--", filename]
|
|
763
|
+
|
|
764
|
+
def stage_file(self, filename: str) -> subprocess.CompletedProcess[str]:
|
|
765
|
+
"""Stage a single file in the parent repo (`git add -- <filename>`).
|
|
766
|
+
|
|
767
|
+
Used for parent-only paths (e.g. re-staging externals after a
|
|
768
|
+
commit). For an entry that may live inside a submodule, use
|
|
769
|
+
:meth:`stage_entry` instead.
|
|
770
|
+
"""
|
|
771
|
+
return self._run("add", "--", filename)
|
|
772
|
+
|
|
773
|
+
def unstage_file(self, filename: str) -> subprocess.CompletedProcess[str]:
|
|
774
|
+
"""Unstage a single file in the parent repo."""
|
|
775
|
+
return self._run("restore", "--staged", "--", filename)
|
|
776
|
+
|
|
777
|
+
def stage_entry(self, entry: GitEntry) -> subprocess.CompletedProcess[str]:
|
|
778
|
+
"""`git add` an entry, routing into its submodule when applicable."""
|
|
779
|
+
cwd, name = self._entry_target(entry)
|
|
780
|
+
return self._run_in(cwd, "add", "--", name)
|
|
781
|
+
|
|
782
|
+
def unstage_entry(self, entry: GitEntry) -> subprocess.CompletedProcess[str]:
|
|
783
|
+
"""`git restore --staged` an entry, routing into its submodule."""
|
|
784
|
+
cwd, name = self._entry_target(entry)
|
|
785
|
+
return self._run_in(cwd, "restore", "--staged", "--", name)
|
|
786
|
+
|
|
787
|
+
def stage_command_for(self, entry: GitEntry) -> list[str]:
|
|
788
|
+
"""Argv preview for :meth:`stage_entry` (includes `-C` for submodules)."""
|
|
789
|
+
cwd, name = self._entry_target(entry)
|
|
790
|
+
if entry.submodule:
|
|
791
|
+
return ["git", "-C", cwd, "add", "--", name]
|
|
792
|
+
return self.stage_command(name)
|
|
793
|
+
|
|
794
|
+
def unstage_command_for(self, entry: GitEntry) -> list[str]:
|
|
795
|
+
"""Argv preview for :meth:`unstage_entry`."""
|
|
796
|
+
cwd, name = self._entry_target(entry)
|
|
797
|
+
if entry.submodule:
|
|
798
|
+
return ["git", "-C", cwd, "restore", "--staged", "--", name]
|
|
799
|
+
return self.unstage_command(name)
|
|
800
|
+
|
|
801
|
+
def commit_with_selection_commands(
|
|
802
|
+
self, entries: list[GitEntry], message: str
|
|
803
|
+
) -> list[list[str]]:
|
|
804
|
+
"""Argv list for :meth:`commit_with_selection` (for the confirm dialog)."""
|
|
805
|
+
groups: dict[str | None, list[GitEntry]] = {}
|
|
806
|
+
for e in entries:
|
|
807
|
+
groups.setdefault(e.submodule, []).append(e)
|
|
808
|
+
sub_keys = sorted(
|
|
809
|
+
(k for k in groups if k is not None),
|
|
810
|
+
key=lambda p: p.count(os.sep),
|
|
811
|
+
reverse=True,
|
|
812
|
+
)
|
|
813
|
+
commands: list[list[str]] = []
|
|
814
|
+
for sub in sub_keys:
|
|
815
|
+
sub_abs = os.path.join(self.path, sub)
|
|
816
|
+
for e in groups[sub]:
|
|
817
|
+
_, name = self._entry_target(e)
|
|
818
|
+
commands.append(["git", "-C", sub_abs, "add", "--", name])
|
|
819
|
+
commands.append(["git", "-C", sub_abs, "commit", "-m", message])
|
|
820
|
+
enclosing = os.path.dirname(sub)
|
|
821
|
+
enclosing_abs = os.path.join(self.path, enclosing) if enclosing else self.path
|
|
822
|
+
gitlink = os.path.basename(sub) if enclosing else sub
|
|
823
|
+
prefix = ["git"] if enclosing_abs == self.path else ["git", "-C", enclosing_abs]
|
|
824
|
+
commands.append(prefix + ["add", "--", gitlink])
|
|
825
|
+
if groups.get(None) or sub_keys:
|
|
826
|
+
commands.append(["git", "commit", "-m", message])
|
|
827
|
+
return commands
|
|
828
|
+
|
|
829
|
+
def commit_with_selection(
|
|
830
|
+
self, entries: list[GitEntry], message: str
|
|
831
|
+
) -> subprocess.CompletedProcess[str]:
|
|
832
|
+
"""Commit a multi-file selection that may cross submodule boundaries.
|
|
833
|
+
|
|
834
|
+
Submodule files are committed inside their submodule first (deepest
|
|
835
|
+
first, so nested submodules land before their parents), the
|
|
836
|
+
resulting gitlink is staged in the enclosing repo, and a final
|
|
837
|
+
parent commit picks up parent files plus all freshly-staged
|
|
838
|
+
gitlinks.
|
|
839
|
+
"""
|
|
840
|
+
groups: dict[str | None, list[GitEntry]] = {}
|
|
841
|
+
for e in entries:
|
|
842
|
+
groups.setdefault(e.submodule, []).append(e)
|
|
843
|
+
sub_keys = sorted(
|
|
844
|
+
(k for k in groups if k is not None),
|
|
845
|
+
key=lambda p: p.count(os.sep),
|
|
846
|
+
reverse=True,
|
|
847
|
+
)
|
|
848
|
+
last: subprocess.CompletedProcess[str] | None = None
|
|
849
|
+
for sub in sub_keys:
|
|
850
|
+
sub_abs = os.path.join(self.path, sub)
|
|
851
|
+
for e in groups[sub]:
|
|
852
|
+
_, name = self._entry_target(e)
|
|
853
|
+
self._run_in(sub_abs, "add", "--", name)
|
|
854
|
+
last = self._run_in(sub_abs, "commit", "-m", message)
|
|
855
|
+
if last.returncode != 0:
|
|
856
|
+
return last
|
|
857
|
+
enclosing = os.path.dirname(sub)
|
|
858
|
+
enclosing_abs = os.path.join(self.path, enclosing) if enclosing else self.path
|
|
859
|
+
gitlink = os.path.basename(sub) if enclosing else sub
|
|
860
|
+
self._run_in(enclosing_abs, "add", "--", gitlink)
|
|
861
|
+
if groups.get(None) or sub_keys:
|
|
862
|
+
last = self._run_in(self.path, "commit", "-m", message)
|
|
863
|
+
if last is None:
|
|
864
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
865
|
+
return last
|
|
866
|
+
|
|
867
|
+
def restore_commands(self, entry: GitEntry) -> list[list[str]]:
|
|
868
|
+
"""Return the argv(s) that would reset *entry* to its HEAD state.
|
|
869
|
+
|
|
870
|
+
Three shapes depending on whether the file existed at HEAD:
|
|
871
|
+
* untracked (never in git): `git clean` deletes the worktree copy.
|
|
872
|
+
* added-in-index (new in index, not in HEAD): unstage, then clean
|
|
873
|
+
the now-untracked worktree copy.
|
|
874
|
+
* everything else (file existed at HEAD with changes): a single
|
|
875
|
+
`git restore --source=HEAD --staged --worktree` overwrites both
|
|
876
|
+
the index and worktree entries from HEAD.
|
|
877
|
+
|
|
878
|
+
For submodule entries, each argv is prefixed with ``-C <cwd>`` and
|
|
879
|
+
uses the submodule-relative filename so the dialog matches what
|
|
880
|
+
runs.
|
|
881
|
+
"""
|
|
882
|
+
cwd, name = self._entry_target(entry)
|
|
883
|
+
prefix = ["git", "-C", cwd] if entry.submodule else ["git"]
|
|
884
|
+
code = entry.code
|
|
885
|
+
if code.is_untracked:
|
|
886
|
+
return [prefix + ["clean", "-f", "--", name]]
|
|
887
|
+
if code.staged == GitStatus.ADDED:
|
|
888
|
+
return [
|
|
889
|
+
prefix + ["restore", "--staged", "--", name],
|
|
890
|
+
prefix + ["clean", "-f", "--", name],
|
|
891
|
+
]
|
|
892
|
+
return [
|
|
893
|
+
prefix
|
|
894
|
+
+ [
|
|
895
|
+
"restore",
|
|
896
|
+
"--source=HEAD",
|
|
897
|
+
"--staged",
|
|
898
|
+
"--worktree",
|
|
899
|
+
"--",
|
|
900
|
+
name,
|
|
901
|
+
]
|
|
902
|
+
]
|
|
903
|
+
|
|
904
|
+
def restore_file(self, entry: GitEntry) -> list[subprocess.CompletedProcess[str]]:
|
|
905
|
+
"""Run `restore_commands(entry)` in order, stopping on first failure.
|
|
906
|
+
|
|
907
|
+
Returned list holds every CompletedProcess that was actually run, so
|
|
908
|
+
the caller can inspect stderr from the failing step.
|
|
909
|
+
"""
|
|
910
|
+
cwd, _ = self._entry_target(entry)
|
|
911
|
+
results: list[subprocess.CompletedProcess[str]] = []
|
|
912
|
+
for argv in self.restore_commands(entry):
|
|
913
|
+
args = argv[3:] if argv[1:3] == ["-C", cwd] else argv[1:]
|
|
914
|
+
result = self._run_in(cwd, *args)
|
|
915
|
+
results.append(result)
|
|
916
|
+
if result.returncode != 0:
|
|
917
|
+
break
|
|
918
|
+
return results
|
|
919
|
+
|
|
920
|
+
def delete_commands(self, entry: GitEntry) -> list[list[str]]:
|
|
921
|
+
"""Return the argv(s) that DELETE would run for *entry*.
|
|
922
|
+
|
|
923
|
+
Operations are determined entirely by the entry's status code, so
|
|
924
|
+
callers can group entries by code and display one command list per
|
|
925
|
+
group. Four shapes:
|
|
926
|
+
* untracked (`??`): `git clean -f` removes the worktree copy.
|
|
927
|
+
* added-in-index (`A_`): unstage the index entry, then clean the
|
|
928
|
+
worktree copy.
|
|
929
|
+
* unstaged delete (` D`): stage the deletion via `git rm --cached`
|
|
930
|
+
(the worktree copy is already gone, so a plain `git rm` would
|
|
931
|
+
fail).
|
|
932
|
+
* everything else tracked: `git rm -f` removes the worktree copy
|
|
933
|
+
and stages the deletion in one step.
|
|
934
|
+
|
|
935
|
+
Submodule entries are prefixed with ``-C <cwd>`` and use the
|
|
936
|
+
submodule-relative filename.
|
|
937
|
+
"""
|
|
938
|
+
cwd, name = self._entry_target(entry)
|
|
939
|
+
prefix = ["git", "-C", cwd] if entry.submodule else ["git"]
|
|
940
|
+
code = entry.code
|
|
941
|
+
if code.is_untracked:
|
|
942
|
+
return [prefix + ["clean", "-f", "--", name]]
|
|
943
|
+
if code.staged == GitStatus.ADDED:
|
|
944
|
+
return [
|
|
945
|
+
prefix + ["rm", "-f", "--cached", "--", name],
|
|
946
|
+
prefix + ["clean", "-f", "--", name],
|
|
947
|
+
]
|
|
948
|
+
if code.unstaged == GitStatus.DELETED:
|
|
949
|
+
return [prefix + ["rm", "--cached", "--", name]]
|
|
950
|
+
return [prefix + ["rm", "-f", "--", name]]
|
|
951
|
+
|
|
952
|
+
def delete_file(self, entry: GitEntry) -> list[subprocess.CompletedProcess[str]]:
|
|
953
|
+
"""Run `delete_commands(entry)` in order, stopping on first failure."""
|
|
954
|
+
cwd, _ = self._entry_target(entry)
|
|
955
|
+
results: list[subprocess.CompletedProcess[str]] = []
|
|
956
|
+
for argv in self.delete_commands(entry):
|
|
957
|
+
args = argv[3:] if argv[1:3] == ["-C", cwd] else argv[1:]
|
|
958
|
+
result = self._run_in(cwd, *args)
|
|
959
|
+
results.append(result)
|
|
960
|
+
if result.returncode != 0:
|
|
961
|
+
break
|
|
962
|
+
return results
|
|
963
|
+
|
|
964
|
+
# ------------------------------------------------------------------
|
|
965
|
+
# Stash
|
|
966
|
+
# ------------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
def stash_command(self, *, filenames: list[str] | None = None) -> list[str]:
|
|
969
|
+
"""Argv for :meth:`stash` (for display/confirmation).
|
|
970
|
+
|
|
971
|
+
`--include-untracked` because the file list the user is looking at
|
|
972
|
+
shows untracked files: a stash that left them behind would clear only
|
|
973
|
+
part of what they just asked to put away. Ignored files stay put —
|
|
974
|
+
that needs `--all`, which this never passes.
|
|
975
|
+
|
|
976
|
+
A pathspec limits the stash to *filenames*; without one the whole
|
|
977
|
+
worktree goes. Submodules are not recursed into either way, which is
|
|
978
|
+
git's own behaviour.
|
|
979
|
+
"""
|
|
980
|
+
argv = ["git", "stash", "push", "--include-untracked"]
|
|
981
|
+
if filenames:
|
|
982
|
+
argv += ["--", *filenames]
|
|
983
|
+
return argv
|
|
984
|
+
|
|
985
|
+
def stash(self, *, filenames: list[str] | None = None) -> subprocess.CompletedProcess[str]:
|
|
986
|
+
"""Run `git stash push`, optionally limited to *filenames*."""
|
|
987
|
+
return self._run(*self.stash_command(filenames=filenames)[1:])
|
|
988
|
+
|
|
989
|
+
def stash_entries(self) -> list[str]:
|
|
990
|
+
"""`git stash list` lines, newest first; empty when there is no stash."""
|
|
991
|
+
result = self._run("stash", "list")
|
|
992
|
+
return [line for line in (result.stdout or "").splitlines() if line.strip()]
|
|
993
|
+
|
|
994
|
+
def stash_pop_command(self) -> list[str]:
|
|
995
|
+
"""Argv for :meth:`stash_pop` (for display/confirmation)."""
|
|
996
|
+
return ["git", "stash", "pop"]
|
|
997
|
+
|
|
998
|
+
def stash_pop(self) -> subprocess.CompletedProcess[str]:
|
|
999
|
+
"""Run `git stash pop` — reapply the newest stash entry and drop it."""
|
|
1000
|
+
return self._run(*self.stash_pop_command()[1:])
|
|
1001
|
+
|
|
1002
|
+
# ------------------------------------------------------------------
|
|
1003
|
+
# Remote
|
|
1004
|
+
# ------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
def remote_names(self) -> list[str]:
|
|
1007
|
+
"""Configured remotes, in git's own order (`origin` first, normally)."""
|
|
1008
|
+
result = self._run("remote")
|
|
1009
|
+
return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
|
1010
|
+
|
|
1011
|
+
def upstream_branch(self) -> str | None:
|
|
1012
|
+
"""The current branch's upstream (`origin/master`), or None if unset."""
|
|
1013
|
+
result = self._run("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
|
|
1014
|
+
if result.returncode != 0:
|
|
1015
|
+
return None
|
|
1016
|
+
name = (result.stdout or "").strip()
|
|
1017
|
+
return name or None
|
|
1018
|
+
|
|
1019
|
+
def pull_command(self) -> list[str]:
|
|
1020
|
+
"""Argv for :meth:`pull` (for display/confirmation).
|
|
1021
|
+
|
|
1022
|
+
`--ff-only` because a pull that has to merge leaves the repo in a
|
|
1023
|
+
state this app can't show or finish: a conflicted merge, or an editor
|
|
1024
|
+
waiting on a merge message in a terminal the TUI is sitting on.
|
|
1025
|
+
Refusing is recoverable — the failure names the divergence, and
|
|
1026
|
+
`Ctrl+O` puts a shell one keystroke away.
|
|
1027
|
+
"""
|
|
1028
|
+
return ["git", "pull", "--ff-only"]
|
|
1029
|
+
|
|
1030
|
+
def pull(self) -> subprocess.CompletedProcess[str]:
|
|
1031
|
+
"""Run `git pull --ff-only`."""
|
|
1032
|
+
return self._run(*self.pull_command()[1:])
|
|
1033
|
+
|
|
1034
|
+
def push_command(self) -> list[str]:
|
|
1035
|
+
"""Argv for :meth:`push` (for display/confirmation).
|
|
1036
|
+
|
|
1037
|
+
A branch with an upstream is a plain `git push`. One without needs the
|
|
1038
|
+
`--set-upstream` form spelled out, because that push creates a branch
|
|
1039
|
+
on the remote — the confirmation is the place to say so, and a bare
|
|
1040
|
+
`git push` would only fail with git's own suggestion to type this.
|
|
1041
|
+
"""
|
|
1042
|
+
if self.upstream_branch() is not None:
|
|
1043
|
+
return ["git", "push"]
|
|
1044
|
+
remotes = self.remote_names()
|
|
1045
|
+
branch = self.branch_name()
|
|
1046
|
+
if remotes and branch:
|
|
1047
|
+
return ["git", "push", "--set-upstream", remotes[0], branch]
|
|
1048
|
+
return ["git", "push"]
|
|
1049
|
+
|
|
1050
|
+
def push(self) -> subprocess.CompletedProcess[str]:
|
|
1051
|
+
"""Run whatever :meth:`push_command` describes."""
|
|
1052
|
+
return self._run(*self.push_command()[1:])
|