Coverage for vibefpmgui/window/git.py: 84%

128 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The Git panel: reading the project's repository's status (git, on a 

2pool thread) when it may have changed, and only while the panel is on view. 

3 

4It may have changed when the project is opened or reloaded, a file is 

5saved, the app becomes active again after a while away (git used in a 

6terminal, another app), or Refresh is clicked. One read at a time: asked 

7again meanwhile, it's read once more when that one is done. 

8""" 

9 

10from __future__ import annotations 

11 

12import time 

13from pathlib import Path 

14 

15from qtpy.QtCore import QFile, Qt 

16from qtpy.QtWidgets import QApplication, QMessageBox 

17 

18from .. import git_status as gs 

19from ..background import call_in_background 

20from ..git_panel import GitPanel 

21from ..widgets import run_dialog 

22 

23 

24def _read(path: Path) -> tuple[gs.RepoStatus | None, str]: 

25 """(status, error message).""" 

26 try: 

27 return gs.read_status(path), "" 

28 except (gs.GitError, OSError) as error: 

29 return None, str(error) or type(error).__name__ 

30 

31 

32def move_to_trash(path: Path) -> bool: 

33 """Move a file to the Trash (the Recycle Bin on Windows); False: it couldn't be.""" 

34 return QFile.moveToTrash(str(path)) 

35 

36 

37class GitMixin: 

38 """The Git panel (a part of `vibefpmgui.main_window.MainWindow`).""" 

39 

40 def _build_git_panel(self): 

41 """The Git panel.""" 

42 self.git_panel = GitPanel() 

43 self.git_panel.icon_for = self.git_panel.log_view.icon_for = self.project_tree.icon_for 

44 self.git_panel.set_code_font(self._mono_font(editor=True)) 

45 self.git_panel.refresh_requested.connect(self.refresh_git) 

46 self.git_panel.open_requested.connect(lambda path, line: self.navigate_to(path, line, 0, preview=True)) 

47 self.git_panel.reveal_requested.connect(self._show_in_project_files) 

48 self.git_panel.discard_requested.connect(self.discard_git_change) 

49 self.git_panel.set_view(str(self.settings.value("git_view", "diff") or "diff")) # (Diff or Stats, as last) 

50 self.git_panel.view_changed.connect(lambda view: self.settings.setValue("git_view", view)) 

51 stats = self.git_panel.stats_view 

52 stats.set_mode(str(self.settings.value("git_stats_plot", "total") or "total")) # (Total or Per month) 

53 stats.mode_changed.connect(lambda mode: self.settings.setValue("git_stats_plot", mode)) 

54 history = self.git_panel.log_view 

55 history.all_branches.setChecked(self.settings.value("git_all_branches", True, type=bool)) 

56 history.all_branches_changed.connect(lambda on: self.settings.setValue("git_all_branches", on)) 

57 self._git_request = 0 # (an answer to an older request is dropped) 

58 self._git_stale = True # (read again once the panel is on view) 

59 self._git_reading = False # (a read is under way) 

60 self._git_again = False # (asked again meanwhile: read again once it's done) 

61 self._inactive_since: float | None = None 

62 QApplication.instance().applicationStateChanged.connect(self._app_state_changed) 

63 

64 def _show_in_project_files(self, path: str): 

65 """A changed file's Show in Project Files: Project files in front, the file selected in it.""" 

66 self.show_panel(self._content(self.docks["files"])) 

67 if not self.project_tree.reveal(path): 

68 name = Path(path).name 

69 self.statusBar().showMessage(f"{name} isn't in Project files (it's outside the project, or filtered " 

70 "out)", 5000) 

71 

72 def discard_git_change(self, change: gs.Change): 

73 """The Git panel's Discard Changes… (Delete File… for an untracked 

74 one): asked first, saying what's lost; files git has no copy of go to 

75 the Trash (not deleted: they can be got back).""" 

76 status = self.git_panel.status 

77 if status is None: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 return 

79 root, name = status.root, change.path 

80 new = change.area == gs.UNTRACKED or (change.area == gs.STAGED and change.kind in ("added", "renamed")) 

81 both = change.area == gs.STAGED and any(c.path == name and c.area == gs.CHANGED for c in status.changes) 

82 if change.area == gs.UNTRACKED: 

83 question = (f"Move {name} to the Trash?\n\nIt isn't in git: this is its only copy (until the " 

84 "Trash is emptied).") 

