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,1677 @@
1
+ """Run the forms that create, move, delete or check out a reference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import replace
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shiboken6 import isValid
11
+
12
+ from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, TAG_PREFIX, ConsoleOperation, SharedText
13
+ from gitpane.controller.refresh_controller import RefreshScope
14
+ from gitpane.history.file_history import WindowCapability
15
+ from gitpane.history.repository_value import ReferenceKind
16
+ from gitpane.history.revision import commit_count_text, commit_difference_in_memory, complete_ancestor_oid_set, is_ancestor_in_memory, resolve_revision_in_memory, revision_text_problem, unmerged_branch_refs_in_memory
17
+ from gitpane.operation.console_line import REPOSITORY_HEADER_LABEL, ConsoleHeader, end_line_with_cause, operation_end_text
18
+ from gitpane.operation.diagnostic import Diagnostic
19
+ from gitpane.operation.operation_status import OperationStatus
20
+ from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
21
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult
22
+ from gitpane.reference.reference_edit import (
23
+ DELETE_LOCAL_BRANCH_OPERATION,
24
+ DELETE_LOCAL_TAG_OPERATION,
25
+ DELETE_REMOTE_BRANCH_OPERATION,
26
+ DELETE_REMOTE_TAG_OPERATION,
27
+ BranchDeletionIntent,
28
+ BranchMode,
29
+ ResetMode,
30
+ deletable_branch_refs,
31
+ merge_base_oid_by_ref,
32
+ push_remote_name,
33
+ remote_branch_destination,
34
+ remote_delete_candidates,
35
+ suggested_branch_name,
36
+ )
37
+ from gitpane.reference.reference_snapshot import short_reference_name
38
+ from gitpane.transport.branch_exchange import TransportRequest
39
+ from gitpane.ui.console.operation_dialog import OperationDialog
40
+ from gitpane.ui.reference.checkout_dialog import CheckoutDialog
41
+ from gitpane.ui.reference.checkout_revision_dialog import CheckoutRevisionDialog
42
+ from gitpane.ui.reference.create_branch_dialog import CreateBranchDialog
43
+ from gitpane.ui.reference.create_tag_dialog import CreateTagDialog
44
+ from gitpane.ui.reference.delete_local_branch_dialog import DeleteLocalBranchDialog
45
+ from gitpane.ui.reference.delete_tag_dialog import NO_PUSH_ADDRESS_CAUSE, NO_REMOTE_CAUSE, SEVERAL_PUSH_ADDRESSES_CAUSE, UNUSABLE_REMOTE_CAUSE, DeleteTagDialog
46
+ from gitpane.ui.reference.form_opening import refuse_form_opening, reveal_form
47
+ from gitpane.ui.reference.reference_dialog import DeleteBranchDialog
48
+ from gitpane.ui.reference.rename_branch_dialog import RenameBranchDialog
49
+ from gitpane.ui.reference.reset_branch_dialog import ResetOtherBranchDialog
50
+ from gitpane.ui.reference.reset_dialog import ResetDialog
51
+ from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
52
+ from gitpane.ui.widget.refresh_advice import refresh_advice_text
53
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
54
+ from gitpane.worktree.command_sequence import GitCommandError
55
+
56
+ if TYPE_CHECKING:
57
+ from collections.abc import Callable, Generator
58
+
59
+ from gitpane.controller.console_registry import ConsoleRegistry
60
+ from gitpane.controller.history_read_controller import HistoryReadController
61
+ from gitpane.controller.operation_policy import OperationPolicy
62
+ from gitpane.controller.reference_read_controller import ReferenceReadController
63
+ from gitpane.controller.refresh_controller import RefreshController
64
+ from gitpane.controller.session_registry import SessionRegistry
65
+ from gitpane.controller.staging_controller import StagingController
66
+ from gitpane.controller.transport_controller import TransportController
67
+ from gitpane.history.repository import GitRepository
68
+ from gitpane.operation.diagnostic import DiagnosticLog
69
+ from gitpane.preference.preference_store import PreferenceStore
70
+ from gitpane.reference.reference_edit import BranchIntent, CheckoutIntent, MergeAbortIntent, ReferenceDeletionIntent, ReferenceService, ResetIntent, TagIntent
71
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
72
+ from gitpane.sequencer.replay import ReplayIntent
73
+ from gitpane.transport.branch_exchange import TransportService
74
+ from gitpane.transport.remote import RemoteService
75
+ from gitpane.transport.remote_config import RemoteSnapshot
76
+ from gitpane.ui.sequencer.replay_dialog import ReplayDialog
77
+ from gitpane.worktree.worktree_service import WorktreeService
78
+
79
+ # The reference forms built around the Git Extensions revision field.
80
+ RevisionForm = CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog
81
+ # Text the --log file keeps at the end of a reference operation (Checkout, branches, tags, Cherry pick, Revert); the
82
+ # console shows the operation's name and the word of its status instead.
83
+ REFERENCE_RESULT_LOG_TEMPLATE = "Result: {status}"
84
+ # The branch forms whose result is a branch on a new commit or a new name, shown at the top of the graph once done.
85
+ HISTORY_TOP_BRANCH_MODE_SET = frozenset((BranchMode.CREATE, BranchMode.CREATE_CHECKOUT, BranchMode.CREATE_ORPHAN, BranchMode.RENAME))
86
+ # A Cherry-pick or Revert console shows what the user would run in a terminal: Git's own command, its output and its
87
+ # result. The reads GitPane runs around it for its checks stay in the --log file only.
88
+ NATIVE_ONLY_CONSOLE_ACTION_SET = frozenset((OperationAction.REPLAY_START, OperationAction.REPLAY_CONTINUE, OperationAction.REPLAY_ABORT, OperationAction.MERGE_ABORT))
89
+
90
+
91
+ class ReferenceFormController:
92
+ """The reference forms of every repository window: Reset, Create tag, the branch forms, the deletions of branches and tags, Checkout and Checkout revision, with the consoles of the reference operations they start."""
93
+
94
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, console_registry_controller: ConsoleRegistry, reference_read_controller: ReferenceReadController,
95
+ refresh_controller: RefreshController, history_read_controller: HistoryReadController, staging_controller: StagingController, transport_controller: TransportController, preference: PreferenceStore, repository: GitRepository,
96
+ worktree_service: WorktreeService, reference: ReferenceService, remote: RemoteService, transport: TransportService, diagnostic_log: DiagnosticLog | None) -> None:
97
+ """Receive the services and the lower controllers the reference forms work with; the wiring builds it after the transport controller, and the repository windows, the replay and the conflict reach it through the wiring."""
98
+ self.session_registry_controller = session_registry_controller
99
+ self.operation_policy_controller = operation_policy_controller
100
+ self.reference_read_controller = reference_read_controller
101
+ self.refresh_controller = refresh_controller
102
+ self.repository = repository
103
+ self.worktree_service = worktree_service
104
+ self.reference = reference
105
+ self.diagnostic_log = diagnostic_log
106
+ self.console_registry_controller = console_registry_controller
107
+ self.history_read_controller = history_read_controller
108
+ self.transport_controller = transport_controller
109
+ self.preference = preference
110
+ self.remote = remote
111
+ self.transport = transport
112
+ self.staging_controller = staging_controller
113
+ self.reset_dialog_by_session: dict[str, ResetDialog] = {}
114
+ self.reset_intent_by_id: dict[str, ResetIntent] = {}
115
+ self.reference_trace_by_id: dict[str, OperationTrace] = {}
116
+ self.reference_label_by_id: dict[str, str] = {}
117
+ self.reference_form_by_id: dict[str, CheckoutDialog | CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog | DeleteLocalBranchDialog | ReplayDialog | None] = {}
118
+ self.branch_dialog_by_session: dict[str, CreateBranchDialog | RenameBranchDialog | ResetOtherBranchDialog] = {}
119
+ self.branch_intent_by_id: dict[str, BranchIntent] = {}
120
+ # The deletion form of each window: a remote branch's, or Delete tag; one at a time per window.
121
+ self.delete_dialog_by_session: dict[str, DeleteBranchDialog | DeleteTagDialog] = {}
122
+ self.delete_intent_by_id: dict[str, ReferenceDeletionIntent] = {}
123
+ # The Delete branch window of local branches, and the group deletions it submitted, by operation.
124
+ self.local_branch_delete_dialog_by_session: dict[str, DeleteLocalBranchDialog] = {}
125
+ self.branch_deletion_intent_by_id: dict[str, BranchDeletionIntent] = {}
126
+ self.tag_dialog_by_session: dict[str, CreateTagDialog] = {}
127
+ self.tag_intent_by_id: dict[str, TagIntent] = {}
128
+ self.created_tag_by_id: dict[str, tuple[TagIntent, str, str]] = {}
129
+ self.checkout_dialog_by_session: dict[str, CheckoutDialog] = {}
130
+ self.checkout_revision_dialog_by_session: dict[str, CheckoutRevisionDialog] = {}
131
+ self.checkout_intent_by_id: dict[str, CheckoutIntent] = {}
132
+
133
+ def open_reset(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> ResetDialog | None:
134
+ """Capture current-branch reset authority and local state before user confirmation."""
135
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
136
+ return None
137
+ window = self.session_registry_controller.window_by_session[session_id]
138
+ if window.context.is_bare:
139
+ return None
140
+ existing = self.reset_dialog_by_session.get(session_id)
141
+ if existing is not None and not existing.closed:
142
+ existing.raise_()
143
+ return existing
144
+ session = self.session_registry_controller.session_by_id[session_id]
145
+ generation = session.generation
146
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
147
+ dialog = ResetDialog(session_id, target_oid, window)
148
+ self.reset_dialog_by_session[session_id] = dialog
149
+ dialog.reset_requested.connect(lambda intent: self.submit_reset(session_id, intent))
150
+ dialog.finished.connect(lambda: self.reset_dialog_by_session.pop(session_id, None) if self.reset_dialog_by_session.get(session_id) is dialog else None)
151
+
152
+ context, git_filename = window.context, self.repository.git_filename
153
+
154
+ def is_current() -> bool:
155
+ """Reject late reads after closing or replacing the form or the repository authority."""
156
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.reset_dialog_by_session, context, git_filename)
157
+
158
+ def metadata_complete(fields: tuple | None, error: Exception | None) -> None:
159
+ """Publish only the originally requested commit's metadata, and never to a submitted form."""
160
+ if is_current() and not dialog.busy and not dialog.completed:
161
+ dialog.show_metadata(fields, str(error) if error else "")
162
+
163
+ preparing = False
164
+
165
+ def prepare_local() -> None:
166
+ """Capture file and index authority only when the chosen mode can change them."""
167
+ nonlocal preparing
168
+ snapshot = dialog.snapshot
169
+ if preparing or snapshot is None or not is_current() or dialog.mode == ResetMode.SOFT or dialog.worktree_snapshot is not None:
170
+ return
171
+ preparing = True
172
+ dialog.status_label.setText("Checking index and working files…")
173
+
174
+ def local_complete(result: tuple | None, error: Exception | None) -> None:
175
+ """Never replace submitted authority when a background read finishes."""
176
+ nonlocal preparing
177
+ preparing = False
178
+ if not is_current() or dialog.busy or dialog.completed:
179
+ return
180
+ if error is not None:
181
+ if dialog.mode != ResetMode.SOFT:
182
+ dialog.show_error(str(error))
183
+ return
184
+ local, signature, paths, target_paths = result
185
+ dialog.populate(snapshot, local, signature, paths, target_paths)
186
+ dialog.status_label.clear()
187
+
188
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.capture_reset(snapshot, target_oid), local_complete)
189
+
190
+ dialog.preparation_requested.connect(prepare_local)
191
+
192
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
193
+ """Check symbolic HEAD and read local bytes before enabling the form."""
194
+ if not is_current() or dialog.busy or dialog.completed:
195
+ return
196
+ if error is not None:
197
+ refuse_form_opening(dialog, str(error))
198
+ return
199
+ if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
200
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
201
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
202
+ return
203
+ if not snapshot.head_ref or not snapshot.head_oid:
204
+ refuse_form_opening(dialog, "Reset requires a current local branch with a commit")
205
+ return
206
+
207
+ dialog.populate(snapshot)
208
+ reveal_form(dialog)
209
+ self.reference.metadata(snapshot, target_oid, metadata_complete)
210
+ prepare_local()
211
+
212
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
213
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_reset(session_id, target_oid, expected_snapshot=capture))
214
+ return dialog
215
+
216
+ def submit_reset(self, session_id: str, intent: ResetIntent) -> str | None:
217
+ """Submit only the captured reset from its authorized live form."""
218
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
219
+ return None
220
+ dialog = self.reset_dialog_by_session.get(session_id)
221
+ if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id or not dialog.busy:
222
+ return None
223
+ session = self.session_registry_controller.session_by_id[session_id]
224
+
225
+ def is_current() -> bool:
226
+ """Recheck live session, executable and context at the actual process launch."""
227
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
228
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
229
+
230
+ self.reset_intent_by_id[intent.operation.operation_id] = intent
231
+ return self.reference.reset(intent, is_current=is_current)
232
+
233
+ def reset_finished(self, outcome: object) -> None:
234
+ """Show observed reset effects and refresh related windows after real process exit."""
235
+ intent = self.reset_intent_by_id.pop(outcome.operation_id, None)
236
+ if intent is None:
237
+ return
238
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
239
+ dialog = self.reset_dialog_by_session.get(intent.operation.session_id)
240
+ if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
241
+ dialog.show_outcome(outcome)
242
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
243
+ self.diagnostic_log.write(outcome.diagnostic)
244
+ if outcome.status == OperationStatus.REFUSED:
245
+ return
246
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
247
+
248
+ def open_tag(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> CreateTagDialog | None:
249
+ """Open the Create tag window on one commit, drawn from what the repository window already holds.
250
+
251
+ The references come from the window's own capture while their files are unchanged, the push remote from their
252
+ listing and the position from the history graph in memory; the window shows no commit details, so opening it
253
+ on a displayed commit of a fully loaded graph starts no Git process.
254
+ """
255
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
256
+ return None
257
+ window = self.session_registry_controller.window_by_session[session_id]
258
+ if window.context.is_bare:
259
+ return None
260
+ existing = self.tag_dialog_by_session.get(session_id)
261
+ if existing is not None and not existing.closed:
262
+ existing.raise_()
263
+ return existing
264
+ session = self.session_registry_controller.session_by_id[session_id]
265
+ generation = session.generation
266
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
267
+ dialog = CreateTagDialog(session_id, target_oid, window)
268
+ dialog.setAccessibleDescription(f"Create a tag in {window.context.repository_dir}")
269
+ self.tag_dialog_by_session[session_id] = dialog
270
+ dialog.tag_requested.connect(lambda intent: self.submit_tag(session_id, intent))
271
+ dialog.finished.connect(lambda: self.tag_dialog_by_session.pop(session_id, None) if self.tag_dialog_by_session.get(session_id) is dialog else None)
272
+ context, git_filename = window.context, self.repository.git_filename
273
+
274
+ def is_current() -> bool:
275
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
276
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.tag_dialog_by_session, context, git_filename) and not dialog.busy
277
+
278
+ self._bind_revision_picker(session_id, dialog, is_current)
279
+
280
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
281
+ """Show the commit and the push remote only while the form and the references it was asked from are current."""
282
+ if not is_current():
283
+ return
284
+ if error is not None:
285
+ refuse_form_opening(dialog, str(error))
286
+ return
287
+ if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
288
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
289
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
290
+ return
291
+ dialog.populate(snapshot, target_oid, push_remote_name(snapshot))
292
+ reveal_form(dialog)
293
+
294
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
295
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_tag(session_id, target_oid, expected_snapshot=capture))
296
+ return dialog
297
+
298
+ def submit_tag(self, session_id: str, intent: TagIntent) -> str | None:
299
+ """Submit only the exact tag intention displayed by the authorized live form."""
300
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
301
+ return None
302
+ dialog = self.tag_dialog_by_session.get(session_id)
303
+ if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
304
+ return None
305
+ session = self.session_registry_controller.session_by_id[session_id]
306
+
307
+ def is_current() -> bool:
308
+ """Recheck the form and repository authority when queued Git actually starts."""
309
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
310
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
311
+
312
+ self.tag_intent_by_id[intent.operation.operation_id] = intent
313
+ self.open_reference_output(intent, dialog)
314
+ return self.reference.create_tag(intent, is_current=is_current)
315
+
316
+ def tag_finished(self, outcome: object) -> None:
317
+ """Publish separate creation and refresh results before refreshing related windows."""
318
+ intent = self.tag_intent_by_id.pop(outcome.operation_id, None)
319
+ if intent is None:
320
+ return
321
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
322
+ dialog = self.tag_dialog_by_session.get(intent.operation.session_id)
323
+ if outcome.status == OperationStatus.SUCCESS and intent.push_after_creation:
324
+ creation = next((step for step in outcome.steps if step.operation == "Create tag"), None)
325
+ object_oid = creation.after.object_oid_by_name.get(intent.full_ref) if creation is not None and creation.after is not None else None
326
+ if object_oid:
327
+ # The remote the form's Push check box named, derived from the very references the request was frozen on.
328
+ self.created_tag_by_id[outcome.operation_id] = (intent, object_oid, push_remote_name(intent.snapshot))
329
+ output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
330
+ if output is not None:
331
+ output.followup_requested.connect(lambda: self.push_created_tag(outcome.operation_id))
332
+ if (dialog is self.reference_form_by_id.get(outcome.operation_id) and dialog is not None
333
+ and isValid(dialog) and not dialog.closed and dialog.intent == intent):
334
+ dialog.show_outcome(outcome)
335
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
336
+ self.diagnostic_log.write(outcome.diagnostic)
337
+ if outcome.status != OperationStatus.REFUSED:
338
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
339
+ self.push_created_tag(outcome.operation_id)
340
+ self.finish_reference_output(outcome)
341
+
342
+ def push_created_tag(self, operation_id: str) -> None:
343
+ """Open the exact created tag's Push form on the remote the Create tag form named, keeping a retry if another Push is busy.
344
+
345
+ The Push check box of Create tag never pushes by itself: this only opens Push, where the user confirms.
346
+ """
347
+ capture = self.created_tag_by_id.get(operation_id)
348
+ output = self.console_registry_controller.reference_output_by_id.get(operation_id)
349
+ if capture is None or output is None or not isValid(output) or output.dismissed:
350
+ return
351
+ intent, object_oid, remote_name = capture
352
+ session_id = intent.operation.session_id
353
+ session = self.session_registry_controller.session_by_id.get(session_id)
354
+ if (not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or session is None
355
+ or intent.snapshot.context != session.repository_context or intent.snapshot.git_filename != self.repository.git_filename):
356
+ output.show_followup("Tag created locally. Reopen the original repository with management access before pushing it.")
357
+ return
358
+ existing = self.transport_controller.push_dialog_by_session.get(session_id)
359
+ if existing is not None and not existing.closed and (existing.busy or existing.commit_push_intent is not None):
360
+ output.show_followup("Tag created locally. Finish and close the existing Push dialog, then choose Push tag.")
361
+ return
362
+ push_dialog = self.transport_controller.open_push(session_id)
363
+ if push_dialog is None or not push_dialog.pin_tag(intent.full_ref, object_oid, remote_name):
364
+ output.show_followup("Tag created locally. Push could not be prepared. Choose Push tag to try again.")
365
+ return
366
+ if existing is push_dialog:
367
+ self.transport_controller.read_push(session_id, push_dialog)
368
+ self.created_tag_by_id.pop(operation_id, None)
369
+ output.show_followup()
370
+ if not output.running:
371
+ output.show_result(OperationStatus.SUCCESS)
372
+
373
+ def open_delete_branch(self, session_id: str, full_ref: str, target_oid: str, *,
374
+ expected_snapshot: ReferenceSnapshot | None = None) -> DeleteBranchDialog | DeleteLocalBranchDialog | DeleteTagDialog | None:
375
+ """Open the deletion form of the clicked reference, only for a live management session.
376
+
377
+ A local branch opens the Delete branch window of Git Extensions, which deletes local branches only; a tag opens
378
+ the Delete tag window; a remote branch keeps its own form, which deletes it on the server.
379
+ """
380
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
381
+ return None
382
+ if full_ref.startswith(LOCAL_PREFIX):
383
+ return self._open_local_branch_deletion(session_id, full_ref, target_oid, expected_snapshot)
384
+ if full_ref.startswith(TAG_PREFIX):
385
+ return self._open_tag_deletion(session_id, full_ref, target_oid, expected_snapshot)
386
+ if not full_ref.startswith(REMOTE_PREFIX):
387
+ return None
388
+ window = self.session_registry_controller.window_by_session[session_id]
389
+ if window.context.is_bare:
390
+ return None
391
+ existing = self.delete_dialog_by_session.get(session_id)
392
+ if existing is not None and not existing.closed:
393
+ existing.raise_()
394
+ return existing
395
+ session = self.session_registry_controller.session_by_id[session_id]
396
+ generation = session.generation
397
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
398
+ dialog = DeleteBranchDialog(session_id, full_ref, window)
399
+ self.delete_dialog_by_session[session_id] = dialog
400
+ dialog.prepare_requested.connect(lambda intent: self._prepare_delete(session_id, dialog, intent))
401
+ dialog.delete_requested.connect(lambda intent: self.submit_delete(session_id, intent))
402
+ dialog.destination_changed.connect(lambda: self._prepare_delete_target(session_id, dialog))
403
+ dialog.retry_requested.connect(lambda: self._retry_delete(session_id, dialog))
404
+ dialog.finished.connect(lambda: self.delete_dialog_by_session.pop(session_id, None) if self.delete_dialog_by_session.get(session_id) is dialog else None)
405
+
406
+ context, git_filename = window.context, self.repository.git_filename
407
+
408
+ def is_current() -> bool:
409
+ """Discard reads for a closed or replaced form or a replaced repository session."""
410
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, context, git_filename)
411
+
412
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
413
+ """Refuse a stale badge capture before exposing a usable Delete button."""
414
+ if not is_current():
415
+ return
416
+ if error is not None:
417
+ refuse_form_opening(dialog, str(error))
418
+ return
419
+ if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
420
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
421
+ or snapshot.object_oid_by_name.get(full_ref) != target_oid):
422
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
423
+ return
424
+ dialog.populate(snapshot)
425
+ reveal_form(dialog)
426
+ def remote_complete(remote_snapshot: RemoteSnapshot | None, remote_error: Exception | None) -> None:
427
+ """Attach remote configuration only when it still describes the same local capture."""
428
+ if not is_current() or dialog.busy or dialog.outcome is not None:
429
+ return
430
+ if remote_error is not None:
431
+ dialog.status_label.setText(dialog.text_filter.redact("Remote destination could not be read: " + str(remote_error)))
432
+ return
433
+ if remote_snapshot.reference_state != snapshot.signature:
434
+ dialog.show_error("References or configuration changed while reading remote destinations")
435
+ return
436
+ dialog.populate_remote(remote_snapshot)
437
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.remote.capture(snapshot.context, snapshot.git_filename, generation), remote_complete)
438
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
439
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_branch(session_id, full_ref, target_oid, expected_snapshot=capture))
440
+ return dialog
441
+
442
+ def _open_local_branch_deletion(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> DeleteLocalBranchDialog | None:
443
+ """Open the Delete branch window on the clicked local branch, from what the repository window already holds.
444
+
445
+ The references come from the window's own capture while their files are unchanged, and the deletable branches
446
+ are computed from them, so opening the window starts no Git process. A capture that no longer matches the
447
+ menu the branch was chosen from refuses the window instead of offering another branch.
448
+ """
449
+ window = self.session_registry_controller.window_by_session[session_id]
450
+ if window.context.is_bare:
451
+ return None
452
+ existing = self.local_branch_delete_dialog_by_session.get(session_id)
453
+ if existing is not None and not existing.closed:
454
+ existing.raise_()
455
+ return existing
456
+ session = self.session_registry_controller.session_by_id[session_id]
457
+ generation = session.generation
458
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
459
+ dialog = DeleteLocalBranchDialog(session_id, window)
460
+ dialog.setAccessibleDescription(f"Delete local branches in {window.context.repository_dir}")
461
+ self.local_branch_delete_dialog_by_session[session_id] = dialog
462
+ dialog.delete_requested.connect(lambda full_refs: self._prepare_branch_deletion(session_id, dialog, full_refs))
463
+ dialog.finished.connect(lambda: self.local_branch_delete_dialog_by_session.pop(session_id, None)
464
+ if self.local_branch_delete_dialog_by_session.get(session_id) is dialog else None)
465
+ context, git_filename = window.context, self.repository.git_filename
466
+
467
+ def is_current() -> bool:
468
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
469
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.local_branch_delete_dialog_by_session, context, git_filename)
470
+
471
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
472
+ """Offer the deletable branches only when the references still match the menu the branch was chosen from."""
473
+ if not is_current():
474
+ return
475
+ if error is not None:
476
+ refuse_form_opening(dialog, str(error))
477
+ return
478
+ if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
479
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
480
+ or snapshot.object_oid_by_name.get(full_ref) != target_oid):
481
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
482
+ return
483
+ dialog.populate(snapshot, deletable_branch_refs(snapshot), full_ref)
484
+ reveal_form(dialog)
485
+
486
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
487
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_branch(session_id, full_ref, target_oid, expected_snapshot=capture))
488
+ return dialog
489
+
490
+ def _local_branch_deletion_is_current(self, session_id: str, dialog: DeleteLocalBranchDialog) -> bool:
491
+ """Tell whether the Delete branch window may still act: open, registered for its window, with management rights."""
492
+ return (isValid(dialog) and not dialog.closed and self.local_branch_delete_dialog_by_session.get(session_id) is dialog
493
+ and dialog.snapshot is not None and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE))
494
+
495
+ def _prepare_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, full_refs: tuple[str, ...]) -> None:
496
+ """Sort the chosen branches into merged and not merged as "git branch -d" would, then ask about the others once.
497
+
498
+ Each branch is compared with its upstream when it has one, and with HEAD otherwise. The history graph in memory
499
+ answers when it is complete and was read for the same HEAD as the form; otherwise Git is asked once per
500
+ distinct base commit. Merged branches alone are deleted without a question.
501
+ """
502
+ if not self._local_branch_deletion_is_current(session_id, dialog):
503
+ return
504
+ snapshot = dialog.snapshot
505
+ for full_ref in full_refs:
506
+ if full_ref not in dialog.candidate_refs:
507
+ dialog.release()
508
+ dialog.show_error(f"'{short_reference_name(full_ref)}' cannot be deleted from this window")
509
+ return
510
+ base_oid_by_ref = merge_base_oid_by_ref(snapshot, full_refs)
511
+ branch_oid_by_ref: dict[str, str] = {}
512
+ for full_ref in full_refs:
513
+ branch_oid_by_ref[full_ref] = snapshot.object_oid_by_name[full_ref]
514
+ window = self.session_registry_controller.window_by_session.get(session_id)
515
+ history_snapshot = window.history_model.snapshot if window is not None and isValid(window) else None
516
+ unmerged_refs = None
517
+ if history_snapshot is not None and history_snapshot.head_oid == snapshot.head_oid:
518
+ unmerged_refs = unmerged_branch_refs_in_memory(branch_oid_by_ref, base_oid_by_ref, history_snapshot.parent_oids_by_oid, history_snapshot.complete)
519
+ if unmerged_refs is not None:
520
+ self._confirm_branch_deletion(session_id, dialog, full_refs, unmerged_refs)
521
+ return
522
+ dialog.classification_generation += 1
523
+ classification_generation = dialog.classification_generation
524
+
525
+ def complete(merged_ref_set: set[str] | None, error: Exception | None) -> None:
526
+ """Use Git's answer only for the request it was asked for, while the window is still the one that asked."""
527
+ if (not self._local_branch_deletion_is_current(session_id, dialog) or dialog.classification_generation != classification_generation
528
+ or not dialog.busy):
529
+ return
530
+ if error is not None:
531
+ dialog.release()
532
+ dialog.show_error(str(error))
533
+ return
534
+ not_merged_refs: list[str] = []
535
+ for full_ref in full_refs:
536
+ if full_ref not in merged_ref_set:
537
+ not_merged_refs.append(full_ref)
538
+ self._confirm_branch_deletion(session_id, dialog, full_refs, tuple(not_merged_refs))
539
+
540
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.merged_branch_refs(snapshot, base_oid_by_ref), complete)
541
+
542
+ def _confirm_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, full_refs: tuple[str, ...], unmerged_refs: tuple[str, ...]) -> None:
543
+ """Delete merged branches at once, or first ask one question that lists every branch that is not merged."""
544
+ unmerged_ref_set = set(unmerged_refs)
545
+ merged_refs: list[str] = []
546
+ for full_ref in full_refs:
547
+ if full_ref not in unmerged_ref_set:
548
+ merged_refs.append(full_ref)
549
+ if not unmerged_refs:
550
+ self._submit_branch_deletion(session_id, dialog, tuple(merged_refs), ())
551
+ return
552
+
553
+ def answer(accepted: bool) -> None:
554
+ """Delete everything on Yes; on No delete nothing and give the form back."""
555
+ if not accepted:
556
+ dialog.release()
557
+ return
558
+ self._submit_branch_deletion(session_id, dialog, tuple(merged_refs), unmerged_refs)
559
+
560
+ dialog.show_unmerged_question(unmerged_refs, answer)
561
+
562
+ def _submit_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, merged_refs: tuple[str, ...], unmerged_refs: tuple[str, ...]) -> str | None:
563
+ """Freeze the classified branches into one request and submit it once, with its console opened before Git starts."""
564
+ if not self._local_branch_deletion_is_current(session_id, dialog):
565
+ return None
566
+ try:
567
+ intent = BranchDeletionIntent.capture(dialog.snapshot, merged_refs, unmerged_refs, session_id=session_id, refresh_advice=refresh_advice_text)
568
+ except ValueError as error:
569
+ dialog.release()
570
+ dialog.show_error(str(error))
571
+ return None
572
+ dialog.intent = intent
573
+ session = self.session_registry_controller.session_by_id[session_id]
574
+
575
+ def is_current() -> bool:
576
+ """Recheck the form and repository authority when queued Git actually starts."""
577
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
578
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
579
+
580
+ self.branch_deletion_intent_by_id[intent.operation.operation_id] = intent
581
+ self.open_reference_output(intent, dialog)
582
+ return self.reference.delete_branches(intent, is_current=is_current)
583
+
584
+ def branch_deletion_finished(self, outcome: OperationOutcome) -> None:
585
+ """Show a group deletion's result in its window and console, then refresh every window of the repository."""
586
+ intent = self.branch_deletion_intent_by_id.pop(outcome.operation_id, None)
587
+ if intent is None:
588
+ return
589
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
590
+ dialog = self.reference_form_by_id.get(outcome.operation_id)
591
+ if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
592
+ dialog.show_outcome(outcome)
593
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
594
+ self.diagnostic_log.write(outcome.diagnostic)
595
+ if outcome.status != OperationStatus.REFUSED:
596
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
597
+ self.finish_reference_output(outcome)
598
+
599
+ def _prepare_delete_target(self, session_id: str, dialog: DeleteBranchDialog) -> None:
600
+ """Capture one effective Push endpoint and its current account without network access."""
601
+ if dialog.closed or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or not dialog.remote_checkbox.isChecked():
602
+ return
603
+ if dialog.remote_snapshot is None or not dialog.remote_selector.currentData() or not dialog.endpoint_selector.currentData():
604
+ dialog.set_push_target(None, "Select a remote repository, remote branch and one Push endpoint")
605
+ return
606
+ try:
607
+ target = self.transport.capture_push_target(dialog.remote_snapshot, dialog.remote_selector.currentData(), dialog.endpoint_selector.currentData())
608
+ dialog.set_push_target(target)
609
+ except (ValueError, OSError) as error:
610
+ dialog.set_push_target(None, str(error))
611
+
612
+ def open_delete_tag(self, session_id: str, full_ref: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> DeleteTagDialog | None:
613
+ """Open the Delete tag window on the clicked tag, only for a tag of a live management session."""
614
+ if not full_ref.startswith(TAG_PREFIX) or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
615
+ return None
616
+ return self._open_tag_deletion(session_id, full_ref, target_oid, expected_snapshot)
617
+
618
+ def _open_tag_deletion(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> DeleteTagDialog | None:
619
+ """Open the Delete tag window on the clicked tag, from what the repository window already holds.
620
+
621
+ The tags and the remote names come from the window's own capture while its reference files are unchanged, so
622
+ opening the window starts no Git process; the remote configuration is read only once the remote option is
623
+ checked. A capture that no longer matches the menu the tag was chosen from refuses the window instead of
624
+ offering another tag.
625
+ """
626
+ window = self.session_registry_controller.window_by_session[session_id]
627
+ if window.context.is_bare:
628
+ return None
629
+ existing = self.delete_dialog_by_session.get(session_id)
630
+ if existing is not None and not existing.closed:
631
+ existing.raise_()
632
+ return existing
633
+ session = self.session_registry_controller.session_by_id[session_id]
634
+ generation = session.generation
635
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
636
+ dialog = DeleteTagDialog(session_id, window)
637
+ dialog.setAccessibleDescription(f"Delete a tag in {window.context.repository_dir}")
638
+ self.delete_dialog_by_session[session_id] = dialog
639
+ dialog.delete_requested.connect(lambda intent: self._submit_tag_deletion(session_id, dialog, intent))
640
+ dialog.remote_requested.connect(lambda: self._read_tag_delete_remote(session_id, dialog))
641
+ dialog.retry_requested.connect(lambda: self._retry_delete(session_id, dialog))
642
+ dialog.finished.connect(lambda: self.delete_dialog_by_session.pop(session_id, None) if self.delete_dialog_by_session.get(session_id) is dialog else None)
643
+ context, git_filename = window.context, self.repository.git_filename
644
+
645
+ def is_current() -> bool:
646
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
647
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, context, git_filename)
648
+
649
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
650
+ """Offer the tags only when the references still match the menu the tag was chosen from."""
651
+ if not is_current():
652
+ return
653
+ if error is not None:
654
+ refuse_form_opening(dialog, str(error))
655
+ return
656
+ if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
657
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
658
+ or snapshot.object_oid_by_name.get(full_ref) != target_oid):
659
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
660
+ return
661
+ dialog.populate(snapshot, full_ref)
662
+ reveal_form(dialog)
663
+
664
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
665
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_tag(session_id, full_ref, target_oid, expected_snapshot=capture))
666
+ return dialog
667
+
668
+ def _read_tag_delete_remote(self, session_id: str, dialog: DeleteTagDialog) -> None:
669
+ """Read the remote configuration once for a Delete tag window, the first time its remote option is checked.
670
+
671
+ The configuration must describe the very references the window shows; one read after a reference change is
672
+ refused, and checking the option again reads it anew.
673
+ """
674
+ snapshot = dialog.snapshot
675
+ session = self.session_registry_controller.session_by_id.get(session_id)
676
+ if snapshot is None or session is None or dialog.closed or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
677
+ dialog.remote_read_requested = False
678
+ return
679
+ generation = session.generation
680
+
681
+ def complete(remote_snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
682
+ """Hand the configuration to the form only while it still shows the same references and nothing was submitted."""
683
+ if (not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, snapshot.context, snapshot.git_filename)
684
+ or dialog.snapshot is not snapshot or dialog.busy or dialog.outcome is not None):
685
+ return
686
+ if error is not None:
687
+ dialog.show_remote_error(str(error))
688
+ return
689
+ if remote_snapshot.reference_state != snapshot.signature:
690
+ dialog.show_remote_error("references or configuration changed while reading it")
691
+ return
692
+ dialog.populate_remote(remote_snapshot)
693
+
694
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.remote.capture(snapshot.context, snapshot.git_filename, generation), complete)
695
+
696
+ def _tag_delete_request(self, dialog: DeleteTagDialog) -> tuple | str:
697
+ """Turn the chosen remote into the one server a tag deletion goes to, or answer why it cannot be used.
698
+
699
+ The push address is the remote's only one: a remote pushing to several addresses is refused rather than
700
+ deleting the tag on several servers, and a remote whose configuration is inactive, ambiguous or gone is refused
701
+ too. Nothing has been deleted when a cause is answered.
702
+ """
703
+ remote_name = dialog.remote_combo.currentData()
704
+ if not remote_name:
705
+ return NO_REMOTE_CAUSE
706
+ remote_snapshot = dialog.remote_snapshot
707
+ entry = remote_snapshot.entry_by_name.get(remote_name) if remote_snapshot is not None else None
708
+ if entry is None or not entry.active or entry.collision:
709
+ return UNUSABLE_REMOTE_CAUSE.format(remote_name)
710
+ endpoint_set = set(entry.push_urls)
711
+ if not endpoint_set:
712
+ return NO_PUSH_ADDRESS_CAUSE.format(remote_name)
713
+ if len(endpoint_set) > 1:
714
+ return SEVERAL_PUSH_ADDRESSES_CAUSE.format(remote_name)
715
+ (endpoint,) = endpoint_set
716
+ try:
717
+ push_target = self.transport.capture_push_target(remote_snapshot, remote_name, endpoint)
718
+ except (ValueError, OSError) as error:
719
+ return str(error)
720
+ return (remote_snapshot, remote_name, dialog.intent.full_ref, endpoint, push_target)
721
+
722
+ def _submit_tag_deletion(self, session_id: str, dialog: DeleteTagDialog, intent: ReferenceDeletionIntent) -> None:
723
+ """Submit the tag deletion Delete froze, locally only or also on the chosen remote's single push address.
724
+
725
+ A remote that cannot be used is refused before anything is deleted. A form this controller cannot run any more
726
+ is given back with the reason, never left frozen.
727
+ """
728
+ if dialog.closed or self.delete_dialog_by_session.get(session_id) is not dialog:
729
+ return
730
+ dialog.remote_request = None
731
+ if dialog.remote_checkbox.isChecked():
732
+ request = self._tag_delete_request(dialog)
733
+ if isinstance(request, str):
734
+ dialog.show_error(request)
735
+ return
736
+ dialog.remote_request = request
737
+ if self.submit_delete(session_id, intent) is None and dialog.busy and not dialog.closed:
738
+ dialog.show_error("The deletion could not start. Close this window and try again.")
739
+
740
+ def _retry_delete(self, session_id: str, dialog: DeleteBranchDialog) -> None:
741
+ """Reread captured reference authority before retrying only its unfinished deletion steps."""
742
+ if dialog.closed or dialog.busy or dialog.outcome is None or dialog.intent is None or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
743
+ return
744
+ previous_outcome, previous_intent, previous_request = dialog.outcome, dialog.intent, dialog.remote_request
745
+ if previous_outcome.status not in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.PARTIAL):
746
+ return
747
+ if any(step.operation == DELETE_REMOTE_TAG_OPERATION and step.status == OperationStatus.UNKNOWN for step in previous_outcome.steps):
748
+ return
749
+ session = self.session_registry_controller.session_by_id[session_id]
750
+ generation = session.generation
751
+ dialog.busy = True
752
+ dialog.retry_button.setEnabled(False)
753
+ dialog.status_label.setText("Rereading the failed step…")
754
+ remote_success = any(step.operation == DELETE_REMOTE_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS for step in previous_outcome.steps)
755
+ local_tag_success = previous_intent.full_ref.startswith(TAG_PREFIX) and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in previous_outcome.steps)
756
+
757
+ def read() -> Generator:
758
+ """Keep the original OID and destination while acquiring fresh local authority."""
759
+ snapshot = yield from self.reference.capture(session.repository_context, self.repository.git_filename, generation)
760
+ oid = snapshot.object_oid_by_name.get(previous_intent.full_ref)
761
+ if not local_tag_success and oid is not None and oid != previous_intent.target_oid:
762
+ reference_kind = "tag" if previous_intent.full_ref.startswith(TAG_PREFIX) else "branch"
763
+ raise ValueError(f"The original {reference_kind} changed; close this result and select the {reference_kind} again")
764
+ remote_snapshot = None
765
+ if previous_request is not None and (not remote_success or dialog.remote_branch):
766
+ remote_snapshot = yield from self.remote.capture(snapshot.context, snapshot.git_filename, generation)
767
+ if remote_snapshot.reference_state != snapshot.signature:
768
+ raise ValueError("References changed while rereading the failed deletion")
769
+ return snapshot, remote_snapshot
770
+
771
+ def complete(value: tuple | None, error: Exception | None) -> None:
772
+ """Present any new unmerged warning before dispatching the fresh retry."""
773
+ if dialog.closed or session.is_closed:
774
+ return
775
+ if session.generation != generation:
776
+ dialog.show_error("The repository view changed while rereading; retry the failed step again")
777
+ return
778
+ if error is not None:
779
+ dialog.show_error(str(error))
780
+ return
781
+ snapshot, remote_snapshot = value
782
+ operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=generation, session_id=session_id,
783
+ action=OperationAction.REF_WRITE, targets=(previous_intent.full_ref, previous_intent.target_oid), operation="Delete tag" if previous_intent.full_ref.startswith(TAG_PREFIX) else "Delete branch",
784
+ from_form=True)
785
+ dialog.snapshot = snapshot
786
+ dialog.intent = replace(previous_intent, operation=operation, snapshot=snapshot, unmerged_confirmed=False)
787
+ dialog.retry_operation_id = previous_outcome.operation_id
788
+ dialog.retry_remote_success = remote_success
789
+ if dialog.remote_branch:
790
+ refreshed_intents: list[ReferenceDeletionIntent] = []
791
+ for candidate in dialog.local_intents:
792
+ local_operation = replace(operation, targets=(candidate.full_ref, candidate.target_oid))
793
+ refreshed_intents.append(replace(candidate, operation=local_operation, snapshot=snapshot, unmerged_confirmed=False))
794
+ dialog.local_intents = tuple(refreshed_intents)
795
+ if previous_request is not None and not remote_success:
796
+ _old_snapshot, name, destination, endpoint, _target = previous_request
797
+ try:
798
+ target = self.transport.capture_push_target(remote_snapshot, name, endpoint)
799
+ except (ValueError, OSError) as failure:
800
+ dialog.show_error(str(failure))
801
+ return
802
+ dialog.remote_snapshot = remote_snapshot
803
+ dialog.remote_request = (remote_snapshot, name, destination, endpoint, target)
804
+ if not dialog.remote_branch:
805
+ # A tag has no merge question to ask: its deletion is checked again when Git starts.
806
+ self.submit_delete(session_id, dialog.intent)
807
+ return
808
+ self._prepare_delete(session_id, dialog, dialog.intent)
809
+ self.worktree_service.run(session.repository_context, self.repository.git_filename, read(), complete)
810
+
811
+ def _prepare_delete(self, session_id: str, dialog: DeleteBranchDialog, intent: ReferenceDeletionIntent) -> None:
812
+ """Read the merge proof asynchronously without holding an owner across a question."""
813
+ if dialog.closed or dialog.intent != intent or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
814
+ return
815
+
816
+ def inspect() -> Generator:
817
+ """Check every selected candidate before asking for any destructive answer."""
818
+ if not dialog.remote_branch:
819
+ return (yield from self.reference.inspect_delete(intent))
820
+ success_ref_set = self._successful_delete_ref_set(dialog)
821
+ warning_refs: list[str] = []
822
+ for candidate in dialog.local_intents:
823
+ if candidate.full_ref in success_ref_set or dialog.retry_remote_success and candidate.full_ref not in intent.snapshot.object_oid_by_name:
824
+ continue
825
+ merged = yield from self.reference.inspect_delete(candidate)
826
+ if not merged:
827
+ warning_refs.append(candidate.full_ref)
828
+ dialog.warning_refs = warning_refs
829
+ return not warning_refs
830
+
831
+ def complete(merged: bool | None, error: Exception | None) -> None:
832
+ """Publish the merge result only to the unchanged original form."""
833
+ if dialog.closed or dialog.intent != intent or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
834
+ return
835
+ if error is not None:
836
+ dialog.show_error(str(error))
837
+ return
838
+ dialog.prepared(merged)
839
+ self.worktree_service.run(intent.snapshot.context, intent.snapshot.git_filename, inspect(), complete)
840
+
841
+ def _successful_delete_ref_set(self, dialog: DeleteBranchDialog) -> set[str]:
842
+ """Keep completed local candidates out of later retries even if their refs reappear."""
843
+ if not dialog.retry_remote_success or dialog.outcome is None:
844
+ return set()
845
+ return {step.full_ref for step in dialog.outcome.steps if step.operation == DELETE_LOCAL_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS}
846
+
847
+ def _delete_remote_candidates(self, dialog: DeleteBranchDialog, intent: ReferenceDeletionIntent, is_current: Callable[[], bool]) -> Generator:
848
+ """Revalidate each remaining tracking branch under the existing server operation owner."""
849
+ success_ref_set = self._successful_delete_ref_set(dialog)
850
+ expected = intent.snapshot
851
+ remote_snapshot, _name, _destination, _endpoint, _target = dialog.remote_request
852
+ steps: list[OperationStepResult] = []
853
+ status, diagnostic = OperationStatus.SUCCESS, None
854
+ for candidate in dialog.local_intents:
855
+ if candidate.full_ref in success_ref_set:
856
+ continue
857
+ if status != OperationStatus.SUCCESS:
858
+ steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.NOT_STARTED, effect="previous local step did not succeed", full_ref=candidate.full_ref))
859
+ continue
860
+ try:
861
+ if not is_current():
862
+ raise ValueError("The deletion form or repository authority is no longer current")
863
+ current = yield from self.reference.capture(expected.context, expected.git_filename, expected.generation)
864
+ if current.signature != expected.signature or current.head_oid != intent.snapshot.head_oid or current.head_ref != intent.snapshot.head_ref:
865
+ raise ValueError("References, HEAD or worktrees changed before local deletion")
866
+ observed = yield from self.remote.capture(expected.context, expected.git_filename, expected.generation)
867
+ if observed.reference_state != current.signature:
868
+ raise ValueError("References changed while checking local tracking")
869
+ absent = candidate.full_ref not in current.object_oid_by_name
870
+ ignored_ref_set = success_ref_set | ({candidate.full_ref} if absent and dialog.retry_remote_success else set())
871
+ ignored_prefixes = tuple("branch." + full_ref.removeprefix(LOCAL_PREFIX) + "." for full_ref in ignored_ref_set)
872
+ expected_values = tuple(value for value in remote_snapshot.values if not value.key.startswith(ignored_prefixes))
873
+ observed_values = tuple(value for value in observed.values if not value.key.startswith(ignored_prefixes))
874
+ if observed_values != expected_values:
875
+ raise ValueError("Remote configuration or branch tracking changed before local deletion")
876
+ if not absent:
877
+ candidates = remote_delete_candidates(current, observed, intent.full_ref, intent.operation.session_id)
878
+ if not any(value.full_ref == candidate.full_ref and value.target_oid == candidate.target_oid for value in candidates):
879
+ raise ValueError("The original local tracking branch changed; select it again")
880
+ operation = replace(intent.operation, targets=(candidate.full_ref, candidate.target_oid))
881
+ local_intent = replace(candidate, operation=operation, snapshot=current, unmerged_confirmed=intent.unmerged_confirmed)
882
+ outcome = yield from self.reference.delete_sequence(local_intent, is_current=is_current, allow_absent=dialog.retry_remote_success)
883
+ for step in outcome.steps:
884
+ steps.append(replace(step, full_ref=candidate.full_ref))
885
+ status, diagnostic = outcome.status, outcome.diagnostic
886
+ if status == OperationStatus.SUCCESS:
887
+ success_ref_set.add(candidate.full_ref)
888
+ *_, last_step = outcome.steps
889
+ expected = last_step.after
890
+ except (ValueError, OSError, GitCommandError) as error:
891
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DELETE_LOCAL_BRANCH_OPERATION, expected.context.repository_dir, expected.git_filename, [], str(error)).prepare()
892
+ status = OperationStatus.REFUSED
893
+ steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, status, effect="not deleted", diagnostic=diagnostic, full_ref=candidate.full_ref))
894
+ return OperationOutcome(intent.operation.operation_id, status, tuple(steps), diagnostic)
895
+
896
+ def submit_delete(self, session_id: str, intent: ReferenceDeletionIntent) -> str | None:
897
+ """Reserve the exact displayed deletion and recheck the form at native launch."""
898
+ dialog = self.delete_dialog_by_session.get(session_id)
899
+ if (dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id
900
+ or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
901
+ return None
902
+ if intent.operation.operation_id in self.transport.run_by_id:
903
+ return intent.operation.operation_id
904
+ session = self.session_registry_controller.session_by_id[session_id]
905
+
906
+ def is_current() -> bool:
907
+ """Reject late writes after authority, executable or form lifetime changes."""
908
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
909
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
910
+ self.delete_intent_by_id[intent.operation.operation_id] = intent
911
+ if dialog.remote_branch and not dialog.remote_checkbox.isChecked():
912
+ dialog.show_error("Select remote repository deletion before continuing")
913
+ return None
914
+ if dialog.remote_checkbox.isChecked():
915
+ if dialog.remote_request is None:
916
+ dialog.show_error("Choose the exact remote deletion destination")
917
+ return None
918
+ snapshot, remote_name, destination_ref, endpoint, target = dialog.remote_request
919
+ request = TransportRequest(intent.operation, remote_name=remote_name, snapshot=snapshot, mode="delete", source_ref=intent.full_ref,
920
+ source_oid=intent.target_oid, destination_ref=destination_ref, endpoint=endpoint, push_target=target,
921
+ delete_local_refs=tuple((candidate.full_ref, candidate.target_oid) for candidate in dialog.local_intents) if dialog.remote_branch else ())
922
+
923
+ def preflight() -> Generator:
924
+ """Prove the local branch is still deletable before changing its remote."""
925
+ if dialog.remote_branch:
926
+ current = yield from self.reference.capture(intent.snapshot.context, intent.snapshot.git_filename, intent.snapshot.generation)
927
+ if current.signature != intent.snapshot.signature or current.object_oid_by_name.get(intent.full_ref) != intent.target_oid:
928
+ raise ValueError("The remote branch capture changed before deletion")
929
+ if remote_branch_destination(snapshot, intent.full_ref) != (remote_name, destination_ref):
930
+ raise ValueError("The displayed remote branch no longer matches its Fetch mapping")
931
+ for candidate in dialog.local_intents:
932
+ merged = yield from self.reference.inspect_delete(candidate)
933
+ if not merged and not intent.unmerged_confirmed:
934
+ raise ValueError("Explicit confirmation is required for an unmerged local tracking branch")
935
+ return
936
+ merged = yield from self.reference.inspect_delete(intent)
937
+ if not merged and not intent.unmerged_confirmed:
938
+ raise ValueError("Explicit confirmation is required for this unmerged branch")
939
+
940
+ def local_sequence() -> Generator:
941
+ """Keep server success and the shared owner while performing only the local step."""
942
+ if dialog.remote_branch:
943
+ return (yield from self._delete_remote_candidates(dialog, intent, lambda: is_current() and not run.cancelled))
944
+ return (yield from self.reference.delete_sequence(intent, is_current=lambda: is_current() and not run.cancelled, allow_absent=dialog.retry_remote_success))
945
+
946
+ local_tag_success = intent.full_ref.startswith(TAG_PREFIX) and dialog.outcome is not None and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in dialog.outcome.steps)
947
+ if dialog.retry_remote_success or local_tag_success:
948
+ run = self.transport.reconcile(request, dialog.retry_operation_id, local_sequence=local_sequence, is_current=is_current)
949
+ else:
950
+ run = self.transport.delete_remote(request, preflight=preflight, local_sequence=local_sequence if not dialog.remote_branch or dialog.local_intents else None, is_current=is_current)
951
+ output_dialog = OperationDialog(run.trace, intent.operation.operation, self.session_registry_controller.window_by_session[session_id], preference=self.preference, console_operation=ConsoleOperation.REMOTE_DELETE)
952
+ self.console_registry_controller.transport_dialog_by_id[intent.operation.operation_id] = output_dialog
953
+ output_dialog.abort_requested.connect(lambda: self.transport.cancel(intent.operation.operation_id))
954
+ output_dialog.finished.connect(run.trace.close)
955
+ output_dialog.set_running()
956
+ open_window_modal(output_dialog, keeps_window_buttons=True)
957
+ return intent.operation.operation_id
958
+ return self.reference.delete(intent, is_current=is_current)
959
+
960
+ def delete_finished(self, outcome: object) -> None:
961
+ """Retain individual results and refresh every related repository window."""
962
+ intent = self.delete_intent_by_id.pop(outcome.operation_id, None)
963
+ if intent is None:
964
+ return
965
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
966
+ dialog = self.delete_dialog_by_session.get(intent.operation.session_id)
967
+ if dialog is not None and not dialog.closed and dialog.intent == intent:
968
+ dialog.show_outcome(outcome)
969
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
970
+ self.diagnostic_log.write(outcome.diagnostic)
971
+ if outcome.status == OperationStatus.REFUSED or outcome.operation_id in self.transport.run_by_id:
972
+ return
973
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
974
+
975
+ def open_branch(self, session_id: str, mode: BranchMode, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None,
976
+ full_ref: str = "") -> CreateBranchDialog | RenameBranchDialog | ResetOtherBranchDialog | None:
977
+ """Open branch editing for one captured commit under a live management authority."""
978
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or mode not in (BranchMode.CREATE, BranchMode.RENAME, BranchMode.MOVE):
979
+ return None
980
+ window = self.session_registry_controller.window_by_session[session_id]
981
+ if window.context.is_bare:
982
+ return None
983
+ existing = self.branch_dialog_by_session.get(session_id)
984
+ if existing is not None and not existing.closed:
985
+ existing.raise_()
986
+ return existing
987
+ if mode == BranchMode.CREATE:
988
+ return self._open_create_branch(session_id, target_oid, expected_snapshot)
989
+ if mode == BranchMode.RENAME:
990
+ return self._open_rename_branch(session_id, full_ref, target_oid, expected_snapshot)
991
+ return self._open_reset_other_branch(session_id, target_oid, expected_snapshot)
992
+
993
+ def _open_reset_other_branch(self, session_id: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> ResetOtherBranchDialog:
994
+ """Open the Reset branch window on one commit, drawn from what the repository window already holds.
995
+
996
+ The references come from the window's own capture while their files are unchanged and the commit box from the
997
+ history graph, so opening on a displayed commit starts no Git process. Whether resetting the chosen branch is
998
+ a fast-forward is answered from the target's ancestors in memory, walked once per window, when the graph is
999
+ complete; otherwise one ``merge-base --is-ancestor`` per chosen branch asks Git.
1000
+ """
1001
+ window = self.session_registry_controller.window_by_session[session_id]
1002
+ session = self.session_registry_controller.session_by_id[session_id]
1003
+ generation = session.generation
1004
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
1005
+ dialog = ResetOtherBranchDialog(session_id, target_oid, window)
1006
+ dialog.setAccessibleDescription(f"Reset another branch in {window.context.repository_dir}")
1007
+ self.branch_dialog_by_session[session_id] = dialog
1008
+ dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
1009
+ dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
1010
+ context, git_filename = window.context, self.repository.git_filename
1011
+ # The target's ancestors never change, so they are walked at most once for this window.
1012
+ target_ancestry_by_oid: dict[str, set[str] | None] = {}
1013
+
1014
+ def is_current() -> bool:
1015
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
1016
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) and not dialog.busy
1017
+
1018
+ def target_ancestry() -> set[str] | None:
1019
+ """Return the target and its ancestors from a complete graph in memory, or None when only Git can tell."""
1020
+ if target_oid not in target_ancestry_by_oid:
1021
+ history_snapshot = window.history_model.snapshot if isValid(window) else None
1022
+ if history_snapshot is None:
1023
+ return None
1024
+ target_ancestry_by_oid[target_oid] = complete_ancestor_oid_set(target_oid, history_snapshot.parent_oids_by_oid, history_snapshot.complete)
1025
+ return target_ancestry_by_oid[target_oid]
1026
+
1027
+ def inspect_ancestry(full_ref: str, old_oid: str) -> None:
1028
+ """Tell the form whether moving the chosen branch to the target is a fast-forward, from memory or from Git."""
1029
+ snapshot = dialog.snapshot
1030
+ if snapshot is None or not is_current():
1031
+ return
1032
+ fast_forward = is_ancestor_in_memory(old_oid, target_ancestry())
1033
+ if fast_forward is not None:
1034
+ dialog.show_ancestry(full_ref, fast_forward)
1035
+ return
1036
+
1037
+ def inspected(result: bool | None, error: Exception | None) -> None:
1038
+ """Ignore ancestry from a closed form or replaced reference capture."""
1039
+ if is_current() and dialog.snapshot is snapshot:
1040
+ dialog.show_ancestry(full_ref, result, str(error) if error else "")
1041
+
1042
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.is_fast_forward(old_oid, target_oid), inspected)
1043
+
1044
+ dialog.ancestry_requested.connect(inspect_ancestry)
1045
+
1046
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
1047
+ """Offer the branches and fill the commit box only while the form and the references it was asked from are current."""
1048
+ if not is_current():
1049
+ return
1050
+ if error is not None:
1051
+ refuse_form_opening(dialog, str(error))
1052
+ return
1053
+ if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
1054
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
1055
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
1056
+ return
1057
+ # Git refuses to reset exactly the branches it refuses to delete: the current one and those another worktree,
1058
+ # a rebase or a bisect holds; symbolic branches and branches without a commit cannot be moved either.
1059
+ dialog.populate(snapshot, target_oid, deletable_branch_refs(snapshot))
1060
+ reveal_form(dialog)
1061
+ self._show_target_commit(session_id, dialog, target_oid, is_current)
1062
+
1063
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
1064
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.MOVE, target_oid, expected_snapshot=capture))
1065
+ return dialog
1066
+
1067
+ def _open_rename_branch(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> RenameBranchDialog:
1068
+ """Open the Rename branch window on one local branch, drawn from the references the repository window already holds.
1069
+
1070
+ The window shows no commit, so nothing but the references is read, and those come from the window's own capture
1071
+ while their files are unchanged: opening it starts no Git process.
1072
+ """
1073
+ window = self.session_registry_controller.window_by_session[session_id]
1074
+ session = self.session_registry_controller.session_by_id[session_id]
1075
+ generation = session.generation
1076
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
1077
+ dialog = RenameBranchDialog(session_id, full_ref, target_oid, window)
1078
+ dialog.setAccessibleDescription(f"Rename a branch in {window.context.repository_dir}")
1079
+ self.branch_dialog_by_session[session_id] = dialog
1080
+ dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
1081
+ dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
1082
+ context, git_filename = window.context, self.repository.git_filename
1083
+
1084
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
1085
+ """Bind the window to the references only while it is current and its branch is still the one clicked."""
1086
+ if not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) or dialog.busy:
1087
+ return
1088
+ if error is not None:
1089
+ refuse_form_opening(dialog, str(error))
1090
+ return
1091
+ if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
1092
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
1093
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
1094
+ return
1095
+ reference = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
1096
+ if reference is None or reference.kind != ReferenceKind.HEADS or reference.symbolic_target or reference.target_oid != target_oid:
1097
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_LOCAL_BRANCH_CHANGED))
1098
+ return
1099
+ dialog.populate(snapshot, full_ref, target_oid)
1100
+ reveal_form(dialog)
1101
+
1102
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
1103
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.RENAME, target_oid, expected_snapshot=capture, full_ref=full_ref))
1104
+ return dialog
1105
+
1106
+ def _open_create_branch(self, session_id: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> CreateBranchDialog:
1107
+ """Open the Create branch window on one commit, drawn from what the repository window already holds.
1108
+
1109
+ The references come from the window's own capture while their files are unchanged, the proposed name from
1110
+ those references, and the commit box and position from the history graph in memory, so opening on a displayed
1111
+ commit of a fully loaded graph starts no Git process.
1112
+ """
1113
+ window = self.session_registry_controller.window_by_session[session_id]
1114
+ session = self.session_registry_controller.session_by_id[session_id]
1115
+ generation = session.generation
1116
+ expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
1117
+ dialog = CreateBranchDialog(session_id, target_oid, window)
1118
+ dialog.setAccessibleDescription(f"Create a branch in {window.context.repository_dir}")
1119
+ self.branch_dialog_by_session[session_id] = dialog
1120
+ dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
1121
+ dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
1122
+ context, git_filename = window.context, self.repository.git_filename
1123
+
1124
+ def is_current() -> bool:
1125
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
1126
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) and not dialog.busy
1127
+
1128
+ self._bind_revision_picker(session_id, dialog, is_current)
1129
+
1130
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
1131
+ """Show the commit and the proposed name only while the form and the references it was asked from are current."""
1132
+ if not is_current():
1133
+ return
1134
+ if error is not None:
1135
+ refuse_form_opening(dialog, str(error))
1136
+ return
1137
+ if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
1138
+ or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
1139
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
1140
+ return
1141
+ dialog.populate(snapshot, target_oid, suggested_branch_name(snapshot, target_oid))
1142
+ reveal_form(dialog)
1143
+
1144
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
1145
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.CREATE, target_oid, expected_snapshot=capture))
1146
+ return dialog
1147
+
1148
+ def _show_target_commit(self, session_id: str, dialog: CreateBranchDialog | ResetOtherBranchDialog, oid: str, is_current: Callable[[], bool]) -> None:
1149
+ """Fill a form's commit box for its new target from the history graph, and from one Git read only when the graph lacks it.
1150
+
1151
+ A commit the graph shows already carries its subject, author and date. Only a commit not loaded yet, or one
1152
+ whose details the graph could not read, costs a ``git show``, whose answer is dropped once the form targets
1153
+ another commit or went away. A box already filled, or refused, for this very commit is left as it is.
1154
+ """
1155
+ if dialog.commit_summary.target_oid != oid or dialog.metadata_ready or dialog.metadata_failed:
1156
+ return
1157
+ window = self.session_registry_controller.window_by_session.get(session_id)
1158
+ history_model = window.history_model if window is not None else None
1159
+ row = history_model.row_by_oid.get(oid) if history_model is not None else None
1160
+ if row is not None and not history_model.commits[row].detail_error:
1161
+ dialog.show_loaded_commit(history_model.commits[row])
1162
+ return
1163
+ snapshot = dialog.snapshot
1164
+ if snapshot is None:
1165
+ return
1166
+
1167
+ def complete(fields: tuple | None, error: Exception | None) -> None:
1168
+ """Publish the commit only to the form and target it was read for."""
1169
+ if not is_current() or dialog.commit_summary is None or dialog.commit_summary.target_oid != oid:
1170
+ return
1171
+ dialog.show_metadata(fields, str(error) if error else "")
1172
+
1173
+ self.reference.metadata(snapshot, oid, complete)
1174
+
1175
+ def submit_branch(self, session_id: str, intent: BranchIntent) -> str | None:
1176
+ """Reserve only the exact branch intention shown by a live management form."""
1177
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1178
+ return None
1179
+ dialog = self.branch_dialog_by_session.get(session_id)
1180
+ if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
1181
+ return None
1182
+ session = self.session_registry_controller.session_by_id[session_id]
1183
+ def is_current() -> bool:
1184
+ """Recheck live session, executable and context at the actual native launch."""
1185
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
1186
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
1187
+ self.branch_intent_by_id[intent.operation.operation_id] = intent
1188
+ if intent.mode == BranchMode.RENAME:
1189
+ return self.reference.rename_branch(intent, is_current=is_current)
1190
+ self.open_reference_output(intent, dialog)
1191
+ if intent.mode == BranchMode.MOVE:
1192
+ return self.reference.move_branch(intent, is_current=is_current)
1193
+ return self.reference.create_branch(intent, is_current=is_current)
1194
+
1195
+ def branch_finished(self, outcome: object) -> None:
1196
+ """Show observed effects and refresh related windows after native branch writes."""
1197
+ intent = self.branch_intent_by_id.pop(outcome.operation_id, None)
1198
+ if intent is None:
1199
+ return
1200
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
1201
+ dialog = self.branch_dialog_by_session.get(intent.operation.session_id)
1202
+ expected_form = self.reference_form_by_id.get(outcome.operation_id) if intent.mode != BranchMode.RENAME else dialog
1203
+ if dialog is not None and dialog is expected_form and isValid(dialog) and not dialog.closed and dialog.intent == intent:
1204
+ dialog.show_outcome(outcome)
1205
+ self.finish_reference_output(outcome)
1206
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
1207
+ self.diagnostic_log.write(outcome.diagnostic)
1208
+ if outcome.status == OperationStatus.REFUSED:
1209
+ return
1210
+ # The scope follows what was asked, not the result: a checkout that started and failed may have changed files.
1211
+ # An orphan branch is always checked out, and replaces the index and working files with the target's tree.
1212
+ checkout_asked = intent.mode in (BranchMode.CREATE_CHECKOUT, BranchMode.CREATE_ORPHAN) or intent.mode == BranchMode.MOVE and intent.checkout_after_reset
1213
+ if outcome.status == OperationStatus.SUCCESS and intent.mode in HISTORY_TOP_BRANCH_MODE_SET and intent.operation.session_id is not None:
1214
+ self.history_read_controller.history_top_session_set.add(intent.operation.session_id)
1215
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL if checkout_asked else RefreshScope.REFERENCES)
1216
+
1217
+ def open_reference_output(self, intent: TagIntent | BranchIntent | CheckoutIntent | BranchDeletionIntent | ReplayIntent | MergeAbortIntent,
1218
+ form: CheckoutDialog | CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog | DeleteLocalBranchDialog | ReplayDialog | None,
1219
+ *, modal: bool = True) -> OperationDialog:
1220
+ """Subscribe before Git starts and keep its output independent of the input form.
1221
+
1222
+ Cherry-pick and Revert consoles are not modal: they can end on a pause that needs the repository window,
1223
+ its banner and the conflict prompt, which a window-modal console would block. A Continue or Abort started
1224
+ from the repository banner has no form, so ``form`` is None there.
1225
+ """
1226
+ operation = intent.operation
1227
+ trace = OperationTrace(operation.operation_id, self.repository.runner, self.diagnostic_log, native_commands_only=operation.action in NATIVE_ONLY_CONSOLE_ACTION_SET,
1228
+ header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
1229
+ output = OperationDialog(trace, operation.operation, self.session_registry_controller.window_by_session[operation.session_id], preference=self.preference, console_operation=ConsoleOperation.REFERENCE)
1230
+ self.reference_trace_by_id[operation.operation_id] = trace
1231
+ self.console_registry_controller.reference_output_by_id[operation.operation_id] = output
1232
+ self.reference_label_by_id[operation.operation_id] = operation.operation
1233
+ self.reference_form_by_id[operation.operation_id] = form
1234
+
1235
+ def closed() -> None:
1236
+ """Release retained output only after the user or success policy dismisses it."""
1237
+ self.reference_trace_by_id.pop(operation.operation_id, None)
1238
+ self.console_registry_controller.reference_output_by_id.pop(operation.operation_id, None)
1239
+ self.reference_label_by_id.pop(operation.operation_id, None)
1240
+ self.reference_form_by_id.pop(operation.operation_id, None)
1241
+ self.created_tag_by_id.pop(operation.operation_id, None)
1242
+ trace.close()
1243
+
1244
+ output.finished.connect(closed)
1245
+ self.session_registry_controller.session_by_id[operation.session_id].closed.connect(output.reject)
1246
+ output.set_running()
1247
+ if modal:
1248
+ open_window_modal(output, keeps_window_buttons=True)
1249
+ return output
1250
+ output.show()
1251
+ output.raise_()
1252
+ output.activateWindow()
1253
+ return output
1254
+
1255
+ def finish_reference_output(self, outcome: OperationOutcome, *, failure_text: str = "") -> None:
1256
+ """Publish the result after all listeners have collected the final native output.
1257
+
1258
+ ``failure_text`` is shown in bold red under the status line of a failed result, for a refusal the user must
1259
+ read to act on it rather than find among the console's status lines.
1260
+
1261
+ A refused operation never reached Git's write, so its console ends with one red line that says why, such as
1262
+ ``Create tag: not started — <reason>``: the step lines and the diagnostic block are stored and logged as before,
1263
+ but not shown, since they would only repeat that nothing started. Every other result shows its step lines.
1264
+ """
1265
+ trace = self.reference_trace_by_id.get(outcome.operation_id)
1266
+ output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
1267
+ label = self.reference_label_by_id.get(outcome.operation_id)
1268
+ self.reference_form_by_id.pop(outcome.operation_id, None)
1269
+ if trace is None or output is None or label is None or not isValid(output):
1270
+ return
1271
+
1272
+ def publish_result() -> None:
1273
+ """Let the trace flush its final fragment before automatic console closure."""
1274
+ if not isValid(output) or output.dismissed:
1275
+ return
1276
+ refused = outcome.status == OperationStatus.REFUSED
1277
+ for step in outcome.steps:
1278
+ # A step about one reference among several names it, so each branch of a group deletion has its own line.
1279
+ subject = step.operation + (" " + short_reference_name(step.full_ref) if step.full_ref else "")
1280
+ step_text = subject + ": " + step.status.value
1281
+ if refused:
1282
+ trace.append_record(outcome.operation_id, "status", step_text, None)
1283
+ else:
1284
+ trace.append_event(outcome.operation_id, "status", step_text)
1285
+ end_text = operation_end_text(label, outcome.status)
1286
+ if outcome.diagnostic is not None and refused:
1287
+ end_text = end_line_with_cause(end_text, trace.unshown_cause(outcome.diagnostic) or "")
1288
+ trace.append_record(outcome.operation_id, "status", outcome.diagnostic.detail, None)
1289
+ elif outcome.diagnostic is not None:
1290
+ trace.append_diagnostic(outcome.operation_id, "status", outcome.diagnostic, outcome.status)
1291
+ failure_reported = outcome.diagnostic is not None
1292
+ trace.append_end_line(outcome.operation_id, "status", REFERENCE_RESULT_LOG_TEMPLATE.format(status=outcome.status.value), end_text,
1293
+ outcome.status, failure_reported=failure_reported)
1294
+ output.show_result(outcome.status, failure_reported=failure_reported, failure_text=failure_text)
1295
+
1296
+ publish_after_trace(publish_result)
1297
+
1298
+ def _bind_revision_picker(self, session_id: str, dialog: RevisionForm, is_current: Callable[[], bool]) -> None:
1299
+ """Connect a form's revision field to the resolution of what is typed and to the position of each new target.
1300
+
1301
+ Every form built around the Git Extensions revision field uses this same binding. ``is_current`` tells
1302
+ whether an answer may still reach the form. The form connects its own target handling first, so a new
1303
+ target's older position and commit details are invalidated before the new ones are filled. Following the
1304
+ field's target covers every way a target arrives: opening, typing, the choice window and a restored commit.
1305
+ """
1306
+ def resolve(text: str, final: bool) -> None:
1307
+ """Resolve the text the field reports after a typing pause or when it is left."""
1308
+ self._resolve_revision_text(session_id, dialog, text, final, is_current)
1309
+
1310
+ def show_position(oid: str | None) -> None:
1311
+ """Count how far each new target is from HEAD and fill the commit box; a field without a target shows neither."""
1312
+ if oid is None or not is_current():
1313
+ return
1314
+ self._show_revision_position(session_id, dialog, oid)
1315
+ if dialog.commit_summary is not None:
1316
+ self._show_target_commit(session_id, dialog, oid, is_current)
1317
+
1318
+ def close_choice() -> None:
1319
+ """Close the revision choice window together with the form it chooses for."""
1320
+ choice = dialog.picker.revision_dialog
1321
+ if choice is not None and isValid(choice) and not choice.closed:
1322
+ choice.reject()
1323
+
1324
+ dialog.picker.revision_edited.connect(resolve)
1325
+ dialog.picker.target_changed.connect(show_position)
1326
+ dialog.picker.choose_requested.connect(lambda: self._open_revision_choice(session_id, dialog, is_current))
1327
+ dialog.finished.connect(close_choice)
1328
+
1329
+ def _resolve_revision_text(self, session_id: str, dialog: RevisionForm, text: str, final: bool, is_current: Callable[[], bool]) -> None:
1330
+ """Turn the text typed in a revision field into the form's target, from memory whenever memory can prove it.
1331
+
1332
+ HEAD, a full commit ID of the loaded graph and an unambiguous reference name are resolved without Git; any
1333
+ other text costs one ``rev-parse``, whose answer is dropped once the text changed or the form went away. A
1334
+ target found for a typing pause keeps the typed text; one found when the field is left shows its short hash.
1335
+ A text already refused is refused again without Git. Leaving the field while the pause's ``rev-parse`` still
1336
+ runs starts no second one: that answer settles the field as if it had been asked when the field was left.
1337
+ """
1338
+ if not is_current() or dialog.snapshot is None:
1339
+ return
1340
+ picker = dialog.picker
1341
+ problem = revision_text_problem(text)
1342
+ if problem or (text == picker.answered_text and picker.answered_oid is None):
1343
+ self._refuse_revision_text(dialog, text, problem, final)
1344
+ return
1345
+ resolution_generation = picker.resolution_generation
1346
+
1347
+ def found(oid: str | None) -> None:
1348
+ """Take the answer only for the text and form it was asked for; a field left meanwhile is settled by it."""
1349
+ if not is_current() or picker.resolution_generation != resolution_generation:
1350
+ return
1351
+ settled = final or picker.settle_generation == resolution_generation
1352
+ if oid is None:
1353
+ self._refuse_revision_text(dialog, text, "", settled)
1354
+ return
1355
+ dialog.set_target(oid, rewrite_text=settled)
1356
+
1357
+ self._find_revision(session_id, dialog.snapshot, text, found)
1358
+
1359
+ def _find_revision(self, session_id: str, snapshot: ReferenceSnapshot, text: str, callback: Callable[[str | None], None]) -> None:
1360
+ """Find the commit a revision text names, from memory when memory can prove it and by one Git read otherwise.
1361
+
1362
+ A memory answer reaches the callback at once, before this returns; a Git answer arrives later. The callback
1363
+ receives None for a text that names no commit, a text Git must never receive, or a failed read.
1364
+ """
1365
+ if revision_text_problem(text):
1366
+ callback(None)
1367
+ return
1368
+ window = self.session_registry_controller.window_by_session.get(session_id)
1369
+ history_snapshot = window.history_model.snapshot if window is not None else None
1370
+ parent_oids_by_oid = history_snapshot.parent_oids_by_oid if history_snapshot is not None else {}
1371
+ oid = resolve_revision_in_memory(text, snapshot.references, snapshot.head_oid, parent_oids_by_oid)
1372
+ if oid is not None:
1373
+ callback(oid)
1374
+ return
1375
+
1376
+ def complete(resolved_oid: str | None, error: Exception | None) -> None:
1377
+ """Treat a failed read like a text that names no commit."""
1378
+ callback(resolved_oid if error is None else None)
1379
+
1380
+ self.reference.resolve_revision(snapshot, text, complete)
1381
+
1382
+ def _open_revision_choice(self, session_id: str, dialog: RevisionForm, is_current: Callable[[], bool]) -> None:
1383
+ """Open the "Choose another revision" window of Cherry-pick for a form's revision field.
1384
+
1385
+ The commit clicked in its list, or the revision typed in it, is resolved like the field's own text: a loaded
1386
+ commit needs no Git. The form takes the commit and the window closes; a text naming no commit stays in the
1387
+ window with its explanation.
1388
+ """
1389
+ if not is_current() or dialog.snapshot is None:
1390
+ return
1391
+ picker = dialog.picker
1392
+ if picker.revision_dialog is not None and isValid(picker.revision_dialog) and not picker.revision_dialog.closed:
1393
+ picker.revision_dialog.raise_()
1394
+ return
1395
+ window = self.session_registry_controller.window_by_session[session_id]
1396
+ selector = RevisionChoiceDialog(window.history_model.commits, dialog)
1397
+ picker.revision_dialog = selector
1398
+
1399
+ def read_revision(text: str) -> None:
1400
+ """Resolve the chosen revision for this edit of the choice window only."""
1401
+ generation = selector.generation
1402
+
1403
+ def found(oid: str | None) -> None:
1404
+ """Hand the commit to the form, or explain in the choice window that the text names none."""
1405
+ if not isValid(selector) or selector.closed or selector.generation != generation or not is_current():
1406
+ return
1407
+ if oid is None:
1408
+ selector.show_error()
1409
+ return
1410
+ dialog.set_target(oid)
1411
+ selector.accept()
1412
+
1413
+ self._find_revision(session_id, dialog.snapshot, text, found)
1414
+
1415
+ selector.revision_requested.connect(read_revision)
1416
+ open_window_modal(selector)
1417
+ selector.revision_edit.setFocus()
1418
+
1419
+ def _refuse_revision_text(self, dialog: RevisionForm, text: str, problem: str, final: bool) -> None:
1420
+ """Answer a text that names no commit: no target while the user types, the previous commit once the field is left.
1421
+
1422
+ Leaving the field explains the refusal in the form's message box, as Git Extensions does. The previous commit
1423
+ comes back after the message, so the form can act on it again.
1424
+ """
1425
+ if not final:
1426
+ dialog.picker.show_invalid()
1427
+ return
1428
+ cause = problem or "'" + text + "' is not a revision of this repository"
1429
+ dialog.show_error(cause + "; the previous revision is kept.")
1430
+ dialog.picker.restore_last_target()
1431
+
1432
+ def _commit_difference_from_graph(self, session_id: str, snapshot: ReferenceSnapshot, target_oid: str) -> tuple[int, int] | None:
1433
+ """Count the commits only in HEAD and only in the target from the window's history graph, or None when it cannot prove them.
1434
+
1435
+ The graph answers only when it was read for the same HEAD as the form's references and holds HEAD's whole
1436
+ ancestry and the target; the counts have the order of ``git rev-list --left-right --count HEAD...target``.
1437
+ """
1438
+ if snapshot.head_oid is None:
1439
+ return None
1440
+ window = self.session_registry_controller.window_by_session.get(session_id)
1441
+ history_model = window.history_model if window is not None and isValid(window) else None
1442
+ history_snapshot = history_model.snapshot if history_model is not None else None
1443
+ if history_snapshot is None or history_snapshot.head_oid != snapshot.head_oid:
1444
+ return None
1445
+ return commit_difference_in_memory(target_oid, history_model.relative_oid_set, history_snapshot.parent_oids_by_oid)
1446
+
1447
+ def _show_revision_position(self, session_id: str, dialog: RevisionForm, target_oid: str) -> None:
1448
+ """Show how far a commit is from HEAD, from the history graph in memory, and from Git only when memory cannot tell.
1449
+
1450
+ HEAD without a commit shows nothing, as in Git Extensions. The graph answers only when it is complete and was
1451
+ read for the same HEAD as the form; otherwise one native count is started, and a late answer for another
1452
+ target or a closed form is dropped.
1453
+ """
1454
+ snapshot = dialog.snapshot
1455
+ if snapshot is None:
1456
+ return
1457
+ if snapshot.head_oid is None:
1458
+ dialog.picker.show_count("")
1459
+ return
1460
+ difference = self._commit_difference_from_graph(session_id, snapshot, target_oid)
1461
+ if difference is not None:
1462
+ dialog.picker.show_count(commit_count_text(difference))
1463
+ return
1464
+ position_generation = dialog.position_generation
1465
+
1466
+ def complete(difference: tuple[int, int] | None, error: Exception | None) -> None:
1467
+ """Show Git's count only for the target and form it was asked for."""
1468
+ if not isValid(dialog) or dialog.closed or dialog.position_generation != position_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1469
+ return
1470
+ dialog.picker.show_count(commit_count_text(difference if error is None else None))
1471
+
1472
+ self.reference.difference(snapshot, target_oid, complete)
1473
+
1474
+ def open_checkout(self, session_id: str, full_ref: str, *, target_oid: str | None = None,
1475
+ expected_snapshot: ReferenceSnapshot | None = None) -> CheckoutDialog | None:
1476
+ """Open a captured branch choice only from a live management window."""
1477
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1478
+ return None
1479
+ window = self.session_registry_controller.window_by_session[session_id]
1480
+ if window.context.is_bare:
1481
+ return None
1482
+ existing = self.checkout_dialog_by_session.get(session_id)
1483
+ if existing is not None and not existing.closed:
1484
+ existing.raise_()
1485
+ return existing
1486
+ session = self.session_registry_controller.session_by_id[session_id]
1487
+ generation = session.generation
1488
+ previous_snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
1489
+ if expected_snapshot is None:
1490
+ expected_snapshot = previous_snapshot
1491
+ if target_oid is None and previous_snapshot is not None:
1492
+ for reference in previous_snapshot.references:
1493
+ if reference.full_name == full_ref:
1494
+ target_oid = reference.target_oid
1495
+ break
1496
+ dialog = CheckoutDialog(session_id, window)
1497
+ dialog.setWindowTitle(f"Checkout — {os.path.basename(window.context.repository_dir)}")
1498
+ dialog.setAccessibleDescription(f"Check out a branch in {window.context.repository_dir}")
1499
+ dialog.checkout_button.setAccessibleDescription(f"Check out the selected reference in {window.context.repository_dir}")
1500
+ self.checkout_dialog_by_session[session_id] = dialog
1501
+ dialog.checkout_requested.connect(lambda intent: self.submit_checkout(session_id, intent))
1502
+ dialog.selection_changed.connect(lambda oid: self._compare_checkout(session_id, dialog, oid))
1503
+ dialog.lost_commit_count_requested.connect(lambda local_oid, remote_oid: self._count_lost_commits(session_id, dialog, local_oid, remote_oid))
1504
+ dialog.finished.connect(lambda: self.checkout_dialog_by_session.pop(session_id, None) if self.checkout_dialog_by_session.get(session_id) is dialog else None)
1505
+
1506
+ context, git_filename = window.context, self.repository.git_filename
1507
+
1508
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
1509
+ """Keep the target frozen and prevent publication after closure, refresh or submission."""
1510
+ if not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.checkout_dialog_by_session, context, git_filename) or dialog.busy:
1511
+ return
1512
+ if error is not None:
1513
+ refuse_form_opening(dialog, str(error))
1514
+ return
1515
+ if expected_snapshot is not None and (snapshot.head_oid != expected_snapshot.head_oid or snapshot.head_ref != expected_snapshot.head_ref
1516
+ or snapshot.generation != expected_snapshot.generation or snapshot.git_filename != expected_snapshot.git_filename):
1517
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.HEAD_CHANGED_BEFORE_OPENING))
1518
+ return
1519
+ if target_oid is not None:
1520
+ selected = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
1521
+ if selected is None or selected.target_oid != target_oid:
1522
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_CHANGED))
1523
+ return
1524
+ dialog.populate(snapshot, full_ref)
1525
+ reveal_form(dialog)
1526
+
1527
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
1528
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_checkout(session_id, full_ref, target_oid=target_oid, expected_snapshot=capture))
1529
+ return dialog
1530
+
1531
+ def _compare_checkout(self, session_id: str, dialog: CheckoutDialog, target_oid: str) -> None:
1532
+ """Show how the chosen branch differs from HEAD, from the history graph in memory, and from Git only when memory cannot tell.
1533
+
1534
+ The answer is bound to the selected target and the dialog generation, so a late Git count for an older choice
1535
+ is dropped.
1536
+ """
1537
+ difference = self._commit_difference_from_graph(session_id, dialog.snapshot, target_oid) if dialog.snapshot is not None else None
1538
+ if difference is not None:
1539
+ dialog.show_difference(difference)
1540
+ return
1541
+ generation = dialog.read_generation
1542
+ def complete(difference: object, error: Exception | None) -> None:
1543
+ """Ignore comparison output after another branch choice or invalidated authority."""
1544
+ if dialog.closed or not isValid(dialog) or generation != dialog.read_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1545
+ return
1546
+ dialog.show_difference(difference, str(error) if error else "")
1547
+ self.reference.difference(dialog.snapshot, target_oid, complete)
1548
+
1549
+ def _count_lost_commits(self, session_id: str, dialog: CheckoutDialog, local_oid: str, remote_oid: str) -> None:
1550
+ """Tell the Checkout window how many local commits resetting the branch onto the remote one would lose.
1551
+
1552
+ The answer is bound to the dialog generation like the commit comparison, so a late count for an older choice
1553
+ is dropped. A count Git could not give shows its cause under the choices and makes Checkout available again.
1554
+ """
1555
+ if dialog.snapshot is None:
1556
+ return
1557
+ generation = dialog.read_generation
1558
+
1559
+ def complete(count: object, error: Exception | None) -> None:
1560
+ """Ignore the count after another branch choice, closure or lost authority."""
1561
+ if dialog.closed or not isValid(dialog) or generation != dialog.read_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1562
+ return
1563
+ if error is not None:
1564
+ dialog.show_lost_commit_failure(str(error))
1565
+ return
1566
+ dialog.show_lost_commits(count)
1567
+
1568
+ self.reference.commits_only_in(dialog.snapshot, local_oid, remote_oid, complete)
1569
+
1570
+ def submit_checkout(self, session_id: str, intent: CheckoutIntent) -> str | None:
1571
+ """Recheck authority and the displayed immutable intent before reserving Git writes."""
1572
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1573
+ return None
1574
+ dialog = self.checkout_dialog_by_session.get(session_id)
1575
+ if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
1576
+ return None
1577
+ return self._launch_checkout(session_id, dialog, intent)
1578
+
1579
+ def open_checkout_revision(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> CheckoutRevisionDialog | None:
1580
+ """Open the Checkout revision window on one commit, only from a live management window with a working tree.
1581
+
1582
+ The references come from the window's own capture while their files are unchanged, and the commit's position
1583
+ from HEAD from the history graph in memory, so opening on a fully loaded graph starts no Git process.
1584
+ """
1585
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1586
+ return None
1587
+ window = self.session_registry_controller.window_by_session[session_id]
1588
+ if window.context.is_bare:
1589
+ return None
1590
+ existing = self.checkout_revision_dialog_by_session.get(session_id)
1591
+ if existing is not None and not existing.closed:
1592
+ existing.raise_()
1593
+ return existing
1594
+ session = self.session_registry_controller.session_by_id[session_id]
1595
+ generation = session.generation
1596
+ if expected_snapshot is None:
1597
+ expected_snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
1598
+ dialog = CheckoutRevisionDialog(session_id, window)
1599
+ dialog.setAccessibleDescription(f"Check out a commit in {window.context.repository_dir}")
1600
+ self.checkout_revision_dialog_by_session[session_id] = dialog
1601
+ dialog.checkout_requested.connect(lambda intent: self.submit_checkout_revision(session_id, intent))
1602
+ dialog.finished.connect(lambda: self.checkout_revision_dialog_by_session.pop(session_id, None)
1603
+ if self.checkout_revision_dialog_by_session.get(session_id) is dialog else None)
1604
+ context, git_filename = window.context, self.repository.git_filename
1605
+
1606
+ def is_current() -> bool:
1607
+ """Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
1608
+ return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.checkout_revision_dialog_by_session, context, git_filename) and not dialog.busy
1609
+
1610
+ self._bind_revision_picker(session_id, dialog, is_current)
1611
+
1612
+ def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
1613
+ """Show the commit only while the form, its window and the HEAD it was asked from are still current."""
1614
+ if not is_current():
1615
+ return
1616
+ if error is not None:
1617
+ refuse_form_opening(dialog, str(error))
1618
+ return
1619
+ if expected_snapshot is not None and (snapshot.head_oid != expected_snapshot.head_oid or snapshot.head_ref != expected_snapshot.head_ref
1620
+ or snapshot.generation != expected_snapshot.generation or snapshot.git_filename != expected_snapshot.git_filename
1621
+ or snapshot.context != expected_snapshot.context):
1622
+ refuse_form_opening(dialog, refresh_advice_text(SharedText.HEAD_CHANGED_BEFORE_OPENING))
1623
+ return
1624
+ dialog.populate(snapshot, target_oid)
1625
+ reveal_form(dialog)
1626
+
1627
+ self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
1628
+ menu_capture=expected_snapshot, reopen=lambda capture: self.open_checkout_revision(session_id, target_oid, expected_snapshot=capture))
1629
+ return dialog
1630
+
1631
+ def submit_checkout_revision(self, session_id: str, intent: CheckoutIntent) -> str | None:
1632
+ """Recheck authority and the request the Checkout revision window shows before reserving the Git write."""
1633
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
1634
+ return None
1635
+ dialog = self.checkout_revision_dialog_by_session.get(session_id)
1636
+ if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
1637
+ return None
1638
+ return self._launch_checkout(session_id, dialog, intent)
1639
+
1640
+ def _launch_checkout(self, session_id: str, dialog: CheckoutDialog | CheckoutRevisionDialog, intent: CheckoutIntent) -> str:
1641
+ """Open the console, then start one checkout that stays current only while its form, window and Git do."""
1642
+ session = self.session_registry_controller.session_by_id[session_id]
1643
+
1644
+ def is_current() -> bool:
1645
+ """Keep captured session, executable and dialog authority current until launch."""
1646
+ return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
1647
+ and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
1648
+
1649
+ self.checkout_intent_by_id[intent.operation.operation_id] = intent
1650
+ self.open_reference_output(intent, dialog)
1651
+ return self.reference.checkout(intent, is_current=is_current)
1652
+
1653
+ def checkout_finished(self, outcome: object) -> None:
1654
+ """Report a checkout's result to its form and console, then refresh the repository's windows unless the form may simply retry.
1655
+
1656
+ A checkout refused only because another operation held the repository wrote nothing, so the windows keep their
1657
+ generation and the form given back can send its request again, as the branch forms do. A checkout refused
1658
+ because its capture no longer matches the repository cannot succeed from that form: the windows read the
1659
+ repository again, which is what the refusal asks for. A checkout that ran, whatever its result, refreshes the
1660
+ repository's windows and its submodules.
1661
+ """
1662
+ intent = self.checkout_intent_by_id.pop(outcome.operation_id, None)
1663
+ if intent is None:
1664
+ return
1665
+ self.reference.outcome_by_id[outcome.operation_id] = outcome
1666
+ # The form that launched this checkout, read before the console bookkeeping below forgets it: the branch
1667
+ # window and the revision window report their result the same way.
1668
+ dialog = self.reference_form_by_id.get(outcome.operation_id)
1669
+ if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
1670
+ dialog.show_outcome(outcome)
1671
+ self.finish_reference_output(outcome)
1672
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
1673
+ self.diagnostic_log.write(outcome.diagnostic)
1674
+ if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
1675
+ return
1676
+ self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
1677
+ self.staging_controller.refresh_submodule_family(intent.snapshot.context)