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,797 @@
1
+ """Run an interactive rebase from a repository window, through its pauses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QObject, QSignalBlocker, Signal
10
+ from shiboken6 import isValid
11
+
12
+ from gitpane.config import LOCAL_PREFIX, REBASE_REFRESH_INTERVAL_MS, SHORT_OID_LENGTH, ConsoleOperation, ProcessPriority, SharedText
13
+ from gitpane.controller.refresh_controller import RebaseRefreshPublication
14
+ from gitpane.history.file_history import WindowCapability
15
+ from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE
16
+ from gitpane.operation.console_line import COMPLETED_STATUS_SET, END_TEXT_TEMPLATE, REBASE_LABEL, REPOSITORY_HEADER_LABEL, ConsoleHeader, operation_end_text
17
+ from gitpane.operation.diagnostic import Diagnostic
18
+ from gitpane.operation.operation_status import OperationStatus
19
+ from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
20
+ from gitpane.operation.repository_operation import REBASE_PAUSE_OBSERVATION, OperationAction, OperationIntent, OperationOutcome, WriteScope
21
+ from gitpane.preference.preference_store import REBASE_PROMPT_PREFERENCE_KEY
22
+ from gitpane.sequencer.conflict import conflict_path_label
23
+ from gitpane.sequencer.rebase import RebaseIntent, RebaseOutcome
24
+ from gitpane.sequencer.rebase_state import REBASE_ENDED_OUTSIDE_MESSAGE, REBASE_STOPPED_STATE_SET, RebaseSnapshot, native_observations_unchanged, read_rebase_commits, state_dirs_from_pause_paths
25
+ from gitpane.ui.console.operation_dialog import OperationDialog
26
+ from gitpane.ui.sequencer.rebase_dialog import RebaseConfirmation, RebaseDialog
27
+ from gitpane.ui.sequencer.rebase_editor_window import EditorValidation
28
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
29
+ from gitpane.worktree.command_sequence import FileTask, GitCommandError
30
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, Generator
34
+
35
+ from gitpane.controller.console_registry import ConsoleRegistry
36
+ from gitpane.controller.history_read_controller import HistoryReadController
37
+ from gitpane.controller.operation_policy import OperationPolicy
38
+ from gitpane.controller.rebase_editor_controller import RebaseEditorController
39
+ from gitpane.controller.reference_read_controller import ReferenceReadController
40
+ from gitpane.controller.refresh_controller import FullRefreshRead, RefreshController, ReplayRefreshPublication
41
+ from gitpane.controller.replay_controller import ReplayController
42
+ from gitpane.controller.session_registry import SessionRegistry
43
+ from gitpane.controller.staging_controller import StagingController
44
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
45
+ from gitpane.controller.window_activation import WindowActivationFilter
46
+ from gitpane.controller.worktree_state_controller import WorktreeStateController
47
+ from gitpane.history.repository import GitRepository
48
+ from gitpane.history.repository_value import RepositoryContext
49
+ from gitpane.operation.diagnostic import DiagnosticLog
50
+ from gitpane.operation.repository_operation import RepositoryOperationService
51
+ from gitpane.preference.preference_store import PreferenceStore
52
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
53
+ from gitpane.sequencer.mergetool import MergetoolService
54
+ from gitpane.sequencer.rebase import RebaseService
55
+ from gitpane.sequencer.rebase_state import RebaseCommit, RebaseStep
56
+ from gitpane.tool.editor import EditorRequest
57
+ from gitpane.worktree.worktree_service import WorktreeService
58
+
59
+ # Console line of a rebase that stopped on a conflict, followed by the conflicting files separated by ", ".
60
+ REBASE_CONFLICT_PAUSE_PREFIX = "Rebase paused: new conflict in "
61
+
62
+
63
+ def outcome_instruction_error(outcome: OperationOutcome) -> str:
64
+ """Answer the error lines of the instructions Git refused and kept for a correction, or an empty text for any other result.
65
+
66
+ Only a rebase command reads them; a refusal before any command, or an Abort, never carries them.
67
+ """
68
+ return outcome.instruction_error if isinstance(outcome, RebaseOutcome) else ""
69
+
70
+
71
+ def rebase_end_notice(snapshot: ReferenceSnapshot | None) -> str:
72
+ """Say that a rebase ended outside GitPane and where HEAD is now, as the references just read tell it."""
73
+ if snapshot is None or snapshot.head_oid is None:
74
+ return REBASE_ENDED_OUTSIDE_MESSAGE
75
+ short_oid = snapshot.head_oid[:SHORT_OID_LENGTH]
76
+ if snapshot.head_ref is not None and snapshot.head_ref.startswith(LOCAL_PREFIX):
77
+ return f"{REBASE_ENDED_OUTSIDE_MESSAGE} HEAD is on {snapshot.head_ref.removeprefix(LOCAL_PREFIX)} at {short_oid}."
78
+ return f"{REBASE_ENDED_OUTSIDE_MESSAGE} HEAD is detached at {short_oid}."
79
+
80
+
81
+ def rebase_conflict_pause_text(outcome: OperationOutcome, state: RebaseSnapshot | None) -> str | None:
82
+ """Return the line naming the files of the new conflict a rebase command stopped on, or None when it did not stop on one.
83
+
84
+ Editing the instructions during a conflict also ends paused, but Git did not stop on a new conflict then: only a
85
+ command Git ended with its conflict exit status is named that way.
86
+ """
87
+ stopped_on_conflict = any(step.return_code == GIT_CONFLICT_RETURN_CODE for step in outcome.steps)
88
+ if outcome.status != OperationStatus.PAUSED or not stopped_on_conflict or state is None or state.state != "conflict" or not state.conflict_paths:
89
+ return None
90
+ labels = []
91
+ for path in state.conflict_paths:
92
+ labels.append(conflict_path_label(path))
93
+ return REBASE_CONFLICT_PAUSE_PREFIX + ", ".join(labels)
94
+
95
+
96
+ def rebase_result_line(outcome: OperationOutcome, state: RebaseSnapshot | None) -> str:
97
+ """Write the console line ending a rebase command, such as ``Rebase: done``; a stop on a new conflict names its files instead."""
98
+ pause_text = rebase_conflict_pause_text(outcome, state)
99
+ if pause_text is not None:
100
+ return pause_text
101
+ return operation_end_text(REBASE_LABEL, outcome.status)
102
+
103
+
104
+ def rebase_result_log_text(outcome: OperationOutcome, state: RebaseSnapshot | None) -> str:
105
+ """Write the ``--log`` line ending a rebase command: the bare status, as the log always kept it, or the same conflict line as the console."""
106
+ pause_text = rebase_conflict_pause_text(outcome, state)
107
+ if pause_text is not None:
108
+ return pause_text
109
+ return END_TEXT_TEMPLATE.format(label=REBASE_LABEL, word=outcome.status.value)
110
+
111
+
112
+ class RebaseController(QObject):
113
+ """The interactive rebase of every repository window: confirmation, launch, console, Rebase window with Continue, Amend and Abort, and the observation of a pause or of a rebase ended outside GitPane."""
114
+
115
+ # Asks to show, in place of the rebase progress, that a repository window waits for its work to end before it
116
+ # closes or GitPane quits: the session id of that window.
117
+ close_wait_requested = Signal(str)
118
+ # Tells the rebase state a repository window has just shown, for the conflict window of that window: the session id
119
+ # and the RebaseSnapshot.
120
+ rebase_state_shown = Signal(str, object)
121
+ # Asks to open the conflict list of a repository window from its Rebase window: the session id.
122
+ conflict_list_requested = Signal(str)
123
+
124
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, window_activation_controller: WindowActivationFilter,
125
+ console_registry_controller: ConsoleRegistry, reference_read_controller: ReferenceReadController, refresh_controller: RefreshController, worktree_state_controller: WorktreeStateController,
126
+ history_read_controller: HistoryReadController, staging_controller: StagingController, replay_controller: ReplayController, rebase_editor_controller: RebaseEditorController, preference: PreferenceStore,
127
+ repository: GitRepository, worktree_service: WorktreeService, repository_operation: RepositoryOperationService, rebase: RebaseService, mergetool: MergetoolService, diagnostic_log: DiagnosticLog | None,
128
+ parent: QObject | None = None) -> None:
129
+ """Receive the services and the lower controllers a rebase works with; the wiring builds it after the replay controller, and the conflict, the close readiness and the repository windows reach it through the wiring.
130
+
131
+ Every edit Git asks for during one of its commands is served by the rebase editor controller, told before each
132
+ command which repository window parents the editor windows and whether their button continues or saves.
133
+ """
134
+ super().__init__(parent)
135
+ self.session_registry_controller = session_registry_controller
136
+ self.operation_policy_controller = operation_policy_controller
137
+ self.staging_session_registry_controller = staging_session_registry_controller
138
+ self.window_activation_controller = window_activation_controller
139
+ self.console_registry_controller = console_registry_controller
140
+ self.reference_read_controller = reference_read_controller
141
+ self.refresh_controller = refresh_controller
142
+ self.worktree_state_controller = worktree_state_controller
143
+ self.history_read_controller = history_read_controller
144
+ self.staging_controller = staging_controller
145
+ self.replay_controller = replay_controller
146
+ self.rebase_editor_controller = rebase_editor_controller
147
+ self.preference = preference
148
+ self.repository = repository
149
+ self.worktree_service = worktree_service
150
+ self.repository_operation = repository_operation
151
+ self.rebase = rebase
152
+ self.mergetool = mergetool
153
+ self.diagnostic_log = diagnostic_log
154
+ self.rebase_dialog_by_session: dict[str, RebaseDialog] = {}
155
+ self.rebase_confirmation_by_session: dict[str, RebaseConfirmation] = {}
156
+ self.rebase_intent_by_id: dict[str, RebaseIntent] = {}
157
+ self.rebase_final_oid_by_session: dict[str, str] = {}
158
+ # Windows owing the line that tells a rebase ended outside GitPane: a refresh of theirs is running, and the line
159
+ # is written once its history is published, so it names the HEAD that refresh read and is not hidden by it.
160
+ self.rebase_end_notice_session_set: set[str] = set()
161
+ self.rebase_observed_at_by_key: dict[WorktreeKey, float] = {}
162
+ # The validating button each command's editor windows were given, so instructions Git refuses reopen with the same one.
163
+ self.validation_by_operation_id: dict[str, EditorValidation] = {}
164
+ # The list edits reopened after Git refused a command that was to continue the rebase: once Git accepts the
165
+ # corrected list, the rebase continues.
166
+ self.continue_after_correction_operation_id_set: set[str] = set()
167
+
168
+ def start_rebase(self, session_id: str, target_oid: str, snapshot: ReferenceSnapshot | None) -> RebaseConfirmation | None:
169
+ """Prepare a graph target against the loaded HEAD before one confirmation."""
170
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or snapshot is None:
171
+ return None
172
+ session = self.session_registry_controller.session_by_id[session_id]
173
+ window = self.session_registry_controller.window_by_session[session_id]
174
+ if session.repository_context.is_bare:
175
+ return None
176
+ existing = self.rebase_confirmation_by_session.get(session_id)
177
+ if existing is not None and isValid(existing) and existing.isVisible():
178
+ existing.raise_()
179
+ return existing
180
+ operation = OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
181
+ session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id], operation=REBASE_LABEL, action=OperationAction.REBASE_START,
182
+ scope=WriteScope.capture(session.repository_context, (snapshot.head_ref,) if snapshot.head_ref else ()))
183
+ intent = RebaseIntent(operation, target_oid, snapshot.head_oid, snapshot.head_ref)
184
+
185
+ def current() -> bool:
186
+ """Keep a pending launch bound to the original repository session."""
187
+ return (self.operation_policy_controller.operation_allowed(operation) and self.repository.git_filename == operation.git_filename and snapshot.context == operation.context
188
+ and snapshot.git_filename == operation.git_filename and snapshot.generation == operation.generation)
189
+
190
+ def prepared(value: RebaseIntent | None, error: Exception | None) -> None:
191
+ """Only a fully resolved source and target may reach confirmation."""
192
+ if session.is_closed or self.session_registry_controller.session_by_id.get(session_id) is not session:
193
+ return
194
+ if error is not None or value is None or not current():
195
+ self._rebase_refused(intent, error or ValueError("The rebase capture changed. Refresh before rebasing."))
196
+ return
197
+ source = (value.source_symbolic_ref or "Detached HEAD") + " (" + value.source_head_oid + ")"
198
+ previous = self.rebase_confirmation_by_session.get(session_id)
199
+ if previous is not None and isValid(previous):
200
+ # A confirmation answered earlier stays parented to the window until replaced; the newest request is the only one that may still launch.
201
+ previous.close()
202
+ previous.deleteLater()
203
+ dialog = RebaseConfirmation(source, value.target_oid, window, preference=self.preference)
204
+ self.rebase_confirmation_by_session[session_id] = dialog
205
+ dialog.submit_requested.connect(lambda: self._confirm_rebase(value, current, dialog))
206
+ if self.preference.read(REBASE_PROMPT_PREFERENCE_KEY):
207
+ dialog.dont_show_again.setChecked(True)
208
+ self._confirm_rebase(value, current, dialog)
209
+ else:
210
+ open_window_modal(dialog)
211
+
212
+ self.rebase.prepare(intent, is_current=current, callback=prepared)
213
+ return None
214
+
215
+ def _confirm_rebase(self, intent: RebaseIntent, current: Callable[[], bool], dialog: RebaseConfirmation) -> None:
216
+ """Launch the intention prepared before confirmation; the edits Git asks for open in GitPane, so no editor choice is needed.
217
+
218
+ The form is not prepared again: the start validates the target, HEAD and the absence of a native rebase inside
219
+ its reservation, and checks references once more at launch, so a change made since the confirmation opened is
220
+ refused there.
221
+ """
222
+ if dialog.submitting or not isValid(dialog) or dialog.cancelled:
223
+ return
224
+ dialog.submitting = True
225
+ dialog.confirm_button.setEnabled(False)
226
+ if not current():
227
+ self._rebase_refused(intent, ValueError("The rebase capture changed. Refresh before rebasing."))
228
+ dialog.reject()
229
+ return
230
+ dialog.accept()
231
+ self._launch_rebase(intent, current)
232
+
233
+ def _create_rebase_output(self, intent: RebaseIntent) -> OperationDialog:
234
+ """Keep rebase diagnostics visible while leaving the main window's deferred Close action accessible."""
235
+ operation = intent.operation
236
+ trace = OperationTrace(operation.operation_id, self.repository.runner, self.diagnostic_log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
237
+ output = OperationDialog(trace, REBASE_LABEL, self.session_registry_controller.window_by_session[operation.session_id], preference=self.preference, console_operation=ConsoleOperation.REBASE)
238
+ output.finished.connect(trace.close)
239
+ output.finished.connect(lambda: self._dispose_rebase_output(operation.operation_id))
240
+ self.rebase_intent_by_id[operation.operation_id] = intent
241
+ self.console_registry_controller.rebase_output_by_id[operation.operation_id] = output
242
+ output.show()
243
+ return output
244
+
245
+ def _dispose_rebase_output(self, operation_id: str) -> None:
246
+ """Release a dismissed diagnostic window with its trace instead of retaining one per command for the whole session."""
247
+ output = self.console_registry_controller.rebase_output_by_id.pop(operation_id, None)
248
+ if output is not None and isValid(output):
249
+ output.deleteLater()
250
+
251
+ def _rebase_refused(self, intent: RebaseIntent, error: Exception) -> None:
252
+ """Expose a preflight cause without creating an editor or repairing native state."""
253
+ operation = intent.operation
254
+ if not self.operation_policy_controller.allows(operation.session_id, WindowCapability.MANAGE):
255
+ return
256
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
257
+ self._create_rebase_output(intent)
258
+ self.rebase_finished(OperationOutcome(operation.operation_id, OperationStatus.REFUSED, diagnostic=diagnostic))
259
+
260
+ def _launch_rebase(self, intent: RebaseIntent, current: Callable[[], bool]) -> None:
261
+ """Create shared diagnostics and run one captured command whose edits open in GitPane, each with Continue."""
262
+ operation = intent.operation
263
+ if not current():
264
+ self._rebase_refused(intent, ValueError("The rebase capture changed. Refresh before rebasing."))
265
+ return
266
+ output = self._create_rebase_output(intent)
267
+ state = RebaseSnapshot(operation.key, operation.generation, head_oid=intent.source_head_oid, state="starting")
268
+ dialog = self._create_rebase_dialog(operation.session_id, state)
269
+ dialog.show()
270
+ self.rebase_progress(operation.operation_id, SharedText.REBASE_APPLYING_COMMITS.value)
271
+ output.set_running()
272
+ self._expect_edits(operation, EditorValidation.CONTINUE)
273
+ self.rebase.start(intent, is_current=current)
274
+
275
+ def _expect_edits(self, operation: OperationIntent, validation: EditorValidation, error_text: str = "") -> None:
276
+ """Tell the rebase editor controller to serve the edits of this command in windows parented to its repository window.
277
+
278
+ An error text, such as the instructions Git refused, is shown above the text of the first window.
279
+ """
280
+ self.validation_by_operation_id[operation.operation_id] = validation
281
+ self.rebase_editor_controller.expect_edits(operation.operation_id, operation.context, operation.git_filename, validation,
282
+ self.session_registry_controller.window_by_session.get(operation.session_id), error_text)
283
+
284
+ def rebase_editor_waiting(self, request: EditorRequest) -> None:
285
+ """Say which file Git waits for while its GitPane editor window is open."""
286
+ self._rebase_editor_changed(request.operation_id, request.kind.progress_text)
287
+
288
+ def _rebase_editor_changed(self, operation_id: str, message: str) -> None:
289
+ """Reread Git's edited list during message waits without confusing it with completion."""
290
+ intent = self.rebase_intent_by_id.get(operation_id)
291
+ if intent is None:
292
+ return
293
+ self.rebase_progress(operation_id, message)
294
+
295
+ def complete(state: RebaseSnapshot) -> None:
296
+ """Apply a current command observation to its still-open worktree views."""
297
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
298
+ if session.is_closed or WorktreeKey.from_context(session.repository_context) != intent.operation.key:
299
+ continue
300
+ dialog = self.rebase_dialog_by_session.get(session_id)
301
+ if dialog is not None and isValid(dialog):
302
+ dialog.show_state(state)
303
+ self.rebase_progress(operation_id, message)
304
+
305
+ self.rebase.observe(intent.operation.context, intent.operation.git_filename,
306
+ is_current=lambda: self.rebase_intent_by_id.get(operation_id) is intent, callback=complete)
307
+
308
+ def edit_rebase_todo(self, session_id: str) -> None:
309
+ """Capture one explicit instruction correction and leave Git paused after its editor exits."""
310
+ # Save writes the list and leaves the rebase paused; the user resumes it with Continue rebase.
311
+ self._edit_rebase_todo(session_id, EditorValidation.SAVE, "")
312
+
313
+ def reopen_rebase_todo(self, session_id: str, validation: EditorValidation, error_text: str) -> None:
314
+ """Open the editor again on the instructions Git refused and kept, with Git's error above what remains to do.
315
+
316
+ The correction reaches Git by git rebase --edit-todo, on the list Git holds: nothing done is replayed. With
317
+ Continue, the rebase continues once Git accepts the corrected list; with Save, it stays paused.
318
+ """
319
+ self._edit_rebase_todo(session_id, validation, error_text)
320
+
321
+ def _edit_rebase_todo(self, session_id: str, validation: EditorValidation, error_text: str) -> None:
322
+ """Submit one git rebase --edit-todo whose editor window validates with the given button and shows the given error first.
323
+
324
+ An edit that validates with Continue is remembered, so the rebase continues once Git accepts the edited list.
325
+ """
326
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
327
+ return
328
+ session = self.session_registry_controller.session_by_id[session_id]
329
+ state = self.rebase.state_by_key.get(WorktreeKey.from_context(session.repository_context))
330
+ if state is None or not state.fresh or state.identity is None or state.state not in REBASE_STOPPED_STATE_SET:
331
+ self.observe_rebase(session_id)
332
+ return
333
+ operation = OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
334
+ session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id], operation="Edit rebase instructions",
335
+ action=OperationAction.REBASE_CONTINUE)
336
+ intent = RebaseIntent(operation, state.identity.onto_oid, state.head_oid, state.identity.head_name)
337
+ output = self._create_rebase_output(intent)
338
+ output.set_running()
339
+ self.rebase_progress(operation.operation_id, "Editing rebase instructions…")
340
+ self._expect_edits(operation, validation, error_text)
341
+ if validation == EditorValidation.CONTINUE:
342
+ self.continue_after_correction_operation_id_set.add(operation.operation_id)
343
+ self.rebase.edit_todo(operation, state, is_current=lambda: self.operation_policy_controller.operation_allowed(operation), error_before_edit=error_text)
344
+
345
+ def rebase_progress(self, operation_id: str, message: str) -> None:
346
+ """Update tracking and banners without replacing native observation facts."""
347
+ intent = self.rebase_intent_by_id.get(operation_id)
348
+ if intent is None:
349
+ return
350
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
351
+ if session.is_closed or WorktreeKey.from_context(session.repository_context) != intent.operation.key:
352
+ continue
353
+ window = self.session_registry_controller.window_by_session[session_id]
354
+ if self.session_registry_controller.quit_pending or session_id in self.session_registry_controller.close_session_set:
355
+ self.close_wait_requested.emit(session_id)
356
+ else:
357
+ window.rebase_label.setText(message)
358
+ window.rebase_banner.show()
359
+ dialog = self.rebase_dialog_by_session.get(session_id)
360
+ if dialog is not None and isValid(dialog):
361
+ dialog.status_label.setText(message)
362
+ dialog.command_message = ""
363
+ dialog.set_busy(True)
364
+
365
+ def follow_rebase_ended_outside(self, state: RebaseSnapshot) -> None:
366
+ """Follow a rebase that ended without a command of GitPane, with no banner: close its Rebase window and say so in one status line.
367
+
368
+ The line names where HEAD is as the references tell it: a refresh already running, or the one a finishing
369
+ rebase command starts, reads them and the line is completed once its history is published; otherwise only the
370
+ references are read, which costs nothing when their files did not change. A HEAD that moved is brought to the
371
+ graph by the worktree read that sees it.
372
+ """
373
+ if not state.ended_outside:
374
+ return
375
+ command_finishing = any(intent.operation.key == state.key for intent in self.rebase_intent_by_id.values())
376
+ for session_id, window in self.worktree_state_controller.managed_windows_of_worktree(state.key):
377
+ window.show_rebase_state(state)
378
+ dialog = self.rebase_dialog_by_session.get(session_id)
379
+ if dialog is not None and isValid(dialog):
380
+ dialog.show_state(state)
381
+ dialog.close()
382
+ self.rebase_end_notice_session_set.add(session_id)
383
+ window.statusBar().showMessage(REBASE_ENDED_OUTSIDE_MESSAGE)
384
+ if self.session_registry_controller.session_by_id[session_id].refresh_active or command_finishing:
385
+ continue
386
+ self.reference_read_controller.read_references(session_id, previous=self.reference_read_controller.reference_snapshot_by_session.get(session_id),
387
+ on_complete=lambda snapshot, _error, session_id=session_id: self.show_rebase_end_notice(session_id, snapshot))
388
+
389
+ def show_rebase_end_notice(self, session_id: str, snapshot: ReferenceSnapshot | None, *, after_history: bool = False) -> None:
390
+ """Write, once, the line telling a window that a rebase ended outside GitPane, after the status line of a just published history if asked."""
391
+ window = self.session_registry_controller.window_by_session.get(session_id)
392
+ if session_id not in self.rebase_end_notice_session_set or window is None or not isValid(window):
393
+ return
394
+ self.rebase_end_notice_session_set.discard(session_id)
395
+ notice = rebase_end_notice(snapshot)
396
+ window.statusBar().showMessage(window.statusBar().currentMessage() + " — " + notice if after_history else notice)
397
+
398
+ def reveal_rebase_result(self, session_id: str) -> None:
399
+ """Select the commit a finished rebase ended on once the history read after it is shown, then close its Rebase window."""
400
+ final_oid = self.rebase_final_oid_by_session.pop(session_id, None)
401
+ if final_oid is None:
402
+ return
403
+ self.session_registry_controller.window_by_session[session_id].reveal_commit(final_oid)
404
+ dialog = self.rebase_dialog_by_session.get(session_id)
405
+ if dialog is not None and isValid(dialog):
406
+ dialog.close()
407
+
408
+ def forget_rebase_end_notice(self, session_id: str) -> None:
409
+ """Drop the line owed about a rebase that ended outside GitPane when the refresh that was to complete it failed."""
410
+ self.rebase_end_notice_session_set.discard(session_id)
411
+
412
+ def rebase_actions_changed(self) -> None:
413
+ """Apply the shared writer availability to every existing tracking window."""
414
+ for session_id, dialog in tuple(self.rebase_dialog_by_session.items()):
415
+ session = self.session_registry_controller.session_by_id.get(session_id)
416
+ if session is None or session.is_closed or not isValid(dialog):
417
+ continue
418
+ operation = OperationIntent.capture("rebase-availability", session.repository_context, self.repository.git_filename,
419
+ action=OperationAction.REBASE_ABORT)
420
+ dialog.set_busy(bool(self.repository_operation.availability_diagnostic(operation)) or self.mergetool.owns_repository(operation.common_git_dir))
421
+
422
+ def rebase_finished(self, outcome: OperationOutcome) -> None:
423
+ """Keep refusals visible and reveal successful HEAD only after graph publication.
424
+
425
+ Instructions Git refused reopen the editor once the result is shown; a list corrected after a refused Continue
426
+ continues the rebase.
427
+ """
428
+ self.rebase_editor_controller.forget(outcome.operation_id)
429
+ validation = self.validation_by_operation_id.pop(outcome.operation_id, None)
430
+ continue_after_correction = outcome.operation_id in self.continue_after_correction_operation_id_set
431
+ self.continue_after_correction_operation_id_set.discard(outcome.operation_id)
432
+ intent = self.rebase_intent_by_id.pop(outcome.operation_id, None)
433
+ if intent is None:
434
+ return
435
+ operation = intent.operation
436
+ self.rebase.outcome_by_id[outcome.operation_id] = outcome
437
+ if outcome.status == OperationStatus.UNKNOWN:
438
+ # Only a command that could not read its own result asks a pending close to reread; observation failures never do.
439
+ self.session_registry_controller.close_reread_count_by_key[operation.key] = 0
440
+ else:
441
+ self.session_registry_controller.close_reread_count_by_key.pop(operation.key, None)
442
+ state = self.rebase.state_by_key.get(operation.key)
443
+ publish_after_trace(lambda: self._publish_rebase_console(outcome, operation, state), self)
444
+ if outcome.status in COMPLETED_STATUS_SET:
445
+ self.console_registry_controller.settle_paused_consoles(operation.key)
446
+ shared_by_key: dict[WorktreeKey, FullRefreshRead] = {}
447
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
448
+ if session.is_closed or WorktreeKey.from_context(session.repository_context) != operation.key:
449
+ continue
450
+ window = self.session_registry_controller.window_by_session[session_id]
451
+ dialog = self.rebase_dialog_by_session.get(session_id)
452
+ if state is not None:
453
+ window.show_rebase_state(state)
454
+ if dialog is not None and isValid(dialog):
455
+ dialog.set_busy(False)
456
+ dialog.show_state(state)
457
+ if outcome.diagnostic is not None:
458
+ dialog.show_command_result(outcome.status, outcome.diagnostic.cause)
459
+ if outcome.status == OperationStatus.ABORTED:
460
+ # Nothing is left to track once the rebase is abandoned; its console keeps the result.
461
+ dialog.close()
462
+ if outcome.status == OperationStatus.SUCCESS and state.head_oid:
463
+ self.rebase_final_oid_by_session[session_id] = state.head_oid
464
+ if session_id == operation.session_id:
465
+ self.history_read_controller.history_top_session_set.add(session_id)
466
+ session.filter_text = ""
467
+ with QSignalBlocker(window.filter_edit):
468
+ window.filter_edit.clear()
469
+ self.refresh_controller.refresh(session_id, full_refresh_read=self.refresh_controller.full_refresh_read_for(session, shared_by_key, explicit=True))
470
+ shared = shared_by_key.get(operation.key)
471
+ if shared is not None:
472
+ shared.rebase_publication = self._rebase_finish_publication(operation.key)
473
+ refreshed_key_set = self.refresh_controller.observe_full_refresh_pauses(shared_by_key)
474
+ self.rebase_actions_changed()
475
+ staging = self.staging_session_registry_controller.staging_by_key.get(operation.key)
476
+ # A preparation session without any open repository window still shows the result.
477
+ if staging is not None and operation.key not in refreshed_key_set:
478
+ staging.refresh(explicit=True)
479
+ self._follow_instruction_correction(outcome, operation, validation, continue_after_correction)
480
+
481
+ def _follow_instruction_correction(self, outcome: OperationOutcome, operation: OperationIntent, validation: EditorValidation | None, continue_after_correction: bool) -> None:
482
+ """Reopen the editor on instructions Git refused, or continue the rebase once Git accepted a list a refused Continue had to correct.
483
+
484
+ Each step starts only from a result of Git that answers a click of the user in an editor window, so the loop
485
+ turns at the user's pace; Abort in the editor ends it with the whole rebase. Nothing is chained while the
486
+ repository window is closing or changing repository, or while GitPane is quitting: the rebase stays paused.
487
+ """
488
+ session = self.session_registry_controller.session_by_id.get(operation.session_id)
489
+ if session is None or session.is_closed:
490
+ return
491
+ if self.replay_controller.replay_session_leaving(operation.session_id):
492
+ # The rebase is paused, a stable state where the close, the quit or the repository change can end; the error
493
+ # stays in the console and the Rebase window lets the user go on later.
494
+ return
495
+ instruction_error = outcome_instruction_error(outcome)
496
+ if instruction_error and validation is not None:
497
+ self.reopen_rebase_todo(operation.session_id, validation, instruction_error)
498
+ return
499
+ if not continue_after_correction or outcome.status != OperationStatus.PAUSED:
500
+ return
501
+ state = self.rebase.state_by_key.get(operation.key)
502
+ if state is not None and state.fresh and state.identity is not None:
503
+ self.continue_rebase(operation.session_id, state)
504
+
505
+ def _publish_rebase_console(self, outcome: OperationOutcome, operation: OperationIntent, state: RebaseSnapshot | None) -> None:
506
+ """Write the result of a rebase in its console, then settle what depends on how that console ended.
507
+
508
+ A console that closed on an expected pause hands the user over to the Rebase window; a console that stays open
509
+ on a failure while the rebase still waits for the user is kept until the rebase ends.
510
+ """
511
+ output = self.console_registry_controller.rebase_output_by_id.get(outcome.operation_id)
512
+ expected_stop = False
513
+ if output is not None and isValid(output):
514
+ if outcome.diagnostic is not None:
515
+ output.trace.append_diagnostic(outcome.operation_id, "status", outcome.diagnostic, outcome.status)
516
+ failure_reported = outcome.diagnostic is not None
517
+ output.trace.append_end_line(outcome.operation_id, "status", rebase_result_log_text(outcome, state), rebase_result_line(outcome, state), outcome.status,
518
+ failure_reported=failure_reported)
519
+ output.show_result(outcome.status, failure_reported=failure_reported)
520
+ expected_stop = outcome.status == OperationStatus.PAUSED and output.completed
521
+ if outcome.status not in COMPLETED_STATUS_SET and state is not None and state.fresh and state.identity is not None and not expected_stop:
522
+ # A step Git refused while the rebase still waits for the user keeps its console open on the failure; that
523
+ # console is settled once the rebase ends.
524
+ self.console_registry_controller.paused_console_ids_by_key.setdefault(operation.key, []).append(outcome.operation_id)
525
+ if expected_stop and not outcome_instruction_error(outcome) and (output is None or not isValid(output) or not output.isVisible()):
526
+ # The console closed on the pause: what waits for the user is the Rebase window, so it comes to the front.
527
+ # Instructions Git refused reopen the editor instead, which must stay in front.
528
+ self._bring_rebase_dialog_forward(operation.session_id)
529
+
530
+ def _bring_rebase_dialog_forward(self, session_id: str) -> None:
531
+ """Show the Rebase window of a session in front and active, opening it as the Open rebase banner does when needed."""
532
+ session = self.session_registry_controller.session_by_id.get(session_id)
533
+ if session is None or session.is_closed:
534
+ return
535
+ dialog = self.rebase_dialog_by_session.get(session_id)
536
+ if dialog is None or not isValid(dialog) or not dialog.isVisible():
537
+ dialog = self.open_rebase_dialog(session_id)
538
+ if dialog is None or not isValid(dialog):
539
+ return
540
+ dialog.raise_()
541
+ dialog.activateWindow()
542
+
543
+ def observe_rebase(self, session_id: str, *, open_paused: bool = False, automatic: bool = False,
544
+ replay_publication: ReplayRefreshPublication | None = None, rebase_publication: RebaseRefreshPublication | None = None) -> None:
545
+ """Observe one worktree without allowing a late response to reach a replaced session.
546
+
547
+ ``replay_publication`` is the state a Cherry-pick or Revert just published when it ended, shown in place of a
548
+ replay read while it is still current. ``rebase_publication`` is the state a rebase command just published: it
549
+ is shown in place of a rebase read once its native files are found unchanged, and read through Git otherwise.
550
+ """
551
+ self.replay_controller.observe_replay(session_id, automatic=automatic, publication=replay_publication)
552
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
553
+ return
554
+ session = self.session_registry_controller.session_by_id[session_id]
555
+ context = session.repository_context
556
+ if context.is_bare:
557
+ return
558
+ identity = self.session_registry_controller.identity_by_session[session_id]
559
+ key = WorktreeKey.from_context(context)
560
+ # Checked before the delay below on purpose: returning here stamps no moment, so the first observation asked
561
+ # for once the worktree is free is not held back by a timestamp it never wrote.
562
+ if automatic and self.repository_operation.owns_worktree(key):
563
+ return
564
+ now = time.monotonic()
565
+ if automatic and (now - self.rebase_observed_at_by_key.get(key, 0)) * 1000 < REBASE_REFRESH_INTERVAL_MS:
566
+ return
567
+ self.rebase_observed_at_by_key[key] = now
568
+ existing_dialog = self.rebase_dialog_by_session.get(session_id)
569
+ if existing_dialog is not None and isValid(existing_dialog):
570
+ existing_dialog.refresh_button.setEnabled(False)
571
+ recipients = []
572
+ for target_id, target_session in self.session_registry_controller.session_by_id.items():
573
+ if (not target_session.is_closed and WindowCapability.MANAGE in target_session.capabilities
574
+ and WorktreeKey.from_context(target_session.repository_context) == key):
575
+ recipients.append((target_id, target_session, self.session_registry_controller.identity_by_session.get(target_id)))
576
+
577
+ def current() -> bool:
578
+ """Require both the original request and its independent worktree to remain current."""
579
+ return (self.session_registry_controller.identity_by_session.get(session_id) == identity and self.session_registry_controller.session_by_id.get(session_id) is session
580
+ and not session.is_closed and WorktreeKey.from_context(session.repository_context) == key)
581
+
582
+ def complete(state: RebaseSnapshot) -> None:
583
+ """Publish to the captured current views sharing this exact worktree."""
584
+ self.repository_operation.register_context(key, context)
585
+ for target_id, target_session, target_identity in recipients:
586
+ window = self.session_registry_controller.window_by_session.get(target_id)
587
+ if (self.session_registry_controller.session_by_id.get(target_id) is not target_session or self.session_registry_controller.identity_by_session.get(target_id) != target_identity
588
+ or target_session.is_closed or window is None or not isValid(window) or window.is_closed):
589
+ continue
590
+ window.show_rebase_state(state)
591
+ dialog = self.rebase_dialog_by_session.get(target_id)
592
+ self.rebase_state_shown.emit(target_id, state)
593
+ if dialog is not None and isValid(dialog):
594
+ dialog.show_state(state)
595
+ dialog.refresh_button.setEnabled(True)
596
+ elif open_paused and state.state != "absent":
597
+ self.open_rebase_dialog(target_id)
598
+
599
+ if rebase_publication is not None and not automatic:
600
+ self._show_rebase_publication(key, context, rebase_publication, current, complete)
601
+ return
602
+ self.worktree_state_controller.mark_pause_observed(key, REBASE_PAUSE_OBSERVATION)
603
+ # An automatic look waits behind every request somebody is waiting for; a click reads as a user action.
604
+ priority = ProcessPriority.BACKGROUND if automatic else ProcessPriority.USER
605
+ source = self.replay_controller.passive_observation_source(key) if automatic else None
606
+ if source is None:
607
+ self.rebase.observe(context, self.repository.git_filename, is_current=current, callback=complete, priority=priority)
608
+ return
609
+ self.rebase.observe_passive(context, self.repository.git_filename, source, is_current=current, callback=complete, priority=priority)
610
+
611
+ def _rebase_finish_publication(self, key: WorktreeKey) -> RebaseRefreshPublication | None:
612
+ """Answer the state the rebase command that just ended published for this worktree, while nothing newer replaced it.
613
+
614
+ Only a fresh state that still carries the native files its capture proved stable is offered: a refusal, an
615
+ error or a message of the command's own leaves nothing to show without reading again. A read that started before
616
+ the publication, such as the one an editor wait asked for, never replaces it, so it does not stand in the way.
617
+ """
618
+ state = self.rebase.state_by_key.get(key)
619
+ if (state is None or not state.fresh or state.native_observations is None or state.generation != self.rebase.published_generation_by_key.get(key)
620
+ or state.generation != self.rebase.generation_by_key.get(key)):
621
+ return None
622
+ return RebaseRefreshPublication(state, self.repository.git_filename)
623
+
624
+ def _rebase_publication_current(self, key: WorktreeKey, publication: RebaseRefreshPublication) -> bool:
625
+ """Tell whether a rebase finish publication may still be shown: the latest state of this worktree, same Git, and no read started since.
626
+
627
+ Every read started after the publication takes a newer generation; one started before it cannot replace it.
628
+ """
629
+ state = publication.state
630
+ return (state.key == key and publication.git_filename == self.repository.git_filename and self.rebase.state_by_key.get(key) is state
631
+ and self.rebase.generation_by_key.get(key) == state.generation)
632
+
633
+ def _show_rebase_publication(self, key: WorktreeKey, context: RepositoryContext, publication: RebaseRefreshPublication, current: Callable[[], bool],
634
+ complete: Callable[[RebaseSnapshot], None]) -> None:
635
+ """Show a rebase command's published state after proving, on a worker, that its native files are still the captured ones.
636
+
637
+ The pause is acknowledged only once that proof holds, so the periodic refresh still looks at anything that moves
638
+ afterwards. A publication that stopped being current, pause paths not known yet, a changed or unreadable native
639
+ file: each reads the rebase state through Git instead.
640
+ """
641
+ git_filename = self.repository.git_filename
642
+ staging = self.staging_session_registry_controller.staging_by_key.get(key)
643
+ filename_by_git_path = staging.pause_probe.filename_by_git_path() if staging is not None else None
644
+
645
+ def read_through_git() -> None:
646
+ """Observe the rebase as any refresh does."""
647
+ self.worktree_state_controller.mark_pause_observed(key, REBASE_PAUSE_OBSERVATION)
648
+ self.rebase.observe(context, git_filename, is_current=current, callback=complete)
649
+
650
+ if filename_by_git_path is None or not self._rebase_publication_current(key, publication):
651
+ read_through_git()
652
+ return
653
+
654
+ def prove() -> Generator[FileTask, object, bool]:
655
+ """Compare the native rebase files with the ones the command's capture proved, off the Qt thread."""
656
+ state_dirs = state_dirs_from_pause_paths(key, filename_by_git_path)
657
+ unchanged = yield FileTask(lambda: native_observations_unchanged(state_dirs, publication.state.native_observations))
658
+ return unchanged
659
+
660
+ def proven(unchanged: bool | None, error: Exception | None) -> None:
661
+ """Show the publication only if it is still the latest state and its files did not move; otherwise read."""
662
+ if not current():
663
+ return
664
+ if error is None and unchanged and self._rebase_publication_current(key, publication) and git_filename == self.repository.git_filename:
665
+ self.worktree_state_controller.mark_pause_observed(key, REBASE_PAUSE_OBSERVATION)
666
+ complete(publication.state)
667
+ return
668
+ read_through_git()
669
+
670
+ self.worktree_service.run(context, git_filename, prove(), proven)
671
+
672
+ def open_rebase_dialog(self, session_id: str) -> RebaseDialog | None:
673
+ """Show the shared observation without resuming or modifying the native operation."""
674
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
675
+ return None
676
+ window = self.session_registry_controller.window_by_session[session_id]
677
+ state = self.rebase.state_by_key.get(WorktreeKey.from_context(window.context))
678
+ if window.context.is_bare or state is None or state.state == "absent":
679
+ return None
680
+ dialog = self.rebase_dialog_by_session.get(session_id)
681
+ if dialog is None or not isValid(dialog):
682
+ dialog = self._create_rebase_dialog(session_id, state)
683
+ dialog.show_state(state)
684
+ dialog.show()
685
+ dialog.raise_()
686
+ return dialog
687
+
688
+ def _create_rebase_dialog(self, session_id: str, state: RebaseSnapshot) -> RebaseDialog:
689
+ """Create the single tracking window wiring so launch and reopen behave identically, releasing the window it replaces."""
690
+ previous = self.rebase_dialog_by_session.get(session_id)
691
+ if previous is not None and isValid(previous):
692
+ # A finished rebase leaves its tracking window parented to the repository window; a new launch replaces it rather than stacking another one.
693
+ previous.close()
694
+ previous.deleteLater()
695
+ dialog = RebaseDialog(state, self.session_registry_controller.window_by_session[session_id])
696
+ dialog.installEventFilter(self.window_activation_controller)
697
+ requested_oid_set: set[str] = set()
698
+
699
+ def read_commit_details(steps: tuple[RebaseStep, ...]) -> None:
700
+ """Load each original commit once without delaying or replacing native rebase observations."""
701
+ missing_steps = []
702
+ for step in steps:
703
+ if step.oid and step.oid not in requested_oid_set:
704
+ requested_oid_set.add(step.oid)
705
+ missing_steps.append(step)
706
+ if not missing_steps:
707
+ return
708
+
709
+ def complete(commits: tuple[RebaseCommit, ...] | None, error: Exception | None) -> None:
710
+ """Discard late display results when their tracking window has been replaced or closed."""
711
+ if isValid(dialog) and self.rebase_dialog_by_session.get(session_id) is dialog and error is None and commits:
712
+ dialog.show_commits(commits)
713
+
714
+ self.worktree_service.run(self.session_registry_controller.window_by_session[session_id].context, self.repository.git_filename, read_rebase_commits(tuple(missing_steps)), complete)
715
+
716
+ dialog.commit_details_requested.connect(read_commit_details)
717
+ # The window showed its first state before this connection existed, and an equal state keeps its rows without asking again.
718
+ dialog.request_commit_details()
719
+ dialog.refresh_requested.connect(requested_oid_set.clear)
720
+ dialog.refresh_requested.connect(lambda: self.observe_rebase(session_id))
721
+ dialog.edit_todo_requested.connect(lambda: self.edit_rebase_todo(session_id))
722
+ dialog.amend_requested.connect(lambda state: self.amend_rebase_commit(session_id, state))
723
+ dialog.continue_requested.connect(lambda state: self.continue_rebase(session_id, state))
724
+ dialog.abort_requested.connect(lambda state: self.abort_rebase(session_id, state))
725
+ dialog.solve_conflicts_requested.connect(lambda: self.conflict_list_requested.emit(session_id))
726
+ self.rebase_dialog_by_session[session_id] = dialog
727
+ dialog.show_state(state)
728
+ # No periodic recomputation comes after this one: a window created while another operation owns the
729
+ # repository has to show it at once.
730
+ self.rebase_actions_changed()
731
+ return dialog
732
+
733
+ def amend_rebase_commit(self, session_id: str, state: RebaseSnapshot) -> None:
734
+ """Open the existing staging view for one explicitly selected native edit pause."""
735
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
736
+ return
737
+ context = self.session_registry_controller.session_by_id[session_id].repository_context
738
+ if state.key != WorktreeKey.from_context(context) or state.state != "edit" or not state.fresh:
739
+ return
740
+ staging = self.staging_session_registry_controller.staging_session(context)
741
+ if staging.action_diagnostic(OperationAction.AMEND):
742
+ self.observe_rebase(session_id)
743
+ return
744
+ if self.staging_controller.open_staging(session_id) is not None:
745
+ staging.request_rebase_amend(state)
746
+
747
+ def abort_rebase(self, session_id: str, state: RebaseSnapshot) -> None:
748
+ """Submit an immediate native Abort independently of the launch confirmation."""
749
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
750
+ return
751
+ session = self.session_registry_controller.session_by_id[session_id]
752
+ if state.key != WorktreeKey.from_context(session.repository_context) or state.identity is None:
753
+ return
754
+ for active in self.repository_operation.active_by_id.values():
755
+ if active.action == OperationAction.REBASE_ABORT and active.key == state.key:
756
+ return
757
+ operation = OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
758
+ session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id], operation="Abort rebase",
759
+ action=OperationAction.REBASE_ABORT)
760
+ intent = RebaseIntent(operation, state.identity.onto_oid, state.head_oid, state.identity.head_name)
761
+ if self.mergetool.owns_repository(operation.common_git_dir):
762
+ self._rebase_refused(intent, ValueError("Wait for the merge tool and its series to finish before aborting the rebase."))
763
+ return
764
+ output = self._create_rebase_output(intent)
765
+ output.set_running()
766
+ self.rebase_progress(operation.operation_id, "Aborting rebase…")
767
+ try:
768
+ self.rebase.abort(operation, state, is_current=lambda: self.operation_policy_controller.operation_allowed(operation)
769
+ and not self.mergetool.owns_repository(operation.common_git_dir))
770
+ except (ValueError, OSError) as error:
771
+ diagnostic = Diagnostic("Abort rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
772
+ self.rebase_finished(OperationOutcome(operation.operation_id, OperationStatus.REFUSED, diagnostic=diagnostic))
773
+
774
+ def continue_rebase(self, session_id: str, state: RebaseSnapshot) -> None:
775
+ """Reserve a distinct explicit Continue and route its native result to the tracking view."""
776
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
777
+ return
778
+ session = self.session_registry_controller.session_by_id[session_id]
779
+ if state.key != WorktreeKey.from_context(session.repository_context) or state.identity is None:
780
+ return
781
+ operation = OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
782
+ session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id], operation="Continue rebase",
783
+ action=OperationAction.REBASE_CONTINUE)
784
+ intent = RebaseIntent(operation, state.identity.onto_oid, state.head_oid, state.identity.head_name)
785
+ if self.mergetool.owns_repository(operation.common_git_dir):
786
+ self._rebase_refused(intent, ValueError("Wait for the merge tool and its series to finish before continuing the rebase."))
787
+ return
788
+ output = self._create_rebase_output(intent)
789
+ output.set_running()
790
+ self.rebase_progress(operation.operation_id, SharedText.REBASE_APPLYING_COMMITS.value)
791
+ self._expect_edits(operation, EditorValidation.CONTINUE)
792
+ try:
793
+ self.rebase.continue_rebase(operation, state,
794
+ is_current=lambda: self.operation_policy_controller.operation_allowed(operation) and not self.mergetool.owns_repository(operation.common_git_dir))
795
+ except (ValueError, OSError) as error:
796
+ diagnostic = Diagnostic("Continue rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
797
+ self.rebase_finished(OperationOutcome(operation.operation_id, OperationStatus.REFUSED, diagnostic=diagnostic))