85 elif change.area == gs.CHANGED: 85 ↛ 90line 85 didn't jump to line 90 because the condition on line 85 was always true

86 staged = any(c.path == name and c.area == gs.STAGED for c in status.changes) 

87 question = (f"Discard the changes to {name}?\n\nIt goes back to its " 

88 + ("staged version (what's staged is kept)" if staged else "version in the last commit") 

89 + ": the changes are lost.") 

90 elif new: 

91 question = (f"Discard {name}, new in the staged changes?\n\nIt's unstaged and moved to the Trash" 

92 + (f", and {change.old_path} gets its name back" if change.kind == "renamed" else "") + ".") 

93 else: 

94 question = (f"Discard the changes to {name}, staged" + (" and not" if both else "") 

95 + "?\n\nIt goes back to its version in the last commit: the changes are lost.") 

96 editor = self._open_editor_for(root / name) 

97 if editor is not None and editor.editor.document().isModified(): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 question += "\n\nIt's open in the editor with unsaved edits: they're kept there, to save or not." 

99 box = QMessageBox(QMessageBox.Warning, "Discard Changes", question, QMessageBox.Cancel, self) 

100 discard = box.addButton("Move to Trash" if new else "Discard", QMessageBox.DestructiveRole) 

101 box.setDefaultButton(QMessageBox.Cancel) 

102 run_dialog(box) 

103 if box.clickedButton() is not discard: 

104 return 

105 

106 def work(): 

107 try: 

108 return gs.discard(root, change), "" 

109 except gs.GitError as error: 

110 return [], str(error) 

111 

112 def done(result): 

113 paths, error = result 

114 if error: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 QMessageBox.warning(self, "Discard Changes", f"git couldn't discard the changes to {name}:\n\n{error}") 

116 kept = [p for p in paths if p.exists() and not move_to_trash(p)] 

117 if kept: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 QMessageBox.warning(self, "Discard Changes", "Couldn't move to the Trash (left where it is):\n" 

119 + "\n".join(str(p) for p in kept)) 

120 elif not error: 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true

121 self.statusBar().showMessage(f"Discarded the changes to {name}" 

122 + (" (moved to the Trash)" if paths else ""), 5000) 

123 self.refresh_git() 

124 

125 call_in_background(work, done, self) 

126 

127 def refresh_git(self): 

128 """Read the repository's status again (or once the panel is on view).""" 

129 panel = self.git_panel 

130 if self.manifest_path is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 panel.show_message("Open a project in a git repository to see its changes here.") 

132 return 

133 if not gs.available(): 

134 panel.show_message("The Git panel needs git: install it (https://git-scm.com) and make sure " 

135 "it's on the PATH, then click Refresh.") 

136 return 

137 if not self.panel_in_front(panel): 

138 self._git_stale = True 

139 return 

140 self._git_stale = False 

141 if self._git_reading: 

142 self._git_again = True 

143 return 

144 self._git_reading = True 

145 self._git_request += 1 

146 request, folder = self._git_request, self.manifest_path.parent 

147 call_in_background(lambda: _read(folder), lambda result: self._git_read(request, *result), self) 

148 

149 def _git_read(self, request: int, status: gs.RepoStatus | None, error: str): 

150 self._git_reading = False 

151 if self._git_again: 

152 self._git_again = False 

153 self.refresh_git() 

154 return # (that one's answer is the one shown) 

155 if request != self._git_request or self.manifest_path is None: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true

156 return 

157 if error: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 self.git_panel.show_message(f"Couldn't read the repository: {error}") 

159 elif status is None: 

160 self.git_panel.show_message(f"{self.manifest_path.parent.name} isn't in a git repository.") 

161 else: 

162 self.git_panel.show_status(status) 

163 

164 def _git_panel_shown(self, shown: bool): 

165 if shown and self._git_stale: 

166 self.refresh_git() 

167 

168 AWAY_SECONDS = 1.0 # (shorter: a dialog, a popup, a focus change; not time to use git elsewhere) 

169 

170 def _app_state_changed(self, state): 

171 if state != Qt.ApplicationActive: 

172 if self._inactive_since is None: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true

173 self._inactive_since = time.monotonic() 

174 return 

175 since, self._inactive_since = self._inactive_since, None 

176 if since is not None and time.monotonic() - since >= self.AWAY_SECONDS: 

177 self.refresh_git() # (back from a terminal, or another app, that may have used git)