gitpane 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1418 @@
1
+ """Coordinate one shared preparation session, its draft included, per worktree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import replace
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QObject, QSignalBlocker, QTimer, Signal
10
+ from PySide6.QtWidgets import QApplication
11
+ from shiboken6 import isValid
12
+
13
+ from gitpane.commit.commit_write import CommitOutcome, CommitRequest, CommitService
14
+ from gitpane.config import WORKTREE_REFRESH_INTERVAL_MS, ProcessPriority
15
+ from gitpane.history.file_history import FileHistoryAnchor
16
+ from gitpane.history.history_parse import parse_tree_entries
17
+ from gitpane.operation.diagnostic import Diagnostic
18
+ from gitpane.operation.operation_status import OperationStatus
19
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, PauseProbe, native_rebase_capture, read_pause_probe_filenames
20
+ from gitpane.sequencer.merge_state import MergeBannerState, merge_message_text, read_merge_banner_state
21
+ from gitpane.submodule.submodule_discovery import SubmoduleService
22
+ from gitpane.tool.system_tool import file_location, worktree_filename
23
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
24
+ from gitpane.worktree.file_menu_action import READ_ONLY_FILE_ACTION_SET, FileAction
25
+ from gitpane.worktree.ignore_rule import IgnoreDestination, IgnoreOutcome, IgnoreRuleService
26
+ from gitpane.worktree.mutation import RECOVER_CHANGES_OPERATION
27
+ from gitpane.worktree.partial_patch import RawLineSelection, RawPatch, read_partial
28
+ from gitpane.worktree.trash import TrashOutcome, TrashRequest, TrashService
29
+ from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, local_history_path, passive_observation_source
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Callable, Generator
33
+
34
+ from PySide6.QtWidgets import QWidget
35
+
36
+ from gitpane.history.repository_value import RepositoryContext
37
+ from gitpane.operation.diagnostic import PreparedDiagnostic
38
+ from gitpane.sequencer.merge_state import MergeInProgress
39
+ from gitpane.sequencer.rebase import RebaseService
40
+ from gitpane.sequencer.rebase_state import NativeMetadata, RebaseSnapshot
41
+ from gitpane.submodule.submodule_discovery import SubmoduleSummary
42
+ from gitpane.worktree.mutation import MutationOutcome, MutationService, PreparedLineReset
43
+ from gitpane.worktree.staged_reset import ResetCandidate
44
+ from gitpane.worktree.worktree_service import WorktreeService
45
+ from gitpane.worktree.worktree_snapshot import LocalEntry, PassiveObservationSource, WorktreeProbe, WorktreeSnapshot
46
+
47
+ # The staging view explains these actions together; the intention it captures for them is never reserved.
48
+ STAGING_AVAILABILITY_OPERATION_ID = "staging-availability"
49
+ STAGING_AVAILABILITY_ACTIONS: tuple[OperationAction, ...] = (OperationAction.COMMIT, OperationAction.AMEND, OperationAction.COMMIT_PUSH,
50
+ OperationAction.STAGE, OperationAction.UNSTAGE)
51
+ # Diagnostics that describe the present state of the worktree rather than the result of an action: they stay shown until
52
+ # a successful read proves them over, whereas a result is forgotten once the user closes the window that showed it.
53
+ READ_CHANGES_OPERATION = "Read changes"
54
+ STATE_DIAGNOSTIC_OPERATION_SET = frozenset({READ_CHANGES_OPERATION, RECOVER_CHANGES_OPERATION})
55
+ # Said when the changes shown moved while a file's history anchor was read and that anchor no longer matches them.
56
+ HISTORY_STATE_CHANGED_CAUSE = "Changes were updated while resolving file history; use File history again"
57
+ # Side accepted by default for a file action: only Unstaged entries may be staged, stop being tracked or show their history.
58
+ UNSTAGED_SIDE_SET = frozenset((StagingSide.UNSTAGED,))
59
+ # Both lists of Prepare changes.
60
+ LISTED_SIDE_SET = frozenset((StagingSide.UNSTAGED, StagingSide.STAGED))
61
+
62
+
63
+ def head_moved_between(previous: WorktreeSnapshot | None, current: WorktreeSnapshot | None) -> bool:
64
+ """Tell whether two successive worktree reads name another HEAD or another branch; a first read, or one without status facts, tells nothing."""
65
+ if previous is None or current is None or previous.probe is None or current.probe is None:
66
+ return False
67
+ return previous.probe.head_oid != current.probe.head_oid or previous.probe.branch_head != current.probe.branch_head
68
+
69
+
70
+ class StagingSession(QObject):
71
+ """Keep writes alive independently of windows while coalescing background refresh."""
72
+
73
+ changed = Signal()
74
+ # The draft has a signal of its own: the subscribers of changed read the worktree state again and start Git
75
+ # observations, which a keystroke never justifies. The text itself never travels in the signal.
76
+ draft_changed = Signal()
77
+ pause_observation_requested = Signal()
78
+ # A worktree read found HEAD, or the branch it is on, other than the read before it. Only a read says so: a state an
79
+ # operation of GitPane publishes after writing never does, since that operation refreshes its windows itself.
80
+ head_moved = Signal()
81
+ # A click on Commit or on Commit & push has just captured this CommitRequest, before the commit service saw it:
82
+ # whoever times the commit starts its clock here. Commit & push may still drop the request while it reads where to
83
+ # push, before submitting it; then the commit service never sees it.
84
+ commit_submitted = Signal(object)
85
+ commit_finished = Signal(object)
86
+ history_requested = Signal(object)
87
+ folder_requested = Signal(object)
88
+ text_editor_requested = Signal(object)
89
+ window_opened = Signal(object)
90
+ commit_push_requested = Signal(object, object)
91
+
92
+ def __init__(self, context: RepositoryContext, git_filename: str, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None, *,
93
+ rebase_service: RebaseService | None = None, window_factory: Callable[[RepositoryContext], QWidget]) -> None:
94
+ """Associate the shared draft and last result with exactly one real worktree.
95
+
96
+ ``rebase_service`` is the application's one rebase observer; an Amend of a paused rebase reads the pause through
97
+ it, and is refused without it. ``window_factory`` builds the Prepare changes window for this worktree the first
98
+ time the session opens; the window class belongs to the interface, which the domain does not import.
99
+ """
100
+ super().__init__(parent)
101
+ self.context = context
102
+ self.rebase_service = rebase_service
103
+ self.window_factory = window_factory
104
+ self.key = WorktreeKey.from_context(context)
105
+ self.git_filename = git_filename
106
+ self.worktree_service = worktree_service
107
+ self.submodule_service = SubmoduleService(worktree_service)
108
+ self.mutation = mutation
109
+ self.commit_service = CommitService(worktree_service.runner, worktree_service, mutation, self)
110
+ self.commit_service.finished.connect(self._commit_finished)
111
+ self.commit_service.waiting.connect(self._commit_waiting)
112
+ self.last_commit_outcome: CommitOutcome | None = None
113
+ self.active_commit_request: CommitRequest | None = None
114
+ self.active_commit_action: OperationAction | None = None
115
+ self.commit_push_session_id: str | None = None
116
+ self.pending_commit_push_request: CommitRequest | None = None
117
+ self.trash_service = TrashService(worktree_service, mutation, parent=self)
118
+ self.trash_service.finished.connect(self._trash_finished)
119
+ self.trash_service.waiting.connect(self._trash_waiting)
120
+ self.last_trash_outcome: TrashOutcome | None = None
121
+ self.ignore_service = IgnoreRuleService(worktree_service, mutation, self)
122
+ self.ignore_service.finished.connect(self._ignore_finished)
123
+ self.last_ignore_outcome: IgnoreOutcome | None = None
124
+ self.snapshot: WorktreeSnapshot | None = None
125
+ # Annotated as the QWidget it is: its class, the Prepare changes window, lives in the interface, which the domain
126
+ # does not import even for a type; the session only uses what that window shows and emits.
127
+ self.window: QWidget | None = None
128
+ self.draft = ""
129
+ self.normal_draft = ""
130
+ self.amend_draft = ""
131
+ self.amend_head_oid: str | None = None
132
+ self.amend = False
133
+ self.message_generation = 0
134
+ self.amend_generation = 0
135
+ self.amend_loading = False
136
+ self.pending_rebase_amend: RebaseSnapshot | None = None
137
+ self.pending_amend_generation = 0
138
+ self.pending_message_generation = 0
139
+ self.amend_rebase_capture: tuple[NativeMetadata | None, ...] | None = None
140
+ # The merge waiting for its commit, as the last read after a worktree read found it; None when there is none or
141
+ # it could not be read, in which case the Commit itself explains why.
142
+ self.merge_in_progress: MergeInProgress | None = None
143
+ # The merge state of the repository window's banner, read with every worktree read whether Prepare changes is
144
+ # open or not, and known before that read is published.
145
+ self.merge_banner_state = MergeBannerState()
146
+ # The parents of the merge whose prepared message was already offered, so it is offered once per merge: a user
147
+ # who empties it keeps an empty message.
148
+ self.merge_message_parent_oids: tuple[str, ...] = ()
149
+ # The text offered for the merge in progress, to take it back when the merge goes away without the user having
150
+ # changed it: a later ordinary Commit must not carry the message of a merge that no longer exists.
151
+ self.merge_message_offered = ""
152
+ self.generation = 0
153
+ self.configuration_generation = 0
154
+ self.selection_generation = 0
155
+ self.refresh_active = False
156
+ self.refresh_queued = False
157
+ # The queue level of the follow-up read asked while another read or a write was running: the most urgent of the
158
+ # requests it merges, and nothing when no follow-up is waiting.
159
+ self.queued_refresh_priority: ProcessPriority | None = None
160
+ self.explicit_refresh = False
161
+ # A window that has just started showing this worktree has no count yet. A read that finds the state this
162
+ # session already had tells nobody, so the next read that publishes must announce its result anyway.
163
+ self.announce_requested = False
164
+ self.probe_active = False
165
+ self.last_outcome: MutationOutcome | None = None
166
+ self.diagnostic = None
167
+ # The diagnostic of a Commit whose result needs attention, kept by identity: the status bar draws it as a
168
+ # warning only while it is the diagnostic shown, so any other diagnostic written later gets the ordinary look
169
+ # without every place that writes one having to say so.
170
+ self.warning_diagnostic: PreparedDiagnostic | None = None
171
+ # The diagnostics of the kept results (last file, commit, trash and ignore outcomes) the user has already read
172
+ # by closing the window that showed them, kept by identity: a newer result is a new object, so it counts as unread.
173
+ self.read_result_diagnostics: tuple[PreparedDiagnostic, ...] = ()
174
+ self.busy = False
175
+ self.line_model: RawPatch | None = None
176
+ # The comparison on screen, as the capture it was read from and the row it describes. Both are set only once an
177
+ # answer was accepted for the still-selected row, so a newer capture can keep showing it without reading it
178
+ # again for as long as the data it came from is unchanged.
179
+ self.preview_snapshot: WorktreeSnapshot | None = None
180
+ self.preview_entry: LocalEntry | None = None
181
+ # The automatic refresh only serves a window somebody is looking at, and the window activation filter is the
182
+ # only place that knows which window that is: it starts and stops this timer through set_window_active.
183
+ self.window_active: bool = False
184
+ # Says whether the pause state deserves a look through Git; the rebase and replay controllers mark each observation they start.
185
+ self.pause_probe = PauseProbe()
186
+ self.timer = QTimer(self)
187
+ self.timer.setInterval(WORKTREE_REFRESH_INTERVAL_MS)
188
+ self.timer.timeout.connect(self._tick)
189
+ mutation.finished.connect(self._finished)
190
+ mutation.repository_operation.context_by_key[self.key] = context
191
+ mutation.repository_operation.changed.connect(self._show)
192
+
193
+ def action_diagnostic(self, action: OperationAction) -> str:
194
+ """Ask the shared business policy without giving the window reservation authority."""
195
+ intent = OperationIntent.capture(STAGING_AVAILABILITY_OPERATION_ID, self.context, self.git_filename, action=action)
196
+ return self.mutation.repository_operation.availability_diagnostic(intent)
197
+
198
+ def open(self) -> QWidget | None:
199
+ """Reuse the live staging window and reject contexts without a working tree."""
200
+ if self.context.is_bare or self.context.worktree_dir is None:
201
+ return None
202
+ self._window_destroyed()
203
+ if self.window is None:
204
+ self.window = self.window_factory(self.context)
205
+ self.window.stage_requested.connect(self.stage)
206
+ self.window.commit_requested.connect(self.commit)
207
+ self.window.commit_push_requested.connect(self.commit_push)
208
+ self.window.amend_checkbox.setChecked(self.amend)
209
+ self.window.amend_changed.connect(self.set_amend)
210
+ self.window.unstage_requested.connect(self.unstage)
211
+ self.window.refresh_requested.connect(lambda: self.refresh(explicit=True))
212
+ self.window.entry_selected.connect(self.select)
213
+ self.window.selection_cleared.connect(self.clear_selection)
214
+ self.window.line_menu.stage_requested.connect(self.stage_lines)
215
+ self.window.line_menu.reset_requested.connect(self.reset_lines)
216
+ self.window.file_menu.requested.connect(self.reset_file)
217
+ self.window.file_menu.delete_requested.connect(self.delete_file)
218
+ self.window.file_menu.ignore_requested.connect(self.add_ignore)
219
+ self.window.file_menu.action_requested.connect(self.consult)
220
+ self.window.load_message(self.draft)
221
+ self.window.message_edit.textChanged.connect(self._draft_changed)
222
+ self.window.closed.connect(self._closed)
223
+ self.window.destroyed.connect(self._window_destroyed)
224
+ self.window_opened.emit(self.window)
225
+ self.window.show()
226
+ self.window.raise_()
227
+ self.window.activateWindow()
228
+ self._show()
229
+ self.refresh()
230
+ return self.window
231
+
232
+ def _draft_changed(self) -> None:
233
+ """Retain exact editor text in the worktree's application-lifetime session."""
234
+ if self.window is not None:
235
+ self.draft = self.window.message_edit.toPlainText()
236
+ self.message_generation += 1
237
+ if self.amend:
238
+ self.amend_draft = self.draft
239
+ else:
240
+ self.normal_draft = self.draft
241
+ self.draft_changed.emit()
242
+
243
+ def request_rebase_amend(self, state: RebaseSnapshot) -> None:
244
+ """Wait for a new worktree read before selecting the captured edit commit."""
245
+ if self.busy or state.key != self.key or not state.fresh or state.state != "edit":
246
+ return
247
+ self.pending_rebase_amend = state
248
+ self.pending_amend_generation = self.generation + 1
249
+ self.pending_message_generation = self.message_generation
250
+ self.amend_generation += 1
251
+ self.amend_head_oid = None
252
+ self.amend_loading = True
253
+ try:
254
+ self.amend_rebase_capture = native_rebase_capture(self.context)
255
+ except (OSError, ValueError) as error:
256
+ self._refuse_rebase_amend(str(error))
257
+ return
258
+ self.refresh(explicit=True)
259
+
260
+ def _refuse_rebase_amend(self, reason: str) -> None:
261
+ """Invalidate only the pending amend authority while preserving the user's text."""
262
+ self.pending_rebase_amend = None
263
+ self.amend_loading = False
264
+ self.amend_head_oid = None
265
+ self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], reason).prepare()
266
+ self._show()
267
+
268
+ def _load_pending_amend(self, generation: int) -> None:
269
+ """Reject a replaced pause and load only the requested fresh HEAD message.
270
+
271
+ The pause is read through the application's rebase service, the same observer every window uses: a read already
272
+ running can answer only if it started after this request, otherwise a newer read is made.
273
+ """
274
+ previous = self.pending_rebase_amend
275
+ snapshot = self.snapshot
276
+ if previous is None or generation < self.pending_amend_generation or self.refresh_queued:
277
+ return
278
+ self.pending_rebase_amend = None
279
+ if self.rebase_service is None:
280
+ self._refuse_rebase_amend("The rebase state cannot be read here. Refresh the repository before amending.")
281
+ return
282
+ amend_generation = self.amend_generation
283
+ message_generation = self.pending_message_generation
284
+ git_filename = self.git_filename
285
+
286
+ def current() -> bool:
287
+ """Answer only the Amend request still waiting in this open session, for the same Git."""
288
+ return isValid(self) and amend_generation == self.amend_generation and git_filename == self.git_filename
289
+
290
+ def complete(state: RebaseSnapshot) -> None:
291
+ """Bind the message request to the same native edit instruction and HEAD."""
292
+ if not current():
293
+ return
294
+ if message_generation != self.message_generation:
295
+ self._refuse_rebase_amend("The message changed while refreshing. Your text is preserved; select Amend commit again to reload.")
296
+ return
297
+ if not state.fresh:
298
+ self._refuse_rebase_amend("The rebase state could not be read. Refresh the rebase before amending.")
299
+ return
300
+ if (state.state != "edit" or state.identity != previous.identity or state.current_step != previous.current_step or state.head_oid != previous.head_oid
301
+ or snapshot is None or not snapshot.fresh or snapshot.head_oid != previous.head_oid
302
+ or self.snapshot is None or not self.snapshot.fresh or self.snapshot.head_oid != previous.head_oid):
303
+ self._refuse_rebase_amend("The edit commit changed. Refresh the rebase before amending.")
304
+ return
305
+ self.set_amend(True, reload=True)
306
+
307
+ self.rebase_service.observe(self.context, git_filename, is_current=current, callback=complete)
308
+
309
+ def set_amend(self, enabled: bool, *, reload: bool = False) -> None:
310
+ """Load HEAD on every new Amend selection without overwriting typing during the read."""
311
+ if self.busy or enabled == self.amend and not reload:
312
+ return
313
+ reason = self.action_diagnostic(OperationAction.AMEND) if enabled else ""
314
+ if reason:
315
+ if reload:
316
+ self.amend_loading = False
317
+ self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], reason).prepare()
318
+ if self.window is not None:
319
+ with QSignalBlocker(self.window.amend_checkbox):
320
+ self.window.amend_checkbox.setChecked(self.amend)
321
+ self._show()
322
+ return
323
+ self.amend = enabled
324
+ if not reload:
325
+ self.pending_rebase_amend = None
326
+ self.amend_rebase_capture = None
327
+ self.amend_generation += 1
328
+ generation = self.amend_generation
329
+ self.amend_head_oid = None
330
+ self.amend_loading = enabled
331
+ if not enabled:
332
+ self._set_message(self.normal_draft)
333
+ self._show()
334
+ return
335
+ snapshot = self.snapshot
336
+ if snapshot is None or not snapshot.fresh or snapshot.head_oid is None:
337
+ self.amend_loading = False
338
+ self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], "There is no current HEAD message; Refresh first").prepare()
339
+ self._show()
340
+ return
341
+ message_generation = self.message_generation
342
+
343
+ def complete(value: tuple[str, str] | None, error: Exception | None) -> None:
344
+ """Apply only the message belonging to the still-selected mode, HEAD and editor generation."""
345
+ if not self.amend or generation != self.amend_generation:
346
+ return
347
+ self.amend_loading = False
348
+ if message_generation != self.message_generation:
349
+ error = ValueError("The message was edited while loading HEAD. Your text is preserved; uncheck and check Amend to reload")
350
+ elif self.snapshot is None or not self.snapshot.fresh or self.snapshot.head_oid != snapshot.head_oid:
351
+ error = ValueError("HEAD changed; Refresh and load Amend again")
352
+ if self.amend_rebase_capture is not None:
353
+ try:
354
+ if native_rebase_capture(self.context) != self.amend_rebase_capture:
355
+ error = ValueError("The native rebase changed while loading Amend. Refresh before amending.")
356
+ except (OSError, ValueError) as failure:
357
+ error = failure
358
+ if error is not None:
359
+ self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
360
+ self._show()
361
+ return
362
+ self.amend_head_oid, message = value
363
+ self.amend_draft = message
364
+ cause_cleared = self.diagnostic is not None
365
+ self.diagnostic = None
366
+ self._set_message(message)
367
+ self._show()
368
+ if cause_cleared:
369
+ # The repository window shows the cause of the session's diagnostic on its Commit button and reads it
370
+ # again only when the worktree state is published.
371
+ self.changed.emit()
372
+
373
+ self._show()
374
+ self.worktree_service.run(self.context, self.git_filename, self.commit_service.read_amend_message(snapshot), complete)
375
+
376
+ def _set_message(self, message: str) -> None:
377
+ """Replace displayed text without turning a programmatic load into a user edit.
378
+
379
+ Publishes the replaced draft, not the worktree state.
380
+ """
381
+ self.draft = message
382
+ if self.window is not None:
383
+ with QSignalBlocker(self.window.message_edit):
384
+ self.window.load_message(message)
385
+ self.draft_changed.emit()
386
+
387
+ def _closed(self) -> None:
388
+ """Detach the closed view while preserving its draft in the shared session, and forget the result it showed.
389
+
390
+ The comparison and its line model belonged to the view, so no line action can outlive it. Closing the window
391
+ counts as having read the result of the last action (Commit, Amend, Move to Trash, ignore rules, staging, Reset,
392
+ file actions): reopened, the window shows the present state, so an old result is never taken for a new
393
+ problem; the result stays in the --log file. A diagnostic of the worktree state (a failed read, a recovery)
394
+ is still true and stays until a successful read, and nothing is forgotten while a write runs. The repository
395
+ window shows the cause of the session's diagnostic on its Commit button, so the state is published once when
396
+ a result was forgotten, and not at all otherwise.
397
+ """
398
+ self.selection_generation += 1
399
+ self._forget_preview()
400
+ if self.amend_loading or self.pending_rebase_amend is not None:
401
+ self.amend_generation += 1
402
+ self.pending_rebase_amend = None
403
+ self.amend_loading = False
404
+ self.amend_head_oid = None
405
+ self.window = None
406
+ if self.busy:
407
+ return
408
+ self.read_result_diagnostics = tuple(self._kept_result_diagnostics())
409
+ if self.diagnostic is None or self.diagnostic.operation in STATE_DIAGNOSTIC_OPERATION_SET:
410
+ return
411
+ self.diagnostic = None
412
+ self.warning_diagnostic = None
413
+ self.changed.emit()
414
+
415
+ def _kept_result_diagnostics(self) -> list[PreparedDiagnostic]:
416
+ """Answer the diagnostics of the results the session keeps beyond its window: the last file, commit, trash and ignore outcomes."""
417
+ diagnostics: list[PreparedDiagnostic] = []
418
+ for outcome in (self.last_outcome, self.last_commit_outcome, self.last_trash_outcome, self.last_ignore_outcome):
419
+ if outcome is not None and outcome.diagnostic is not None:
420
+ diagnostics.append(outcome.diagnostic)
421
+ return diagnostics
422
+
423
+ def _unread_result_kept(self) -> bool:
424
+ """Tell whether a kept result has a diagnostic the user has not read yet by closing the window."""
425
+ for diagnostic in self._kept_result_diagnostics():
426
+ if not any(diagnostic is read_diagnostic for read_diagnostic in self.read_result_diagnostics):
427
+ return True
428
+ return False
429
+
430
+ def _window_destroyed(self, *arguments: object) -> None:
431
+ """Drop a native-deleted widget without discarding its session-owned draft or result."""
432
+ if self.window is not None and not isValid(self.window):
433
+ self._closed()
434
+
435
+ def commit(self) -> None:
436
+ """Capture one explicit commit with the still-current HEAD loaded for Amend.
437
+
438
+ commit_submitted tells that this click sent the captured request, just before the commit service receives it,
439
+ so a refusal the service reports at once still comes after it.
440
+ """
441
+ if self.busy or self.amend_loading or self.pending_rebase_amend is not None or self.snapshot is None or not self.snapshot.fresh:
442
+ return
443
+ if self.amend and (self.amend_head_oid is None or self.amend_head_oid != self.snapshot.head_oid):
444
+ self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], "HEAD is not the loaded Amend commit; Refresh and load Amend again").prepare()
445
+ self._show()
446
+ return
447
+ request = CommitRequest.capture(self.snapshot, self.draft, self.amend)
448
+ if self.amend and self.amend_rebase_capture is not None:
449
+ request = replace(request, rebase_capture=self.amend_rebase_capture)
450
+ self.active_commit_request = request
451
+ self.active_commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
452
+ self.busy = True
453
+ self.last_commit_outcome = None
454
+ self.diagnostic = None
455
+ self.warning_diagnostic = None
456
+ self._show()
457
+ self.commit_submitted.emit(request)
458
+ self.commit_service.submit(request)
459
+
460
+ def commit_push(self) -> None:
461
+ """Reserve the UI immediately and capture one request before asynchronous routing reads.
462
+
463
+ commit_submitted tells that this click captured the request, as for Commit, just before the routing reads start.
464
+ """
465
+ if self.busy or self.window is None or not self.window.commit_push_button.isEnabled() or self.snapshot is None:
466
+ return
467
+ if self.amend and (self.amend_head_oid is None or self.amend_head_oid != self.snapshot.head_oid):
468
+ return
469
+ request = CommitRequest.capture(self.snapshot, self.draft, self.amend)
470
+ self.pending_commit_push_request = request
471
+ self.active_commit_request = request
472
+ self.active_commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
473
+ self.busy = True
474
+ self.last_commit_outcome = None
475
+ self.diagnostic = None
476
+ self.warning_diagnostic = None
477
+ self._show()
478
+ self.commit_submitted.emit(request)
479
+ self.commit_push_requested.emit(self, request)
480
+
481
+ def finish_commit_action(self, operation_id: str) -> None:
482
+ """Clear local progress only when the completed request still owns its presentation."""
483
+ if self.active_commit_request is None or self.active_commit_request.operation_id != operation_id:
484
+ return
485
+ self.active_commit_request = None
486
+ self.active_commit_action = None
487
+
488
+ def _commit_finished(self, outcome: CommitOutcome) -> None:
489
+ """Empty the draft once a commit exists, keep it otherwise, and close only after Git's actual result is verified.
490
+
491
+ The draft, Amend and a waiting merge are cleared as soon as the read after the write proves a new commit, even
492
+ when the result is a warning: committing the same message again would add a second commit. The window closes
493
+ on success only; a warning or a failure keeps it open, reopened if needed. When no commit was proven, the
494
+ status unknown included, the draft stays for another try.
495
+
496
+ A successful commit shows the complete read the commit service made after writing and does not read the
497
+ worktree again for it. A failure, a missing or stale read, or a refresh requested meanwhile still reads again.
498
+ """
499
+ if self.active_commit_request is not None and self.active_commit_request.operation_id != outcome.operation_id:
500
+ return
501
+ self.finish_commit_action(outcome.operation_id)
502
+ self.last_commit_outcome = outcome
503
+ self.busy = False
504
+ self.diagnostic = outcome.diagnostic
505
+ self.warning_diagnostic = outcome.diagnostic if outcome.status == OperationStatus.PARTIAL else None
506
+ if outcome.after is not None:
507
+ self.snapshot = outcome.after.snapshot
508
+ if self.snapshot.git_filename != self.git_filename:
509
+ self.snapshot = replace(self.snapshot, fresh=False)
510
+ elif self.snapshot is not None:
511
+ self.snapshot = replace(self.snapshot, fresh=False)
512
+ if outcome.commit_created or outcome.status == OperationStatus.SUCCESS:
513
+ self.normal_draft = ""
514
+ self.amend_draft = ""
515
+ self.amend = False
516
+ self.amend_head_oid = None
517
+ # A new commit concludes any merge that was waiting; the next read says whether another one exists.
518
+ self.merge_in_progress = None
519
+ self.merge_banner_state = MergeBannerState()
520
+ self.merge_message_parent_oids = ()
521
+ self.merge_message_offered = ""
522
+ self._set_message("")
523
+ if outcome.status == OperationStatus.SUCCESS:
524
+ self._show()
525
+ if self.window is not None:
526
+ self.window.close()
527
+ self._show()
528
+ if outcome.status != OperationStatus.SUCCESS and self.window is None:
529
+ self.open()
530
+ self.changed.emit()
531
+ self.commit_finished.emit(outcome)
532
+ if outcome.status != OperationStatus.SUCCESS or self.snapshot is None or not self.snapshot.fresh or self.explicit_refresh or self.refresh_active:
533
+ self.refresh(priority=ProcessPriority.CONTINUATION)
534
+ else:
535
+ # The snapshot shown above is the complete read made after the commit: a timer or window request queued
536
+ # while the commit ran would read exactly the same state again.
537
+ self.refresh_queued = False
538
+ self.queued_refresh_priority = None
539
+
540
+ def _commit_waiting(self, diagnostic: object) -> None:
541
+ """Keep delayed native writes visible while their reservation and draft remain owned."""
542
+ self.diagnostic = diagnostic
543
+ self._show()
544
+
545
+ def set_git_filename(self, git_filename: str) -> None:
546
+ """Change future operations while preserving captured active sequences and session drafts."""
547
+ if self.git_filename == git_filename:
548
+ return
549
+ self.git_filename = git_filename
550
+ self.configuration_generation += 1
551
+ self.amend_generation += 1
552
+ self.pending_rebase_amend = None
553
+ self.amend_rebase_capture = None
554
+ self.amend_loading = False
555
+ self.amend_head_oid = None
556
+ self.invalidate_conversion()
557
+
558
+ def invalidate_conversion(self) -> None:
559
+ """Discard line candidates and reread real Git state after conversion settings change."""
560
+ self._forget_preview()
561
+ self.selection_generation += 1
562
+ if self.snapshot is not None:
563
+ self.snapshot = replace(self.snapshot, fresh=False)
564
+ self._show()
565
+ self.refresh()
566
+
567
+ def set_window_active(self, active: bool) -> None:
568
+ """Start or stop the automatic refresh when a window of this worktree gains or loses activation, and read once on return.
569
+
570
+ Coming back to the window is the moment the displayed state may be stale, so it is also the moment worth one read.
571
+ """
572
+ if self.window_active == active:
573
+ return
574
+ self.window_active = active
575
+ if not active:
576
+ self.timer.stop()
577
+ return
578
+ if self.worktree_service.runner.closed:
579
+ return
580
+ self.timer.start()
581
+ if self.mutation.repository_operation.owns_worktree(self.key):
582
+ # The operation checks the repository itself and refreshes when it ends; a read taken in the middle of a
583
+ # write would only show a passing state.
584
+ return
585
+ if self.refresh_active:
586
+ # The read already in flight will publish the state; a change made while it runs shows at the next period,
587
+ # and the period has just been restarted.
588
+ return
589
+ self.refresh()
590
+
591
+ def _tick(self) -> None:
592
+ """Read the worktree periodically for an active window, through the probe when a complete snapshot can serve as the comparison."""
593
+ if not self.window_active:
594
+ # This attribute can be written directly, so the timer must never outlive the activation it serves.
595
+ self.timer.stop()
596
+ return
597
+ if self.mutation.repository_operation.owns_worktree(self.key):
598
+ # The operation checks the repository itself and refreshes when it ends; a read taken in the middle of a
599
+ # write would only show a passing state.
600
+ return
601
+ if self.refresh_active or self.probe_active:
602
+ # The read in flight is this period's read; asking for another one would only queue a duplicate.
603
+ return
604
+ snapshot = self.snapshot
605
+ if (snapshot is None or not snapshot.fresh or snapshot.probe is None or snapshot.git_filename != self.git_filename or self.busy
606
+ or self.refresh_queued or self.explicit_refresh or self.context.is_bare or self.worktree_service.runner.closed):
607
+ self.refresh(priority=ProcessPriority.BACKGROUND)
608
+ return
609
+ self.probe_active = True
610
+ generation = self.generation
611
+ self.worktree_service.run(self.context, self.git_filename, self.worktree_service.probe(snapshot),
612
+ lambda probe, error: self._probe_finished(snapshot, generation, probe, error), priority=ProcessPriority.BACKGROUND)
613
+
614
+ def _probe_finished(self, snapshot: WorktreeSnapshot, generation: int, probe: WorktreeProbe | None, error: Exception | None) -> None:
615
+ """Publish nothing while the displayed state still holds, and read everything again in every other case."""
616
+ if not isValid(self):
617
+ return
618
+ if not self.probe_active:
619
+ return
620
+ self.probe_active = False
621
+ if (generation != self.generation or self.snapshot is not snapshot or self.refresh_active or self.busy or not self.window_active
622
+ or self.mutation.repository_operation.owns_worktree(self.key)):
623
+ # A complete read has started or will start on its own: the end of an operation, or the return to the window.
624
+ return
625
+ # The probe never publishes a state itself; it only says whether one must be read again.
626
+ if error is not None or probe != snapshot.probe:
627
+ self.refresh(priority=ProcessPriority.BACKGROUND)
628
+ return
629
+ if self._pause_observation_due():
630
+ self.pause_observation_requested.emit()
631
+ self._read_merge_banner_state(generation)
632
+
633
+ def _pause_observation_due(self) -> bool:
634
+ """Tell whether Git's pause state deserves a look although the worktree read has not changed.
635
+
636
+ It does when a pause file or a fact of the last status differs from what the last observation that really
637
+ started covered. Nothing is recorded here.
638
+ """
639
+ return self.pause_probe.observation_due(self._displayed_worktree_probe())
640
+
641
+ def _displayed_worktree_probe(self) -> WorktreeProbe | None:
642
+ """Answer the status facts of the snapshot on screen, even when an explicit read has just marked it stale."""
643
+ if self.snapshot is None:
644
+ return None
645
+ return self.snapshot.probe
646
+
647
+ def passive_observation_source(self) -> PassiveObservationSource | None:
648
+ """Answer what the automatic refresh has just read, for the observation that makes the banner follow the pause.
649
+
650
+ Nothing is answered unless the state on screen is fresh and at rest: the observation then reads everything through
651
+ Git. An operation never reads this.
652
+ """
653
+ snapshot = self.snapshot
654
+ if snapshot is None or not snapshot.fresh or snapshot.probe is None:
655
+ return None
656
+ if snapshot.head_oid != snapshot.probe.head_oid or snapshot.git_filename != self.git_filename:
657
+ return None
658
+ if self.refresh_active or self.refresh_queued or self.explicit_refresh or self.busy:
659
+ return None
660
+ filename_by_git_path = self.pause_probe.filename_by_git_path()
661
+ if filename_by_git_path is None:
662
+ return None
663
+ try:
664
+ return passive_observation_source(snapshot.probe, filename_by_git_path)
665
+ except ValueError:
666
+ # A status record that cannot be taken apart is not trusted: the observation reads it through Git instead.
667
+ return None
668
+
669
+ def mark_pause_observed(self, observation: str) -> None:
670
+ """Note that an observation of the pause state starts now.
671
+
672
+ The signature is taken before the observation reads anything, so whatever moves afterwards is looked at again.
673
+ """
674
+ # An explicit read has just marked the snapshot stale, but its facts are still the ones the screen shows.
675
+ self.pause_probe.mark_observed(observation, self._displayed_worktree_probe())
676
+
677
+ def refresh(self, *, explicit: bool = False, priority: ProcessPriority = ProcessPriority.USER, announce: bool = False) -> None:
678
+ """Allow one active read and at most one follow-up, without racing an index write.
679
+
680
+ The first read of the session starts by asking Git where the pause state files are. The priority is where the
681
+ read waits in the process queue: the user's own request by default, and always for an explicit Refresh. A
682
+ follow-up merging several requests waits at the most urgent of their levels, so a later background request
683
+ never lowers a user's.
684
+
685
+ announce is for a window that starts showing this worktree with an unknown count: the next read that publishes
686
+ tells the subscribers of changed about its result even when it matches the state already held. The session
687
+ outlives its windows, so a worktree shown again usually reads exactly what it had, and would otherwise stay
688
+ unknown in that window until something in it changes.
689
+ """
690
+ if self.worktree_service.runner.closed:
691
+ self.timer.stop()
692
+ self.refresh_queued = False
693
+ self.queued_refresh_priority = None
694
+ return
695
+ if announce:
696
+ self.announce_requested = True
697
+ if explicit:
698
+ priority = ProcessPriority.USER
699
+ self.explicit_refresh = True
700
+ if self.snapshot is not None:
701
+ self.snapshot = replace(self.snapshot, fresh=False)
702
+ self._show()
703
+ self.changed.emit()
704
+ if self.busy or self.refresh_active:
705
+ self._queue_refresh(priority)
706
+ return
707
+ if self.context.is_bare:
708
+ return
709
+ if self.queued_refresh_priority is not None:
710
+ priority = min(priority, self.queued_refresh_priority)
711
+ # This new read already includes requests received before it started.
712
+ # Keep the queue only for requests that arrive while this read is active.
713
+ self.refresh_queued = False
714
+ self.queued_refresh_priority = None
715
+ self.generation += 1
716
+ generation = self.generation
717
+ configuration_generation = self.configuration_generation
718
+ self.refresh_active = True
719
+
720
+ def complete(snapshot: WorktreeSnapshot | None, error: Exception | None) -> None:
721
+ """Publish only the latest requested generation and preserve failures explicitly."""
722
+ if generation != self.generation:
723
+ return
724
+ self.refresh_active = False
725
+ if configuration_generation != self.configuration_generation:
726
+ self._queue_refresh(priority)
727
+ self._start_queued_refresh()
728
+ return
729
+ if self.busy:
730
+ self._queue_refresh(priority)
731
+ return
732
+ explicit_finished = self.explicit_refresh and not self.refresh_queued
733
+ if not self.refresh_queued:
734
+ self.explicit_refresh = False
735
+ previous = self.snapshot
736
+ previous_cause = self.diagnostic.cause if self.diagnostic is not None else ""
737
+ merge_changed = False
738
+ if error is not None:
739
+ self.diagnostic = Diagnostic(READ_CHANGES_OPERATION, self.context.repository_dir, self.git_filename, [], str(error)).prepare()
740
+ if self.snapshot is not None:
741
+ self.snapshot = replace(self.snapshot, fresh=False, diagnostic=self.diagnostic)
742
+ else:
743
+ if snapshot.merge_banner_state is not None:
744
+ merge_changed = snapshot.merge_banner_state != self.merge_banner_state
745
+ self._apply_merge_banner_state(snapshot.merge_banner_state)
746
+ self.snapshot = snapshot
747
+ # Recovery can publish an outcome synchronously; queue its reread behind this callback.
748
+ self.refresh_active = True
749
+ try:
750
+ recovery = self.mutation.recover(self.context, snapshot.index_filename)
751
+ finally:
752
+ self.refresh_active = False
753
+ if recovery:
754
+ self.diagnostic = Diagnostic(RECOVER_CHANGES_OPERATION, self.context.repository_dir, self.git_filename, [], recovery).prepare()
755
+ elif (self.diagnostic is None or self.diagnostic.operation in STATE_DIAGNOSTIC_OPERATION_SET) and not self._unread_result_kept():
756
+ self.diagnostic = None
757
+ if self.explicit_refresh and self.snapshot is not None:
758
+ self.snapshot = replace(self.snapshot, fresh=False)
759
+ # Background reads must not invalidate open menus or pending history lookups when only the read number changed.
760
+ if previous is not None and self.snapshot is not None and replace(self.snapshot, generation=previous.generation) == previous:
761
+ self.snapshot = previous
762
+ content_changed = previous is None or self.snapshot is not previous
763
+ cause_changed = (self.diagnostic.cause if self.diagnostic is not None else "") != previous_cause
764
+ observation_due = self._pause_observation_due()
765
+ if content_changed:
766
+ self.selection_generation += 1
767
+ self._show()
768
+ if content_changed:
769
+ self._reconcile_selection()
770
+ # Subscribers read the snapshot, the diagnostic cause and the end of a read the user asked for (a Rescan
771
+ # waits for this signal to finish). An identical background read tells them nothing, and every signal
772
+ # starts Git observations again; only a window that has just started showing this worktree, with no count
773
+ # yet, needs to hear it.
774
+ announced = self.announce_requested
775
+ self.announce_requested = False
776
+ if content_changed and head_moved_between(previous, self.snapshot):
777
+ # Told first: the graph refresh this starts gives the windows a new generation, and a pause observation
778
+ # started before it by the signals below would then be dropped as out of date.
779
+ self.head_moved.emit()
780
+ if content_changed or cause_changed or merge_changed or explicit_finished or announced:
781
+ self.changed.emit()
782
+ elif observation_due:
783
+ self.pause_observation_requested.emit()
784
+ if self.refresh_queued:
785
+ self._start_queued_refresh()
786
+ else:
787
+ self._load_pending_amend(generation)
788
+
789
+ def start_read() -> None:
790
+ """Start the worktree read of this refresh, compared with the snapshot on screen; it also reads the merge state."""
791
+ self.worktree_service.read(self.context, self.git_filename, generation, complete, previous=self.snapshot, priority=priority)
792
+
793
+ if self.pause_probe.filenames is not None:
794
+ start_read()
795
+ return
796
+
797
+ # Git locates the pause state files once per session, before the first read, so the first observation marked
798
+ # as started already has a complete signature to be compared with.
799
+ def paths_read(filenames: tuple[str, ...] | None, error: Exception | None) -> None:
800
+ """Keep the located paths and go on with the read; a failure only makes every period ask for an observation."""
801
+ if not isValid(self):
802
+ return
803
+ if self.worktree_service.runner.closed or generation != self.generation:
804
+ self.refresh_active = False
805
+ return
806
+ if error is None:
807
+ self.pause_probe.filenames = filenames
808
+ start_read()
809
+
810
+ self.worktree_service.run(self.context, self.git_filename, read_pause_probe_filenames(self.context), paths_read, priority=priority)
811
+
812
+ def _apply_merge_banner_state(self, state: MergeBannerState) -> None:
813
+ """Keep the merge state a read found, and for an open Prepare changes allow the merge's Commit and offer Git's message.
814
+
815
+ The message is offered once per merge, in an empty normal draft: a message the user wrote, an Amend message and
816
+ a write or Amend load in progress are never replaced. A merge that could not be read offers nothing: the Commit
817
+ then refuses it with the cause.
818
+
819
+ When the merge is gone (aborted, or concluded elsewhere), the offered message is taken back if the user did not
820
+ change it, whether Prepare changes is open or not, so a later ordinary Commit does not carry it. The merge already
821
+ offered is forgotten at the same time, Prepare changes open or not, so the same merge done again later, whose
822
+ merged commit has not moved, offers Git's message again.
823
+ """
824
+ self.merge_banner_state = state
825
+ if state.merge is None and not state.cause:
826
+ self.merge_message_parent_oids = ()
827
+ if self.merge_message_offered and not self.busy:
828
+ if self.normal_draft == self.merge_message_offered:
829
+ self.normal_draft = ""
830
+ if not self.amend and not self.amend_loading:
831
+ self._set_message("")
832
+ self.merge_message_offered = ""
833
+ if self.window is None:
834
+ return
835
+ self.merge_in_progress = state.merge
836
+ if self.merge_in_progress is None:
837
+ self.merge_message_parent_oids = ()
838
+ return
839
+ parent_oids = self.merge_in_progress.parent_oids
840
+ if parent_oids == self.merge_message_parent_oids or self.amend or self.amend_loading or self.busy or self.draft:
841
+ return
842
+ self.merge_message_parent_oids = parent_oids
843
+ text = merge_message_text(self.merge_in_progress.message)
844
+ if text:
845
+ self.normal_draft = text
846
+ self.merge_message_offered = text
847
+ self._set_message(text)
848
+
849
+ def _read_merge_banner_state(self, generation: int) -> None:
850
+ """Read the merge state again on the file worker, for a pause file that moved while the worktree read did not change.
851
+
852
+ A changed state is published as a change of the session; an unchanged one, or an answer that a newer read has
853
+ overtaken, publishes nothing.
854
+ """
855
+ if self.context.is_bare or self.worktree_service.runner.closed:
856
+ return
857
+ git_dir = self.context.git_dir
858
+
859
+ def sequence() -> Generator:
860
+ """Read the merge files in the worker that runs the session's file reads."""
861
+ return (yield FileTask(lambda: read_merge_banner_state(git_dir)))
862
+
863
+ def merge_read(state: MergeBannerState | None, error: Exception | None) -> None:
864
+ """Publish the merge state when it changed and no newer read has started."""
865
+ if not isValid(self) or error is not None or generation != self.generation or self.refresh_active or state == self.merge_banner_state:
866
+ return
867
+ self._apply_merge_banner_state(state)
868
+ self._show()
869
+ self.changed.emit()
870
+
871
+ self.worktree_service.run(self.context, self.git_filename, sequence(), merge_read, priority=ProcessPriority.BACKGROUND)
872
+
873
+ def _queue_refresh(self, priority: ProcessPriority) -> None:
874
+ """Keep one follow-up read at the most urgent level of the requests it merges."""
875
+ self.refresh_queued = True
876
+ self.queued_refresh_priority = priority if self.queued_refresh_priority is None else min(self.queued_refresh_priority, priority)
877
+
878
+ def _start_queued_refresh(self) -> None:
879
+ """Start the waiting follow-up read on the next turn of the event loop.
880
+
881
+ The follow-up stays queued until that read really starts: in the turn between the two reads the session is not at
882
+ rest, so nothing that waits for a settled state acts on the result about to be replaced.
883
+ """
884
+ QTimer.singleShot(0, self, self._run_queued_refresh)
885
+
886
+ def _run_queued_refresh(self) -> None:
887
+ """Start the follow-up read still queued, at the most urgent level of the requests it merges.
888
+
889
+ Nothing starts when the queue was emptied meanwhile: a read started since then already covers it, or the result
890
+ shown made it useless, or the session was closed.
891
+ """
892
+ if not self.refresh_queued:
893
+ return
894
+ priority = self.queued_refresh_priority if self.queued_refresh_priority is not None else ProcessPriority.USER
895
+ self.refresh(priority=priority)
896
+
897
+ def _forget_preview(self) -> None:
898
+ """Drop the identity of the comparison on screen and its line model, so nothing can keep or act on them."""
899
+ self.preview_snapshot = None
900
+ self.preview_entry = None
901
+ self.line_model = None
902
+
903
+ def _preview_still_valid(self, entry: LocalEntry) -> bool:
904
+ """Tell whether the finished comparison on screen still describes the selected row of the snapshot now shown.
905
+
906
+ The row must be the very one that was read (side, paths, status, modes and object IDs), in a fresh capture of
907
+ the same worktree and Git, with the same conversion rules. The unstaged side also needs the same working bytes,
908
+ the staged side the same HEAD. Equal labels prove nothing: a file edited again keeps its status line. A
909
+ submodule is always read again, because its checkout moves without changing the parent's row.
910
+ """
911
+ read_from, current = self.preview_snapshot, self.snapshot
912
+ if read_from is None or current is None or not current.fresh or self.preview_entry != entry or entry.is_gitlink:
913
+ return False
914
+ if self.line_model is not None and (self.line_model.snapshot is not read_from or self.line_model.entry != entry):
915
+ return False
916
+ if current.key != read_from.key or current.git_filename != read_from.git_filename or current.git_filename != self.git_filename:
917
+ return False
918
+ if current.conversion != read_from.conversion:
919
+ return False
920
+ if entry.side == StagingSide.STAGED:
921
+ return current.head_oid == read_from.head_oid
922
+ working = current.fingerprint_by_path.get(entry.path)
923
+ return working is not None and working == read_from.fingerprint_by_path.get(entry.path)
924
+
925
+ def _reconcile_selection(self) -> None:
926
+ """Keep, rebind or read again the comparison of the row still selected once a new state is shown.
927
+
928
+ A finished comparison whose data is unchanged stays on screen with its text, cursor and scroll, and its line
929
+ model is bound to the new snapshot; line intentions captured before keep their old read number and stay
930
+ refused. Any other selected row is read again once. No selected row clears the comparison and its line model.
931
+ """
932
+ if self.window is None:
933
+ self._forget_preview()
934
+ return
935
+ entries = self.window.selected_entries(self.window.unstaged_list) or self.window.selected_entries(self.window.staged_list)
936
+ if not entries:
937
+ self._forget_preview()
938
+ self.window.clear_summary()
939
+ self.window.diff_view.clear()
940
+ self.window.line_menu.bind(None)
941
+ return
942
+ entry = next(iter(entries))
943
+ if not self._preview_still_valid(entry):
944
+ self.select(entry)
945
+ return
946
+ self.preview_snapshot = self.snapshot
947
+ if self.line_model is not None:
948
+ self.line_model = replace(self.line_model, snapshot=self.snapshot, entry=entry)
949
+ self.window.line_menu.bind(self.line_model)
950
+
951
+ def _publish_operation_snapshot(self, snapshot: WorktreeSnapshot | None) -> tuple[bool, bool]:
952
+ """Show the complete read an operation made after writing; answer whether it was shown and whether it spares another read.
953
+
954
+ A read of another worktree never becomes this session's state. A read made with another Git is shown as not up
955
+ to date. A missing or stale read, a refresh the user asked for, or a read already running all leave the next
956
+ state to a new worktree read. A shown read is followed by the selection reconciliation, which keeps, rebinds or
957
+ drops the comparison on screen and its line model.
958
+ """
959
+ if snapshot is None or snapshot.key != self.key:
960
+ if self.snapshot is not None:
961
+ self.snapshot = replace(self.snapshot, fresh=False)
962
+ return False, False
963
+ shown = snapshot if snapshot.git_filename == self.git_filename else replace(snapshot, fresh=False)
964
+ changed = shown is not self.snapshot
965
+ self.snapshot = shown
966
+ if changed:
967
+ # Rows and pending previews of the previous state no longer describe the files.
968
+ self.selection_generation += 1
969
+ return changed, shown.fresh and not self.explicit_refresh and not self.refresh_active
970
+
971
+ def _show(self) -> None:
972
+ """Publish current preparation and message availability to the live view."""
973
+ self._window_destroyed()
974
+ if self.window is not None:
975
+ with QSignalBlocker(self.window.amend_checkbox):
976
+ self.window.amend_checkbox.setChecked(self.amend)
977
+ # The capture reads the native rebase and replay files on the thread of the interface and does not depend on the action.
978
+ intent = OperationIntent.capture(STAGING_AVAILABILITY_OPERATION_ID, self.context, self.git_filename)
979
+ for action in STAGING_AVAILABILITY_ACTIONS:
980
+ availability = self.mutation.repository_operation.availability(replace(intent, action=action))
981
+ own_commit = self.active_commit_request is not None and availability.owner_operation_id == self.active_commit_request.operation_id
982
+ self.window.reason_by_action[action] = "" if own_commit else availability.reason
983
+ self.window.merge_in_progress = self.merge_in_progress is not None
984
+ self.window.amend_ready = not self.amend_loading and self.amend_head_oid is not None and self.snapshot is not None and self.amend_head_oid == self.snapshot.head_oid
985
+ self.window.show_snapshot(self.snapshot, self.busy, self.diagnostic, warning=self.diagnostic is not None and self.diagnostic is self.warning_diagnostic,
986
+ commit_action=self.active_commit_action)
987
+ if self.explicit_refresh and not self.busy:
988
+ self.window.status_label.setText("Reading changes…")
989
+
990
+ def select(self, entry: LocalEntry) -> None:
991
+ """Read a selected comparison without allowing a late response to replace a newer one.
992
+
993
+ The comparison becomes the one on screen, with its capture and row, only when its answer is accepted.
994
+ """
995
+ self.selection_generation += 1
996
+ generation = self.selection_generation
997
+ snapshot = self.snapshot
998
+ self._forget_preview()
999
+ if snapshot is None or entry not in snapshot.entries or self.window is None:
1000
+ return
1001
+ self.window.diff_view.clear()
1002
+ self.window.clear_summary()
1003
+ self.window.line_menu.bind(None)
1004
+
1005
+ if entry.is_gitlink:
1006
+ window, context = self.window, self.context
1007
+ self.window.show_summary_status("Reading submodule changes…")
1008
+
1009
+ def summary_complete(summary: SubmoduleSummary | None, error: Exception | None) -> None:
1010
+ """Publish only to the same live repository, snapshot and selected side."""
1011
+ if (generation != self.selection_generation or self.window is not window or window.close_requested
1012
+ or self.context is not context or self.snapshot is not snapshot):
1013
+ return
1014
+ if error is not None or summary is None:
1015
+ window.show_summary_status("Differences could not be read. Review the details and refresh the repository.\n\n" + str(error))
1016
+ return
1017
+ if summary.side != entry.side or summary.reference.path != entry.path or summary.reference.parent_dir != context.repository_dir:
1018
+ return
1019
+ expected_source = entry.old_blob_oid if entry.old_blob_oid and entry.old_blob_oid.strip("0") else None
1020
+ expected_target = entry.new_blob_oid if entry.new_blob_oid and entry.new_blob_oid.strip("0") else None
1021
+ _fingerprint, _separator, expected_checkout = snapshot.fingerprint_by_path.get(entry.path, "").rpartition(":")
1022
+ if (summary.reference.parent_head_oid != snapshot.head_oid or summary.source.oid != expected_source
1023
+ or (entry.side == StagingSide.STAGED and summary.target.oid != expected_target)
1024
+ or (expected_checkout and expected_checkout != "uninitialized" and summary.reference.checkout_oid != expected_checkout)):
1025
+ window.show_summary_status("The submodule changed since it was read. Refresh the repository and review it before staging again.")
1026
+ return
1027
+ window.show_summary(summary, entry)
1028
+
1029
+ self.submodule_service.compare(context, snapshot.git_filename, entry, summary_complete)
1030
+ return
1031
+
1032
+ def complete(patch: bytes | RawPatch | None, error: Exception | None) -> None:
1033
+ """Route a complete preview only to the selection that requested it."""
1034
+ if generation != self.selection_generation or self.window is None:
1035
+ return
1036
+ if error is not None:
1037
+ self.window.reader_toolbar.status_label.setText("Preview incomplete: " + str(error) + ". Whole-file staging remains available.")
1038
+ if entry.side == StagingSide.UNSTAGED:
1039
+ self.worktree_service.run(self.context, snapshot.git_filename, self.worktree_service.diff(snapshot, entry), fallback)
1040
+ return
1041
+ self.preview_snapshot = snapshot
1042
+ self.preview_entry = entry
1043
+ if isinstance(patch, RawPatch):
1044
+ self.line_model = patch
1045
+ self.window.show_comparison(patch.patch, entry)
1046
+ self.window.line_menu.bind(patch)
1047
+ else:
1048
+ self.window.show_comparison(patch, entry)
1049
+
1050
+ def fallback(patch: bytes | None, error: Exception | None) -> None:
1051
+ """Keep whole-file previews readable when partial transformations are unavailable."""
1052
+ if generation == self.selection_generation and self.window is not None and error is None:
1053
+ self.window.show_comparison(patch, entry, complete=False)
1054
+
1055
+ sequence = read_partial(snapshot, entry) if entry.side == StagingSide.UNSTAGED and not entry.is_gitlink else self.worktree_service.diff(snapshot, entry)
1056
+ self.worktree_service.run(self.context, snapshot.git_filename, sequence, complete)
1057
+
1058
+ def clear_selection(self) -> None:
1059
+ """Invalidate delayed previews and line actions when no visible file remains selected."""
1060
+ self.selection_generation += 1
1061
+ self._forget_preview()
1062
+
1063
+ def stage_lines(self, selection: RawLineSelection | None) -> None:
1064
+ """Recheck direct calls against the active complete model before reserving a write."""
1065
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.line_model is None or selection is None:
1066
+ return
1067
+ try:
1068
+ self.line_model.selected(selection)
1069
+ self.mutation._validate(self.line_model.snapshot, self.snapshot, (selection.path,))
1070
+ except ValueError as error:
1071
+ self.diagnostic = Diagnostic("Stage selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
1072
+ self.refresh()
1073
+ return
1074
+ self.busy = True
1075
+ self.last_outcome = None
1076
+ self._show()
1077
+ self.mutation.stage_lines(self.line_model, selection)
1078
+
1079
+ def reset_lines(self, selection: RawLineSelection | None) -> None:
1080
+ """Apply the selected line reset directly while rejecting stale content or a closed source view."""
1081
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.line_model is None or selection is None or self.window is None:
1082
+ return
1083
+ try:
1084
+ self.line_model.selected(selection)
1085
+ self.mutation._validate(self.line_model.snapshot, self.snapshot, (selection.path,))
1086
+ except ValueError as error:
1087
+ self.diagnostic = Diagnostic("Reset selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
1088
+ self.refresh()
1089
+ return
1090
+ window = self.window
1091
+ generation = self.selection_generation
1092
+ self.busy = True
1093
+ self.last_outcome = None
1094
+ self._show()
1095
+
1096
+ def complete(prepared: PreparedLineReset | None, error: Exception | None) -> None:
1097
+ """Publish the computed candidate only while its original selection remains current."""
1098
+ if error is not None:
1099
+ self.busy = False
1100
+ self.diagnostic = Diagnostic("Reset selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
1101
+ self._show()
1102
+ self.refresh()
1103
+ return
1104
+ if self._confirmation_is_current(window, generation):
1105
+ self.mutation.reset_lines(prepared)
1106
+ return
1107
+ try:
1108
+ self.mutation.cancel_reset_lines(prepared)
1109
+ except (OSError, ValueError) as failure:
1110
+ self.diagnostic = Diagnostic("Cancel reset", self.context.repository_dir, self.git_filename, [], str(failure)).prepare()
1111
+ self._finish_cancelled_confirmation(window)
1112
+
1113
+ self.mutation.prepare_reset_lines(self.line_model, selection, complete)
1114
+
1115
+ def _confirmation_is_current(self, window: QWidget, generation: int) -> bool:
1116
+ """Reject late confirmation answers after selection changes or a requested closure."""
1117
+ return self.window is window and generation == self.selection_generation and not window.close_requested
1118
+
1119
+ def _finish_cancelled_confirmation(self, window: QWidget) -> None:
1120
+ """Release the busy UI after cancellation and honor a closure before any write starts."""
1121
+ self.busy = False
1122
+ self._show()
1123
+ if self.window is window and window.close_requested:
1124
+ window.close()
1125
+ self.changed.emit()
1126
+ self.refresh()
1127
+
1128
+ def stage(self, entries: tuple[LocalEntry, ...]) -> None:
1129
+ """Validate session capability and selection before submitting an irreversible index result."""
1130
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.context.is_bare:
1131
+ return
1132
+ self.busy = True
1133
+ self.last_outcome = None
1134
+ self._show()
1135
+ self.mutation.stage_file(self.snapshot, entries)
1136
+
1137
+ def reset_file(self, entries: tuple[LocalEntry, ...]) -> None:
1138
+ """Reset captured files in their original live view, confirming only new additions."""
1139
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.window is None or not entries:
1140
+ return
1141
+ if any(entry not in self.snapshot.entries for entry in entries):
1142
+ self.refresh()
1143
+ return
1144
+ window = self.window
1145
+ generation = self.selection_generation
1146
+ self.busy = True
1147
+ self.last_outcome = None
1148
+ self._show()
1149
+
1150
+ def complete(prepared: ResetCandidate | None, error: Exception | None) -> None:
1151
+ """Recheck the source view before publication and after any new-file confirmation."""
1152
+ if error is not None:
1153
+ self.busy = False
1154
+ self.diagnostic = Diagnostic("Reset", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
1155
+ self._show()
1156
+ self.refresh()
1157
+ return
1158
+ needs_confirmation = prepared.snapshot.requires_file_confirmation(tuple(change.path for change in prepared.changes if change.before.exists))
1159
+ accepted = self._confirmation_is_current(window, generation) and (not needs_confirmation or window.file_menu.confirm(prepared))
1160
+ if accepted and self._confirmation_is_current(window, generation):
1161
+ self.mutation.reset_file(prepared)
1162
+ return
1163
+ try:
1164
+ self.mutation.cancel_reset_file(prepared)
1165
+ except (OSError, ValueError) as failure:
1166
+ self.diagnostic = Diagnostic("Cancel Reset", self.context.repository_dir, self.git_filename, [], str(failure)).prepare()
1167
+ self._finish_cancelled_confirmation(window)
1168
+
1169
+ self.mutation.prepare_reset_file(self.snapshot, entries, complete)
1170
+
1171
+ def delete_file(self, entries: tuple[LocalEntry, ...], selected_snapshot: WorktreeSnapshot | None = None) -> None:
1172
+ """Move captured files to Trash directly, confirming batches that include a new addition."""
1173
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.window is None or not entries:
1174
+ return
1175
+ window = self.window
1176
+ generation = self.selection_generation
1177
+ self.busy = True
1178
+ self.last_trash_outcome = None
1179
+ self._show()
1180
+ self.changed.emit()
1181
+
1182
+ def complete(request: TrashRequest | None, error: Exception | None) -> None:
1183
+ """Require the original live view before moving files or confirming new additions."""
1184
+ if error is not None:
1185
+ self.busy = False
1186
+ self.diagnostic = Diagnostic("Move to Trash", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
1187
+ self._show()
1188
+ self.changed.emit()
1189
+ return
1190
+ needs_confirmation = request.snapshot.requires_file_confirmation(tuple(target.path for target in request.targets))
1191
+ accepted = self._confirmation_is_current(window, generation) and (not needs_confirmation or window.file_menu.confirm_trash(request))
1192
+ if accepted and self._confirmation_is_current(window, generation):
1193
+ self.trash_service.submit(request)
1194
+ return
1195
+ self.trash_service.cancel(request)
1196
+ self._finish_cancelled_confirmation(window)
1197
+
1198
+ self.trash_service.prepare(selected_snapshot or self.snapshot, entries, complete)
1199
+
1200
+ def _trash_finished(self, outcome: TrashOutcome) -> None:
1201
+ """Retain OS results beyond window closure and show the complete read the operation made after moving.
1202
+
1203
+ That read already shows the non-staged Git deletions, after a partial or refused batch too, so the worktree is
1204
+ read again only when the read cannot be used.
1205
+ """
1206
+ self.last_trash_outcome = outcome
1207
+ self.busy = False
1208
+ self.diagnostic = outcome.diagnostic
1209
+ shown, reusable = self._publish_operation_snapshot(outcome.snapshot)
1210
+ if reusable:
1211
+ # A timer or window request queued while the files moved would read exactly the state now shown.
1212
+ self.refresh_queued = False
1213
+ self.queued_refresh_priority = None
1214
+ self._show()
1215
+ if shown:
1216
+ self._reconcile_selection()
1217
+ if outcome.status == OperationStatus.SUCCESS and self.window is not None and self.window.close_requested:
1218
+ self.window.close()
1219
+ reopened = False
1220
+ if outcome.status != OperationStatus.SUCCESS and self.window is None:
1221
+ # Opening the window starts its own worktree read.
1222
+ reopened = self.open() is not None
1223
+ self.changed.emit()
1224
+ if not reusable and not reopened:
1225
+ self.refresh(priority=ProcessPriority.CONTINUATION)
1226
+
1227
+ def add_ignore(self, entries: tuple[LocalEntry, ...], selected_snapshot: WorktreeSnapshot, destination: IgnoreDestination) -> None:
1228
+ """Apply only a captured current Unstaged intention to the named rule destination."""
1229
+ if self.busy or self.window is None or self.window.close_requested or self.snapshot is None or not self.snapshot.fresh:
1230
+ return
1231
+ if selected_snapshot is not self.snapshot or not entries or any(entry not in self.snapshot.entries or entry.side != StagingSide.UNSTAGED for entry in entries):
1232
+ self.refresh()
1233
+ return
1234
+ self.busy = True
1235
+ self.last_ignore_outcome = None
1236
+ self._show()
1237
+ self.ignore_service.submit(selected_snapshot, entries, destination)
1238
+
1239
+ def _local_intention_is_current(self, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot, side_set: frozenset[StagingSide] = UNSTAGED_SIDE_SET) -> bool:
1240
+ """Validate direct handlers as well as menu dispatch against the live worktree read.
1241
+
1242
+ By default only Unstaged entries are accepted; a read-only action passes ``LISTED_SIDE_SET`` so that an entry
1243
+ of either list is accepted.
1244
+ """
1245
+ return (not self.busy and self.window is not None and not self.window.close_requested and self.snapshot is snapshot and snapshot.fresh
1246
+ and not self.context.is_bare and bool(entries) and all(entry in snapshot.entries and entry.side in side_set for entry in entries))
1247
+
1248
+ def stop_tracking(self, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot) -> None:
1249
+ """Confirm cached removal while retaining the exact snapshot and window lifetime."""
1250
+ if not self._local_intention_is_current(entries, snapshot):
1251
+ self.refresh()
1252
+ return
1253
+ if any(entry.old_mode not in ("100644", "100755", "120000") or entry.skip_worktree or entry.conflict_stages for entry in entries):
1254
+ self._consultation_failed("Select tracked ordinary files to stop tracking")
1255
+ return
1256
+ window = self.window
1257
+ generation = self.selection_generation
1258
+ self.busy = True
1259
+ self.last_outcome = None
1260
+ self._show()
1261
+ accepted = window.file_menu.confirm_stop_tracking(entries)
1262
+ if not accepted or not self._confirmation_is_current(window, generation):
1263
+ self._finish_cancelled_confirmation(window)
1264
+ return
1265
+ self.mutation.stop_tracking(snapshot, entries)
1266
+
1267
+ def consult(self, action: FileAction, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot) -> None:
1268
+ """Dispatch captured local-file commands without inventing historical object identities.
1269
+
1270
+ Show in folder and Show in text editor come from both lists, since they only open the working copy or its
1271
+ folder; every other action comes only from the Unstaged list.
1272
+ """
1273
+ side_set = LISTED_SIDE_SET if action in READ_ONLY_FILE_ACTION_SET else UNSTAGED_SIDE_SET
1274
+ if not self._local_intention_is_current(entries, snapshot, side_set=side_set):
1275
+ return
1276
+ if action == FileAction.STAGE:
1277
+ self.stage(entries)
1278
+ return
1279
+ if action == FileAction.STOP_TRACKING:
1280
+ self.stop_tracking(entries, snapshot)
1281
+ return
1282
+ if action in (FileAction.COPY_BASENAME, FileAction.COPY_ABSOLUTE):
1283
+ labels: list[str] = []
1284
+ try:
1285
+ for entry in entries:
1286
+ filename = worktree_filename(self.context, entry.path)
1287
+ labels.append(os.path.basename(filename) if action == FileAction.COPY_BASENAME else filename)
1288
+ except ValueError as error:
1289
+ self._consultation_failed(str(error))
1290
+ return
1291
+ QApplication.clipboard().setText("\n".join(labels))
1292
+ if any("\n" in label or "\r" in label for label in labels):
1293
+ self._consultation_failed("Paths copied literally. A filename contains a line break, so displayed lines do not identify separate files.")
1294
+ return
1295
+ if action not in READ_ONLY_FILE_ACTION_SET and action != FileAction.HISTORY:
1296
+ return
1297
+ if len(entries) != 1:
1298
+ self._consultation_failed("Select one file for this action")
1299
+ return
1300
+ entry = next(iter(entries))
1301
+ if action in READ_ONLY_FILE_ACTION_SET:
1302
+ try:
1303
+ file_location(self.context, entry.path, allow_missing=True)
1304
+ except (OSError, ValueError) as error:
1305
+ self._consultation_failed(str(error))
1306
+ return
1307
+ signal = self.folder_requested if action == FileAction.FOLDER else self.text_editor_requested
1308
+ signal.emit(entry.path)
1309
+ return
1310
+ path = local_history_path(snapshot, entry)
1311
+ if path is None:
1312
+ self._consultation_failed("No file history exists at HEAD for this file")
1313
+ return
1314
+ window = self.window
1315
+
1316
+ def read_anchor() -> Generator[GitCommand, bytes, FileHistoryAnchor]:
1317
+ """Resolve the tree entry of the snapshot's HEAD commit, then check once that HEAD still names that commit.
1318
+
1319
+ The tree is asked for by the commit ID, which never changes, so HEAD moving before the read cannot change
1320
+ what is read: the single check at the end refuses the anchor instead.
1321
+ """
1322
+ output = yield GitCommand(["ls-tree", "-z", snapshot.head_oid, "--", os.fsdecode(path)])
1323
+ tree_entries = parse_tree_entries(output)
1324
+ historical = next((candidate for candidate in tree_entries if candidate.path == path and candidate.object_type == "blob"), None)
1325
+ if historical is None:
1326
+ raise ValueError("No file history exists at HEAD for this file")
1327
+ after = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
1328
+ if after.strip().decode("ascii", "replace") != snapshot.head_oid:
1329
+ raise ValueError("HEAD changed while resolving file history; Refresh first")
1330
+ return FileHistoryAnchor(snapshot.head_oid, path, historical.oid)
1331
+
1332
+ def complete(anchor: FileHistoryAnchor | None, error: Exception | None) -> None:
1333
+ """Open the history for the still-open requesting view when the anchor still matches the changes shown, and say why otherwise.
1334
+
1335
+ A worktree read published meanwhile replaces the snapshot without changing what the anchor names, so the
1336
+ anchor is compared with the changes now shown rather than with the snapshot it was read from. Only a closed
1337
+ view means nobody waits for the answer any more.
1338
+ """
1339
+ if self.window is not window or window.close_requested:
1340
+ return
1341
+ if error is not None:
1342
+ self._consultation_failed(str(error))
1343
+ return
1344
+ if not self._history_anchor_is_current(anchor, entry.path):
1345
+ self._consultation_failed(HISTORY_STATE_CHANGED_CAUSE)
1346
+ return
1347
+ self.history_requested.emit(anchor)
1348
+
1349
+ self.worktree_service.run(self.context, self.git_filename, read_anchor(), complete)
1350
+
1351
+ def _history_anchor_is_current(self, anchor: FileHistoryAnchor, path: bytes) -> bool:
1352
+ """Tell whether the snapshot now shown would give the same history anchor for the unstaged file at ``path``.
1353
+
1354
+ An anchor names a commit and the file's name in it, and Git never changes either object. A newer read that still
1355
+ has HEAD on that commit and still lists the file as unstaged under the same name at HEAD gives exactly the same
1356
+ anchor, whatever else it changed; the window must also still accept a request on that read.
1357
+ """
1358
+ current = self.snapshot
1359
+ if current is None:
1360
+ return False
1361
+ entry = next((candidate for candidate in current.entries if candidate.side == StagingSide.UNSTAGED and candidate.path == path), None)
1362
+ if entry is None or not self._local_intention_is_current((entry,), current):
1363
+ return False
1364
+ return current.head_oid == anchor.commit_oid and local_history_path(current, entry) == anchor.path
1365
+
1366
+ def _consultation_failed(self, cause: str, diagnostic: PreparedDiagnostic | None = None) -> None:
1367
+ """Explain an unavailable action in the existing staging status area.
1368
+
1369
+ A tool started outside Git, such as the text editor, hands over its own diagnostic so the details name that
1370
+ program instead of Git.
1371
+ """
1372
+ if diagnostic is None:
1373
+ diagnostic = Diagnostic("Local file action", self.context.repository_dir, self.git_filename, [], cause).prepare()
1374
+ self.diagnostic = diagnostic
1375
+ self._show()
1376
+
1377
+ def _ignore_finished(self, outcome: IgnoreOutcome) -> None:
1378
+ """Retain rule outcomes and reread without staging either rules or selected files."""
1379
+ self.last_ignore_outcome = outcome
1380
+ self.busy = False
1381
+ self.diagnostic = outcome.diagnostic
1382
+ self._show()
1383
+ if self.window is not None and self.window.close_requested:
1384
+ self.window.close()
1385
+ self.changed.emit()
1386
+ self.refresh(priority=ProcessPriority.CONTINUATION)
1387
+
1388
+ def _trash_waiting(self, outcome: TrashOutcome) -> None:
1389
+ """Keep an outstanding native call visible without ending its mutation reservation."""
1390
+ self.last_trash_outcome = outcome
1391
+ self.diagnostic = outcome.diagnostic
1392
+ self._show()
1393
+ self.changed.emit()
1394
+
1395
+ def _finished(self, outcome: MutationOutcome) -> None:
1396
+ """Publish file-operation outcomes without unlocking an unrelated active native commit."""
1397
+ if outcome.key != self.key:
1398
+ return
1399
+ if self.commit_service.active_by_id or self.trash_service.active_by_id or self.ignore_service.active or self.key in self.mutation.active_key_set:
1400
+ return
1401
+ self.last_outcome = outcome
1402
+ self.busy = False
1403
+ self.diagnostic = outcome.diagnostic
1404
+ if self.snapshot is not None:
1405
+ self.snapshot = replace(self.snapshot, fresh=False)
1406
+ self._show()
1407
+ if outcome.status == "success" and self.window is not None and self.window.close_requested:
1408
+ self.window.close()
1409
+ self.refresh(priority=ProcessPriority.CONTINUATION)
1410
+
1411
+ def unstage(self, entries: tuple[LocalEntry, ...]) -> None:
1412
+ """Remove whole selected staged changes while retaining the session's working content."""
1413
+ if self.busy or self.snapshot is None or not self.snapshot.fresh or self.context.is_bare:
1414
+ return
1415
+ self.busy = True
1416
+ self.last_outcome = None
1417
+ self._show()
1418
+ self.mutation.unstage_file(self.snapshot, entries)