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,885 @@
1
+ """Resolve the conflicts of a merge, a rebase or a replay from a repository window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, replace
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QObject, Qt
10
+ from PySide6.QtWidgets import QMessageBox
11
+ from shiboken6 import isValid
12
+
13
+ from gitpane.controller.refresh_controller import RefreshScope
14
+ from gitpane.controller.stash_controller import StashDropAfterResolution
15
+ from gitpane.history.file_history import WindowCapability
16
+ from gitpane.history.repository_value import FileStatus
17
+ from gitpane.operation.diagnostic import Diagnostic
18
+ from gitpane.operation.operation_status import OperationStatus
19
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent
20
+ from gitpane.preference.preference_store import CONFLICT_PROMPT_PREFERENCE_KEY
21
+ from gitpane.reference.reference_edit import MergeAbortIntent
22
+ from gitpane.reference.stash import StashObservation
23
+ from gitpane.sequencer.conflict import NO_CONFLICT_SELECTED_TEXT, ConflictResolutionIntent, capture_conflict_result, conflict_path_label, has_deleted_side, snapshot_has_conflicts
24
+ from gitpane.sequencer.merge_state import ABORT_ACTION, MERGE_ABORT_REFRESH_REASON, MergeBannerState, merge_action_reason
25
+ from gitpane.sequencer.mergetool import MergetoolRequest, MergetoolStatus
26
+ from gitpane.sequencer.replay_state import BUSY_ACTION_REASON
27
+ from gitpane.tool.mergetool_recipe import MERGETOOL_COMMAND_PREFERENCE_KEY, MERGETOOL_FILENAME_PREFERENCE_KEY, MergetoolRecipe
28
+ from gitpane.ui.sequencer.conflict_dialog import ConflictConfirmation, ConflictDialog, ConflictPrompt, DeletedFileDialog, MergeConfirmation
29
+ from gitpane.ui.sequencer.merge_recovery_dialog import MergeRecoveryDialog
30
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
31
+ from gitpane.worktree.command_sequence import FileTask, GitCommandError
32
+ from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE, WorktreeKey
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Generator
36
+
37
+ from gitpane.controller.operation_policy import OperationPolicy
38
+ from gitpane.controller.rebase_controller import RebaseController
39
+ from gitpane.controller.reference_form_controller import ReferenceFormController
40
+ from gitpane.controller.refresh_controller import RefreshController
41
+ from gitpane.controller.replay_controller import ReplayController
42
+ from gitpane.controller.session_registry import SessionRegistry
43
+ from gitpane.controller.setting_controller import SettingController
44
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
45
+ from gitpane.controller.stash_controller import StashController
46
+ from gitpane.controller.window_activation import WindowActivationFilter
47
+ from gitpane.controller.worktree_state_controller import WorktreeStateController
48
+ from gitpane.history.repository import GitRepository
49
+ from gitpane.operation.diagnostic import DiagnosticLog
50
+ from gitpane.operation.repository_operation import OperationOutcome, RepositoryOperationService
51
+ from gitpane.preference.preference_store import PreferenceStore
52
+ from gitpane.reference.reference_edit import ReferenceService
53
+ from gitpane.reference.stash import StashService
54
+ from gitpane.sequencer.conflict import ConflictSelection, ConflictService, ConflictState
55
+ from gitpane.sequencer.mergetool import MergetoolResult, MergetoolSeries, MergetoolService
56
+ from gitpane.sequencer.rebase import RebaseService
57
+ from gitpane.sequencer.rebase_state import RebaseSnapshot
58
+ from gitpane.worktree.mutation import MutationOutcome, MutationService
59
+ from gitpane.worktree.staging import StagingSession
60
+ from gitpane.worktree.worktree_service import WorktreeService
61
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
62
+
63
+ # The name of the merge banner's Abort in its console and in its question.
64
+ MERGE_ABORT_LABEL = "Abort merge"
65
+ # Asked only when the working tree no longer matches the tree the merge left, or when that could not be read.
66
+ MERGE_ABORT_QUESTION = ("Abort the merge in progress?\n\nChanges made since the merge stopped, such as resolved conflicts, will be lost. "
67
+ "Submodule content is not changed.")
68
+ MERGE_ABORT_CANCEL_LABEL = "Cancel"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class MergetoolPublication:
73
+ """What a merge tool series must still match for its terminal reads to become the displayed worktree state.
74
+
75
+ The worktree, the Git executable and the configuration generation are the ones the series started with, and the
76
+ displayed read is still the last one this series published: any other read published meanwhile wins.
77
+ """
78
+
79
+ key: WorktreeKey
80
+ git_filename: str
81
+ staging_generation: int
82
+ configuration_generation: int
83
+
84
+
85
+ class ConflictController(QObject):
86
+ """The conflicts of every repository window: the conflict list with Mark solved and Delete file, the merge tool series, the retained merge results, the stash a Stash pop kept, and the Abort and Continue of a merge."""
87
+
88
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, window_activation_controller: WindowActivationFilter,
89
+ refresh_controller: RefreshController, worktree_state_controller: WorktreeStateController, setting_controller: SettingController, reference_form_controller: ReferenceFormController,
90
+ replay_controller: ReplayController, rebase_controller: RebaseController, stash_controller: StashController, preference: PreferenceStore, repository: GitRepository, worktree_service: WorktreeService,
91
+ repository_operation: RepositoryOperationService, rebase: RebaseService, reference: ReferenceService, stash: StashService, mutation: MutationService, conflict: ConflictService, mergetool: MergetoolService,
92
+ diagnostic_log: DiagnosticLog | None, parent: QObject | None = None) -> None:
93
+ """Receive the services and the lower controllers conflicts are resolved with; the wiring builds it after the stash controller, and the repository windows reach it through the wiring."""
94
+ super().__init__(parent)
95
+ self.session_registry_controller = session_registry_controller
96
+ self.operation_policy_controller = operation_policy_controller
97
+ self.staging_session_registry_controller = staging_session_registry_controller
98
+ self.window_activation_controller = window_activation_controller
99
+ self.refresh_controller = refresh_controller
100
+ self.worktree_state_controller = worktree_state_controller
101
+ self.setting_controller = setting_controller
102
+ self.reference_form_controller = reference_form_controller
103
+ self.replay_controller = replay_controller
104
+ self.rebase_controller = rebase_controller
105
+ self.stash_controller = stash_controller
106
+ self.preference = preference
107
+ self.repository = repository
108
+ self.worktree_service = worktree_service
109
+ self.repository_operation = repository_operation
110
+ self.rebase = rebase
111
+ self.reference = reference
112
+ self.stash = stash
113
+ self.mutation = mutation
114
+ self.conflict = conflict
115
+ self.mergetool = mergetool
116
+ self.diagnostic_log = diagnostic_log
117
+ self.merge_abort_intent_by_id: dict[str, MergeAbortIntent] = {}
118
+ # The operation ID of the latest Abort click of each window: an answer about the work at risk for an older click is dropped.
119
+ self.merge_abort_pending_by_session: dict[str, str] = {}
120
+ self.merge_abort_confirmation_by_session: dict[str, QMessageBox] = {}
121
+ self.merge_recovery_dialog_by_session: dict[str, MergeRecoveryDialog] = {}
122
+ self.merge_confirmation_by_id: dict[str, MergeConfirmation | DeletedFileDialog] = {}
123
+ self.mergetool_publication_by_series: dict[str, MergetoolPublication] = {}
124
+ self.conflict_dialog_by_session: dict[str, ConflictDialog] = {}
125
+ self.conflict_confirmation_by_session: dict[str, ConflictConfirmation] = {}
126
+ self.conflict_resolution_by_id: dict[str, ConflictResolutionIntent] = {}
127
+ self.conflict_followup_by_id: dict[str, tuple[ConflictResolutionIntent, ...]] = {}
128
+ self.conflict_prompt_by_session: dict[str, ConflictPrompt] = {}
129
+ self.conflict_retention_by_key: dict[WorktreeKey, StashObservation] = {}
130
+ self.conflict_retention_read_set: set[WorktreeKey] = set()
131
+
132
+ def open_conflict_dialog(self, session_id: str, selected_state: ConflictState | None = None) -> ConflictDialog | None:
133
+ """Open only current conflicts belonging to a management-capable repository window."""
134
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
135
+ return None
136
+ window = self.session_registry_controller.window_by_session[session_id]
137
+ key = WorktreeKey.from_context(window.context)
138
+ state = self.conflict.state_by_key.get(key)
139
+ if window.context.is_bare or state is None or not state.fresh or not state.entries or (selected_state is not None and selected_state != state):
140
+ window.statusBar().showMessage("Refresh the repository before opening its current conflicts")
141
+ return None
142
+ dialog = self.conflict_dialog_by_session.get(session_id)
143
+ if dialog is None or not isValid(dialog):
144
+ dialog = ConflictDialog(state, window)
145
+ dialog.installEventFilter(self.window_activation_controller)
146
+ dialog.refresh_requested.connect(lambda: self._refresh_conflict_dialog(session_id))
147
+ dialog.mark_solved_requested.connect(lambda selection: self.mark_conflict_solved(session_id, selection))
148
+ dialog.delete_requested.connect(lambda selection: self.mark_conflict_solved(session_id, selection, delete_file=True))
149
+ dialog.open_tool_requested.connect(lambda selection: self.open_mergetool(session_id, selection))
150
+ dialog.settings_requested.connect(lambda: self.setting_controller.show_setting(session_id, section="mergetool"))
151
+ dialog.stop_series_requested.connect(lambda: self._stop_mergetool_series(session_id))
152
+ dialog.finished.connect(lambda: self._stop_mergetool_series(session_id))
153
+ self.conflict_dialog_by_session[session_id] = dialog
154
+ dialog.show_state(state)
155
+ dialog.show_rebase_state(self.rebase.state_by_key.get(key))
156
+ self.replay_controller.replay_actions_changed()
157
+ dialog.show()
158
+ dialog.raise_()
159
+ if not self._conflict_state_is_recent(key, state):
160
+ self._refresh_conflict_dialog(session_id)
161
+ return dialog
162
+
163
+ def _conflict_state_is_recent(self, key: WorktreeKey, state: ConflictState) -> bool:
164
+ """Tell whether a conflict list can be shown as it was last read, with no read of its own.
165
+
166
+ It can when it comes from the complete worktree read currently shown, made with the current Git, and no read is
167
+ running or requested. Showing it writes nothing: every action on it checks the repository again before writing,
168
+ and Rescan stays the explicit way to see a resolution made outside the application.
169
+ """
170
+ staging = self.staging_session_registry_controller.staging_by_key.get(key)
171
+ if staging is None or staging.refresh_active or staging.refresh_queued or staging.explicit_refresh:
172
+ return False
173
+ snapshot = staging.snapshot
174
+ return (snapshot is not None and snapshot.fresh and snapshot.key == key and snapshot.generation == state.generation
175
+ and snapshot.git_filename == staging.git_filename == self.repository.git_filename)
176
+
177
+ def _refresh_conflict_dialog(self, session_id: str) -> None:
178
+ """Refresh the shared worktree without granting limited windows mutation access."""
179
+ if self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
180
+ dialog = self.conflict_dialog_by_session.get(session_id)
181
+ if dialog is not None and isValid(dialog):
182
+ dialog.begin_rescan()
183
+ self.staging_session_registry_controller.staging_session(self.session_registry_controller.session_by_id[session_id].repository_context).refresh(explicit=True)
184
+
185
+ def _conflict_action_snapshot(self, session_id: str, selection: ConflictSelection) -> WorktreeSnapshot | None:
186
+ """Reject expired selections with visible feedback instead of silently losing the click."""
187
+ dialog = self.conflict_dialog_by_session.get(session_id)
188
+ if dialog is None or not isValid(dialog) or not dialog.isVisible():
189
+ return None
190
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
191
+ dialog.error_label.setText("This window can no longer resolve conflicts. Reopen the repository.")
192
+ return None
193
+ if dialog.busy:
194
+ dialog.error_label.setText("A conflict resolution is already running. Wait for it to finish.")
195
+ return None
196
+ if not selection.paths:
197
+ dialog.error_label.setText(NO_CONFLICT_SELECTED_TEXT)
198
+ return None
199
+ staging = self.staging_session_registry_controller.staging_by_key.get(selection.key)
200
+ snapshot = staging.snapshot if staging is not None else None
201
+ if (dialog.selection() != selection or not dialog.state.fresh or snapshot is None or not snapshot.fresh
202
+ or snapshot.generation != selection.generation):
203
+ dialog.error_label.setText("The conflict list changed or is refreshing. Wait for the scan to finish, then select the file and try again.")
204
+ self._refresh_conflict_dialog(session_id)
205
+ return None
206
+ return snapshot
207
+
208
+ def open_merge_recovery(self, session_id: str) -> MergeRecoveryDialog | None:
209
+ """Open retained results even when current conflicts are absent or unreadable."""
210
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
211
+ return None
212
+ dialog = self.merge_recovery_dialog_by_session.get(session_id)
213
+ if dialog is None or not isValid(dialog):
214
+ dialog = MergeRecoveryDialog(self.session_registry_controller.window_by_session[session_id])
215
+ dialog.installEventFilter(self.window_activation_controller)
216
+ dialog.refresh_requested.connect(lambda: self._refresh_merge_recovery(session_id))
217
+ self.merge_recovery_dialog_by_session[session_id] = dialog
218
+ dialog.show()
219
+ dialog.raise_()
220
+ self._refresh_merge_recovery(session_id)
221
+ return dialog
222
+
223
+ def _refresh_merge_recovery(self, session_id: str) -> None:
224
+ """Discover retained output independently of the vanished session that produced it."""
225
+ dialog = self.merge_recovery_dialog_by_session.get(session_id)
226
+ session = self.session_registry_controller.session_by_id.get(session_id)
227
+ if dialog is None or not isValid(dialog) or session is None:
228
+ return
229
+ context = session.repository_context
230
+ key = WorktreeKey.from_context(context)
231
+ store = self.mergetool.temporary_store.recovery_store
232
+ dialog.read_generation += 1
233
+ generation = dialog.read_generation
234
+ dialog.refresh_button.setEnabled(False)
235
+ dialog.recovery_text.setPlainText("Reading retained merge results...")
236
+
237
+ def sequence() -> Generator:
238
+ """Keep recovery directory and content reads off the Qt thread."""
239
+ return (yield FileTask(lambda: store.discover(context)))
240
+
241
+ def complete(results: object, error: Exception | None) -> None:
242
+ """Show only locations discovered for this still-open worktree."""
243
+ current = self.session_registry_controller.session_by_id.get(session_id)
244
+ if not isValid(dialog) or self.merge_recovery_dialog_by_session.get(session_id) is not dialog or current is None or WorktreeKey.from_context(current.repository_context) != key:
245
+ return
246
+ if generation != dialog.read_generation:
247
+ return
248
+ if error is not None:
249
+ dialog.show_results((), "Could not read retained merge results. Inspect the recovery directory: " + store.root_dir(context))
250
+ return
251
+ dialog.show_results(results, recovery_dir=store.root_dir(context))
252
+
253
+ self.worktree_service.run(context, self.repository.git_filename, sequence(), complete)
254
+
255
+ def open_mergetool(self, session_id: str, selection: ConflictSelection) -> None:
256
+ """Capture all selected conflicts once under the original session authority."""
257
+ snapshot = self._conflict_action_snapshot(session_id, selection)
258
+ if snapshot is None:
259
+ return
260
+ dialog = self.conflict_dialog_by_session[session_id]
261
+ path = next(iter(selection.paths))
262
+ entry = next((entry for entry in dialog.state.entries if entry.path == path), None)
263
+ if entry is None:
264
+ return
265
+ self.preference.initialize_mergetool()
266
+ owner = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, session_id=session_id,
267
+ generation=self.session_registry_controller.generation_by_session[session_id], operation="Resolve conflict with external tool", action=OperationAction.CONFLICT_TOOL)
268
+ recipe = MergetoolRecipe(str(self.preference.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY]), str(self.preference.value_by_key[MERGETOOL_COMMAND_PREFERENCE_KEY]))
269
+ request = MergetoolRequest(snapshot, path, entry.sides, owner, recipe)
270
+ dialog.merge_series_id = owner.operation_id
271
+ staging = self.staging_session_registry_controller.staging_by_key.get(selection.key)
272
+ if staging is not None:
273
+ self.mergetool_publication_by_series[owner.operation_id] = MergetoolPublication(snapshot.key, snapshot.git_filename, staging.generation,
274
+ staging.configuration_generation)
275
+ dialog.set_busy(True)
276
+ dialog.error_label.setText("Preparing conflict resolution: " + conflict_path_label(path))
277
+ dialog.settings_button.hide()
278
+ def is_current() -> bool:
279
+ """Bind every file in this series to the captured session, Git and executable recipe."""
280
+ return (self.operation_policy_controller.operation_allowed(owner) and self.repository.git_filename == snapshot.git_filename
281
+ and self.preference.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY] == recipe.program_filename
282
+ and self.preference.value_by_key[MERGETOOL_COMMAND_PREFERENCE_KEY] == recipe.command)
283
+
284
+ self.mergetool.merge_series(request, selection.paths, is_current)
285
+
286
+ def _stop_mergetool_series(self, session_id: str) -> None:
287
+ """Stop future editors while the current one remains owned by its service."""
288
+ for series_id, series in tuple(self.mergetool.series_by_id.items()):
289
+ if series.request.operation.session_id == session_id:
290
+ self.mergetool.stop_series(series_id)
291
+
292
+ def mergetool_series_changed(self, series: MergetoolSeries) -> None:
293
+ """Keep mutation controls disabled throughout capture, process and publication waits."""
294
+ self.rebase_controller.rebase_actions_changed()
295
+ self.replay_controller.replay_actions_changed()
296
+ self.worktree_state_controller.merge_banner_actions_changed()
297
+ if not self.operation_policy_controller.operation_allowed(series.request.operation):
298
+ return
299
+ dialog = self.conflict_dialog_by_session.get(series.request.operation.session_id)
300
+ if dialog is None or not isValid(dialog) or dialog.merge_series_id != series.request.operation.operation_id:
301
+ return
302
+ dialog.set_busy(True)
303
+ dialog.stop_series_button.setVisible(len(series.paths) > 1)
304
+ dialog.stop_series_button.setEnabled(not series.stop_requested)
305
+ if series.stop_requested:
306
+ dialog.error_label.setText("Series stopped. Waiting for the current KDiff3 process.")
307
+ elif series.current_id:
308
+ current_position = series.position - 1
309
+ dialog.error_label.setText("Resolving: " + conflict_path_label(series.paths[current_position]))
310
+ else:
311
+ dialog.error_label.setText("Reading current merge conflicts...")
312
+
313
+ def mergetool_series_finished(self, series: MergetoolSeries) -> None:
314
+ """Expose failed rereads and preserve the list until fresh Git proves it empty."""
315
+ self.mergetool_publication_by_series.pop(series.request.operation.operation_id, None)
316
+ self.stash_controller.continue_stash_drops_after_resolution()
317
+ self.rebase_controller.rebase_actions_changed()
318
+ self.replay_controller.replay_actions_changed()
319
+ self.worktree_state_controller.merge_banner_actions_changed()
320
+ dialog = self.conflict_dialog_by_session.get(series.request.operation.session_id)
321
+ if dialog is None or not isValid(dialog) or dialog.merge_series_id != series.request.operation.operation_id:
322
+ return
323
+ dialog.merge_series_id = ""
324
+ dialog.set_busy(False)
325
+ dialog.stop_series_button.hide()
326
+ if not self.operation_policy_controller.operation_allowed(series.request.operation):
327
+ if dialog.state.key == series.request.snapshot.key:
328
+ dialog.error_label.setText(series.diagnostic or "Series stopped. The original repository session is no longer current.")
329
+ return
330
+ if series.diagnostic:
331
+ # A series stops at the first result that offers Settings, and that result's diagnostic is the series' own.
332
+ settings_available = bool(series.results) and series.results[-1].settings_available
333
+ dialog.show_mergetool_result(series.diagnostic, settings_available=settings_available)
334
+ if series.snapshot is not None and not series.diagnostic:
335
+ # The last terminal read has already been published, adopted or observed; only a read the series made
336
+ # after it, to skip a file resolved elsewhere, still has to become the list.
337
+ state = self.conflict.state_by_key.get(series.snapshot.key)
338
+ last_result_snapshot = series.results[-1].snapshot if series.results else None
339
+ if state is None or series.snapshot is not last_result_snapshot:
340
+ state = self.conflict.observe(series.snapshot.key, series.snapshot)
341
+ dialog.show_state(state)
342
+ if state.fresh and not state.entries:
343
+ self.replay_controller.request_replay_auto_continue(series.request.operation.session_id, series.snapshot.key)
344
+ dialog.close()
345
+
346
+ def confirm_mergetool(self, request: MergetoolRequest, reason: str) -> None:
347
+ """Present an explicit answer only in the surviving original repository session."""
348
+ request_id = request.operation.operation_id
349
+ window = self.session_registry_controller.window_by_session.get(request.operation.session_id)
350
+ if window is None or not isValid(window) or not self.operation_policy_controller.operation_allowed(request.operation):
351
+ self.mergetool.confirm(request_id, False)
352
+ return
353
+ if reason == "deleted-side":
354
+ confirmation = DeletedFileDialog(request.path, window, keep_available=request.snapshot.fingerprint_by_path.get(request.path) != "absent")
355
+ confirmation.finished.connect(lambda result: self.mergetool.confirm_deletion(request_id, confirmation.delete_result if result else None))
356
+ dialog = self.conflict_dialog_by_session.get(request.operation.session_id)
357
+ if dialog is not None and isValid(dialog):
358
+ dialog.error_label.setText("Choose whether to keep or delete: " + conflict_path_label(request.path))
359
+ else:
360
+ confirmation = MergeConfirmation(request.path, reason, window)
361
+ confirmation.finished.connect(lambda result: self.mergetool.confirm(request_id, bool(result)))
362
+ confirmation.installEventFilter(self.window_activation_controller)
363
+ self.merge_confirmation_by_id[request_id] = confirmation
364
+ confirmation.finished.connect(lambda: self.merge_confirmation_by_id.pop(request_id, None))
365
+ open_window_modal(confirmation)
366
+
367
+ def close_merge_confirmation(self, request_id: str) -> None:
368
+ """Dismiss obsolete questions when shutdown or invalidation completes their owner."""
369
+ confirmation = self.merge_confirmation_by_id.pop(request_id, None)
370
+ if confirmation is not None and isValid(confirmation):
371
+ confirmation.reject()
372
+
373
+ def mergetool_finished(self, result: MergetoolResult) -> None:
374
+ """Show the terminal conflict read immediately, as the staging state when nothing newer wins, else read again."""
375
+ request = result.request
376
+ if result.recovery_filename:
377
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
378
+ if WorktreeKey.from_context(session.repository_context) == request.snapshot.key:
379
+ self._refresh_merge_recovery(session_id)
380
+ if self.diagnostic_log is not None:
381
+ message = f"Result: {result.status.value}. " + result.diagnostic
382
+ if result.recovery_filename:
383
+ message += " Saved result kept at: " + result.recovery_filename
384
+ self.diagnostic_log.write(Diagnostic("External merge", request.snapshot.context.repository_dir, request.recipe.program_filename, [], message, return_code=result.return_code).prepare())
385
+ series_id = self.mergetool.series_id_by_request.get(request.operation.operation_id, request.operation.operation_id)
386
+ allowed = self.operation_policy_controller.operation_allowed(request.operation)
387
+ staging = self.staging_session_registry_controller.staging_by_key.get(request.snapshot.key)
388
+ if allowed and result.status == MergetoolStatus.SAVED:
389
+ # Asked before the tool's terminal read is published: that publication is the read that decides.
390
+ self._request_stash_drop_after_resolution(request.operation)
391
+ adopted = allowed and staging is not None and not self.session_registry_controller.shutdown_requested and self._adopt_mergetool_snapshot(series_id, result, staging)
392
+ dialog = self.conflict_dialog_by_session.get(request.operation.session_id)
393
+ if dialog is not None and isValid(dialog) and dialog.merge_series_id == series_id:
394
+ message = result.diagnostic
395
+ if result.recovery_filename:
396
+ message += " Saved result retained at: " + result.recovery_filename
397
+ if not allowed:
398
+ dialog.set_busy(False)
399
+ dialog.stop_series_button.hide()
400
+ if dialog.state.key == request.snapshot.key:
401
+ dialog.error_label.setText(message)
402
+ return
403
+ busy = any(series.request.operation.session_id == request.operation.session_id for series in self.mergetool.series_by_id.values())
404
+ dialog.set_busy(busy)
405
+ dialog.show_mergetool_result(message, settings_available=result.settings_available)
406
+ if result.snapshot is not None and not adopted:
407
+ state = self.conflict.observe(request.snapshot.key, result.snapshot)
408
+ dialog.show_state(state)
409
+ if not allowed:
410
+ return
411
+ if staging is not None and not self.session_registry_controller.shutdown_requested:
412
+ if not adopted:
413
+ staging.refresh(explicit=True)
414
+ self.rebase_controller.observe_rebase(request.operation.session_id)
415
+
416
+ def _adopt_mergetool_snapshot(self, series_id: str, result: MergetoolResult, staging: StagingSession) -> bool:
417
+ """Publish the merge tool's terminal read as the staging state when nothing newer has to win; answer whether it did.
418
+
419
+ That read is a complete read of the worktree taken after the tool's last write. It becomes the displayed state
420
+ only while the series still owns its publication, for the same worktree, Git and configuration, and while no
421
+ other read has been published, is running or was asked for. It is shown under a new read number of the staging
422
+ session, so selections and prepared actions of the previous state no longer apply.
423
+ """
424
+ publication = self.mergetool_publication_by_series.get(series_id)
425
+ snapshot = result.snapshot
426
+ if publication is None or snapshot is None or not snapshot.fresh:
427
+ return False
428
+ if (snapshot.key != publication.key or staging.key != publication.key or snapshot.git_filename != publication.git_filename
429
+ or staging.git_filename != publication.git_filename or self.repository.git_filename != publication.git_filename
430
+ or staging.configuration_generation != publication.configuration_generation):
431
+ return False
432
+ if staging.generation != publication.staging_generation or staging.refresh_active or staging.refresh_queued or staging.explicit_refresh or staging.busy:
433
+ return False
434
+ staging.generation += 1
435
+ shown, _reusable = staging._publish_operation_snapshot(replace(snapshot, generation=staging.generation))
436
+ staging._show()
437
+ if shown:
438
+ staging._reconcile_selection()
439
+ self.mergetool_publication_by_series[series_id] = replace(publication, staging_generation=staging.generation)
440
+ staging.changed.emit()
441
+ return True
442
+
443
+ def mark_conflict_solved(self, session_id: str, selection: ConflictSelection, *, delete_file: bool = False) -> None:
444
+ """Recapture the selected working results and stage their exact bytes as they were at the click.
445
+
446
+ A file without conflict markers is staged without a question, since the click is the user's choice; a file that
447
+ still holds markers gets a warning naming their lines, and a deletion keeps its confirmation.
448
+ """
449
+ snapshot = self._conflict_action_snapshot(session_id, selection)
450
+ if snapshot is None:
451
+ return
452
+ dialog = self.conflict_dialog_by_session[session_id]
453
+ owner = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, session_id=session_id,
454
+ generation=self.session_registry_controller.generation_by_session[session_id], operation="Resolve conflict", action=OperationAction.CONFLICT_STAGE)
455
+ dialog.set_busy(True)
456
+ dialog.error_label.clear()
457
+
458
+ def capture() -> Generator:
459
+ """Accept the bytes present at the click, then have Git verify everything else against the list shown.
460
+
461
+ An edit made before the click is the result the user chose, so its bytes are accepted locally. The index,
462
+ HEAD and conversion settings are never taken from now: Git compares them with the read the list was built
463
+ from, and the accepted images are proven unchanged again before the intents are built from them.
464
+ """
465
+ accepted, image_by_path = yield FileTask(lambda: capture_conflict_result(snapshot, selection.paths))
466
+ yield from self.worktree_service.verify(accepted, selection.paths)
467
+ intents = []
468
+ for path in selection.paths:
469
+ entry = next(entry for entry in accepted.entries if entry.path == path and entry.status == FileStatus.UNMERGED)
470
+ role_set = set()
471
+ for side in entry.conflict_sides:
472
+ role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
473
+ if delete_file and not has_deleted_side(entry.conflict_sides):
474
+ raise ValueError("Delete file requires a conflict with a deleted side")
475
+ image = image_by_path[path]
476
+ delete = delete_file or not image.exists and not {"LOCAL", "REMOTE"}.issubset(role_set)
477
+ intent_owner = replace(owner, operation_id=uuid.uuid4().hex)
478
+ # The markers are looked for in the bytes accepted here, so the warning describes exactly what would be staged.
479
+ intents.append((yield FileTask(lambda path=path, image=image, intent_owner=intent_owner, delete=delete:
480
+ ConflictResolutionIntent.accept(accepted, path, image, intent_owner, delete=delete).with_marker_lines())))
481
+ return tuple(intents)
482
+
483
+ def complete(value: object, error: Exception | None) -> None:
484
+ """Only a surviving originating dialog can confirm the captured result."""
485
+ if not isValid(dialog):
486
+ return
487
+ if error is not None or not self.operation_policy_controller.operation_allowed(owner) or not dialog.isVisible():
488
+ dialog.set_busy(False)
489
+ if error is not None:
490
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Resolve conflict", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
491
+ dialog.error_label.setText("Could not prepare conflict resolution: " + diagnostic.cause)
492
+ self._refresh_conflict_dialog(session_id)
493
+ return
494
+ self._confirm_manual_result(session_id, value)
495
+
496
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, capture(), complete)
497
+
498
+ def _confirm_manual_result(self, session_id: str, intents: tuple[ConflictResolutionIntent, ...]) -> None:
499
+ """Stage each original selected result in turn, asking first only for a file that still holds conflict markers or for a deletion.
500
+
501
+ Clicking Mark conflict as solved or Keep modified file already is the user's explicit choice, so a file without
502
+ markers is staged straight away. Markers left in the file get a warning, and a deletion either removes a working
503
+ file or stages something other than what Keep modified file says, so both keep their question. Each result is
504
+ the one captured at the click: nothing is ever retargeted from the live selection.
505
+ """
506
+ dialog = self.conflict_dialog_by_session.get(session_id)
507
+ if dialog is None or not isValid(dialog):
508
+ return
509
+ intent, *remaining_intents = intents
510
+ owner = intent.operation
511
+ if not dialog.isVisible() or not self.operation_policy_controller.operation_allowed(owner):
512
+ dialog.set_busy(False)
513
+ return
514
+ if not intent.marker_lines and not intent.delete:
515
+ self._submit_manual_result(intent, tuple(remaining_intents))
516
+ return
517
+ confirmation = ConflictConfirmation(intent.path, dialog, delete=intent.delete, remove_existing=intent.delete and intent.image.exists, marker_lines=intent.marker_lines)
518
+ confirmation.installEventFilter(self.window_activation_controller)
519
+ self.conflict_confirmation_by_session[session_id] = confirmation
520
+
521
+ def answer(result: int) -> None:
522
+ """Submit only explicit agreement; stale results are rechecked by the mutation."""
523
+ self.conflict_confirmation_by_session.pop(session_id, None)
524
+ if not result or not dialog.isVisible() or not self.operation_policy_controller.operation_allowed(owner):
525
+ dialog.set_busy(False)
526
+ return
527
+ self._submit_manual_result(intent, tuple(remaining_intents))
528
+
529
+ confirmation.finished.connect(answer)
530
+ open_window_modal(confirmation)
531
+
532
+ def _submit_manual_result(self, intent: ConflictResolutionIntent, remaining_intents: tuple[ConflictResolutionIntent, ...]) -> None:
533
+ """Hand one accepted result to the mutation and remember the selected files still waiting behind it.
534
+
535
+ The mutation checks again that the accepted bytes, the index and HEAD are unchanged before staging anything, so
536
+ a result submitted without a question is refused exactly like a confirmed one when another program changed it.
537
+ """
538
+ operation_id = intent.operation.operation_id
539
+ self.conflict_resolution_by_id[operation_id] = intent
540
+ self.conflict_followup_by_id[operation_id] = remaining_intents
541
+ self.mutation.resolve_conflict(intent)
542
+
543
+ def conflict_resolution_finished(self, outcome: MutationOutcome) -> None:
544
+ """Keep refusal diagnostics visible until a deliberate rescan or new selection."""
545
+ intent = self.conflict_resolution_by_id.pop(outcome.operation_id, None)
546
+ remaining_intents = self.conflict_followup_by_id.pop(outcome.operation_id, ())
547
+ if intent is None:
548
+ return
549
+ dialog = self.conflict_dialog_by_session.get(intent.operation.session_id)
550
+ if dialog is not None and isValid(dialog):
551
+ dialog.set_busy(bool(remaining_intents) and outcome.status == OperationStatus.SUCCESS.value)
552
+ if outcome.diagnostic is not None:
553
+ dialog.error_label.setText(outcome.diagnostic.cause)
554
+ if remaining_intents and outcome.status == OperationStatus.SUCCESS.value:
555
+ self._read_manual_followup(intent, remaining_intents)
556
+ if not remaining_intents and outcome.status == OperationStatus.SUCCESS.value:
557
+ self._request_stash_drop_after_resolution(intent.operation)
558
+ refusal = self._replay_auto_continue_refusal(intent.operation)
559
+ if not refusal:
560
+ self.replay_controller.request_replay_auto_continue(intent.operation.session_id, intent.operation.key)
561
+ elif self.diagnostic_log is not None:
562
+ self.diagnostic_log.write(Diagnostic("Automatic replay continue", intent.operation.context.repository_dir, self.repository.git_filename, [],
563
+ "Conflict resolution finished in GitPane: automatic Continue not requested (" + refusal + ").").prepare())
564
+
565
+ def _replay_auto_continue_refusal(self, operation: OperationIntent) -> str:
566
+ """Tell why a finished resolution may not ask for the automatic Continue, or return an empty text when it may.
567
+
568
+ Only the requesting window has to be alive, allowed to write and still on the same worktree. A graph refresh
569
+ in between (F5, another operation) changes the history generation but not the paused operation, and the
570
+ request rereads the native state, its owner and its mode itself before continuing anything.
571
+ """
572
+ if operation.session_id is None:
573
+ return "no repository window requested this resolution"
574
+ session = self.session_registry_controller.session_by_id.get(operation.session_id)
575
+ if session is None or not self.operation_policy_controller.allows(operation.session_id, WindowCapability.MANAGE):
576
+ return "the repository window was closed or can no longer write"
577
+ if session.repository_context != operation.context:
578
+ return "the repository window now shows another worktree"
579
+ return ""
580
+
581
+ def _read_manual_followup(self, completed: ConflictResolutionIntent, remaining_intents: tuple[ConflictResolutionIntent, ...]) -> None:
582
+ """Rebase only confirmed index changes while retaining every pending result's identity."""
583
+ snapshot = completed.snapshot
584
+ session_id = completed.operation.session_id
585
+
586
+ def capture() -> Generator:
587
+ """Require unchanged neighbours and pending bytes before the next separate confirmation."""
588
+ from gitpane.worktree.worktree_file import validate_image
589
+
590
+ current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
591
+ yield from self.worktree_service.verify_conversion(snapshot)
592
+ if current.head_oid != snapshot.head_oid:
593
+ raise ValueError("Repository changed during conflict resolution; rescan merge conflicts")
594
+ unchanged_records = []
595
+ for output in (snapshot.index_entries, current.index_entries):
596
+ records = []
597
+ for record in output.split(b"\0"):
598
+ _metadata, _separator, path = record.partition(b"\t")
599
+ if record and path != completed.path:
600
+ records.append(record)
601
+ unchanged_records.append(sorted(records))
602
+ before_records, after_records = unchanged_records
603
+ if before_records != after_records:
604
+ raise ValueError("Other index entries changed; rescan merge conflicts")
605
+ intents = []
606
+ for pending in remaining_intents:
607
+ yield FileTask(lambda pending=pending: validate_image(current.context, pending.path, pending.image))
608
+ intents.append(replace(pending, snapshot=current))
609
+ return tuple(intents)
610
+
611
+ def complete(value: object, error: Exception | None) -> None:
612
+ """A lost reread stops the selection without undoing already staged results."""
613
+ dialog = self.conflict_dialog_by_session.get(session_id)
614
+ if dialog is None or not isValid(dialog):
615
+ return
616
+ if error is not None:
617
+ dialog.set_busy(False)
618
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Resolve conflict", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
619
+ dialog.error_label.setText(diagnostic.cause)
620
+ return
621
+ self._confirm_manual_result(session_id, value)
622
+
623
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, capture(), complete)
624
+
625
+ def offer_conflict_resolution(self, session_id: str) -> None:
626
+ """Offer a current conflict list or open it directly when the proposal is suppressed."""
627
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
628
+ return
629
+ session = self.session_registry_controller.session_by_id[session_id]
630
+ state = self.conflict.state_by_key.get(WorktreeKey.from_context(session.repository_context))
631
+ if state is None or not state.fresh or not state.entries:
632
+ return
633
+ if self.preference.read(CONFLICT_PROMPT_PREFERENCE_KEY):
634
+ self.open_conflict_dialog(session_id, state)
635
+ return
636
+ existing = self.conflict_prompt_by_session.get(session_id)
637
+ if existing is not None and isValid(existing) and existing.isVisible():
638
+ return
639
+ generation = session.generation
640
+ prompt = ConflictPrompt(self.preference, self.session_registry_controller.window_by_session[session_id])
641
+ prompt.installEventFilter(self.window_activation_controller)
642
+ self.conflict_prompt_by_session[session_id] = prompt
643
+
644
+ def resolve() -> None:
645
+ """Discard an answer whose originating session has since been refreshed or closed."""
646
+ if not session.is_closed and session.generation == generation:
647
+ self.open_conflict_dialog(session_id, state)
648
+
649
+ prompt.resolve_requested.connect(resolve)
650
+ prompt.show()
651
+
652
+ def track_conflict_retention(self, key: WorktreeKey, observation: StashObservation) -> None:
653
+ """Keep watching the stash a pop left behind only while that can still tell the user something.
654
+
655
+ A stash known to be gone (retained False) or a pop that left no conflict ends the watch. An unknown terminal
656
+ read (no conflict list) keeps it until a fresh read says otherwise. The pop's own result is never changed.
657
+ A drop still waiting for an earlier pop's resolution is forgotten: only the stash this pop kept may be dropped.
658
+ """
659
+ self.stash_controller.stash_drop_after_resolution_by_key.pop(key, None)
660
+ if observation.retained is False or observation.unmerged_paths == ():
661
+ self._stop_conflict_retention(key)
662
+ return
663
+ self.conflict_retention_by_key[key] = observation
664
+
665
+ def _stop_conflict_retention(self, key: WorktreeKey) -> None:
666
+ """End the watch of a worktree's retained stash and clear it from the conflict lists still open on that worktree."""
667
+ if self.conflict_retention_by_key.pop(key, None) is None:
668
+ return
669
+ for dialog in self.conflict_dialog_by_session.values():
670
+ if isValid(dialog) and dialog.state.key == key:
671
+ dialog.show_retention(None)
672
+
673
+ def read_conflict_retention(self, key: WorktreeKey) -> None:
674
+ """Recheck a previously observed stash independently of historic operation output, and only while conflicts remain."""
675
+ previous = self.conflict_retention_by_key.get(key)
676
+ staging = self.staging_session_registry_controller.staging_by_key.get(key)
677
+ if previous is None or staging is None:
678
+ return
679
+ snapshot = staging.snapshot
680
+ if snapshot is None or not snapshot.fresh:
681
+ return
682
+ # Checked before the read in progress, so a resolution ends the watch even while an older read is still out.
683
+ if previous.retained is False or not snapshot_has_conflicts(snapshot):
684
+ self._stop_conflict_retention(key)
685
+ return
686
+ if key in self.conflict_retention_read_set:
687
+ return
688
+ self.conflict_retention_read_set.add(key)
689
+
690
+ def complete(observation: StashObservation | None, error: Exception | None) -> None:
691
+ """Publish only for the watch, session and worktree read this answer was asked for."""
692
+ self.conflict_retention_read_set.discard(key)
693
+ if (self.staging_session_registry_controller.staging_by_key.get(key) is not staging or staging.snapshot is not snapshot or staging.context != snapshot.context
694
+ or self.conflict_retention_by_key.get(key) is not previous):
695
+ return
696
+ if not snapshot_has_conflicts(snapshot):
697
+ self._stop_conflict_retention(key)
698
+ return
699
+ current = replace(previous, retained=None)
700
+ if error is None and observation is not None:
701
+ current = observation.retention_of(StashObservation(previous.retained_oid, ()))
702
+ if current.retained is False:
703
+ self._stop_conflict_retention(key)
704
+ return
705
+ self.conflict_retention_by_key[key] = current
706
+ for dialog in self.conflict_dialog_by_session.values():
707
+ if isValid(dialog) and dialog.state.key == key and dialog.state.fresh:
708
+ dialog.show_retention(current)
709
+
710
+ self.worktree_service.run(staging.context, staging.git_filename, self.stash.observe(staging.context), complete)
711
+
712
+ def _request_stash_drop_after_resolution(self, operation: OperationIntent) -> None:
713
+ """Ask to drop the stash a Stash pop kept, after a conflict of that pop was resolved in GitPane.
714
+
715
+ Only the two places where GitPane itself resolves a conflict call this: Mark solved (or Delete file) in the
716
+ conflict list, and a result saved in the merge tool GitPane started. A resolution made in a terminal, a reset
717
+ or an abandoned merge never asks, so the stash then stays. Nothing is asked when no kept stash is watched.
718
+ """
719
+ key, session_id = operation.key, operation.session_id
720
+ retention = self.conflict_retention_by_key.get(key)
721
+ staging = self.staging_session_registry_controller.staging_by_key.get(key)
722
+ if session_id is None or retention is None or retention.retained is False or retention.retained_oid is None or staging is None:
723
+ return
724
+ self.stash_controller.stash_drop_after_resolution_by_key[key] = StashDropAfterResolution(session_id, retention.retained_oid, staging.generation)
725
+ self.stash_controller.continue_stash_drop_after_resolution(key)
726
+
727
+ def abort_merge(self, session_id: str) -> None:
728
+ """Abort the merge the banner shows with Git's own merge --abort, asking first only when work would be lost.
729
+
730
+ The merge and HEAD are taken from the state the window shows, so the Abort applies to that merge only. One Git
731
+ read tells whether anything changed since the merge stopped; with nothing to lose the Abort starts at once,
732
+ otherwise a question opens with Cancel as the default and Escape answer. A new click replaces an open question.
733
+ The question opens once that read answers, so it is found in ``merge_abort_confirmation_by_session``. A click
734
+ that cannot act always says why in the status bar: busy, a merge that could not be read, or no merge, snapshot
735
+ or HEAD read yet, which a refresh brings.
736
+ """
737
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
738
+ return
739
+ window = self.session_registry_controller.window_by_session.get(session_id)
740
+ session = self.session_registry_controller.session_by_id[session_id]
741
+ context = session.repository_context
742
+ if window is None or context.is_bare:
743
+ return
744
+ staging = self.staging_session_registry_controller.staging_by_key.get(WorktreeKey.from_context(context))
745
+ merge_state = staging.merge_banner_state if staging is not None else MergeBannerState()
746
+ snapshot = staging.snapshot if staging is not None else None
747
+ busy = self.mergetool.owns_repository(context.common_git_dir) or self.repository_operation.owns_worktree(WorktreeKey.from_context(context))
748
+ reason = merge_action_reason(merge_state, ABORT_ACTION, conflict_count=0, busy=busy)
749
+ if not reason and (not merge_state.in_progress or merge_state.merge is None or snapshot is None or snapshot.head_oid is None):
750
+ reason = MERGE_ABORT_REFRESH_REASON
751
+ if reason:
752
+ window.statusBar().showMessage(reason)
753
+ return
754
+ previous = self.merge_abort_confirmation_by_session.pop(session_id, None)
755
+ if previous is not None and isValid(previous):
756
+ previous.close()
757
+ previous.deleteLater()
758
+ merge_head_oids = merge_state.merge.parent_oids
759
+ operation = OperationIntent.capture(uuid.uuid4().hex, context, self.repository.git_filename, session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id],
760
+ targets=(*merge_head_oids, snapshot.head_oid), operation=MERGE_ABORT_LABEL, action=OperationAction.MERGE_ABORT)
761
+ intent = MergeAbortIntent(operation, merge_head_oids, snapshot.head_oid)
762
+ self.merge_abort_pending_by_session[session_id] = operation.operation_id
763
+
764
+ def answered(at_risk: object, error: Exception | None) -> None:
765
+ """Abort at once when nothing would be lost; otherwise ask first."""
766
+ if self.merge_abort_pending_by_session.get(session_id) != operation.operation_id:
767
+ return
768
+ del self.merge_abort_pending_by_session[session_id]
769
+ if self.session_registry_controller.session_by_id.get(session_id) is not session or session.is_closed or not self.operation_policy_controller.operation_allowed(operation):
770
+ return
771
+ if error is None and not at_risk:
772
+ self._submit_merge_abort(intent)
773
+ return
774
+ self._ask_merge_abort(intent)
775
+
776
+ self.reference.merge_work_at_risk(context, operation.git_filename, answered)
777
+
778
+ def _ask_merge_abort(self, intent: MergeAbortIntent) -> None:
779
+ """Ask before an Abort that would lose work, and abort only on an explicit Abort merge."""
780
+ session_id = intent.operation.session_id
781
+ window = self.session_registry_controller.window_by_session.get(session_id)
782
+ if window is None:
783
+ return
784
+ confirmation = QMessageBox(QMessageBox.Icon.Question, MERGE_ABORT_LABEL, MERGE_ABORT_QUESTION, QMessageBox.StandardButton.NoButton, window)
785
+ prepare_message_box(confirmation, MERGE_ABORT_LABEL)
786
+ confirmation.setTextFormat(Qt.TextFormat.PlainText)
787
+ abort_button = confirmation.addButton(MERGE_ABORT_LABEL, QMessageBox.ButtonRole.DestructiveRole)
788
+ cancel_button = confirmation.addButton(MERGE_ABORT_CANCEL_LABEL, QMessageBox.ButtonRole.RejectRole)
789
+ confirmation.setDefaultButton(cancel_button)
790
+ confirmation.setEscapeButton(cancel_button)
791
+ self.merge_abort_confirmation_by_session[session_id] = confirmation
792
+
793
+ def answer() -> None:
794
+ """Abort only on an explicit Abort merge; any other ending leaves the merge in place."""
795
+ if self.merge_abort_confirmation_by_session.get(session_id) is confirmation:
796
+ del self.merge_abort_confirmation_by_session[session_id]
797
+ accepted = confirmation.clickedButton() is abort_button
798
+ confirmation.deleteLater()
799
+ if accepted:
800
+ self._submit_merge_abort(intent)
801
+
802
+ confirmation.finished.connect(answer)
803
+ open_window_modal(confirmation)
804
+
805
+ def _submit_merge_abort(self, intent: MergeAbortIntent) -> None:
806
+ """Open the console, then hand the Abort to the reference service while its window, repository and Git are unchanged.
807
+
808
+ A merge tool series may have begun during the read of the work at risk or while the question was open: it does
809
+ not hold the repository between two files, so it is looked at here and again just before Git starts, as the
810
+ rebase's Abort and Continue do, and the Abort then waits with the reason in the status bar.
811
+ """
812
+ operation = intent.operation
813
+ session_id = operation.session_id
814
+ session = self.session_registry_controller.session_by_id.get(session_id)
815
+ if session is None or session.is_closed or not self.operation_policy_controller.operation_allowed(operation) or self.repository.git_filename != operation.git_filename:
816
+ return
817
+ if self.mergetool.owns_repository(operation.common_git_dir):
818
+ window = self.session_registry_controller.window_by_session.get(session_id)
819
+ if window is not None:
820
+ window.statusBar().showMessage(BUSY_ACTION_REASON)
821
+ return
822
+
823
+ def is_current() -> bool:
824
+ """Keep the window's authority, repository and Git current, and no merge tool at work, until Git starts."""
825
+ return (self.session_registry_controller.session_by_id.get(session_id) is session and not session.is_closed and self.operation_policy_controller.operation_allowed(operation)
826
+ and self.repository.git_filename == operation.git_filename and not self.mergetool.owns_repository(operation.common_git_dir))
827
+
828
+ self.merge_abort_intent_by_id[operation.operation_id] = intent
829
+ self.reference_form_controller.open_reference_output(intent, None, modal=True)
830
+ self.reference.abort_merge(intent, is_current=is_current)
831
+
832
+ def merge_abort_finished(self, outcome: OperationOutcome) -> None:
833
+ """Publish the Abort's result in its console, log a failure, close the worktree's Prepare changes after a successful Abort and refresh the windows of the repository."""
834
+ intent = self.merge_abort_intent_by_id.pop(outcome.operation_id, None)
835
+ if intent is None:
836
+ return
837
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
838
+ # A refusal explains itself in GitPane's words (the merge changed or ended), which the user needs to read.
839
+ refusal_text = outcome.diagnostic.cause if outcome.status == OperationStatus.REFUSED and outcome.diagnostic is not None else ""
840
+ self.reference_form_controller.finish_reference_output(outcome, failure_text=refusal_text)
841
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
842
+ self.diagnostic_log.write(outcome.diagnostic)
843
+ if outcome.status == OperationStatus.SUCCESS:
844
+ # Nothing is left to commit once the merge is abandoned; the console keeps the result, and a message the
845
+ # user typed stays in the session and comes back when Prepare changes is reopened, as the rebase window
846
+ # closes after its Abort.
847
+ staging = self.staging_session_registry_controller.staging_by_key.get(intent.operation.key)
848
+ if staging is not None and staging.window is not None:
849
+ staging.window.close()
850
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
851
+
852
+ def show_rebase_state_in_conflict_dialog(self, session_id: str, state: RebaseSnapshot) -> None:
853
+ """Show in the conflict window of one repository window the rebase state that window has just shown."""
854
+ conflict_dialog = self.conflict_dialog_by_session.get(session_id)
855
+ if conflict_dialog is not None and isValid(conflict_dialog):
856
+ conflict_dialog.show_rebase_state(state)
857
+
858
+ def show_conflict_write_availability(self, session_id: str, locked: bool) -> None:
859
+ """Lock or unlock the writes of the conflict window of one repository window, as the replay availability just computed it."""
860
+ dialog = self.conflict_dialog_by_session.get(session_id)
861
+ if dialog is not None and isValid(dialog):
862
+ # Only a possible replay locks the conflict list up front. An ordinary conflict keeps its buttons, so a second window
863
+ # that tries to write while a merge series runs gets the explicit refusal instead of silently disabled controls.
864
+ dialog.show_write_availability("Wait for the current operation or refresh the native state." if locked else "")
865
+
866
+ def show_window_conflict_state(self, session_id: str, key: WorktreeKey, conflict_state: ConflictState) -> None:
867
+ """Bring the conflict window and the conflict prompts of one repository window to the conflict state it has just shown.
868
+
869
+ A conflict window shows the new state, ends its rescan once no read is running or waiting, and closes once no
870
+ conflict is left; a conflict resolution or a replay that waited for this read is offered once it is fresh.
871
+ """
872
+ staging = self.staging_session_registry_controller.staging_by_key[key]
873
+ dialog = self.conflict_dialog_by_session.get(session_id)
874
+ if dialog is not None and isValid(dialog):
875
+ dialog.show_state(conflict_state)
876
+ if not staging.refresh_active and not staging.refresh_queued and not staging.explicit_refresh:
877
+ dialog.finish_rescan()
878
+ if conflict_state.fresh and not conflict_state.entries and not dialog.busy and not staging.refresh_active and not staging.refresh_queued:
879
+ dialog.close()
880
+ if session_id in self.session_registry_controller.conflict_pending_session_set and conflict_state.fresh and not staging.refresh_active and not staging.refresh_queued:
881
+ self.session_registry_controller.conflict_pending_session_set.discard(session_id)
882
+ self.offer_conflict_resolution(session_id)
883
+ if session_id in self.session_registry_controller.replay_pending_resolve_set and conflict_state.fresh and not staging.refresh_active and not staging.refresh_queued:
884
+ self.session_registry_controller.replay_pending_resolve_set.discard(session_id)
885
+ self.open_conflict_dialog(session_id, conflict_state)