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,805 @@
1
+ """Cherry-pick or revert commits from a repository window, through their pauses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, replace
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtCore import QObject, QTimer, Signal
12
+ from PySide6.QtWidgets import QMessageBox
13
+ from shiboken6 import isValid
14
+
15
+ from gitpane.config import ProcessPriority
16
+ from gitpane.controller.refresh_controller import ReplayRefreshPublication
17
+ from gitpane.history.file_history import WindowCapability
18
+ from gitpane.operation.console_line import COMPLETED_STATUS_SET
19
+ from gitpane.operation.console_tone import ConsoleTone
20
+ from gitpane.operation.diagnostic import Diagnostic
21
+ from gitpane.operation.operation_status import OperationStatus
22
+ from gitpane.operation.repository_operation import REPLAY_PAUSE_OBSERVATION, OperationAction, OperationIntent, OperationOutcome
23
+ from gitpane.sequencer.replay import ReplayIntent
24
+ from gitpane.sequencer.replay_state import COMMIT_OPERATION_LABEL, REPLAY_REFRESH_INTERVAL_MS, ReplayAction, ReplayCommitMode, ReplayKind, replay_action_diagnostic, replay_auto_continue_ready, replay_effect_message, replay_may_be_present
25
+ from gitpane.system.path_identity import canonical_path
26
+ from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
27
+ from gitpane.ui.repository.repository_window import LEGACY_BACKUP_NOTICE_KIND
28
+ from gitpane.ui.sequencer.replay_conflict_prompt import ReplayConflictPrompt
29
+ from gitpane.ui.sequencer.replay_dialog import ReplayDialog
30
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
31
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
32
+
33
+ if TYPE_CHECKING:
34
+ from gitpane.controller.console_registry import ConsoleRegistry
35
+ from gitpane.controller.history_read_controller import HistoryReadController
36
+ from gitpane.controller.operation_policy import OperationPolicy
37
+ from gitpane.controller.reference_form_controller import ReferenceFormController
38
+ from gitpane.controller.reference_read_controller import ReferenceReadController
39
+ from gitpane.controller.refresh_controller import FullRefreshRead, RefreshController
40
+ from gitpane.controller.session_registry import SessionRegistry
41
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
42
+ from gitpane.controller.worktree_state_controller import WorktreeStateController
43
+ from gitpane.history.repository import GitRepository
44
+ from gitpane.history.repository_value import RepositoryContext, RequestIdentity
45
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
46
+ from gitpane.operation.repository_operation import RepositoryOperationService
47
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
48
+ from gitpane.sequencer.mergetool import MergetoolService
49
+ from gitpane.sequencer.replay import ReplayService, ReplaySource
50
+ from gitpane.sequencer.replay_state import ReplaySnapshot
51
+ from gitpane.worktree.worktree_snapshot import PassiveObservationSource
52
+
53
+ REPLAY_SUBMISSION_REFUSED_TEXT = "This window can no longer start the operation. Refresh the repository and try again."
54
+ # Cherry-picking the checked-out commit onto itself has nothing to apply; the graph menu greys the action out on it too.
55
+ REPLAY_CURRENT_COMMIT_REFUSED_TEXT = "This is the current commit: there is nothing to cherry-pick. Choose another revision."
56
+ REPLAY_AUTO_CONTINUE_TEXT = "All conflicts are marked as solved. Continuing automatically."
57
+ # Written in the console once Git's own Abort has ended a Cherry-pick or Revert: Git brought the checkout back, GitPane checked nothing more.
58
+ REPLAY_ABORT_FINISHED_TEXT = "Abort finished. Git restored the checkout the operation started from."
59
+ # Followed by the folder holding the backups and a final period.
60
+ LEGACY_REPLAY_BACKUP_NOTICE_PREFIX = "An earlier GitPane version kept backups of an interrupted cherry-pick or revert in "
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ReplayResumeObservation:
65
+ """The complete replay read that qualified an automatic Continue, with where it came from.
66
+
67
+ It was asked by the automatic request itself, through Git, for this session under this request identity and Git
68
+ executable. It replaces only the reread the Continue would otherwise make before submitting; the reservation
69
+ still captures the pause twice and checks it before Git runs.
70
+ """
71
+
72
+ session_id: str
73
+ identity: RequestIdentity
74
+ git_filename: str
75
+ state: ReplaySnapshot
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ReplayAutoContinueRequest:
80
+ """Remember that a conflict resolution made in GitPane asked to continue its paused Cherry-pick or Revert.
81
+
82
+ ``known_generation`` is the replay read generation when the request was made: only a later read can show the
83
+ staged resolution, so an older state still listing the conflict must neither launch nor cancel the request.
84
+ ``observation`` is the read the request asked for, once it answered with a newer state; the Continue uses it once.
85
+ """
86
+
87
+ session_id: str
88
+ known_generation: int
89
+ observation: ReplayResumeObservation | None = None
90
+
91
+
92
+ # A successful launch or Continue has just created a commit (or changed the checkout); its console only reports
93
+ # success once the refreshed graph shows that result.
94
+ REPLAY_GRAPH_WAITING_ACTION_SET = frozenset((OperationAction.REPLAY_START, OperationAction.REPLAY_CONTINUE))
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class PendingReplayRefresh:
99
+ """A successful Cherry-pick, Revert or Continue whose console waits for a graph that shows its result.
100
+
101
+ ``required_generation`` is the first history generation read after the result: a graph read that started
102
+ earlier, before the commit existed, must not report it.
103
+ """
104
+
105
+ outcome: OperationOutcome
106
+ required_generation: int
107
+
108
+
109
+ class ReplayController(QObject):
110
+ """The Cherry-pick and Revert of every repository window: form, launch, console waiting for the refreshed graph, pause banner with Continue and Abort, automatic Continue, and the backups an earlier version left."""
111
+
112
+ # Tells whether the conflict window of a repository window must refuse writes while a replay may be present and
113
+ # another write or the merge tool holds its worktree: the session id and True to lock, False to unlock.
114
+ conflict_write_availability_changed = Signal(str, bool)
115
+
116
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, console_registry_controller: ConsoleRegistry,
117
+ reference_read_controller: ReferenceReadController, refresh_controller: RefreshController, worktree_state_controller: WorktreeStateController, history_read_controller: HistoryReadController,
118
+ reference_form_controller: ReferenceFormController, repository: GitRepository, repository_operation: RepositoryOperationService, replay: ReplayService, mergetool: MergetoolService, diagnostic_log: DiagnosticLog | None,
119
+ parent: QObject | None = None) -> None:
120
+ """Receive the services and the lower controllers a replay works with; the wiring builds it after the reference form controller, and the rebase, the conflict and the repository windows reach it through the wiring."""
121
+ super().__init__(parent)
122
+ self.session_registry_controller = session_registry_controller
123
+ self.operation_policy_controller = operation_policy_controller
124
+ self.staging_session_registry_controller = staging_session_registry_controller
125
+ self.console_registry_controller = console_registry_controller
126
+ self.reference_read_controller = reference_read_controller
127
+ self.refresh_controller = refresh_controller
128
+ self.worktree_state_controller = worktree_state_controller
129
+ self.history_read_controller = history_read_controller
130
+ self.reference_form_controller = reference_form_controller
131
+ self.repository = repository
132
+ self.repository_operation = repository_operation
133
+ self.replay = replay
134
+ self.mergetool = mergetool
135
+ self.diagnostic_log = diagnostic_log
136
+ self.replay_dialog_by_session: dict[str, ReplayDialog] = {}
137
+ self.replay_prompt_by_session: dict[str, ReplayConflictPrompt | QMessageBox] = {}
138
+ self.replay_intent_by_id: dict[str, ReplayIntent] = {}
139
+ # Every successful launch of a session still waiting for the refreshed graph, in launch order: a second launch
140
+ # can be accepted before the first one's graph is published, and each console must still get its result.
141
+ self.replay_refresh_by_session: dict[str, list[PendingReplayRefresh]] = {}
142
+ self.replay_auto_continue_by_key: dict[WorktreeKey, ReplayAutoContinueRequest] = {}
143
+ # Worktrees whose Continue or Abort is rereading the pause before it reserves the repository, with the requesting
144
+ # session. Until the reservation exists nothing else marks the worktree busy, so a second Continue could start.
145
+ self.replay_resume_session_by_key: dict[WorktreeKey, str] = {}
146
+ self.replay_observed_at_by_key: dict[WorktreeKey, float] = {}
147
+ # Repositories, by canonical common Git folder, whose anchor references left by earlier versions were already
148
+ # looked at during this run of the program.
149
+ self.legacy_replay_checked_common_dir_set: set[str] = set()
150
+ # What the retirement answered for each canonical common Git folder during this run: the backup folders an
151
+ # earlier version kept there. Every working tree of that repository opened later is told from this answer.
152
+ self.legacy_replay_kept_dirs_by_common_dir: dict[str, tuple[str, ...]] = {}
153
+ # Working trees already told about those backups during this run, so that each is told once.
154
+ self.legacy_replay_notified_worktree_set: set[WorktreeKey] = set()
155
+
156
+ def open_replay(self, session_id: str, source_oid: str, kind: ReplayKind, snapshot: ReferenceSnapshot | None = None) -> ReplayDialog | None:
157
+ """Open read-only source metadata while capturing the current destination checkout."""
158
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
159
+ return None
160
+ session = self.session_registry_controller.session_by_id[session_id]
161
+ window = self.session_registry_controller.window_by_session[session_id]
162
+ snapshot = snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
163
+ if window.context.is_bare or snapshot is None:
164
+ return None
165
+ existing = self.replay_dialog_by_session.get(session_id)
166
+ if existing is not None and isValid(existing) and not existing.closed:
167
+ existing.raise_()
168
+ return existing
169
+ dialog = ReplayDialog(session_id, source_oid, kind, window)
170
+ self.replay_dialog_by_session[session_id] = dialog
171
+ operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
172
+ session_id=session_id, operation="Cherry pick" if kind == ReplayKind.CHERRY_PICK else "Revert", action=OperationAction.REPLAY_START, from_form=True)
173
+ intent = ReplayIntent(operation, kind, source_oid, snapshot.head_oid, snapshot.head_ref)
174
+ dialog.replay_requested.connect(lambda captured: self.submit_replay(session_id, captured))
175
+ dialog.revision_requested.connect(lambda: self.open_replay_revision(session_id))
176
+ open_window_modal(dialog)
177
+
178
+ def current() -> bool:
179
+ """Discard preparation after session replacement or closing the source form."""
180
+ return (isValid(dialog) and not dialog.closed and not session.is_closed and session.repository_context == operation.context
181
+ and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and self.repository.git_filename == operation.git_filename)
182
+
183
+ def prepared(captured: ReplayIntent | None, error: Exception | None) -> None:
184
+ """Read complete metadata only after destination and source validation."""
185
+ if not current():
186
+ return
187
+ if error is not None:
188
+ dialog.show_error(str(error))
189
+ return
190
+
191
+ def metadata_complete(source: ReplaySource | None, error: Exception | None) -> None:
192
+ """Publish real commit details without allowing stale reads to enable a closed form."""
193
+ if not current():
194
+ return
195
+ if error is not None:
196
+ dialog.show_error(str(error))
197
+ return
198
+ dialog.populate(captured, source.fields, source.parents)
199
+ dialog.summary.show_references(snapshot)
200
+
201
+ # The preparation has just resolved and checked this very commit: only its metadata remains to be read.
202
+ self.replay.read_source(operation.context, operation.git_filename, captured.source_oid, callback=metadata_complete, resolved=True)
203
+
204
+ self.replay.prepare(intent, is_current=current, callback=prepared)
205
+ return dialog
206
+
207
+ def open_replay_revision(self, session_id: str) -> None:
208
+ """Replace a prepared source only after the latest local revision read succeeds."""
209
+ dialog = self.replay_dialog_by_session.get(session_id)
210
+ if (dialog is None or dialog.closed or dialog.busy or dialog.completed or dialog.kind != ReplayKind.CHERRY_PICK
211
+ or dialog.intent is None or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
212
+ return
213
+ session = self.session_registry_controller.session_by_id[session_id]
214
+ window = self.session_registry_controller.window_by_session[session_id]
215
+ if dialog.revision_dialog is not None and not dialog.revision_dialog.closed:
216
+ dialog.revision_dialog.raise_()
217
+ return
218
+ selector = RevisionChoiceDialog(window.history_model.commits, dialog)
219
+ dialog.revision_dialog = selector
220
+ dialog.source_generation += 1
221
+ source_generation = dialog.source_generation
222
+ captured = dialog.intent
223
+
224
+ def read_revision(revision: str) -> None:
225
+ """Capture the edit generation so older callbacks cannot overwrite later choices."""
226
+ generation = selector.generation
227
+
228
+ def complete(source: ReplaySource | None, error: Exception | None) -> None:
229
+ """Publish the new frame and empty parent choice only in the original session."""
230
+ if (not isValid(dialog) or not isValid(selector) or dialog.closed or dialog.busy or dialog.completed or selector.closed
231
+ or selector.generation != generation or dialog.source_generation != source_generation or session.is_closed
232
+ or session.repository_context != captured.operation.context or self.repository.git_filename != captured.operation.git_filename
233
+ or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
234
+ return
235
+ if error is not None or source is None:
236
+ selector.show_error()
237
+ return
238
+ if source.oid == captured.original_head_oid:
239
+ selector.show_error(REPLAY_CURRENT_COMMIT_REFUSED_TEXT)
240
+ return
241
+ dialog.source_generation += 1
242
+ dialog.populate(replace(captured, source_oid=source.oid), source.fields, source.parents)
243
+ snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
244
+ if snapshot is not None:
245
+ dialog.summary.show_references(snapshot)
246
+ selector.accept()
247
+
248
+ self.replay.read_source(captured.operation.context, captured.operation.git_filename, revision, callback=complete)
249
+
250
+ selector.revision_requested.connect(read_revision)
251
+ open_window_modal(selector)
252
+ selector.revision_edit.setFocus()
253
+
254
+ def submit_replay(self, session_id: str, intent: ReplayIntent) -> str | None:
255
+ """Open a non-modal console, launch the captured native operation, then close the form.
256
+
257
+ The form closes as soon as the launch is accepted: a form or console left window-modal on the repository
258
+ window would block the conflict prompt and the banner once Git pauses. Results,
259
+ errors and refusals from then on are shown in the console.
260
+ """
261
+ dialog = self.replay_dialog_by_session.get(session_id)
262
+ if dialog is None or not isValid(dialog):
263
+ return None
264
+ if (not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or dialog.closed or dialog.intent != intent
265
+ or intent.operation.session_id != session_id or intent.operation.operation_id in self.replay_intent_by_id):
266
+ dialog.show_submission_refused(REPLAY_SUBMISSION_REFUSED_TEXT)
267
+ return None
268
+ session = self.session_registry_controller.session_by_id[session_id]
269
+
270
+ def current() -> bool:
271
+ """Retain requester and worktree identity through the last native launch check."""
272
+ return (not session.is_closed and session.repository_context == intent.operation.context
273
+ and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and self.repository.git_filename == intent.operation.git_filename)
274
+
275
+ self.replay_intent_by_id[intent.operation.operation_id] = intent
276
+ self.reference_form_controller.open_reference_output(intent, dialog, modal=False)
277
+ try:
278
+ operation_id = self.replay.start(intent, is_current=current)
279
+ except ValueError as error:
280
+ # The service refused before any Git command ran (it is closed or unavailable): release the request and end
281
+ # the console just opened with the cause, so neither the form nor the console stays stuck as running.
282
+ self.replay_intent_by_id.pop(intent.operation.operation_id, None)
283
+ diagnostic = Diagnostic(intent.operation.operation, intent.operation.context.repository_dir, intent.operation.git_filename, [], str(error)).prepare()
284
+ if self.diagnostic_log is not None:
285
+ self.diagnostic_log.write(diagnostic)
286
+ self.reference_form_controller.finish_reference_output(OperationOutcome(intent.operation.operation_id, OperationStatus.REFUSED, diagnostic=diagnostic))
287
+ dialog.close_after_submission()
288
+ return None
289
+ dialog.close_after_submission()
290
+ return operation_id
291
+
292
+ def replay_finished(self, outcome: OperationOutcome) -> None:
293
+ """Refresh live repository views, publish the console result and offer resolution for a fresh conflict."""
294
+ intent = self.replay_intent_by_id.pop(outcome.operation_id, None)
295
+ if intent is None:
296
+ return
297
+ self.replay.outcome_by_id[outcome.operation_id] = outcome
298
+ if outcome.status == OperationStatus.ABORTED:
299
+ trace = self.reference_form_controller.reference_trace_by_id.get(outcome.operation_id)
300
+ if trace is not None:
301
+ # The Abort was verified before this line is written: like the result line after it, it tells a success.
302
+ trace.append_event(outcome.operation_id, "status", REPLAY_ABORT_FINISHED_TEXT, ConsoleTone.SUCCESS)
303
+ session = self.session_registry_controller.session_by_id.get(intent.operation.session_id)
304
+ conflict_state = None
305
+ waits_for_graph = False
306
+ if session is not None and not session.is_closed and session.repository_context == intent.operation.context:
307
+ state = self.replay.state_by_key.get(intent.operation.key)
308
+ if outcome.status == OperationStatus.PAUSED and state is not None and state.fresh and state.unmerged_paths and intent.operation.action == OperationAction.REPLAY_START:
309
+ conflict_state = state
310
+ if outcome.status == OperationStatus.SUCCESS and intent.operation.action in REPLAY_GRAPH_WAITING_ACTION_SET:
311
+ self.history_read_controller.history_top_session_set.add(intent.operation.session_id)
312
+ # The next history read, queued behind any read already running, is the first one that can show the commit.
313
+ required_generation = session.generation + 1
314
+ self.replay_refresh_by_session.setdefault(intent.operation.session_id, []).append(PendingReplayRefresh(outcome, required_generation))
315
+ waits_for_graph = True
316
+ shared_by_key: dict[WorktreeKey, FullRefreshRead] = {}
317
+ for session_id, current_session in tuple(self.session_registry_controller.session_by_id.items()):
318
+ if not current_session.is_closed and canonical_path(current_session.repository_context.common_git_dir) == intent.operation.common_git_dir:
319
+ self.refresh_controller.refresh(session_id, full_refresh_read=self.refresh_controller.full_refresh_read_for(current_session, shared_by_key))
320
+ shared = shared_by_key.get(intent.operation.key)
321
+ if shared is not None:
322
+ # The operation has just published the state of its own worktree; the refresh shows it instead of reading
323
+ # it again. Other worktrees of the repository read their own state.
324
+ shared.replay_publication = self._replay_finish_publication(intent.operation.key)
325
+ self.refresh_controller.observe_full_refresh_pauses(shared_by_key)
326
+ if not waits_for_graph:
327
+ # A replay refused before Git ran says why in GitPane's own words (files a submodule would lose, staged
328
+ # changes, a path outside the working tree), which the user needs in plain sight to fix and try again.
329
+ refusal_text = outcome.diagnostic.cause if outcome.status == OperationStatus.REFUSED and outcome.diagnostic is not None else ""
330
+ self.reference_form_controller.finish_reference_output(outcome, failure_text=refusal_text)
331
+ if outcome.status == OperationStatus.PAUSED:
332
+ self.console_registry_controller.paused_console_ids_by_key.setdefault(intent.operation.key, []).append(outcome.operation_id)
333
+ elif outcome.status in COMPLETED_STATUS_SET:
334
+ self.console_registry_controller.settle_paused_consoles(intent.operation.key)
335
+ if conflict_state is not None:
336
+ self._offer_replay_resolution(intent.operation.session_id, conflict_state, outcome.operation_id)
337
+
338
+ def _replay_finish_publication(self, key: WorktreeKey) -> ReplayRefreshPublication | None:
339
+ """Answer the state the replay operation that just ended published for this worktree, while nothing newer replaced it.
340
+
341
+ Only a fresh state without a message of its own is offered. A published message (an Abort's result, a refusal,
342
+ a result that could not be verified) stays on the banner until the next read replaces the state, so that read
343
+ still runs and the banner reads exactly as before.
344
+ """
345
+ state = self.replay.state_by_key.get(key)
346
+ if (state is None or not state.fresh or state.diagnostic or state.generation != self.replay.published_generation_by_key.get(key)
347
+ or state.generation != self.replay.generation_by_key.get(key)):
348
+ return None
349
+ return ReplayRefreshPublication(state, self.repository.git_filename)
350
+
351
+ def _replay_publication_current(self, key: WorktreeKey, publication: ReplayRefreshPublication) -> bool:
352
+ """Tell whether a finish publication may still be shown: the latest state of this worktree, same Git, and no read started since."""
353
+ state = publication.state
354
+ return (state.key == key and publication.git_filename == self.repository.git_filename and self.replay.state_by_key.get(key) is state
355
+ and self.replay.generation_by_key.get(key) == state.generation and key not in self.replay.observers_by_key)
356
+
357
+ def finish_replay_refresh(self, session_id: str, diagnostic: PreparedDiagnostic | None = None, *, published_generation: int | None = None) -> None:
358
+ """Finish the launches this published graph can show, retaining a failed refresh for inspection.
359
+
360
+ A failed refresh (``diagnostic``) drops any queued refresh, so it finishes every waiting launch as partial.
361
+ A published graph only finishes the launches whose result it was read after; later ones wait for the queued refresh.
362
+ """
363
+ remaining_pendings = []
364
+ finished_pendings = []
365
+ for pending in self.replay_refresh_by_session.pop(session_id, []):
366
+ if diagnostic is None and published_generation is not None and pending.required_generation > published_generation:
367
+ remaining_pendings.append(pending)
368
+ else:
369
+ finished_pendings.append(pending)
370
+ if remaining_pendings:
371
+ self.replay_refresh_by_session[session_id] = remaining_pendings
372
+ for pending in finished_pendings:
373
+ self._finish_replay_launch(session_id, pending.outcome, diagnostic)
374
+
375
+ def _finish_replay_launch(self, session_id: str, outcome: OperationOutcome, diagnostic: PreparedDiagnostic | None) -> None:
376
+ """Publish one launch result now that its commit is visible, with any "no commit was created" message."""
377
+ if diagnostic is not None:
378
+ outcome = replace(outcome, status=OperationStatus.PARTIAL, diagnostic=diagnostic)
379
+ self.reference_form_controller.finish_reference_output(outcome)
380
+ if outcome.status == OperationStatus.SUCCESS:
381
+ for step in outcome.steps:
382
+ message = replay_effect_message(step.effect)
383
+ if message:
384
+ prompt = QMessageBox(QMessageBox.Icon.Information, COMMIT_OPERATION_LABEL, message, QMessageBox.StandardButton.Ok, self.session_registry_controller.window_by_session[session_id])
385
+ prepare_message_box(prompt, COMMIT_OPERATION_LABEL)
386
+ prompt.finished.connect(lambda _result, prompt=prompt: self._release_replay_message(session_id, prompt))
387
+ self._replace_replay_prompt(session_id, prompt)
388
+ open_window_modal(prompt)
389
+ trace = self.reference_form_controller.reference_trace_by_id.get(outcome.operation_id)
390
+ if trace is not None:
391
+ trace.append_event(outcome.operation_id, "status", message)
392
+
393
+ def observe_replay(self, session_id: str, *, automatic: bool = False, publication: ReplayRefreshPublication | None = None,
394
+ auto_continue_request: ReplayAutoContinueRequest | None = None) -> None:
395
+ """Publish a passive native replay banner only to the original current views.
396
+
397
+ ``publication`` is the state an operation just published when it ended: while it is still the service's latest
398
+ state it is shown as the answer, and no read starts. ``auto_continue_request`` is the automatic Continue request
399
+ this read is asked for: the complete read that answers it is kept with the request, for that Continue to use.
400
+ """
401
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
402
+ return
403
+ session = self.session_registry_controller.session_by_id[session_id]
404
+ context = session.repository_context
405
+ if context.is_bare:
406
+ return
407
+ key = WorktreeKey.from_context(context)
408
+ identity = self.session_registry_controller.identity_by_session[session_id]
409
+ git_filename = self.repository.git_filename
410
+ # Checked before the delay below on purpose: returning here stamps no moment, so the first observation asked
411
+ # for once the worktree is free is not held back by a timestamp it never wrote.
412
+ if automatic and self.repository_operation.owns_worktree(key):
413
+ return
414
+ now = time.monotonic()
415
+ if automatic and (now - self.replay_observed_at_by_key.get(key, 0)) * 1000 < REPLAY_REFRESH_INTERVAL_MS:
416
+ return
417
+ self.replay_observed_at_by_key[key] = now
418
+ self.worktree_state_controller.mark_pause_observed(key, REPLAY_PAUSE_OBSERVATION)
419
+ recipients = []
420
+ for target_id, target_session in self.session_registry_controller.session_by_id.items():
421
+ if not target_session.is_closed and WindowCapability.MANAGE in target_session.capabilities and WorktreeKey.from_context(target_session.repository_context) == key:
422
+ recipients.append((target_id, target_session, self.session_registry_controller.identity_by_session.get(target_id)))
423
+
424
+ def current() -> bool:
425
+ """Require session identity as well as canonical worktree identity."""
426
+ return (self.session_registry_controller.session_by_id.get(session_id) is session and self.session_registry_controller.identity_by_session.get(session_id) == identity
427
+ and not session.is_closed and WorktreeKey.from_context(session.repository_context) == key)
428
+
429
+ def complete(state: ReplaySnapshot) -> None:
430
+ """Refresh all captured current views without opening any auxiliary window."""
431
+ self.repository_operation.register_context(key, context)
432
+ for target_id, target_session, target_identity in recipients:
433
+ window = self.session_registry_controller.window_by_session.get(target_id)
434
+ if (self.session_registry_controller.session_by_id.get(target_id) is target_session and self.session_registry_controller.identity_by_session.get(target_id) == target_identity
435
+ and not target_session.is_closed and window is not None and isValid(window) and not window.is_closed):
436
+ self._show_replay_state(target_id, state)
437
+ if qualifying_request is not None:
438
+ self._qualify_replay_auto_continue(key, qualifying_request, ReplayResumeObservation(session_id, identity, git_filename, state))
439
+ self._continue_requested_replay(key)
440
+ if current():
441
+ self._retire_legacy_replay_anchors(session_id, state)
442
+
443
+ if publication is not None and self._replay_publication_current(key, publication):
444
+ # Shown for display only: a publication never qualifies an automatic Continue.
445
+ qualifying_request = None
446
+ complete(publication.state)
447
+ return
448
+ source = self.passive_observation_source(key) if automatic else None
449
+ # Only a complete read through Git, asked by the request itself, may qualify it.
450
+ qualifying_request = auto_continue_request if source is None else None
451
+ # An automatic look waits behind every request somebody is waiting for; a click reads as a user action.
452
+ priority = ProcessPriority.BACKGROUND if automatic else ProcessPriority.USER
453
+ if source is None:
454
+ self.replay.observe(context, git_filename, is_current=current, callback=complete, priority=priority)
455
+ return
456
+ self.replay.observe_passive(context, git_filename, source, is_current=current, callback=complete, priority=priority)
457
+
458
+ def _retire_legacy_replay_anchors(self, session_id: str, state: ReplaySnapshot) -> None:
459
+ """Retire, once per repository and run, the anchor references earlier versions left, and tell each working tree once per run about kept backups.
460
+
461
+ The retirement waits for a fresh read showing no Cherry-pick or Revert, and a repository another write holds
462
+ waits for a later read. When an earlier version stopped in the middle of a restoration, its backups stay and
463
+ every working tree of that repository opened during the run gets a notice naming their folder, including one
464
+ opened after the retirement already ran for another working tree.
465
+ """
466
+ session = self.session_registry_controller.session_by_id.get(session_id)
467
+ if session is None or session.is_closed:
468
+ return
469
+ context = session.repository_context
470
+ if context.is_bare:
471
+ return
472
+ common_git_dir = canonical_path(context.common_git_dir)
473
+ if common_git_dir in self.legacy_replay_checked_common_dir_set:
474
+ # The notice does not depend on the replay state; before the answer arrives, the retirement's callback tells this window.
475
+ self._announce_legacy_replay_backups(common_git_dir)
476
+ return
477
+ if not state.fresh or state.kind != ReplayKind.ABSENT or state.unmerged_paths or self.replay_writer_busy(context):
478
+ return
479
+ self.legacy_replay_checked_common_dir_set.add(common_git_dir)
480
+ identity = self.session_registry_controller.identity_by_session.get(session_id)
481
+
482
+ def current() -> bool:
483
+ """Require the same open session as when the retirement was asked."""
484
+ return self.session_registry_controller.session_by_id.get(session_id) is session and self.session_registry_controller.identity_by_session.get(session_id) == identity and not session.is_closed
485
+
486
+ def retired(kept_dirs: tuple[str, ...]) -> None:
487
+ """Keep the answer for the whole run, even when the asking window closed meanwhile, and tell every open working tree of this repository."""
488
+ self.legacy_replay_kept_dirs_by_common_dir[common_git_dir] = kept_dirs
489
+ self._announce_legacy_replay_backups(common_git_dir)
490
+
491
+ self.replay.retire_legacy_anchors(context, self.repository.git_filename, is_current=current, callback=retired, session_id=session_id,
492
+ generation=self.session_registry_controller.generation_by_session.get(session_id, 0))
493
+
494
+ def _announce_legacy_replay_backups(self, common_git_dir: str) -> None:
495
+ """Name, once per working tree and run, the folder of the backups an earlier version kept for this repository.
496
+
497
+ Only windows that manage their working tree are told. The notice stays on the history status line of the
498
+ window, so that the lines a loading history writes next do not hide it.
499
+ """
500
+ kept_dirs = self.legacy_replay_kept_dirs_by_common_dir.get(common_git_dir, ())
501
+ if not kept_dirs:
502
+ return
503
+ notice = LEGACY_REPLAY_BACKUP_NOTICE_PREFIX + os.path.commonpath(kept_dirs) + "."
504
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
505
+ if session.is_closed or WindowCapability.MANAGE not in session.capabilities:
506
+ continue
507
+ if canonical_path(session.repository_context.common_git_dir) != common_git_dir:
508
+ continue
509
+ key = WorktreeKey.from_context(session.repository_context)
510
+ window = self.session_registry_controller.window_by_session.get(session_id)
511
+ if key in self.legacy_replay_notified_worktree_set or window is None or not isValid(window):
512
+ continue
513
+ self.legacy_replay_notified_worktree_set.add(key)
514
+ window.show_status_notice(key, LEGACY_BACKUP_NOTICE_KIND, notice)
515
+
516
+ def _show_replay_state(self, session_id: str, state: ReplaySnapshot, *, busy: bool | None = None) -> None:
517
+ """Publish one observed pause to the repository banner with common writer availability."""
518
+ window = self.session_registry_controller.window_by_session.get(session_id)
519
+ if window is None or not isValid(window):
520
+ return
521
+ if busy is None:
522
+ busy = self.replay_writer_busy(window.context)
523
+ busy = busy or WorktreeKey.from_context(window.context) in self.replay_resume_session_by_key
524
+ abort_available = callable(getattr(self.replay, "abort", None))
525
+ window.show_replay_state(state, busy=busy, abort_available=abort_available)
526
+
527
+ def replay_actions_changed(self) -> None:
528
+ """Reconcile action buttons whenever shared writing or tool ownership changes."""
529
+ busy_by_key = {}
530
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
531
+ if session.is_closed:
532
+ continue
533
+ key = WorktreeKey.from_context(session.repository_context)
534
+ state = self.replay.state_by_key.get(key)
535
+ if state is not None:
536
+ if key not in busy_by_key:
537
+ busy_by_key[key] = self.replay_writer_busy(session.repository_context)
538
+ self._show_replay_state(session_id, state, busy=busy_by_key[key])
539
+ locked = busy_by_key[key] and replay_may_be_present(state)
540
+ self.conflict_write_availability_changed.emit(session_id, locked)
541
+ for key in tuple(self.replay_auto_continue_by_key):
542
+ self._continue_requested_replay(key, busy=busy_by_key.get(key))
543
+
544
+ def replay_writer_busy(self, context: RepositoryContext) -> bool:
545
+ """Tell whether another write or the merge tool currently holds this worktree, which blocks every replay action."""
546
+ operation = OperationIntent.capture("replay-availability", context, self.repository.git_filename, action=OperationAction.CONFLICT_STAGE)
547
+ return bool(self.repository_operation.availability_diagnostic(operation)) or self.mergetool.owns_repository(operation.common_git_dir)
548
+
549
+ def _qualify_replay_auto_continue(self, key: WorktreeKey, request: ReplayAutoContinueRequest, observation: ReplayResumeObservation) -> None:
550
+ """Keep the read an automatic request asked for with that request, when the request is still armed and the read is newer than it."""
551
+ if self.replay_auto_continue_by_key.get(key) is not request or observation.state.key != key or observation.state.generation <= request.known_generation:
552
+ return
553
+ self.replay_auto_continue_by_key[key] = replace(request, observation=observation)
554
+
555
+ def _replay_resume_observation_holds(self, observation: ReplayResumeObservation, session_id: str, state: ReplaySnapshot) -> bool:
556
+ """Tell whether a qualifying read still describes this very pause for this session, so a Continue may use it instead of reading again.
557
+
558
+ It must be this state object, for this session under the same request identity and Git executable, and still the
559
+ service's latest state of its worktree with no read started since; a window closing or leaving refuses it.
560
+ """
561
+ session = self.session_registry_controller.session_by_id.get(session_id)
562
+ key = state.key
563
+ return (observation.session_id == session_id and observation.state is state and session is not None and not session.is_closed
564
+ and WorktreeKey.from_context(session.repository_context) == key and self.session_registry_controller.identity_by_session.get(session_id) == observation.identity
565
+ and observation.git_filename == self.repository.git_filename and self.replay.state_by_key.get(key) is state
566
+ and self.replay.generation_by_key.get(key) == state.generation and key not in self.replay.observers_by_key
567
+ and not self.replay_session_leaving(session_id))
568
+
569
+ def _take_auto_continue_observation(self, session_id: str, state: ReplaySnapshot) -> ReplayResumeObservation | None:
570
+ """Take the armed automatic request of this worktree, so it starts one Continue at most, and answer its read while that still holds."""
571
+ request = self.replay_auto_continue_by_key.pop(state.key, None)
572
+ if request is None or request.observation is None or request.session_id != session_id:
573
+ return None
574
+ if not self._replay_resume_observation_holds(request.observation, session_id, state):
575
+ return None
576
+ return request.observation
577
+
578
+ def replay_session_leaving(self, session_id: str) -> bool:
579
+ """Tell whether the user asked to close, quit or leave this repository window, which must not start a new Continue on its own."""
580
+ return self.session_registry_controller.quit_pending or session_id in self.session_registry_controller.close_session_set or session_id in self.session_registry_controller.navigation_request_by_session
581
+
582
+ def _continue_requested_replay(self, key: WorktreeKey, *, busy: bool | None = None) -> None:
583
+ """Start the requested automatic Continue once, only when a newer read qualifies and nothing holds the worktree.
584
+
585
+ A read older than the request is ignored. A busy worktree keeps the request for the next read or availability
586
+ change. Any other newer state ends the request, starting Continue only when every conflict is solved. A request
587
+ that starts its Continue stays armed until that Continue takes it, with the read that qualified it.
588
+ """
589
+ request = self.replay_auto_continue_by_key.get(key)
590
+ if request is None:
591
+ return
592
+ session = self.session_registry_controller.session_by_id.get(request.session_id)
593
+ if session is None or session.is_closed or WorktreeKey.from_context(session.repository_context) != key or self.replay_session_leaving(request.session_id):
594
+ self.replay_auto_continue_by_key.pop(key, None)
595
+ return
596
+ state = self.replay.state_by_key.get(key)
597
+ if state is None or state.generation <= request.known_generation:
598
+ return
599
+ if busy is None:
600
+ busy = self.replay_writer_busy(session.repository_context)
601
+ if busy:
602
+ return
603
+ ready = replay_auto_continue_ready(state)
604
+ if not ready:
605
+ self.replay_auto_continue_by_key.pop(key, None)
606
+ if self.diagnostic_log is not None:
607
+ decision = "starting Continue" if ready else "Continue left to the user (" + (state.diagnostic or "conflicts remain or the commit mode is unknown") + ")"
608
+ self.diagnostic_log.write(Diagnostic("Automatic replay continue", session.repository_context.repository_dir, self.repository.git_filename, [],
609
+ "Conflict resolution finished in GitPane: " + decision + ".").prepare())
610
+ if ready:
611
+ self.continue_replay(request.session_id, state, automatic=True)
612
+ # The Continue takes the request before it submits anything; should it not have, the request still ends here,
613
+ # so that it can never start a second Continue.
614
+ remaining = self.replay_auto_continue_by_key.get(key)
615
+ if remaining is not None and (remaining.session_id, remaining.known_generation) == (request.session_id, request.known_generation):
616
+ del self.replay_auto_continue_by_key[key]
617
+
618
+ def _replace_replay_prompt(self, session_id: str, prompt: ReplayConflictPrompt | QMessageBox) -> None:
619
+ """Keep one replay question or message per session, closing and deleting the one it replaces."""
620
+ previous = self.replay_prompt_by_session.get(session_id)
621
+ if previous is not None and previous is not prompt and isValid(previous):
622
+ previous.close()
623
+ previous.deleteLater()
624
+ self.replay_prompt_by_session[session_id] = prompt
625
+
626
+ def _release_replay_message(self, session_id: str, prompt: QMessageBox) -> None:
627
+ """Forget and delete an answered "no commit was created" message instead of keeping it under the window."""
628
+ if self.replay_prompt_by_session.get(session_id) is prompt:
629
+ del self.replay_prompt_by_session[session_id]
630
+ if isValid(prompt):
631
+ prompt.deleteLater()
632
+
633
+ def _offer_replay_resolution(self, session_id: str, state: ReplaySnapshot, operation_id: str) -> None:
634
+ """Ask once after a locally launched conflict independently of other prompt preferences.
635
+
636
+ The launch console already closed on the pause unless Keep dialog open is checked. Whatever the answer (Yes,
637
+ No, Escape or the title-bar close), that console has done its job and is settled, so unchecking the option later
638
+ still closes it.
639
+ """
640
+ prompt = ReplayConflictPrompt(self.session_registry_controller.window_by_session[session_id])
641
+ self._replace_replay_prompt(session_id, prompt)
642
+ prompt.resolve_requested.connect(lambda: self.resolve_replay(session_id, state))
643
+ prompt.finished.connect(lambda _result: self.console_registry_controller.settle_answered_console(operation_id))
644
+
645
+ def present() -> None:
646
+ """Bring the prompt to the front after the console has published the pause."""
647
+ if not isValid(prompt) or self.replay_prompt_by_session.get(session_id) is not prompt:
648
+ return
649
+ prompt.show()
650
+ prompt.raise_()
651
+ prompt.activateWindow()
652
+
653
+ # The console publishes its pause from a zero timer queued just before this one; presenting the prompt
654
+ # afterwards keeps the console from ending up in front of it.
655
+ QTimer.singleShot(0, present)
656
+
657
+ def resolve_replay(self, session_id: str, state: ReplaySnapshot) -> None:
658
+ """Refresh the existing conflict list before opening it; never launch a tool here."""
659
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
660
+ return
661
+ context = self.session_registry_controller.session_by_id[session_id].repository_context
662
+ if state.key != WorktreeKey.from_context(context):
663
+ return
664
+ self.session_registry_controller.replay_pending_resolve_set.add(session_id)
665
+ self.staging_session_registry_controller.staging_session(context).refresh(explicit=True)
666
+ self.observe_replay(session_id)
667
+
668
+ def continue_replay(self, session_id: str, state: ReplaySnapshot, *, automatic: bool = False) -> None:
669
+ """Relate a Continue to its original pause and a new write reservation.
670
+
671
+ ``automatic`` marks the Continue GitPane starts by itself after the last conflict was solved in its own list;
672
+ it goes through exactly the same checks as the button and says so in its console. It takes the armed request
673
+ of its worktree, and submits the read that qualified it instead of reading the pause again while that read holds.
674
+ """
675
+ observed = self._take_auto_continue_observation(session_id, state) if automatic else None
676
+ self._submit_replay_resume(session_id, state, abort=False, automatic=automatic, observed=observed)
677
+
678
+ def _submit_replay_resume(self, session_id: str, state: ReplaySnapshot, *, abort: bool, automatic: bool = False,
679
+ observed: ReplayResumeObservation | None = None) -> None:
680
+ """Reobserve before submitting, keeping changed identities and active tools refused.
681
+
682
+ ``observed`` is the read that qualified an automatic Continue of this very pause: it is checked the same way as
683
+ a reread and handed to the service in its place while it still holds; otherwise the pause is read again. The
684
+ service captures the pause twice again inside its reservation either way.
685
+ """
686
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
687
+ return
688
+ session = self.session_registry_controller.session_by_id[session_id]
689
+ context = session.repository_context
690
+ git_filename = self.repository.git_filename
691
+ if state.key != WorktreeKey.from_context(context):
692
+ return
693
+ action = ReplayAction.ABORT if abort else ReplayAction.CONTINUE
694
+ resume = getattr(self.replay, "abort" if abort else "continue_replay", None)
695
+ diagnostic = replay_action_diagnostic(state, action, busy=self.mergetool.owns_repository(context.common_git_dir), abort_available=callable(resume))
696
+ if diagnostic or not callable(resume):
697
+ self.session_registry_controller.window_by_session[session_id].statusBar().showMessage(diagnostic or "This recovery action is unavailable.")
698
+ return
699
+ key = state.key
700
+ if key in self.replay_resume_session_by_key:
701
+ self.session_registry_controller.window_by_session[session_id].statusBar().showMessage("A Continue or Abort of this commit operation is already starting.")
702
+ return
703
+ self.replay_resume_session_by_key[key] = session_id
704
+ self.replay_actions_changed()
705
+
706
+ def current() -> bool:
707
+ """Hold session and tool ownership until the service's final launch check."""
708
+ return (self.session_registry_controller.session_by_id.get(session_id) is session and not session.is_closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)
709
+ and session.repository_context == context and self.repository.git_filename == git_filename
710
+ and not self.mergetool.owns_repository(context.common_git_dir))
711
+
712
+ def read_current() -> bool:
713
+ """Release the starting mark when the reread is dropped, so the banner does not stay disabled."""
714
+ if current():
715
+ return True
716
+ self._release_replay_resume(key, session_id)
717
+ return False
718
+
719
+ def complete(observed: ReplaySnapshot) -> None:
720
+ """Refuse a different pause rather than applying a click to replacement metadata."""
721
+ try:
722
+ submit(observed)
723
+ finally:
724
+ self._release_replay_resume(key, session_id)
725
+
726
+ def submit(observed: ReplaySnapshot) -> None:
727
+ """Open the console and hand the checked pause to the service, which reserves the repository."""
728
+ if not current():
729
+ return
730
+ if (observed.identity != state.identity or observed.head_oid != state.head_oid or observed.symbolic_ref != state.symbolic_ref
731
+ or observed.launch != state.launch or replay_action_diagnostic(observed, action, abort_available=callable(resume))):
732
+ self._show_replay_state(session_id, observed)
733
+ self.session_registry_controller.window_by_session[session_id].statusBar().showMessage("The commit operation changed. Review its current state before trying again.")
734
+ return
735
+ operation = OperationIntent.capture(uuid.uuid4().hex, context, self.repository.git_filename, session_id=session_id,
736
+ generation=self.session_registry_controller.generation_by_session[session_id], operation="Abort replay" if abort else "Continue replay",
737
+ action=OperationAction.REPLAY_ABORT if abort else OperationAction.REPLAY_CONTINUE)
738
+ # A pause GitPane launched in this run keeps the source and the choices of its form; any other one is named by Git's instruction.
739
+ launch = observed.launch
740
+ source_oid = launch.source_oid if launch is not None else next(iter(observed.instruction_oids))
741
+ add_reference = launch.add_reference if launch is not None else False
742
+ intent = ReplayIntent(operation, observed.kind, source_oid, observed.head_oid, observed.symbolic_ref, create_commit=observed.mode == ReplayCommitMode.COMMIT,
743
+ add_reference=add_reference)
744
+ self.replay_intent_by_id[operation.operation_id] = intent
745
+ self.reference_form_controller.open_reference_output(intent, None, modal=False)
746
+ trace = self.reference_form_controller.reference_trace_by_id.get(operation.operation_id)
747
+ if automatic and trace is not None:
748
+ trace.append_event(operation.operation_id, "status", REPLAY_AUTO_CONTINUE_TEXT)
749
+ resume(operation, observed, is_current=current)
750
+
751
+ if observed is None:
752
+ self.replay.observe(context, self.repository.git_filename, is_current=read_current, callback=complete)
753
+ return
754
+ qualified = observed
755
+
756
+ def hand_over_qualified_read() -> None:
757
+ """Submit with the qualifying read while it still holds, or read the pause again as for a click."""
758
+ if not read_current():
759
+ return
760
+ if self._replay_resume_observation_holds(qualified, session_id, qualified.state):
761
+ complete(qualified.state)
762
+ return
763
+ self.replay.observe(context, self.repository.git_filename, is_current=read_current, callback=complete)
764
+
765
+ # Handed over on the next turn of the event loop, the way a read answers: until then the starting mark stays set,
766
+ # so a banner click in between is refused as for a Continue already starting.
767
+ QTimer.singleShot(0, self, hand_over_qualified_read)
768
+
769
+ def _release_replay_resume(self, key: WorktreeKey, session_id: str) -> None:
770
+ """End the starting mark of a Continue or Abort once it reserved the repository or gave up, then refresh the buttons.
771
+
772
+ The buttons are refreshed from a zero timer because this can run while the replay service is still publishing a read.
773
+ """
774
+ if self.replay_resume_session_by_key.get(key) != session_id:
775
+ return
776
+ del self.replay_resume_session_by_key[key]
777
+ QTimer.singleShot(0, self, self.replay_actions_changed)
778
+
779
+ def passive_observation_source(self, key: WorktreeKey) -> PassiveObservationSource | None:
780
+ """Answer what the automatic refresh of this worktree has just read, or nothing when the observation must read through Git."""
781
+ # While a Continue or an Abort waits for its decision, that decision is taken on a state read through Git.
782
+ if key in self.replay_auto_continue_by_key or key in self.replay_resume_session_by_key:
783
+ return None
784
+ staging = self.staging_session_registry_controller.staging_by_key.get(key)
785
+ if staging is None:
786
+ return None
787
+ return staging.passive_observation_source()
788
+
789
+ def request_replay_auto_continue(self, session_id: str, key: WorktreeKey) -> None:
790
+ """Ask to continue a Cherry-pick or Revert once a new read proves that its last conflict is solved.
791
+
792
+ Only a resolution made in GitPane's conflict list or merge tool series calls this; nothing here runs on a passive
793
+ refresh or a reopening. The pause may have been started by GitPane or in a terminal: the newer read decides, and
794
+ only a pause the banner Continue accepts (known commit mode) is continued.
795
+ """
796
+ state = self.replay.state_by_key.get(key)
797
+ if not replay_may_be_present(state) or session_id not in self.session_registry_controller.session_by_id or self.replay_session_leaving(session_id):
798
+ return
799
+ request = ReplayAutoContinueRequest(session_id, self.replay.generation_by_key.get(key, 0))
800
+ self.replay_auto_continue_by_key[key] = request
801
+ self.observe_replay(session_id, auto_continue_request=request)
802
+
803
+ def abort_replay(self, session_id: str, state: ReplaySnapshot) -> None:
804
+ """Submit Git's own Abort of the pause the banner shows, after the same checks as Continue."""
805
+ self._submit_replay_resume(session_id, state, abort=True)