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/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:])