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,813 @@
1
+ """Exchange branches with the remotes of a repository from its windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import replace
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QObject, QTimer
10
+ from shiboken6 import isValid
11
+
12
+ from gitpane.account.authentication import transfer_account
13
+ from gitpane.config import LOCAL_PREFIX, TAG_PREFIX, ConsoleOperation, SharedText
14
+ from gitpane.controller.refresh_controller import RefreshScope
15
+ from gitpane.history.file_history import WindowCapability
16
+ from gitpane.operation.diagnostic import Diagnostic
17
+ from gitpane.operation.operation_status import OperationStatus
18
+ from gitpane.operation.repository_operation import NO_LONGER_AUTHORIZED_TEXT, OperationAction, OperationIntent
19
+ from gitpane.preference.preference_store import FETCH_DEFAULT_PREFERENCE_KEY, FetchMode, fetch_mode_preference
20
+ from gitpane.process.process_runner import READ_PROFILE
21
+ from gitpane.reference.reference_edit import reference_signature
22
+ from gitpane.system.path_identity import canonical_path
23
+ from gitpane.transport.branch_exchange import TransportRequest
24
+ from gitpane.transport.push_tracking import PushTrackingChoice, push_tracking_choice, save_push_tracking_choice, tracking_question_applies
25
+ from gitpane.transport.refusal_notice import account_notice, failure_headline, failure_summary, server_refusal_headline
26
+ from gitpane.transport.remote_config import fetch_remote_name
27
+ from gitpane.ui.console.operation_dialog import OperationDialog
28
+ from gitpane.ui.transport.push_dialog import PushDialog
29
+ from gitpane.ui.transport.push_rejection_prompt import PushRejectionPrompt
30
+ from gitpane.ui.transport.push_tracking_question import PushTrackingAnswer, ask_push_tracking
31
+ from gitpane.ui.transport.remote_dialog import RemoteDialog
32
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
33
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable, Generator, Sequence
37
+
38
+ from PySide6.QtWidgets import QMessageBox, QWidget
39
+
40
+ from gitpane.account.authentication import AuthenticationService
41
+ from gitpane.commit.commit_push import CommitPushIntent, CommitPushService
42
+ from gitpane.commit.commit_write import CommitRequest
43
+ from gitpane.controller.account_controller import AccountController
44
+ from gitpane.controller.clone_controller import CloneController
45
+ from gitpane.controller.console_registry import ConsoleRegistry
46
+ from gitpane.controller.operation_policy import OperationPolicy
47
+ from gitpane.controller.reference_read_controller import ReferenceReadController
48
+ from gitpane.controller.refresh_controller import RefreshController
49
+ from gitpane.controller.session_registry import SessionRegistry
50
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
51
+ from gitpane.history.repository import GitRepository
52
+ from gitpane.operation.diagnostic import DiagnosticLog
53
+ from gitpane.operation.repository_operation import RepositoryOperationService
54
+ from gitpane.preference.preference_store import PreferenceStore
55
+ from gitpane.transport.branch_exchange import TransportOutcome, TransportRun, TransportService
56
+ from gitpane.transport.remote import RemoteService
57
+ from gitpane.transport.remote_config import RemoteSnapshot
58
+ from gitpane.ui.transport.push_dialog import PushWindowRequest
59
+ from gitpane.worktree.staging import StagingSession
60
+ from gitpane.worktree.worktree_service import WorktreeService
61
+
62
+
63
+ class TransportController(QObject):
64
+ """Fetch, Push and Manage remotes of every repository window: their forms, the consoles of their transfers, the Push a Commit & push hands over, and what the end of a transfer shows and reads again in the windows of the same repository."""
65
+
66
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, console_registry_controller: ConsoleRegistry,
67
+ reference_read_controller: ReferenceReadController, refresh_controller: RefreshController, account_controller: AccountController, clone_controller: CloneController, preference: PreferenceStore, repository: GitRepository,
68
+ worktree_service: WorktreeService, repository_operation: RepositoryOperationService, remote: RemoteService, authentication: AuthenticationService, transport: TransportService, commit_push: CommitPushService,
69
+ diagnostic_log: DiagnosticLog | None, parent: QObject | None = None) -> None:
70
+ """Receive the services and the lower controllers a transfer works with; the wiring builds it after the staging controller, and the repository windows, the preparation sessions and the commit-and-push service reach it through the wiring."""
71
+ super().__init__(parent)
72
+ self.session_registry_controller = session_registry_controller
73
+ self.operation_policy_controller = operation_policy_controller
74
+ self.console_registry_controller = console_registry_controller
75
+ self.reference_read_controller = reference_read_controller
76
+ self.refresh_controller = refresh_controller
77
+ self.account_controller = account_controller
78
+ self.clone_controller = clone_controller
79
+ self.preference = preference
80
+ self.repository = repository
81
+ self.worktree_service = worktree_service
82
+ self.remote = remote
83
+ self.authentication = authentication
84
+ self.transport = transport
85
+ self.commit_push = commit_push
86
+ self.diagnostic_log = diagnostic_log
87
+ self.staging_session_registry_controller = staging_session_registry_controller
88
+ self.repository_operation = repository_operation
89
+ self.push_dialog_by_session: dict[str, PushDialog] = {}
90
+ self.push_rejection_prompt_by_session: dict[str, PushRejectionPrompt] = {}
91
+ self.push_tracking_question_by_session: dict[str, QMessageBox] = {}
92
+ self.fetch_read_by_session: dict[str, str] = {}
93
+ self.remote_dialog_by_session: dict[str, RemoteDialog] = {}
94
+
95
+ def prepare_push_target(self, session_id: str, dialog: PushDialog) -> None:
96
+ """Bind the displayed destination to its current account and tools without contacting it."""
97
+ if dialog.loading or dialog.snapshot is None or dialog.closed or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
98
+ return
99
+ endpoint = dialog.chosen_endpoint()
100
+ name = dialog.remote_selector.currentData() if dialog.remote_radio.isChecked() else ""
101
+ dialog.push_target = None
102
+ dialog.target_error = ""
103
+ try:
104
+ dialog.push_target = self.transport.capture_push_target(dialog.snapshot, name or "", endpoint)
105
+ except (ValueError, OSError, RuntimeError) as error:
106
+ dialog.target_error = str(error)
107
+ dialog.update_action()
108
+
109
+ def read_push(self, session_id: str, dialog: PushDialog) -> None:
110
+ """Read local sources and effective destinations without contacting a remote server."""
111
+ if not isValid(dialog) or dialog.closed or dialog.busy or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
112
+ return
113
+ session = self.session_registry_controller.session_by_id[session_id]
114
+ generation = self.session_registry_controller.generation_by_session[session_id]
115
+ git_filename = self.repository.git_filename
116
+ dialog.loading = True
117
+ read_id = uuid.uuid4().hex
118
+ dialog.read_id = read_id
119
+ dialog.update_action()
120
+
121
+ def read() -> Generator:
122
+ """Capture actual HEAD and tags alongside the remote configuration."""
123
+ reference_state = yield FileTask(lambda: reference_signature(session.repository_context))
124
+ head = yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1), profile=READ_PROFILE)
125
+ tags = yield GitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)", TAG_PREFIX], profile=READ_PROFILE)
126
+ snapshot = yield from self.remote.capture(session.repository_context, git_filename, generation)
127
+ if reference_state != snapshot.reference_state:
128
+ raise ValueError("References changed while reading Push. Reread destination.")
129
+ tag_oid_by_ref = {}
130
+ for record in tags.splitlines():
131
+ full_ref, oid = record.decode("utf-8", "surrogateescape").split("\0")
132
+ tag_oid_by_ref[full_ref] = oid
133
+ return snapshot, head.decode("utf-8", "surrogateescape").strip(), tag_oid_by_ref
134
+
135
+ def complete(value: object, error: Exception | None) -> None:
136
+ """Discard closed or stale responses while retaining a recoverable visible draft."""
137
+ if not isValid(dialog) or dialog.closed or dialog.read_id != read_id or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
138
+ return
139
+ if generation != self.session_registry_controller.generation_by_session.get(session_id):
140
+ dialog.needs_reread = True
141
+ dialog.show_error("The repository view changed. Reread destination before sending.")
142
+ return
143
+ if error is not None:
144
+ dialog.needs_reread = True
145
+ dialog.show_error(str(error))
146
+ return
147
+ dialog.populate(*value)
148
+ self.prepare_push_target(session_id, dialog)
149
+
150
+ self.worktree_service.run(session.repository_context, git_filename, read(), complete)
151
+
152
+ def _reread_push_windows(self, common_git_dir: str) -> None:
153
+ """Reread every idle Push window of a repository after a Fetch, so its remote branches and fetched state are current.
154
+
155
+ After a refused push, the user fetches from the rejection prompt and pushes again from the same window.
156
+ """
157
+ for session_id, dialog in tuple(self.push_dialog_by_session.items()):
158
+ session = self.session_registry_controller.session_by_id.get(session_id)
159
+ if session is None or session.is_closed or not isValid(dialog) or dialog.closed or dialog.busy:
160
+ continue
161
+ if canonical_path(session.repository_context.common_git_dir) == common_git_dir:
162
+ self.read_push(session_id, dialog)
163
+
164
+ def _start_push(self, session_id: str, request: TransportRequest) -> TransportRun:
165
+ """Send one Push request and show its console, which Abort can stop like Fetch."""
166
+ operation = request.operation
167
+ run = self.transport.push(request)
168
+ output_dialog = OperationDialog(run.trace, "Push", self.session_registry_controller.window_by_session[session_id], preference=self.preference, console_operation=ConsoleOperation.PUSH)
169
+ self.console_registry_controller.transport_dialog_by_id[operation.operation_id] = output_dialog
170
+ output_dialog.abort_requested.connect(lambda: self.transport.cancel(operation.operation_id))
171
+ output_dialog.finished.connect(run.trace.close)
172
+ output_dialog.set_running()
173
+ open_window_modal(output_dialog, keeps_window_buttons=True)
174
+ return run
175
+
176
+ def _show_push_rejection(self, run: TransportRun, outcome: TransportOutcome) -> None:
177
+ """Explain a Push refused because the server branch holds commits it would replace, and offer Fetch or Force push with lease.
178
+
179
+ The prompt opens after every other reaction to the result (console, Push window, Commit & push follow-up), so it
180
+ is the newest window and answers first. A new refusal replaces an older prompt of the same repository window.
181
+ Whatever the answer (Force push with lease, Fetch, Cancel, Escape or the title-bar close), the refused push
182
+ console has done its job, so it is settled and closes unless Keep dialog open is checked.
183
+ """
184
+ session_id = run.request.operation.session_id
185
+ if outcome.rejection is None or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
186
+ return
187
+ previous = self.push_rejection_prompt_by_session.get(session_id)
188
+ if previous is not None and isValid(previous):
189
+ previous.reject()
190
+ prompt = PushRejectionPrompt(outcome.rejection, self.session_registry_controller.window_by_session[session_id])
191
+ remote_name = outcome.rejection.remote_name
192
+ prompt.fetch_requested.connect(lambda: self.fetch(session_id, (FetchMode.FETCH, remote_name, None)))
193
+ prompt.force_requested.connect(lambda: self._force_push_with_lease(run))
194
+ prompt.finished.connect(lambda _result: self.console_registry_controller.settle_answered_console(run.request.operation.operation_id))
195
+ self.push_rejection_prompt_by_session[session_id] = prompt
196
+ open_window_modal(prompt)
197
+
198
+ def _force_push_with_lease(self, run: TransportRun) -> None:
199
+ """Send a refused branch again as a force push with lease on the last fetched state, chosen from the rejection prompt.
200
+
201
+ The local capture is read again first; the transport then checks that the branch and destination did not change.
202
+ A Commit & push keeps going through its own service so the created commit is never committed twice. The tracking
203
+ answer given before the refused Push is kept without asking again, so the branch is linked only if that answer
204
+ asked for it and the forced Push is verified.
205
+ """
206
+ request = run.request
207
+ session_id = request.operation.session_id
208
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
209
+ return
210
+ session = self.session_registry_controller.session_by_id[session_id]
211
+ generation = self.session_registry_controller.generation_by_session[session_id]
212
+ git_filename = self.repository.git_filename
213
+ intent = self.commit_push.intent_by_push_id.get(request.operation.operation_id)
214
+
215
+ def refused(message: str) -> None:
216
+ """Show why the force push could not start, in the Push window when it is open, else in the status bar."""
217
+ dialog = self.push_dialog_by_session.get(session_id)
218
+ if dialog is not None and isValid(dialog) and not dialog.closed:
219
+ dialog.show_error(message)
220
+ return
221
+ window = self.session_registry_controller.window_by_session.get(session_id)
222
+ if window is not None and isValid(window):
223
+ window.statusBar().showMessage(message)
224
+
225
+ def captured(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
226
+ """Start the force push under the current authority, or explain why it cannot start."""
227
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
228
+ return
229
+ if generation != self.session_registry_controller.generation_by_session.get(session_id):
230
+ refused("The repository view changed. Push again to choose Force push with lease.")
231
+ return
232
+ if error is not None:
233
+ refused(str(error))
234
+ return
235
+ try:
236
+ target = self.transport.capture_push_target(snapshot, request.remote_name, request.endpoint)
237
+ except (ValueError, OSError, RuntimeError) as failure:
238
+ refused(str(failure))
239
+ return
240
+ if intent is not None:
241
+ forced = self.commit_push.retry_push(intent, snapshot, request.remote_name, request.endpoint, request.destination_ref, target, True, add_tracking=request.add_tracking)
242
+ else:
243
+ operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation, session_id=session_id,
244
+ operation="Push", action=OperationAction.REMOTE_WRITE, targets=(request.source_ref, request.destination_ref, request.endpoint))
245
+ forced = self._start_push(session_id, TransportRequest(operation, remote_name=request.remote_name, snapshot=snapshot, mode="push", source_ref=request.source_ref,
246
+ source_oid=request.source_oid, destination_ref=request.destination_ref, endpoint=request.endpoint,
247
+ force=True, push_target=target, add_tracking=request.add_tracking))
248
+ if forced is None:
249
+ refused("This commit's repository is no longer authorized for Push.")
250
+ return
251
+ dialog = self.push_dialog_by_session.get(session_id)
252
+ if dialog is not None and isValid(dialog) and not dialog.closed:
253
+ dialog.follow_operation(forced.request.operation.operation_id)
254
+
255
+ self.worktree_service.run(session.repository_context, git_filename, self.remote.capture(session.repository_context, git_filename, generation), captured)
256
+
257
+ def fetch(self, session_id: str, choice: tuple | None = None) -> OperationDialog | None:
258
+ """Capture a management intention and show all preparation and Fetch output.
259
+
260
+ ``choice`` is the Fetch mode, the remote and its capture picked in a menu; without it the saved default mode is used.
261
+ """
262
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
263
+ return None
264
+ session = self.session_registry_controller.session_by_id[session_id]
265
+ for operation_id, run in self.transport.run_by_id.items():
266
+ if run.request.operation.common_git_dir == canonical_path(session.repository_context.common_git_dir) and operation_id not in self.transport.outcome_by_id:
267
+ return self.console_registry_controller.transport_dialog_by_id.get(operation_id)
268
+ if session_id in self.fetch_read_by_session:
269
+ return None
270
+ mode, remote_name, snapshot = choice if choice is not None else (fetch_mode_preference(self.preference.read(FETCH_DEFAULT_PREFERENCE_KEY)), "", None)
271
+ if mode == FetchMode.FETCH and snapshot is None and not remote_name:
272
+ self.read_fetch_choice(session_id, launch=True)
273
+ return None
274
+ if mode == FetchMode.FETCH and not remote_name:
275
+ return None
276
+ operation = OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
277
+ session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id], operation="Fetch", action=OperationAction.REMOTE_WRITE)
278
+ run = self.transport.fetch(TransportRequest(operation, mode == FetchMode.PRUNE_ALL, remote_name, snapshot))
279
+ dialog = OperationDialog(run.trace, "Fetch", self.session_registry_controller.window_by_session[session_id], preference=self.preference, console_operation=ConsoleOperation.FETCH)
280
+ self.console_registry_controller.transport_dialog_by_id[operation.operation_id] = dialog
281
+ dialog.abort_requested.connect(lambda: self.transport.cancel(operation.operation_id))
282
+ dialog.finished.connect(run.trace.close)
283
+ dialog.set_running()
284
+ open_window_modal(dialog, keeps_window_buttons=True)
285
+ for related_window in self.session_registry_controller.window_by_session.values():
286
+ if canonical_path(related_window.context.common_git_dir) == operation.common_git_dir:
287
+ related_window.set_fetch_busy(True)
288
+ return dialog
289
+
290
+ def fetch_busy_for(self, common_git_dir: str) -> bool:
291
+ """Tell whether a Fetch or another transport that has not finished yet runs on this repository.
292
+
293
+ Every window of the repository blocks a second primary Fetch while one runs, including a window that has just
294
+ moved to it from another repository.
295
+ """
296
+ canonical_git_dir = canonical_path(common_git_dir)
297
+ for operation_id, run in self.transport.run_by_id.items():
298
+ if run.request.operation.common_git_dir == canonical_git_dir and operation_id not in self.transport.outcome_by_id:
299
+ return True
300
+ return False
301
+
302
+ def read_fetch_choice(self, session_id: str, *, launch: bool = False) -> None:
303
+ """Read destinations without network activity and bind later clicks to the displayed capture."""
304
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or session_id in self.fetch_read_by_session:
305
+ return
306
+ window = self.session_registry_controller.window_by_session[session_id]
307
+ if window.fetch_busy:
308
+ return
309
+ session = self.session_registry_controller.session_by_id[session_id]
310
+ generation = self.session_registry_controller.generation_by_session[session_id]
311
+ git_filename = self.repository.git_filename
312
+ request_id = uuid.uuid4().hex
313
+ self.fetch_read_by_session[session_id] = request_id
314
+ branch_ref = window.branch_selector.currentData()
315
+ reference_snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
316
+ if branch_ref is None and reference_snapshot is not None:
317
+ branch_ref = reference_snapshot.head_ref
318
+ window.show_fetch_choice(None, "", "Reading remotes…")
319
+ window.fetch_choice_action_by_mode[FetchMode.FETCH].setEnabled(False)
320
+ window.set_fetch_busy(True)
321
+
322
+ def complete(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
323
+ """Keep stale or closed menu reads from selecting another repository or launching later."""
324
+ if self.fetch_read_by_session.get(session_id) != request_id:
325
+ return
326
+ self.fetch_read_by_session.pop(session_id, None)
327
+ if not isValid(window) or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
328
+ return
329
+ window.set_fetch_busy(self.fetch_busy_for(window.context.common_git_dir))
330
+ if self.session_registry_controller.generation_by_session.get(session_id) != generation:
331
+ window.show_fetch_choice(None, "", "The repository view changed. Open the Fetch menu again.")
332
+ return
333
+ if error is not None:
334
+ diagnostic = Diagnostic("Read Fetch remotes", window.context.repository_dir, self.repository.git_filename, [], str(error)).prepare()
335
+ window.show_fetch_choice(None, "", diagnostic.cause)
336
+ return
337
+ name = fetch_remote_name(snapshot, branch_ref)
338
+ window.show_fetch_choice(snapshot, name)
339
+ if not launch:
340
+ return
341
+ if name:
342
+ self.fetch(session_id, (FetchMode.FETCH, name, snapshot))
343
+ else:
344
+ window.fetch_remote_menu.popup(window.fetch_button.mapToGlobal(window.fetch_button.rect().bottomLeft()))
345
+
346
+ def read() -> Generator:
347
+ """Resolve an initial loading toolbar from native HEAD before capturing its remotes."""
348
+ nonlocal branch_ref
349
+ if branch_ref is None:
350
+ output = yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1), profile=READ_PROFILE)
351
+ branch_ref = output.decode("utf-8", "surrogateescape").strip() or None
352
+ return (yield from self.remote.capture(session.repository_context, git_filename, generation))
353
+
354
+ self.worktree_service.run(session.repository_context, git_filename, read(), complete)
355
+
356
+ def save_fetch_default(self, session_id: str, mode: FetchMode) -> None:
357
+ """Publish a non-secret default across windows only after its storage sync succeeds; the file receives the mode's saved word."""
358
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
359
+ return
360
+ window = self.session_registry_controller.window_by_session[session_id]
361
+ try:
362
+ self.preference.update({FETCH_DEFAULT_PREFERENCE_KEY: mode.value})
363
+ except (OSError, ValueError, TypeError) as error:
364
+ diagnostic = Diagnostic("Save Fetch default", window.context.repository_dir, "", [], str(error)).prepare()
365
+ window.statusBar().showMessage(diagnostic.cause)
366
+ if self.diagnostic_log is not None:
367
+ self.diagnostic_log.write(diagnostic)
368
+ window.apply_fetch_preference(self.preference.value_by_key)
369
+
370
+ def transport_finished(self, outcome: object) -> None:
371
+ """Publish terminal output and refresh related management views without losing reading state."""
372
+ run = self.transport.run_by_id[outcome.operation_id]
373
+ for window in self.session_registry_controller.window_by_session.values():
374
+ if canonical_path(window.context.common_git_dir) == run.request.operation.common_git_dir:
375
+ busy = any(active.request.operation.common_git_dir == run.request.operation.common_git_dir and operation_id not in self.transport.outcome_by_id
376
+ for operation_id, active in self.transport.run_by_id.items())
377
+ window.set_fetch_busy(busy)
378
+ window.statusBar().showMessage(run.request.operation.operation + ": " + outcome.status.value)
379
+ account = transfer_account(outcome.selection, outcome.endpoint, self.authentication.missing_secret_namespace_set)
380
+ push_dialog = self.push_dialog_by_session.get(run.request.operation.session_id)
381
+ if push_dialog is not None and isValid(push_dialog) and not push_dialog.closed and push_dialog.operation_id == outcome.operation_id:
382
+ push_dialog.show_outcome(outcome, account)
383
+ if run.request.mode == "push" and outcome.rejection is not None:
384
+ QTimer.singleShot(0, self, lambda: self._show_push_rejection(run, outcome))
385
+ dialog = self.console_registry_controller.transport_dialog_by_id.get(outcome.operation_id)
386
+ failed = outcome.status != OperationStatus.SUCCESS and outcome.diagnostic is not None
387
+ if dialog is not None and isValid(dialog):
388
+ failure_text = failure_summary(outcome.diagnostic) if failed else ""
389
+ # The same first sentence as the Push window: the server's own refusal when there is one, else the account one.
390
+ headline = (server_refusal_headline(outcome.server_refusals, outcome.diagnostic.stderr) or failure_headline(outcome.diagnostic, account)) if failed else ""
391
+ dialog.show_result(outcome.status, failure_text=failure_text, failure_headline=headline, stays_open=outcome.upstream_not_linked)
392
+ # Workaround: a refused Commit & push push reopens its Push window from this same signal, after this handler. Qt
393
+ # only lets the newest modal window take input, so Settings or the install window opened here, before the Push
394
+ # window, would be locked out by it (on macOS the Push window is modal for the whole application). Both open on
395
+ # the next turn of the event loop instead, above the Push window, in the same order as for any other refusal.
396
+ reopens_push_window = outcome.operation_id in self.commit_push.intent_by_push_id
397
+ if failed:
398
+ self.clone_controller.lookup_refused_account(outcome.selection, outcome.diagnostic, lambda: self._show_transport_needs_reconnect(run, outcome))
399
+ # After the console and the Push window show their red failure, which stays; Settings only comes in addition.
400
+ notice = account_notice(outcome.diagnostic, account, outcome.remote_name or (account.address if account is not None else ""))
401
+ if notice is not None:
402
+ # Bound now: the deferred call must open Settings for the refused repository, whatever window opened since.
403
+ refused_session_id = run.request.operation.session_id
404
+ if reopens_push_window:
405
+ QTimer.singleShot(0, self, lambda refused_session_id=refused_session_id, notice=notice: self.account_controller.offer_account_after_refusal(refused_session_id, notice))
406
+ else:
407
+ self.account_controller.offer_account_after_refusal(refused_session_id, notice)
408
+ # The console and the Push window keep their red line; the install window only comes in addition, even for a
409
+ # partial Fetch, since its HTTPS remote was stopped.
410
+ if outcome.credential_manager_refusal is not None:
411
+ if reopens_push_window:
412
+ QTimer.singleShot(0, self, lambda: self._offer_transport_credential_manager_install(run, outcome))
413
+ else:
414
+ self._offer_transport_credential_manager_install(run, outcome)
415
+ if not self.operation_policy_controller.allows(run.request.operation.session_id, WindowCapability.MANAGE):
416
+ run.trace.close()
417
+ if outcome.snapshot is None:
418
+ return
419
+ if not outcome.refresh_required:
420
+ # The transport proved that neither the server nor the repository changed: the views already show the
421
+ # truth, so nothing is read again. A refresh the user asked for meanwhile keeps its own course.
422
+ return
423
+ # Fetch, Push and remote deletion change remote-tracking and local references only; a HEAD that moved
424
+ # meanwhile still makes the refresh read everything.
425
+ for related_session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
426
+ if (not session.is_closed and WindowCapability.MANAGE in session.capabilities
427
+ and canonical_path(session.repository_context.common_git_dir) == run.request.operation.common_git_dir):
428
+ self.refresh_controller.checkout_refresh_session_set.add(related_session_id)
429
+ self.refresh_controller.refresh(related_session_id, scope=RefreshScope.REFERENCES)
430
+ # After the refresh, which starts a new view generation that an earlier read would not belong to.
431
+ if run.request.mode == "fetch":
432
+ self._reread_push_windows(run.request.operation.common_git_dir)
433
+
434
+ def _offer_transport_credential_manager_install(self, run: TransportRun, outcome: TransportOutcome) -> None:
435
+ """Open the install window for a transport stopped by the credential manager search, over its console while that console is shown."""
436
+ dialog = self.console_registry_controller.transport_dialog_by_id.get(outcome.operation_id)
437
+ parent = dialog if dialog is not None and isValid(dialog) and dialog.isVisible() else self.session_registry_controller.window_by_session.get(run.request.operation.session_id)
438
+ self.account_controller.offer_credential_manager_install(outcome.credential_manager_refusal, parent)
439
+
440
+ def _show_transport_needs_reconnect(self, run: TransportRun, outcome: TransportOutcome) -> None:
441
+ """Say that the refused account needs a reconnect in the console of this transfer and in the Push window, while each still shows its failure.
442
+
443
+ A console belongs to one transfer; a Push window only counts while its last send is still this transfer.
444
+ """
445
+ account = transfer_account(outcome.selection, outcome.endpoint, self.authentication.missing_secret_namespace_set)
446
+ dialog = self.console_registry_controller.transport_dialog_by_id.get(outcome.operation_id)
447
+ if dialog is not None and isValid(dialog) and not dialog.running:
448
+ dialog.show_failure_text(failure_summary(outcome.diagnostic), failure_headline(outcome.diagnostic, account))
449
+ push_dialog = self.push_dialog_by_session.get(run.request.operation.session_id)
450
+ if push_dialog is not None and isValid(push_dialog) and not push_dialog.closed and not push_dialog.busy and push_dialog.operation_id == outcome.operation_id:
451
+ push_dialog.show_outcome(outcome, account)
452
+
453
+ def open_push(self, session_id: str) -> PushDialog | None:
454
+ """Open a read-only Push draft for the actual management repository."""
455
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
456
+ return None
457
+ existing = self.push_dialog_by_session.get(session_id)
458
+ if existing is not None and not existing.closed:
459
+ existing.raise_()
460
+ return existing
461
+ session = self.session_registry_controller.session_by_id[session_id]
462
+ common_git_dir = canonical_path(session.repository_context.common_git_dir)
463
+ dialog = PushDialog(session.repository_context.repository_dir, self.session_registry_controller.window_by_session[session_id],
464
+ is_available=lambda: not any(operation.common_git_dir == common_git_dir for operation in self.repository_operation.active_by_id.values()))
465
+ self.push_dialog_by_session[session_id] = dialog
466
+ # A bound method of the dialog: Qt drops the connection when the dialog is destroyed, and a closed dialog ignores the call.
467
+ self.repository_operation.changed.connect(dialog.check_availability)
468
+ dialog.push_requested.connect(lambda request: self.submit_push(session_id, dialog, request))
469
+ dialog.reread_requested.connect(lambda: self.read_push(session_id, dialog))
470
+ dialog.destination_changed.connect(lambda: self.prepare_push_target(session_id, dialog))
471
+
472
+ def manage() -> None:
473
+ """Reconcile the invoking draft after editing the same repository's remotes."""
474
+ remote_dialog = self.open_remote(session_id)
475
+ if remote_dialog is not None:
476
+ remote_dialog.finished.connect(lambda: self.read_push(session_id, dialog))
477
+
478
+ dialog.remote_requested.connect(manage)
479
+ open_window_modal(dialog)
480
+ self.read_push(session_id, dialog)
481
+ return dialog
482
+
483
+ def commit_push_requested(self, staging: StagingSession, request: CommitRequest) -> None:
484
+ """Bind the staging intention to a live managing window before committing once."""
485
+ if not staging.busy or request is not staging.pending_commit_push_request or self.staging_session_registry_controller.staging_by_key.get(staging.key) is not staging:
486
+ return
487
+ session_id = staging.commit_push_session_id
488
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or self.session_registry_controller.session_by_id[session_id].repository_context != staging.context:
489
+ staging.finish_commit_action(request.operation_id)
490
+ staging.busy = False
491
+ staging.pending_commit_push_request = None
492
+ staging.diagnostic = Diagnostic("Commit & push", staging.context.repository_dir, staging.git_filename, [], NO_LONGER_AUTHORIZED_TEXT).prepare()
493
+ staging._show()
494
+ return
495
+ operation = OperationIntent.capture(request.operation_id, staging.context, staging.git_filename, session_id=session_id,
496
+ generation=self.session_registry_controller.generation_by_session[session_id], operation="Commit & push", action=OperationAction.COMMIT_PUSH)
497
+
498
+ def captured(intent: CommitPushIntent | None, error: Exception | None) -> None:
499
+ """Submit the original request once after capture or release its refused preparation."""
500
+ if staging.pending_commit_push_request is not request or self.staging_session_registry_controller.staging_by_key.get(staging.key) is not staging:
501
+ return
502
+ staging.pending_commit_push_request = None
503
+ if error is not None:
504
+ staging.finish_commit_action(request.operation_id)
505
+ staging.busy = False
506
+ staging.diagnostic = Diagnostic("Commit & push", staging.context.repository_dir, staging.git_filename, [], str(error)).prepare()
507
+ staging._show()
508
+ return
509
+ staging.commit_service.submit(intent.commit_request)
510
+
511
+ self.commit_push.capture(operation, request, captured)
512
+
513
+ def commit_push_started(self, run: TransportRun) -> None:
514
+ """Show the actual Push operation after its local commit has already succeeded."""
515
+ operation = run.request.operation
516
+ parent = self.session_registry_controller.window_by_session.get(operation.session_id)
517
+ dialog = OperationDialog(run.trace, "Push", parent, preference=self.preference, console_operation=ConsoleOperation.PUSH)
518
+ self.console_registry_controller.transport_dialog_by_id[operation.operation_id] = dialog
519
+ dialog.abort_requested.connect(lambda: self.transport.cancel(operation.operation_id))
520
+ dialog.finished.connect(run.trace.close)
521
+ dialog.set_running()
522
+ open_window_modal(dialog, keeps_window_buttons=True)
523
+
524
+ def commit_push_decide_tracking(self, intent: CommitPushIntent) -> None:
525
+ """Decide the tracking reference of a Commit & push's created commit, asking over the repository window when nothing decides it, then hand the Push back to the service.
526
+
527
+ The decision reads the capture taken before the commit, whose configuration the service checks again after the
528
+ answer. Yes or a kept Always add pushes and then links the branch, No or a kept Never add pushes without a link;
529
+ once the answer comes, the service checks the authority and the captured commit again, and reads the
530
+ configuration, HEAD and destination again right before the Push. Cancel sends nothing, keeps the created commit
531
+ and says so in the status bar of the repository window. A window that lost its management authority, before
532
+ the question or while it was open, gets no Push and says so in its status bar; a window that is gone has
533
+ nothing to say it in.
534
+ """
535
+ session_id = intent.operation.session_id
536
+ window = self.session_registry_controller.window_by_session.get(session_id)
537
+ if window is None or not isValid(window):
538
+ return
539
+
540
+ def show(text: SharedText) -> None:
541
+ """Tell in the repository window, while it is still there, what became of the created commit."""
542
+ if isValid(window):
543
+ window.statusBar().showMessage(text.value)
544
+
545
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
546
+ show(SharedText.COMMIT_PUSH_NOT_SENT)
547
+ return
548
+ destination = intent.destination
549
+ self._decide_push_tracking(session_id, window, intent.snapshot, destination.remote_name, intent.source_ref, destination.destination_ref, (),
550
+ send=lambda add_tracking: self.commit_push.push_after_decision(intent, add_tracking, lambda: show(SharedText.COMMIT_PUSH_NOT_SENT)),
551
+ cancel=lambda: show(SharedText.COMMIT_PUSH_CANCELLED))
552
+
553
+ def commit_push_choose(self, intent: CommitPushIntent, message: str) -> None:
554
+ """Keep the created commit visible when default routing needs an explicit choice, with its push console still usable."""
555
+ dialog = self.open_push(intent.operation.session_id)
556
+ if dialog is not None:
557
+ dialog.pin_commit(intent, message)
558
+ if dialog.needs_reread:
559
+ self.read_push(intent.operation.session_id, dialog)
560
+ self._keep_push_consoles_over(intent, dialog)
561
+
562
+ def _keep_push_consoles_over(self, intent: CommitPushIntent, push_dialog: PushDialog) -> None:
563
+ """Keep the open push consoles of this Commit & push in front of its Push window and able to take clicks.
564
+
565
+ Workaround: the console opens before the Push window, both modal, and Qt lets only the newest modal window take
566
+ input, so the Push window shut the console out until Push was closed. Making the Push window the console's owner
567
+ window keeps the console above it and lets it take clicks again; Push stays usable too. This holds on Windows and
568
+ Linux, where both block the repository window, and on macOS, where both block the whole application.
569
+ """
570
+ push_handle = push_dialog.windowHandle()
571
+ if push_handle is None:
572
+ return
573
+ for push_id, pushed_intent in tuple(self.commit_push.intent_by_push_id.items()):
574
+ if pushed_intent.commit_request.operation_id != intent.commit_request.operation_id:
575
+ continue
576
+ console = self.console_registry_controller.transport_dialog_by_id.get(push_id)
577
+ if console is None or not isValid(console) or not console.isVisible() or console.windowHandle() is None:
578
+ continue
579
+ console.windowHandle().setTransientParent(push_handle)
580
+ console.raise_()
581
+ console.activateWindow()
582
+
583
+ def submit_push(self, session_id: str, dialog: PushDialog, request: PushWindowRequest) -> None:
584
+ """Capture the form's branch or tag objects under the current management authority."""
585
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or dialog.closed or request != dialog.request or not dialog.busy:
586
+ return
587
+ snapshot, remote_name, _endpoint, source_ref, destination_ref, force, _push_target, tag_refs = request
588
+ if dialog.created_tag is not None and tag_refs != dialog.created_tag.selection():
589
+ dialog.show_error("Push must contain only the captured created tag. Close Push and review the tag before sending.")
590
+ return
591
+ if (snapshot.context != self.session_registry_controller.session_by_id[session_id].repository_context or snapshot.git_filename != self.repository.git_filename
592
+ or snapshot.generation != self.session_registry_controller.generation_by_session.get(session_id)):
593
+ dialog.needs_reread = True
594
+ dialog.show_error("The repository capture changed. Reread destination before sending.")
595
+ return
596
+ if dialog.commit_push_intent is not None and (tag_refs or source_ref != (dialog.commit_push_intent.source_ref or dialog.commit_push_intent.created_oid)):
597
+ dialog.show_error("Choose a destination for the captured commit.")
598
+ return
599
+ self._decide_push_tracking(session_id, dialog, snapshot, remote_name, source_ref, destination_ref, tag_refs,
600
+ send=lambda add_tracking: self._send_push(session_id, dialog, request, add_tracking),
601
+ cancel=lambda: self._cancel_push_send(dialog, request, force))
602
+
603
+ def _decide_push_tracking(self, session_id: str, parent: QWidget, snapshot: RemoteSnapshot, remote_name: str, source_ref: str, destination_ref: str, tag_refs: Sequence[tuple[str, str]],
604
+ send: Callable[[bool], None], cancel: Callable[[], None]) -> None:
605
+ """Decide whether a Push also links its branch to the remote branch, asking the user only when nothing decides it already.
606
+
607
+ A branch that already has an upstream, a tag push, a typed address, an inactive remote or a destination no Fetch
608
+ refspec maps to a remote-tracking branch is sent without a link. Otherwise the choice kept for the repository
609
+ decides; without one, the question is asked before anything is sent, and ``send`` receives whether to link, or
610
+ ``cancel`` runs when the user pushes nothing. Don't ask again keeps Yes as Always add and No as Never add for the
611
+ repository before sending; with Cancel it keeps nothing.
612
+ """
613
+ if not tracking_question_applies(snapshot, remote_name, source_ref, destination_ref, tag_refs):
614
+ send(False)
615
+ return
616
+ choice = push_tracking_choice(self.preference, snapshot.context.common_git_dir)
617
+ if choice == PushTrackingChoice.ALWAYS_ADD:
618
+ send(True)
619
+ return
620
+ if choice == PushTrackingChoice.NEVER_ADD:
621
+ send(False)
622
+ return
623
+ previous = self.push_tracking_question_by_session.get(session_id)
624
+ if previous is not None and isValid(previous):
625
+ previous.reject()
626
+ box: QMessageBox | None = None
627
+
628
+ def answered(answer: PushTrackingAnswer, dont_ask_again: bool) -> None:
629
+ """Keep the answer when asked to, then send with or without the link, or push nothing, as the user answered."""
630
+ if self.push_tracking_question_by_session.get(session_id) is box:
631
+ del self.push_tracking_question_by_session[session_id]
632
+ if answer == PushTrackingAnswer.YES:
633
+ if dont_ask_again:
634
+ self._remember_push_tracking(session_id, snapshot.context.common_git_dir, PushTrackingChoice.ALWAYS_ADD)
635
+ send(True)
636
+ elif answer == PushTrackingAnswer.NO:
637
+ if dont_ask_again:
638
+ self._remember_push_tracking(session_id, snapshot.context.common_git_dir, PushTrackingChoice.NEVER_ADD)
639
+ send(False)
640
+ else:
641
+ cancel()
642
+
643
+ box = ask_push_tracking(parent, source_ref.removeprefix(LOCAL_PREFIX), destination_ref.removeprefix(LOCAL_PREFIX), answered)
644
+ self.push_tracking_question_by_session[session_id] = box
645
+
646
+ def _remember_push_tracking(self, session_id: str, common_git_dir: str, choice: PushTrackingChoice) -> None:
647
+ """Keep the tracking answer for the repository in the preferences file, and say in the repository window when it cannot be written.
648
+
649
+ The check box only spares the question next time, so a write that fails never stops the Push the user asked for.
650
+ """
651
+ try:
652
+ save_push_tracking_choice(self.preference, common_git_dir, choice)
653
+ except (OSError, ValueError, TypeError) as error:
654
+ window = self.session_registry_controller.window_by_session.get(session_id)
655
+ repository_dir = window.context.repository_dir if window is not None and isValid(window) else ""
656
+ diagnostic = Diagnostic(SharedText.SAVE_TRACKING_CHOICE.value, repository_dir, "", [], str(error)).prepare()
657
+ if window is not None and isValid(window):
658
+ window.statusBar().showMessage(SharedText.PUSH_TRACKING_NOT_SAVED.value.format(cause=diagnostic.cause))
659
+ if self.diagnostic_log is not None:
660
+ self.diagnostic_log.write(diagnostic)
661
+
662
+ def _send_push(self, session_id: str, dialog: PushDialog, request: PushWindowRequest, add_tracking: bool) -> None:
663
+ """Start the Push the form asked for, checking again what may have changed while the tracking question was open.
664
+
665
+ A window holding a Commit & push's created commit sends that commit through its service, so it is never
666
+ committed twice.
667
+ """
668
+ if (not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or not isValid(dialog) or dialog.closed or request is not dialog.request
669
+ or not dialog.busy):
670
+ return
671
+ snapshot, remote_name, endpoint, source_ref, destination_ref, force, push_target, tag_refs = request
672
+ if (snapshot.context != self.session_registry_controller.session_by_id[session_id].repository_context or snapshot.git_filename != self.repository.git_filename
673
+ or snapshot.generation != self.session_registry_controller.generation_by_session.get(session_id)):
674
+ dialog.needs_reread = True
675
+ dialog.show_error("The repository capture changed. Reread destination before sending.")
676
+ return
677
+ if dialog.commit_push_intent is not None:
678
+ run = self.commit_push.retry_push(dialog.commit_push_intent, snapshot, remote_name, endpoint, destination_ref, push_target, force, add_tracking=add_tracking)
679
+ if run is None:
680
+ dialog.show_error("This commit's repository is no longer authorized for Push.")
681
+ return
682
+ dialog.operation_id = run.request.operation.operation_id
683
+ return
684
+ targets = []
685
+ for full_ref, oid in tag_refs:
686
+ targets.extend((full_ref, oid))
687
+ operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
688
+ session_id=session_id, operation="Push", action=OperationAction.REMOTE_WRITE, targets=(*targets, endpoint) if tag_refs else (source_ref, destination_ref, endpoint),
689
+ from_form=True)
690
+ dialog.operation_id = operation.operation_id
691
+ self._start_push(session_id, TransportRequest(operation, remote_name=remote_name, snapshot=snapshot, mode="push", source_ref=source_ref,
692
+ source_oid=snapshot.tracking_by_ref[source_ref].oid if source_ref else "", destination_ref=destination_ref,
693
+ endpoint=endpoint, force=force, push_target=push_target, tag_refs=tag_refs, add_tracking=add_tracking))
694
+
695
+ def _cancel_push_send(self, dialog: PushDialog, request: PushWindowRequest, force: bool) -> None:
696
+ """Give the Push window its draft back after Cancel, unless it was closed or sent another draft meanwhile."""
697
+ if not isValid(dialog) or dialog.closed or request is not dialog.request:
698
+ return
699
+ dialog.cancel_send(force)
700
+
701
+ def open_remote(self, session_id: str) -> RemoteDialog | None:
702
+ """Open the remote editor only for a live repository management window."""
703
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
704
+ return None
705
+ existing = self.remote_dialog_by_session.get(session_id)
706
+ if existing is not None and not existing.closed:
707
+ existing.raise_()
708
+ return existing
709
+ session = self.session_registry_controller.session_by_id[session_id]
710
+ generation = session.generation
711
+ dialog = RemoteDialog(session_id, self.session_registry_controller.window_by_session[session_id])
712
+ self.remote_dialog_by_session[session_id] = dialog
713
+ dialog.finished.connect(lambda: self.remote_dialog_by_session.pop(session_id, None) if self.remote_dialog_by_session.get(session_id) is dialog else None)
714
+
715
+ def is_current() -> bool:
716
+ """Reject late reads and queued writes after the originating view changes."""
717
+ return (isValid(dialog) and not dialog.closed and not session.is_closed and session.generation == generation
718
+ and self.session_registry_controller.generation_by_session.get(session_id) == generation
719
+ and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE))
720
+
721
+ def complete(snapshot: object, error: Exception | None) -> None:
722
+ """Publish only the captured repository's complete current configuration."""
723
+ if not is_current():
724
+ return
725
+ if error is not None:
726
+ diagnostic = Diagnostic("Read remotes", session.repository_context.repository_dir, self.repository.git_filename, [], str(error)).prepare()
727
+ dialog.show_error(diagnostic.cause)
728
+ return
729
+ dialog.populate(snapshot)
730
+
731
+ def submit(request: tuple, action: str = "save") -> None:
732
+ """Bind remote changes to the exact live form until their native completion."""
733
+ if not is_current() or request != dialog.request or not dialog.busy:
734
+ if isValid(dialog) and not dialog.closed and request == dialog.request:
735
+ dialog.request = None
736
+ dialog.show_error("The repository view changed. Reopen Manage remotes before making another change.")
737
+ return
738
+ snapshot, name, changes = request
739
+ if snapshot.context != session.repository_context or snapshot.generation != generation or snapshot.git_filename != self.repository.git_filename:
740
+ dialog.request = None
741
+ dialog.show_error("The remote capture changed. Reopen Manage remotes before making another change.")
742
+ return
743
+ operation_id = uuid.uuid4().hex
744
+
745
+ def finished(outcome: object) -> None:
746
+ """Publish each field result without silently changing the editor generation."""
747
+ nonlocal generation
748
+ if outcome.operation_id != operation_id:
749
+ return
750
+ self.repository_operation.finished.disconnect(finished)
751
+ self.remote.outcome_by_id[operation_id] = outcome
752
+ if is_current():
753
+ dialog.show_outcome(outcome)
754
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
755
+ self.diagnostic_log.write(outcome.diagnostic)
756
+ if outcome.status != OperationStatus.REFUSED:
757
+ references_changed = any(step.operation in ("Name", "Remove remote") and step.status in (OperationStatus.SUCCESS, OperationStatus.UNKNOWN) for step in outcome.steps)
758
+ for current_id, current_session in tuple(self.session_registry_controller.session_by_id.items()):
759
+ if not current_session.is_closed and canonical_path(current_session.repository_context.common_git_dir) == canonical_path(snapshot.context.common_git_dir):
760
+ if not references_changed:
761
+ self.reference_read_controller.read_references(current_id)
762
+ continue
763
+ current_form = current_id == session_id and is_current()
764
+ already_refreshing = current_session.refresh_active
765
+ self.refresh_controller.refresh(current_id, scope=RefreshScope.REFERENCES)
766
+ if current_form and not already_refreshing:
767
+ # The editor's own ref change refreshes the graph; retain its exact config
768
+ # capture while advancing only the generation that this refresh owns.
769
+ generation = current_session.generation
770
+ if dialog.snapshot is not None:
771
+ dialog.snapshot = replace(dialog.snapshot, generation=generation)
772
+ elif current_form:
773
+ dialog.uncertain = True
774
+ dialog.show_error("Remote saved. The repository is refreshing; reopen Manage remotes before making another change.")
775
+ dialog.save_button.setEnabled(False)
776
+
777
+ self.repository_operation.finished.connect(finished)
778
+ if action == "active":
779
+ self.remote.set_active(snapshot, name, changes, session_id=session_id, is_current=is_current, operation_id=operation_id)
780
+ elif action == "remove":
781
+ self.remote.remove(snapshot, name, session_id=session_id, is_current=is_current, operation_id=operation_id)
782
+ elif action == "tracking":
783
+ self.remote.set_tracking(snapshot, name, changes, session_id=session_id, is_current=is_current, operation_id=operation_id)
784
+ else:
785
+ self.remote.save(snapshot, name, changes, session_id=session_id, is_current=is_current, operation_id=operation_id)
786
+
787
+ def reread_tracking() -> None:
788
+ """Refresh the same branch editor only after an explicit reread request."""
789
+ if not is_current():
790
+ if isValid(dialog) and not dialog.closed:
791
+ dialog.show_error("The repository view changed. Reopen Manage remotes to read its current tracking.")
792
+ return
793
+
794
+ def read_complete(snapshot: object, error: Exception | None) -> None:
795
+ """Accept fresh tracking only while the requesting form remains current."""
796
+ if not is_current():
797
+ return
798
+ if error is not None:
799
+ diagnostic = Diagnostic("Read tracking", session.repository_context.repository_dir, self.repository.git_filename, [], str(error)).prepare()
800
+ dialog.show_error(diagnostic.cause)
801
+ return
802
+ dialog.tracking_reloaded(snapshot)
803
+
804
+ self.remote.read(session.repository_context, self.repository.git_filename, generation, read_complete)
805
+
806
+ dialog.save_requested.connect(submit)
807
+ dialog.active_requested.connect(lambda request: submit(request, "active"))
808
+ dialog.remove_requested.connect(lambda request: submit(request, "remove"))
809
+ dialog.tracking_requested.connect(lambda request: submit(request, "tracking"))
810
+ dialog.tracking_reload_requested.connect(reread_tracking)
811
+ open_window_modal(dialog)
812
+ self.remote.read(session.repository_context, self.repository.git_filename, generation, complete)
813
+ return dialog