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,1690 @@
1
+ """Exchange captured branches with native Git, keeping each observed remote result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import uuid
9
+ from dataclasses import dataclass, field, replace
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PySide6.QtCore import QObject, QTimer, Signal
13
+
14
+ from gitpane.account.authentication import PREVIEW_HELPER_COMMANDS, isolated_http_arguments
15
+ from gitpane.account.credential_manager import CredentialManagerRefusal
16
+ from gitpane.config import CREDENTIAL_READ_RETRY_DELAY_MS, GCM_TRACE_SECRETS_NAME, GIT_MUTATION_TIMEOUT_MS, GIT_TRANSPORT_TIMEOUT_MS, LOCAL_PREFIX, REMOTE_PREFIX, SUBMODULE_RECURSION_OFF_ARGUMENTS, TAG_PREFIX, TRANSPORT_IDLE_TIMEOUT_MS, SharedText
17
+ from gitpane.history.repository_value import validate_oid
18
+ from gitpane.operation.console_line import DELETED_LOCALLY_ONLY_TEMPLATE, DELETED_REMOTELY_ONLY_TEMPLATE, FAILED_REMOTES_TEMPLATE, NOT_PUSHED_REFS_TEMPLATE, REPOSITORY_HEADER_LABEL, ConsoleHeader, ConsoleLine, operation_end_text
19
+ from gitpane.operation.console_tone import ConsoleTone
20
+ from gitpane.operation.diagnostic import Diagnostic
21
+ from gitpane.operation.operation_status import OperationStatus, operation_status
22
+ from gitpane.operation.operation_trace import OperationTrace
23
+ from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OperationAction, OperationIntent, OperationOutcome, OperationStepResult, WriteScope
24
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, NETWORK_PROFILE, READ_PROFILE, ProcessRequest, ProcessStatus
25
+ from gitpane.reference.reference_edit import DELETE_LOCAL_BRANCH_OPERATION, DELETE_LOCAL_TAG_OPERATION, DELETE_REMOTE_BRANCH_OPERATION, DELETE_REMOTE_TAG_OPERATION, reference_signature
26
+ from gitpane.transport.clone import HTTPS_ENDPOINT_KIND, SSH_ENDPOINT_KIND, endpoint_kind
27
+ from gitpane.transport.credential_read_retry import credential_read_retry_due, credential_read_retry_text
28
+ from gitpane.transport.remote_config import (
29
+ MERGE_TRACKING_FIELD,
30
+ config_signature,
31
+ fetch_branch_refspecs,
32
+ field_values,
33
+ refspec_write_refs,
34
+ remote_boolean,
35
+ remote_reference_oid,
36
+ remote_tracking_ref,
37
+ rewrite_url,
38
+ selected_endpoint_arguments,
39
+ tracking_values,
40
+ )
41
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
42
+
43
+ # Said when a transport is asked to start while GitPane is shutting down.
44
+ TRANSPORT_CLOSED_TEXT = "GitPane is closing, so nothing was started."
45
+ # Said when an HTTPS process would start before its operation found its credential manager; the steps never run in
46
+ # that order, so this only guards against a broken sequence.
47
+ CREDENTIAL_MANAGER_NOT_SEARCHED_TEXT = "The credential manager was not searched for this operation. Start it again."
48
+ TRANSPORT_TRACE_ENVIRONMENT_BY_NAME = {
49
+ "GIT_TRACE": "1", "GIT_TRACE_CURL": "1", "GIT_TRACE_CURL_NO_DATA": "0", "GIT_TRACE_REDACT": "1",
50
+ "GCM_TRACE": "1", GCM_TRACE_SECRETS_NAME: "0",
51
+ }
52
+ PUSH_ARGUMENT_LIMIT = 24000
53
+ WINDOWS_CODE_UNIT_BYTES = 2
54
+ TRACKING_UPDATE_OPERATION = "Update remote-tracking branch"
55
+ TRACKING_DELETE_OPERATION = "Delete remote-tracking branch"
56
+ # The reflog message Git itself writes when a push moves a remote-tracking branch.
57
+ TRACKING_REFLOG_MESSAGE = "update by push"
58
+ # Porcelain reasons of a branch refused because the server holds commits the push would replace (without force, or with a
59
+ # lease that no longer matches). Other refusals, such as a server hook, keep their own diagnostic.
60
+ PUSH_REJECTION_REASONS = ("non-fast-forward", "fetch first", "stale info")
61
+ # Porcelain summary of a reference the server itself refused after receiving it, for instance from a hook, with Git's
62
+ # reason in parentheses: "[remote rejected] (hook declined)".
63
+ SERVER_REFUSAL_SUMMARY_PREFIX = "[remote rejected]"
64
+ SERVER_REFUSAL_REASON_PATTERN = re.compile(r"\((?P<reason>.*)\)\s*$")
65
+ REFUSED_PORCELAIN_FLAG = "!"
66
+ # The final read after a remote-tracking update: every local and remote-tracking branch, with the upstream Git resolves.
67
+ TRACKING_RESULT_FORMAT = "--format=%(refname)%00%(objectname)%00%(symref)%00%(upstream)"
68
+ TRACKING_RESULT_FIELD_COUNT = 4
69
+ # The local steps of a remote deletion: the tag deleted before its server counterpart, the branches after theirs.
70
+ LOCAL_DELETE_OPERATION_SET = frozenset({DELETE_LOCAL_TAG_OPERATION, DELETE_LOCAL_BRANCH_OPERATION})
71
+ # The network steps a transport starts again by itself when the credential manager could not read back a secret, a
72
+ # failure that happens before Git sends any authenticated request, so the server is still as it was. Each is safe to repeat:
73
+ # - delete-before, delete-recheck, delete-after, push-before and push-after are ls-remote reads;
74
+ # - delete is a push with --force-with-lease on the object the ls-remote before it read, so it can only delete that object;
75
+ # - push sends a replaced branch or tag only with a lease per reference, and an addition already made reads "up to date";
76
+ # - fetch only moves remote-tracking branches, exactly as a successful fetch would.
77
+ CREDENTIAL_READ_RETRY_STAGE_SET = frozenset({"delete-before", "delete-recheck", "delete", "delete-after", "push-before", "push", "push-after", "fetch"})
78
+ # The steps that read the server after a push or a deletion was sent: once cancelled, their result stays unknown.
79
+ OBSERVATION_STAGE_SET = frozenset({"push-after", "delete-after"})
80
+ PUSH_ARGUMENTS = ("-c", "push.followTags=false", "-c", "push.recurseSubmodules=no", "push", "--porcelain", "--progress", "--no-follow-tags", "--recurse-submodules=no", "--no-mirror", "--no-prune")
81
+
82
+ if TYPE_CHECKING:
83
+ from collections.abc import Callable, Generator, Sequence
84
+
85
+ from gitpane.account.authentication import AccountSelection, AuthenticationService
86
+ from gitpane.account.credential_manager import CredentialManagerDetection
87
+ from gitpane.account.ssh import SshService
88
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
89
+ from gitpane.operation.repository_operation import RepositoryOperationService
90
+ from gitpane.process.process_runner import ProcessResult, ProcessRunner
91
+ from gitpane.transport.remote import RemoteService
92
+ from gitpane.transport.remote_config import RemoteSnapshot
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class TransportRequest:
97
+ """Capture the repository, binary and authority before asynchronous preparation."""
98
+
99
+ operation: OperationIntent
100
+ prune_all: bool = True
101
+ remote_name: str = ""
102
+ snapshot: RemoteSnapshot | None = None
103
+ mode: str = "fetch"
104
+ source_ref: str = ""
105
+ source_oid: str = ""
106
+ destination_ref: str = ""
107
+ endpoint: str = ""
108
+ force: bool = False
109
+ push_target: PushTarget | None = None
110
+ tag_refs: tuple[tuple[str, str], ...] = ()
111
+ delete_local_refs: tuple[tuple[str, str], ...] = ()
112
+ immutable_commit: bool = False
113
+ # Link source_ref to the remote branch of remote_name it was pushed to, once the server is observed holding it.
114
+ add_tracking: bool = False
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class PushTarget:
119
+ """Freeze one visible endpoint and its effective authentication and tool configuration."""
120
+
121
+ remote_name: str
122
+ endpoint: str
123
+ command_endpoint: str
124
+ kind: str
125
+ arguments: tuple[str, ...] = ()
126
+ environment_by_name: dict[str, str] = field(default_factory=dict)
127
+ selection: AccountSelection | None = None
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class FetchTarget:
132
+ """Retain one remote's endpoint, branch mapping and authentication for the entire run."""
133
+
134
+ remote_name: str
135
+ endpoint: str = ""
136
+ command_endpoint: str = ""
137
+ refspecs: tuple[str, ...] = ()
138
+ arguments: tuple[str, ...] = ()
139
+ environment_by_name: dict[str, str] = field(default_factory=dict)
140
+ selection: AccountSelection | None = None
141
+ kind: str = ""
142
+ error: str = ""
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class FetchResult:
147
+ """Distinguish each remote's observed effects from a later refusal or interruption."""
148
+
149
+ remote_name: str
150
+ endpoint: str
151
+ status: OperationStatus
152
+ effect: str = "unknown"
153
+ diagnostic: PreparedDiagnostic | None = None
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class PushResult:
158
+ """Keep each exact destination ref's observed effect separate from the process exit."""
159
+
160
+ full_ref: str
161
+ source_oid: str
162
+ endpoint: str
163
+ status: OperationStatus
164
+ before_oid: str | None = None
165
+ after_oid: str | None = None
166
+ effect: str = "unknown"
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class PushRejection:
171
+ """Describe a branch Push the server refused because its branch holds commits the push would replace.
172
+
173
+ ``unseen_commits`` is true when the server branch is not at the last fetched position (the remote-tracking branch):
174
+ someone pushed commits this repository has not fetched, or there is no fetched state at all. Only when it is false
175
+ can a force push with lease succeed.
176
+ """
177
+
178
+ remote_name: str
179
+ destination_ref: str
180
+ reason: str
181
+ remote_oid: str
182
+ tracking_ref: str
183
+ tracking_oid: str
184
+ unseen_commits: bool
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class ServerRefusal:
189
+ """Name a reference the server refused after receiving the Push, with Git's own reason, such as "hook declined"."""
190
+
191
+ full_ref: str
192
+ reason: str
193
+
194
+ def short_name(self) -> str:
195
+ """Return the branch or tag name as the user knows it, without its refs/heads/ or refs/tags/ prefix."""
196
+ for prefix in (LOCAL_PREFIX, TAG_PREFIX):
197
+ if self.full_ref.startswith(prefix):
198
+ return self.full_ref.removeprefix(prefix)
199
+ return self.full_ref
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class TransportOutcome(OperationOutcome):
204
+ """Keep the exact endpoint and reread references independently of process success."""
205
+
206
+ remote_name: str = ""
207
+ endpoint: str = ""
208
+ snapshot: RemoteSnapshot | None = None
209
+ remote_results: tuple[FetchResult, ...] = ()
210
+ ref_results: tuple[PushResult, ...] = ()
211
+ rejection: PushRejection | None = None
212
+ # Whether the views must read the repository again. Only a Fetch or Push proven to have changed nothing, on the
213
+ # server or locally, sets it to False; every other outcome keeps the conservative default.
214
+ refresh_required: bool = True
215
+ # The account of the last network step, so a failure can name the account used; it never holds a secret.
216
+ selection: AccountSelection | None = None
217
+ # The references the server itself refused (a hook, for instance), in the order they were sent; empty otherwise.
218
+ server_refusals: tuple[ServerRefusal, ...] = ()
219
+ # The detection of the credential manager refusal, set only when that refusal stopped the operation or the HTTPS
220
+ # remotes of a Fetch, so the window explaining how to install it can open.
221
+ credential_manager_refusal: CredentialManagerDetection | None = None
222
+ # The Push itself succeeded but the link to its remote branch that the user asked for was not set: its console
223
+ # carries the red line saying so and must stay readable even when it would close by itself after a success.
224
+ upstream_not_linked: bool = False
225
+
226
+
227
+ def unchanged_observation(before: RemoteSnapshot, after: RemoteSnapshot) -> bool:
228
+ """Tell whether two captures of one repository describe exactly the same configuration, branches and references."""
229
+ return (before.context == after.context and before.git_filename == after.git_filename and before.values == after.values
230
+ and before.direct_values == after.direct_values and before.signature == after.signature and before.entry_by_name == after.entry_by_name
231
+ and before.tracking_by_ref == after.tracking_by_ref and before.references == after.references and before.reference_state == after.reference_state)
232
+
233
+
234
+ @dataclass
235
+ class TransportRun:
236
+ """Retain one operation's captured configuration until its last reader exits."""
237
+
238
+ request: TransportRequest
239
+ trace: OperationTrace
240
+ snapshot: RemoteSnapshot | None = None
241
+ remote_name: str = ""
242
+ endpoint: str = ""
243
+ command_endpoint: str = ""
244
+ arguments: list[str] = field(default_factory=list)
245
+ environment_by_name: dict[str, str] = field(default_factory=dict)
246
+ selection: AccountSelection | None = None
247
+ kind: str = ""
248
+ stage: str = "preparing"
249
+ process_id: str = ""
250
+ cancelled: bool = False
251
+ steps: list[OperationStepResult] = field(default_factory=list)
252
+ targets: tuple[FetchTarget, ...] = ()
253
+ target_index: int = 0
254
+ remote_results: list[FetchResult] = field(default_factory=list)
255
+ output: bytearray = field(default_factory=bytearray)
256
+ before_oid: str | None = None
257
+ push_status: ProcessStatus | None = None
258
+ push_diagnostic: PreparedDiagnostic | None = None
259
+ ref_results: list[PushResult] = field(default_factory=list)
260
+ push_batches: list[tuple[tuple[str, str], ...]] = field(default_factory=list)
261
+ batch_index: int = 0
262
+ before_oid_by_ref: dict[str, str] = field(default_factory=dict)
263
+ porcelain_flag_by_ref: dict[str, str] = field(default_factory=dict)
264
+ porcelain_summary_by_ref: dict[str, str] = field(default_factory=dict)
265
+ rejection: PushRejection | None = None
266
+ server_refusals: list[ServerRefusal] = field(default_factory=list)
267
+ delete_local: Callable[[], Generator] | None = None
268
+ delete_current: Callable[[], bool] | None = None
269
+ local_started: bool = False
270
+ delete_started: bool = False
271
+ # Set when the link asked for after a successful Push was not set, so the outcome can keep its console open.
272
+ upstream_not_linked: bool = False
273
+ tracking_checked: bool = False
274
+ # The credential manager command this operation found once, off the Qt thread, before its first HTTPS process. It
275
+ # belongs to this run only and is cleared when the run ends; the search itself keeps its success for the next
276
+ # operation with the same Git.
277
+ verified_helper_commands: tuple[str, ...] | None = None
278
+ # A helper command injected into the authentication service when the run was created; a change refuses the run.
279
+ helper_override: tuple[str, ...] | None = None
280
+ # The sentence of the credential manager refusal for this run, empty until the search refuses. A Fetch ends each of
281
+ # its HTTPS remotes on it without searching a second time, and goes on with its other remotes, which never need the
282
+ # credential manager.
283
+ helper_refusal: str = ""
284
+ # The detection of the credential manager refusal that stopped the HTTPS work of this run, kept for the window that
285
+ # explains how to install the credential manager.
286
+ credential_manager_refusal: CredentialManagerDetection | None = None
287
+ # The operation's first capture, kept only to compare the final observation with it; it never authorizes a write.
288
+ first_snapshot: RemoteSnapshot | None = None
289
+ # The arguments the current step was last started with, so it can be started again unchanged.
290
+ launch_arguments: list[str] = field(default_factory=list)
291
+ # How many times the current step was started again after a failed secret read; a new step starts from zero.
292
+ credential_read_retry_count: int = 0
293
+ # Set while the current step waits to be started again, so a cancel during the wait ends the operation at once.
294
+ retry_waiting: bool = False
295
+
296
+
297
+ def transport_console_label(request: TransportRequest) -> str:
298
+ """Return the operation's name as the user knows it, such as ``Push`` or ``Delete tag``, for its console and its diagnostics.
299
+
300
+ It differs from the operation name given to each Git process (``Delete remote branch``…), which the ``--log`` file
301
+ keeps as it is.
302
+ """
303
+ if request.mode == "delete":
304
+ return "Delete tag" if request.source_ref.startswith(TAG_PREFIX) else "Delete branch"
305
+ if request.mode == "push":
306
+ return "Push"
307
+ return "Fetch"
308
+
309
+
310
+ def unsuccessful_count(results: Sequence[PushResult | FetchResult]) -> int:
311
+ """Count the references or remotes whose result is not a success, whatever else it is: refused, unknown or never started."""
312
+ count = 0
313
+ for result in results:
314
+ if result.status != OperationStatus.SUCCESS:
315
+ count += 1
316
+ return count
317
+
318
+
319
+ def push_argument_size(arguments: list[str]) -> int:
320
+ """Bound quoted Windows code units and POSIX encoded bytes with one conservative budget."""
321
+ command = subprocess.list2cmdline(arguments)
322
+ return max(len(command.encode("utf-16-le", "surrogatepass")) // WINDOWS_CODE_UNIT_BYTES, len(os.fsencode(command))) + 1
323
+
324
+
325
+ def lease_argument(full_ref: str, expected_oid: str) -> str:
326
+ """Let Git replace a server ref only while it still holds the expected object; an empty expectation requires the ref to be absent."""
327
+ return "--force-with-lease=" + full_ref + ":" + expected_oid
328
+
329
+
330
+ def push_batches(program_filename: str, arguments: list[str], remote_name: str, refs: tuple[tuple[str, str], ...], force: bool) -> list[tuple[tuple[str, str], ...]]:
331
+ """Split explicit refs before an OS argument limit can omit or duplicate a selected tag.
332
+
333
+ A forced ref also carries its lease argument; its expected object has the same length as the pushed one, so the
334
+ budget is measured with the pushed object in its place.
335
+ """
336
+ base_arguments = [program_filename, *arguments, *PUSH_ARGUMENTS, "--", remote_name]
337
+ batches: list[tuple[tuple[str, str], ...]] = []
338
+ batch: list[tuple[str, str]] = []
339
+ ref_arguments: list[str] = []
340
+ for full_ref, oid in refs:
341
+ new_arguments = [oid + ":" + full_ref]
342
+ if force:
343
+ new_arguments.append(lease_argument(full_ref, oid))
344
+ if push_argument_size([*base_arguments, *new_arguments]) > PUSH_ARGUMENT_LIMIT:
345
+ raise ValueError("A Push reference or endpoint exceeds the command argument limit.")
346
+ if batch and push_argument_size([*base_arguments, *ref_arguments, *new_arguments]) > PUSH_ARGUMENT_LIMIT:
347
+ batches.append(tuple(batch))
348
+ batch, ref_arguments = [], []
349
+ batch.append((full_ref, oid))
350
+ ref_arguments.extend(new_arguments)
351
+ if batch:
352
+ batches.append(tuple(batch))
353
+ return batches
354
+
355
+
356
+ class TransportService(QObject):
357
+ """Own explicit network exchanges and reconcile local effects before releasing Git."""
358
+
359
+ finished = Signal(object)
360
+
361
+ def __init__(self, remote: RemoteService, authentication: AuthenticationService, ssh: SshService,
362
+ repository_operation: RepositoryOperationService, runner: ProcessRunner, parent: QObject | None = None,
363
+ *, is_current: Callable[[OperationIntent], bool], log: DiagnosticLog | None = None) -> None:
364
+ """Reuse the application's process, authentication and common-repository owners."""
365
+ super().__init__(parent)
366
+ self.remote = remote
367
+ self.authentication = authentication
368
+ self.ssh = ssh
369
+ self.repository_operation = repository_operation
370
+ self.runner = runner
371
+ self.is_current = is_current
372
+ self.log = log
373
+ self.run_by_id: dict[str, TransportRun] = {}
374
+ self.outcome_by_id: dict[str, TransportOutcome] = {}
375
+ # Fetch, Push and remote deletion all accept a remote that is a local folder, as Git Extensions does.
376
+ self.allow_local_endpoint = True
377
+ self.closed = False
378
+ runner.finished.connect(self._process_finished)
379
+ runner.stdout_chunk.connect(self._stdout)
380
+
381
+ def capture_push_target(self, snapshot: RemoteSnapshot, remote_name: str, endpoint: str) -> PushTarget:
382
+ """Prepare one displayed destination without network calls or stored configuration edits.
383
+
384
+ An HTTPS target holds a preview of its credential configuration, with every helper disabled: capturing and
385
+ comparing a destination never searches the credential manager, which is found once per operation before sending.
386
+ """
387
+ kind = endpoint_kind(endpoint, self.allow_local_endpoint)
388
+ entry = snapshot.entry_by_name.get(remote_name)
389
+ if remote_name and (entry is None or entry.collision or endpoint not in entry.push_urls):
390
+ raise ValueError("The selected Push endpoint is no longer configured. Reread destination.")
391
+ if rewrite_url(endpoint, snapshot.values) != endpoint:
392
+ raise ValueError("The endpoint has chained rewrite rules. Configure an unambiguous destination.")
393
+ command_endpoint, arguments, environment_by_name, selection = endpoint, [], {}, None
394
+ if kind == HTTPS_ENDPOINT_KIND:
395
+ selection = self.authentication.account_for_endpoint(endpoint, snapshot.context)
396
+ command_endpoint, arguments = self.authentication.command_configuration(endpoint, snapshot.context, selection=selection, helper_commands=PREVIEW_HELPER_COMMANDS)
397
+ arguments.extend(isolated_http_arguments(command_endpoint))
398
+ elif kind == SSH_ENDPOINT_KIND:
399
+ arguments, environment_by_name = self.ssh.command_configuration()
400
+ if rewrite_url(command_endpoint, snapshot.values) != command_endpoint:
401
+ raise ValueError("The authenticated URL would be rewritten to another destination. Review its configuration.")
402
+ return PushTarget(remote_name, endpoint, command_endpoint, kind, tuple(arguments), environment_by_name, selection)
403
+
404
+ def _prepare_network(self, run: TransportRun) -> Generator:
405
+ """Find the credential manager once for this operation, in a background task, just before its first HTTPS process.
406
+
407
+ The search runs with the operation's own Git, off the Qt thread, and its answer is kept only by this run: a later
408
+ remote or batch of the same operation reuses it. A refusal is noted in the run and raises ValueError with its
409
+ sentence before any HTTPS process starts: Push and deletion end on it, while a Fetch ends only its HTTPS remotes
410
+ on it. A refusal that comes back once the user cancelled the operation is not kept for the install window. A
411
+ helper command injected into the service is used as it is, as the service itself does. Authority and targets are
412
+ checked again once the background task returns.
413
+ """
414
+ if run.verified_helper_commands is not None:
415
+ return
416
+ if run.helper_override:
417
+ verified = tuple(run.helper_override)
418
+ else:
419
+ git_filename = run.request.operation.git_filename
420
+ try:
421
+ helper = yield FileTask(lambda: self.authentication.credential_helper(git_filename))
422
+ except ValueError as refusal:
423
+ run.helper_refusal = str(refusal)
424
+ # An operation the user cancelled was stopped by the user, not by the refusal, so it never opens the
425
+ # install window, as for a clone or a submodule initialization.
426
+ if isinstance(refusal, CredentialManagerRefusal) and not run.cancelled:
427
+ run.credential_manager_refusal = refusal.detection
428
+ raise
429
+ verified = helper.commands
430
+ self._check_current(run)
431
+ run.verified_helper_commands = verified
432
+
433
+ def _network_arguments(self, run: TransportRun, target: PushTarget | FetchTarget) -> list[str]:
434
+ """Compose the arguments one network process really receives, from the frozen target and the credential manager found for this run.
435
+
436
+ Push and deletion also name their private single-URL alias. The same composition checks a queued process at its
437
+ real launch, so no second list of credential settings exists to drift from the one that is sent.
438
+ """
439
+ arguments = list(target.arguments)
440
+ if target.kind == HTTPS_ENDPOINT_KIND:
441
+ if not run.verified_helper_commands:
442
+ raise ValueError(CREDENTIAL_MANAGER_NOT_SEARCHED_TEXT)
443
+ command_endpoint, arguments = self.authentication.command_configuration(target.endpoint, run.snapshot.context, selection=target.selection,
444
+ helper_commands=run.verified_helper_commands)
445
+ if command_endpoint != target.command_endpoint:
446
+ raise ValueError("The account URL of this destination changed. Reread the destination.")
447
+ arguments.extend(isolated_http_arguments(command_endpoint))
448
+ if run.request.mode == "fetch":
449
+ return arguments
450
+ return [*selected_endpoint_arguments(run.snapshot, run.request.remote_name, target.command_endpoint, run.command_endpoint), *arguments]
451
+
452
+ def _network_target(self, run: TransportRun) -> PushTarget | FetchTarget | None:
453
+ """Return the frozen destination the run's next network process addresses, or nothing when none can start."""
454
+ if run.request.mode != "fetch":
455
+ return run.request.push_target
456
+ if run.target_index < len(run.targets) and not run.targets[run.target_index].error:
457
+ return run.targets[run.target_index]
458
+ return None
459
+
460
+ def _check_https_launch(self, run: TransportRun, target: PushTarget | FetchTarget) -> None:
461
+ """Refuse an HTTPS process whose account, injected helper or composed credential settings changed since capture."""
462
+ if self.authentication.helper_commands != run.helper_override:
463
+ raise ValueError("The credential helper changed during this operation. Start it again.")
464
+ if run.request.mode == "fetch" and self.authentication.account_for_endpoint(target.endpoint, run.snapshot.context) != target.selection:
465
+ raise ValueError("The account selected for " + target.remote_name + " changed. Start Fetch again.")
466
+ if (run.verified_helper_commands is not None and run.arguments and run.remote_name == target.remote_name and run.endpoint == target.endpoint
467
+ and run.arguments != self._network_arguments(run, target)):
468
+ raise ValueError("The credential configuration of this destination changed. Start the transport again.")
469
+
470
+ def _scoped_request(self, request: TransportRequest) -> TransportRequest:
471
+ """Include local tracking updates and explicit deletion continuations in transport ownership."""
472
+ operation = request.operation
473
+ refs = list(operation.scope.refs if operation.scope is not None else ())
474
+ if request.mode != "fetch" and request.snapshot is not None and request.remote_name:
475
+ refspecs = tuple(value.value for value in field_values(request.snapshot, request.remote_name, "fetch"))
476
+ refs.extend(refspec_write_refs(refspecs))
477
+ if request.mode == "delete":
478
+ refs.append(request.source_ref)
479
+ for full_ref, _oid in request.delete_local_refs:
480
+ refs.append(full_ref)
481
+ action = operation.action if operation.action != OperationAction.UNKNOWN else OperationAction.REMOTE_WRITE
482
+ operation = replace(operation, action=action, scope=WriteScope.capture(operation.context, tuple(sorted(set(refs)))))
483
+ return replace(request, operation=operation)
484
+
485
+ def push(self, request: TransportRequest) -> TransportRun:
486
+ """Send one branch or captured tag objects and retain ownership through observation."""
487
+ request = self._scoped_request(request)
488
+ operation = request.operation
489
+ previous = self.run_by_id.get(operation.operation_id)
490
+ if previous is not None:
491
+ return previous
492
+ trace = OperationTrace(operation.operation_id, self.runner, self.log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
493
+ run = TransportRun(request, trace, snapshot=request.snapshot, helper_override=self.authentication.helper_commands, first_snapshot=request.snapshot)
494
+ self.run_by_id[operation.operation_id] = run
495
+ if self.closed or not self.is_current(operation) or not self.repository_operation.reserve(operation):
496
+ cause = self._reservation_refusal(operation)
497
+ QTimer.singleShot(0, self, lambda: self._fail(run, cause, OperationStatus.REFUSED))
498
+ return run
499
+
500
+ def validate() -> Generator:
501
+ """Validate both full reference names and the captured source with native Git."""
502
+ self._check_current(run)
503
+ snapshot = request.snapshot
504
+ if snapshot is None or snapshot.context != operation.context or snapshot.git_filename != operation.git_filename or snapshot.generation != operation.generation:
505
+ raise ValueError("The Push capture changed. Reread the destination before sending.")
506
+ if request.tag_refs:
507
+ if request.source_ref or request.source_oid or request.destination_ref:
508
+ raise ValueError("Choose either tags or one branch for Push.")
509
+ seen_ref_set: set[str] = set()
510
+ for full_ref, oid in request.tag_refs:
511
+ validate_oid(oid)
512
+ if not full_ref.startswith(TAG_PREFIX) or full_ref in seen_ref_set:
513
+ raise ValueError("Choose distinct full tag references for Push.")
514
+ seen_ref_set.add(full_ref)
515
+ output = yield GitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)", TAG_PREFIX], before_start=lambda: self._check_current(run))
516
+ oid_by_ref = {}
517
+ for record in output.splitlines():
518
+ full_ref, oid = record.decode("utf-8", "surrogateescape").split("\0")
519
+ oid_by_ref[full_ref] = oid
520
+ for full_ref, oid in request.tag_refs:
521
+ if oid_by_ref.get(full_ref) != oid:
522
+ raise ValueError("A selected tag changed or disappeared. Reread before sending.")
523
+ refs = tuple(sorted(request.tag_refs))
524
+ else:
525
+ validate_oid(request.source_oid)
526
+ if (not request.immutable_commit and not request.source_ref.startswith(LOCAL_PREFIX)) or not request.destination_ref.startswith(LOCAL_PREFIX):
527
+ raise ValueError("Choose a local source branch and a remote branch destination.")
528
+ source = snapshot.tracking_by_ref.get(request.source_ref)
529
+ if not request.immutable_commit and (source is None or source.oid != request.source_oid):
530
+ raise ValueError("The source branch changed. Reread before sending.")
531
+ for full_ref in ((request.destination_ref,) if request.immutable_commit else (request.source_ref, request.destination_ref)):
532
+ yield GitCommand(["check-ref-format", full_ref], before_start=lambda: self._check_current(run))
533
+ if request.immutable_commit:
534
+ output = yield GitCommand(["cat-file", "-t", request.source_oid], before_start=lambda: self._check_current(run))
535
+ if output.strip() != b"commit":
536
+ raise ValueError("The captured source is not a commit object.")
537
+ else:
538
+ output = yield GitCommand(["rev-parse", "--verify", request.source_ref], before_start=lambda: self._check_current(run))
539
+ if output.decode().strip() != request.source_oid:
540
+ raise ValueError("The source branch changed. Reread before sending.")
541
+ refs = ((request.destination_ref, request.source_oid),)
542
+ target = request.push_target
543
+ if target is None or target != self.capture_push_target(snapshot, request.remote_name, request.endpoint):
544
+ raise ValueError("The Push endpoint, account or tool changed. Reread destination before sending.")
545
+ run.remote_name, run.endpoint = request.remote_name, request.endpoint
546
+ run.command_endpoint = "gitpane-push-" + uuid.uuid4().hex
547
+ run.kind, run.selection = target.kind, target.selection
548
+ if target.kind == HTTPS_ENDPOINT_KIND:
549
+ yield from self._prepare_network(run)
550
+ run.arguments = self._network_arguments(run, target)
551
+ run.environment_by_name = dict(target.environment_by_name)
552
+ run.environment_by_name.update(TRANSPORT_TRACE_ENVIRONMENT_BY_NAME)
553
+ run.push_batches = push_batches(operation.git_filename, run.arguments, run.command_endpoint, refs, request.force)
554
+
555
+ def validated(_value: object, error: Exception | None) -> None:
556
+ """Start read-only remote observation after validating the explicit request."""
557
+ try:
558
+ if error is not None:
559
+ raise ValueError(str(error))
560
+ run.trace.append_event(operation.operation_id, "status", "Push remote " + (run.remote_name or "Url") + " to " + run.endpoint)
561
+ self._next_push_batch(run)
562
+ except (ValueError, OSError, RuntimeError) as failure:
563
+ self._fail(run, str(failure), OperationStatus.REFUSED)
564
+
565
+ self.remote.worktree_service.run(operation.context, operation.git_filename, validate(), validated, operation_id=operation.operation_id)
566
+ return run
567
+
568
+ def delete_remote(self, request: TransportRequest, *, preflight: Callable[[], Generator] | None = None,
569
+ local_sequence: Callable[[], Generator] | None = None, is_current: Callable[[], bool] | None = None, previous_operation_id: str = "") -> TransportRun:
570
+ """Delete one exact server ref and retain ownership through any local continuation."""
571
+ request = self._scoped_request(request)
572
+ operation = request.operation
573
+ previous = self.run_by_id.get(operation.operation_id)
574
+ if previous is not None:
575
+ return previous
576
+ trace = OperationTrace(operation.operation_id, self.runner, self.log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
577
+ run = TransportRun(request, trace, snapshot=request.snapshot, delete_local=local_sequence, delete_current=is_current, helper_override=self.authentication.helper_commands)
578
+ self.run_by_id[operation.operation_id] = run
579
+ if previous_operation_id:
580
+ previous = self.run_by_id.get(previous_operation_id)
581
+ outcome = self.outcome_by_id.get(previous_operation_id)
582
+ valid = (request.source_ref.startswith(TAG_PREFIX) and previous is not None and outcome is not None
583
+ and previous.request.source_ref == request.source_ref and previous.request.source_oid == request.source_oid
584
+ and previous.request.destination_ref == request.destination_ref and previous.request.endpoint == request.endpoint
585
+ and previous.request.remote_name == request.remote_name and previous.request.mode == request.mode == "delete"
586
+ and previous.request.operation.context == operation.context and previous.request.operation.git_filename == operation.git_filename
587
+ and outcome.status in (OperationStatus.PARTIAL, OperationStatus.FAILED, OperationStatus.REFUSED) and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in outcome.steps)
588
+ and not any(result.status in (OperationStatus.SUCCESS, OperationStatus.UNKNOWN) for result in outcome.ref_results))
589
+ if not valid:
590
+ QTimer.singleShot(0, self, lambda: self._fail(run, "Remote tag retry requires a matching captured destination and a known local success", OperationStatus.REFUSED))
591
+ return run
592
+ for step in outcome.steps:
593
+ if step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS:
594
+ run.steps.append(step)
595
+ run.local_started = True
596
+ if self.closed or not self.is_current(operation) or not self.repository_operation.reserve(operation):
597
+ cause = self._reservation_refusal(operation)
598
+ QTimer.singleShot(0, self, lambda: self._fail(run, cause, OperationStatus.REFUSED))
599
+ return run
600
+
601
+ def validate() -> Generator:
602
+ """Validate full destination and captured account before contacting the server."""
603
+ snapshot = request.snapshot
604
+ if (request.mode != "delete" or snapshot is None or snapshot.context != operation.context or snapshot.git_filename != operation.git_filename
605
+ or snapshot.generation != operation.generation or request.force or request.tag_refs
606
+ or not request.destination_ref.startswith((LOCAL_PREFIX, TAG_PREFIX))):
607
+ raise ValueError("Deletion does not match its captured destination")
608
+ if request.source_ref.startswith(TAG_PREFIX) and (request.destination_ref != request.source_ref or operation.targets != (request.source_ref, request.source_oid)
609
+ or not run.local_started and local_sequence is None):
610
+ raise ValueError("Tag deletion requires the captured tag object and its local-first step")
611
+ self._check_current(run)
612
+ yield GitCommand(["check-ref-format", request.destination_ref])
613
+ if preflight is not None and not run.local_started:
614
+ yield from preflight()
615
+ target = request.push_target
616
+ if target is None or target != self.capture_push_target(snapshot, request.remote_name, request.endpoint):
617
+ raise ValueError("The deletion endpoint, account or tool changed. Reread the destination.")
618
+ run.remote_name, run.endpoint = request.remote_name, request.endpoint
619
+ run.command_endpoint = "gitpane-delete-" + uuid.uuid4().hex
620
+ run.kind, run.selection = target.kind, target.selection
621
+ if target.kind == HTTPS_ENDPOINT_KIND:
622
+ yield from self._prepare_network(run)
623
+ run.arguments = self._network_arguments(run, target)
624
+ run.environment_by_name = dict(target.environment_by_name)
625
+ run.environment_by_name.update(TRANSPORT_TRACE_ENVIRONMENT_BY_NAME)
626
+ run.push_batches = [((request.destination_ref, ""),)]
627
+
628
+ def validated(_value: object, error: Exception | None) -> None:
629
+ """Observe the exact server branch before deleting any reference."""
630
+ try:
631
+ if error is not None:
632
+ raise ValueError(str(error))
633
+ run.trace.append_event(operation.operation_id, "status", "Delete " + request.destination_ref + " on " + run.remote_name + " at " + run.endpoint)
634
+ self._start(run, "delete-before", ["ls-remote", "--refs", "--", run.command_endpoint, request.destination_ref])
635
+ except (ValueError, OSError, RuntimeError) as failure:
636
+ self._fail(run, str(failure), OperationStatus.REFUSED)
637
+ self.remote.worktree_service.run(operation.context, operation.git_filename, validate(), validated, operation_id=operation.operation_id)
638
+ return run
639
+
640
+ def _advance_delete(self, run: TransportRun, result: ProcessResult) -> None:
641
+ """Keep server refusal, known absence and local continuation results distinct."""
642
+ request = run.request
643
+ diagnostic = GitCommandError(result).diagnostic if result.status != ProcessStatus.SUCCESS else None
644
+ run.steps.append(OperationStepResult(run.stage, operation_status(result.status), process_status=result.status, diagnostic=diagnostic, return_code=result.return_code))
645
+ if run.stage == "delete-before":
646
+ if result.status != ProcessStatus.SUCCESS:
647
+ self._finish(run, operation_status(result.status), diagnostic)
648
+ return
649
+ run.before_oid = self._observed_oid_by_ref(run).get(request.destination_ref)
650
+ if request.source_ref.startswith(TAG_PREFIX):
651
+ if run.before_oid is not None and run.before_oid != request.source_oid:
652
+ self._fail(run, "The server tag changed; close this result and select the observed tag again", OperationStatus.REFUSED)
653
+ return
654
+ if not run.local_started:
655
+ self._delete_tag_local(run)
656
+ else:
657
+ self._send_ref_delete(run)
658
+ return
659
+ if run.before_oid is None:
660
+ self._deleted_remote(run, OperationStatus.SUCCESS, None, "already absent")
661
+ return
662
+ if request.source_ref.startswith(REMOTE_PREFIX) and run.before_oid != request.source_oid:
663
+ self._fail(run, "The server branch changed since this remote reference was captured; fetch and select it again", OperationStatus.REFUSED)
664
+ return
665
+ self._send_ref_delete(run)
666
+ return
667
+ if run.stage == "delete-recheck":
668
+ if result.status != ProcessStatus.SUCCESS:
669
+ self._finish(run, OperationStatus.UNKNOWN, diagnostic)
670
+ return
671
+ observed_oid = self._observed_oid_by_ref(run).get(request.destination_ref)
672
+ if observed_oid != run.before_oid:
673
+ self._fail(run, "The server tag changed during local deletion; make a fresh choice before deleting it", OperationStatus.REFUSED)
674
+ return
675
+ self._send_ref_delete(run)
676
+ return
677
+ if run.stage == "delete":
678
+ if not run.delete_started:
679
+ self._finish(run, OperationStatus.REFUSED, diagnostic)
680
+ return
681
+ run.push_status, run.push_diagnostic = result.status, diagnostic
682
+ self._read_porcelain(run)
683
+ try:
684
+ self._start(run, "delete-after", ["ls-remote", "--refs", "--", run.command_endpoint, request.destination_ref])
685
+ except (ValueError, OSError, RuntimeError) as error:
686
+ self._fail(run, "Deletion outcome unknown: " + str(error), OperationStatus.UNKNOWN)
687
+ return
688
+ after_oid = self._observed_oid_by_ref(run).get(request.destination_ref) if result.status == ProcessStatus.SUCCESS else None
689
+ if result.status != ProcessStatus.SUCCESS:
690
+ status, effect = OperationStatus.UNKNOWN, "inspect server state"
691
+ elif after_oid is None:
692
+ status, effect = OperationStatus.SUCCESS, "deleted"
693
+ elif run.push_status == ProcessStatus.FAILED and after_oid == run.before_oid:
694
+ status, effect = OperationStatus.FAILED, "not deleted"
695
+ elif run.push_status == ProcessStatus.FAILED and run.porcelain_flag_by_ref.get(request.destination_ref) == REFUSED_PORCELAIN_FLAG:
696
+ status, effect = OperationStatus.REFUSED, "server reference changed; deletion refused"
697
+ else:
698
+ status, effect = OperationStatus.UNKNOWN, "server ref changed; inspect before retrying"
699
+ self._deleted_remote(run, status, after_oid, effect, run.push_diagnostic or diagnostic)
700
+
701
+ def _delete_tag_local(self, run: TransportRun) -> None:
702
+ """Hold the shared owner while deleting the local tag before its server counterpart."""
703
+ run.local_started = True
704
+ run.stage = "delete-local"
705
+ operation = run.request.operation
706
+
707
+ def completed(outcome: OperationOutcome | None, error: Exception | None) -> None:
708
+ """Advance only after proven local success, using its observed reference state."""
709
+ if error is not None:
710
+ self._fail(run, str(error), OperationStatus.UNKNOWN)
711
+ return
712
+ run.steps.extend(outcome.steps)
713
+ if outcome.status != OperationStatus.SUCCESS:
714
+ self._finish(run, outcome.status, outcome.diagnostic)
715
+ return
716
+ local_step = next(step for step in outcome.steps if step.operation == DELETE_LOCAL_TAG_OPERATION)
717
+ run.snapshot = replace(run.snapshot, reference_state=local_step.after.signature)
718
+ try:
719
+ self._start(run, "delete-recheck", ["ls-remote", "--refs", "--", run.command_endpoint, run.request.destination_ref])
720
+ except (ValueError, OSError, RuntimeError) as failure:
721
+ self._fail(run, str(failure), OperationStatus.REFUSED)
722
+ self.remote.worktree_service.run(operation.context, operation.git_filename, run.delete_local(), completed, operation_id=operation.operation_id)
723
+
724
+ def _send_ref_delete(self, run: TransportRun) -> None:
725
+ """Delete only the explicitly observed server reference, or retain its proven absence."""
726
+ if run.before_oid is None:
727
+ self._deleted_remote(run, OperationStatus.SUCCESS, None, "already absent")
728
+ return
729
+ try:
730
+ # An explicit old object prevents deletion of a reference replaced after ls-remote and before the server advertisement.
731
+ expected_object_argument = lease_argument(run.request.destination_ref, run.before_oid)
732
+ self._start(run, "delete", [*PUSH_ARGUMENTS, expected_object_argument, "--", run.command_endpoint, ":" + run.request.destination_ref])
733
+ except (ValueError, OSError, RuntimeError) as failure:
734
+ self._fail(run, str(failure), OperationStatus.REFUSED)
735
+
736
+ def reconcile(self, request: TransportRequest, previous_operation_id: str, *, local_sequence: Callable[[], Generator],
737
+ is_current: Callable[[], bool] | None = None) -> TransportRun:
738
+ """Retry only the unfinished deletion step while preserving each known success."""
739
+ request = self._scoped_request(request)
740
+ if request.source_ref.startswith(TAG_PREFIX):
741
+ return self.delete_remote(request, is_current=is_current, previous_operation_id=previous_operation_id)
742
+ operation = request.operation
743
+ existing = self.run_by_id.get(operation.operation_id)
744
+ if existing is not None:
745
+ return existing
746
+ previous = self.run_by_id.get(previous_operation_id)
747
+ outcome = self.outcome_by_id.get(previous_operation_id)
748
+ # The remote-tracking branch belongs to the server deletion, which the first attempt already finished: Git drops it
749
+ # then and never again. Retrying only the local branch must therefore leave it alone, or a tracking branch that a
750
+ # concurrent push recreated in the meantime would be deleted although its server branch exists.
751
+ trace = OperationTrace(operation.operation_id, self.runner, self.log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
752
+ run = TransportRun(request, trace, snapshot=request.snapshot, remote_name=request.remote_name, endpoint=request.endpoint, delete_local=local_sequence,
753
+ delete_current=is_current, tracking_checked=True)
754
+ self.run_by_id[operation.operation_id] = run
755
+ valid = (previous is not None and outcome is not None and previous.request.mode == request.mode == "delete"
756
+ and previous.request.source_ref == request.source_ref and previous.request.source_oid == request.source_oid
757
+ and previous.request.destination_ref == request.destination_ref and previous.request.endpoint == request.endpoint
758
+ and previous.request.remote_name == request.remote_name and previous.request.operation.context == operation.context
759
+ and previous.request.delete_local_refs == request.delete_local_refs
760
+ and previous.request.operation.git_filename == operation.git_filename and outcome.ref_results
761
+ and all(result.status == OperationStatus.SUCCESS for result in outcome.ref_results)
762
+ and any(step.operation == DELETE_LOCAL_BRANCH_OPERATION and step.status in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.NOT_STARTED) for step in outcome.steps))
763
+ if not valid:
764
+ QTimer.singleShot(0, self, lambda: self._fail(run, "A fresh local deletion can resume only a known remote success with an unfinished local step", OperationStatus.REFUSED))
765
+ return run
766
+ run.ref_results.extend(outcome.ref_results)
767
+ run.remote_results.extend(outcome.remote_results)
768
+ for step in outcome.steps:
769
+ if step.operation == DELETE_REMOTE_BRANCH_OPERATION or step.operation == DELETE_LOCAL_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS:
770
+ run.steps.append(step)
771
+ if self.closed or not self.is_current(operation) or not self.repository_operation.reserve(operation):
772
+ QTimer.singleShot(0, self, lambda: self._fail(run, "Local deletion cannot start while its repository is unavailable or busy", OperationStatus.REFUSED))
773
+ return run
774
+ run.trace.append_event(operation.operation_id, "status", "Remote deletion already succeeded; retrying only the local branch")
775
+ QTimer.singleShot(0, self, lambda: self._finish(run, OperationStatus.SUCCESS))
776
+ return run
777
+
778
+ def _deleted_remote(self, run: TransportRun, status: OperationStatus, after_oid: str | None, effect: str, diagnostic: PreparedDiagnostic | None = None) -> None:
779
+ """Record the observed server effect before deciding whether local work may begin."""
780
+ request = run.request
781
+ run.ref_results.append(PushResult(request.destination_ref, "", run.endpoint, status, run.before_oid, after_oid, effect))
782
+ run.remote_results.append(FetchResult(run.remote_name, run.endpoint, status, effect, diagnostic))
783
+ label = DELETE_REMOTE_TAG_OPERATION if request.source_ref.startswith(TAG_PREFIX) else DELETE_REMOTE_BRANCH_OPERATION
784
+ run.steps.append(OperationStepResult(label, status, run.before_oid, after_oid, run.push_status or ProcessStatus.SUCCESS, effect, diagnostic, full_ref=request.destination_ref))
785
+ self._finish(run, status, diagnostic)
786
+
787
+ def _next_push_batch(self, run: TransportRun) -> None:
788
+ """Observe only the next frozen batch without discovering additional tags."""
789
+ run.trace.append_event(run.request.operation.operation_id, "status", "Push batch " + str(run.batch_index + 1) + " of " + str(len(run.push_batches)))
790
+ refs = []
791
+ for full_ref, _oid in run.push_batches[run.batch_index]:
792
+ refs.append(full_ref)
793
+ self._start(run, "push-before", ["ls-remote", "--refs", "--", run.command_endpoint, *refs])
794
+
795
+ def _stdout(self, request: ProcessRequest, data: bytes) -> None:
796
+ """Retain the current sending or deletion reader's output for exact ref observation."""
797
+ run = self.run_by_id.get(request.operation_id)
798
+ if run is not None and run.request.mode in ("push", "delete") and request.request_id == run.process_id:
799
+ run.output.extend(data)
800
+
801
+ def _advance_push(self, run: TransportRun, result: ProcessResult) -> None:
802
+ """Observe the same destination after sending, including a failed native command."""
803
+ request = run.request
804
+ diagnostic = GitCommandError(result).diagnostic if result.status != ProcessStatus.SUCCESS else None
805
+ run.steps.append(OperationStepResult(run.stage, operation_status(result.status), process_status=result.status, diagnostic=diagnostic, return_code=result.return_code))
806
+ if run.stage == "push-before":
807
+ if result.status != ProcessStatus.SUCCESS:
808
+ self._finish(run, operation_status(result.status), diagnostic)
809
+ return
810
+ run.before_oid_by_ref = self._observed_oid_by_ref(run)
811
+ run.before_oid = run.before_oid_by_ref.get(request.destination_ref)
812
+ try:
813
+ # Force is always a force with lease, never a plain "+" refspec: a server ref is only replaced while it still
814
+ # holds the expected object, so commits nobody here has seen are never overwritten.
815
+ leases: list[str] = []
816
+ refspecs: list[str] = []
817
+ for full_ref, oid in run.push_batches[run.batch_index]:
818
+ refspecs.append(oid + ":" + full_ref)
819
+ if request.force:
820
+ expected_oid = self._lease_expectation(run, full_ref)
821
+ leases.append(lease_argument(full_ref, expected_oid))
822
+ run.trace.append_event(request.operation.operation_id, "status", "Force push with lease: " + full_ref + " is replaced only if the server still has "
823
+ + (expected_oid if expected_oid else "no such reference") + ".")
824
+ self._start(run, "push", [*PUSH_ARGUMENTS, *leases, "--", run.command_endpoint, *refspecs])
825
+ except (ValueError, OSError, RuntimeError) as error:
826
+ self._fail(run, str(error), OperationStatus.REFUSED)
827
+ return
828
+ if run.stage == "push":
829
+ run.push_status = result.status
830
+ run.push_diagnostic = diagnostic or run.push_diagnostic
831
+ self._read_porcelain(run)
832
+ self._record_server_refusals(run)
833
+ try:
834
+ refs = []
835
+ for full_ref, _oid in run.push_batches[run.batch_index]:
836
+ refs.append(full_ref)
837
+ self._start(run, "push-after", ["ls-remote", "--refs", "--", run.command_endpoint, *refs])
838
+ except (ValueError, OSError, RuntimeError) as error:
839
+ self._fail(run, "Outcome unknown. Remote observation failed: " + str(error), OperationStatus.UNKNOWN)
840
+ return
841
+ after_oid_by_ref = self._observed_oid_by_ref(run) if result.status == ProcessStatus.SUCCESS else {}
842
+ for full_ref, oid in run.push_batches[run.batch_index]:
843
+ before_oid = run.before_oid_by_ref.get(full_ref)
844
+ after_oid = after_oid_by_ref.get(full_ref)
845
+ effect = "unknown" if result.status != ProcessStatus.SUCCESS else "unchanged" if after_oid == before_oid else "changed"
846
+ status = OperationStatus.UNKNOWN if result.status != ProcessStatus.SUCCESS else operation_status(run.push_status)
847
+ if result.status == ProcessStatus.SUCCESS and after_oid == oid:
848
+ flag = run.porcelain_flag_by_ref.get(full_ref)
849
+ reported_partial = bool(request.tag_refs) and REFUSED_PORCELAIN_FLAG in run.porcelain_flag_by_ref.values()
850
+ status = OperationStatus.SUCCESS if run.push_status == ProcessStatus.SUCCESS or reported_partial and flag in (" ", "+", "*", "=") else OperationStatus.PARTIAL
851
+ elif run.push_status == ProcessStatus.SUCCESS:
852
+ status = OperationStatus.UNKNOWN
853
+ run.steps.append(OperationStepResult("Observe " + full_ref, status, before=before_oid, after=after_oid, effect=effect))
854
+ run.ref_results.append(PushResult(full_ref, oid, run.endpoint, status, before_oid, after_oid, effect))
855
+ run.trace.append_event(request.operation.operation_id, "status", full_ref + ": " + status.value + " (" + effect + ")")
856
+ if result.status == ProcessStatus.SUCCESS and not run.cancelled and run.push_status in (ProcessStatus.SUCCESS, ProcessStatus.FAILED) and run.batch_index + 1 < len(run.push_batches):
857
+ try:
858
+ run.batch_index += 1
859
+ self._next_push_batch(run)
860
+ except (ValueError, OSError, RuntimeError) as error:
861
+ self._fail(run, str(error), OperationStatus.REFUSED)
862
+ return
863
+ if not request.tag_refs and run.push_status == ProcessStatus.FAILED:
864
+ remote_oid = after_oid_by_ref.get(request.destination_ref, "") if result.status == ProcessStatus.SUCCESS else run.before_oid_by_ref.get(request.destination_ref, "")
865
+ run.rejection = self._push_rejection(run, remote_oid)
866
+ status = self._push_status(run, OperationStatus.SUCCESS if result.status == ProcessStatus.SUCCESS else OperationStatus.UNKNOWN)
867
+ effect_set = {item.effect for item in run.ref_results}
868
+ effect = "unknown" if "unknown" in effect_set else "changed" if "changed" in effect_set else "unchanged"
869
+ run.remote_results.append(FetchResult(run.remote_name, run.endpoint, status, effect, run.push_diagnostic or diagnostic))
870
+ operation = request.operation
871
+
872
+ def reconcile() -> Generator:
873
+ """Read the repository's remotes again once the exact remote commit is observed, and update its remote-tracking branches."""
874
+ snapshot = yield from self.remote.capture(operation.context, operation.git_filename, operation.generation)
875
+ if not self.closed and not run.cancelled and (yield from self._update_remote_tracking(run, snapshot)):
876
+ snapshot = yield from self._read_tracking_result(run, snapshot)
877
+ if request.add_tracking and not self.closed and not run.cancelled:
878
+ snapshot = yield from self._link_upstream(run, snapshot)
879
+ return snapshot
880
+
881
+ def reread(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
882
+ """Keep the common owner until the local configuration and refs have been read."""
883
+ if error is not None:
884
+ self._fail(run, "Push finished but the local state could not be reread: " + str(error), OperationStatus.UNKNOWN)
885
+ return
886
+ self._finish(run, status, run.push_diagnostic or diagnostic, snapshot)
887
+
888
+ self.remote.worktree_service.run(operation.context, operation.git_filename, reconcile(), reread,
889
+ operation_id=operation.operation_id)
890
+
891
+ def _lease_expectation(self, run: TransportRun, full_ref: str) -> str:
892
+ """Return the object a forced ref must still hold on the server, or "" when it must not exist.
893
+
894
+ A branch expects its remote-tracking branch, the last fetched state, exactly like Git's own --force-with-lease; with
895
+ no remote-tracking branch it expects the branch to be absent. A tag has no remote-tracking reference, so it expects
896
+ the server tag observed just before sending.
897
+ """
898
+ if full_ref.startswith(TAG_PREFIX):
899
+ return run.before_oid_by_ref.get(full_ref, "")
900
+ tracking_ref = remote_tracking_ref(run.snapshot, run.request.remote_name, full_ref)
901
+ return remote_reference_oid(run.snapshot, tracking_ref) if tracking_ref else ""
902
+
903
+ def _push_rejection(self, run: TransportRun, remote_oid: str) -> PushRejection | None:
904
+ """Recognize a branch refused because the server holds commits the push would replace, and compare it with the last Fetch."""
905
+ destination_ref = run.request.destination_ref
906
+ if run.porcelain_flag_by_ref.get(destination_ref) != REFUSED_PORCELAIN_FLAG:
907
+ return None
908
+ summary = run.porcelain_summary_by_ref.get(destination_ref, "")
909
+ reason = ""
910
+ for candidate in PUSH_REJECTION_REASONS:
911
+ if "(" + candidate + ")" in summary:
912
+ reason = candidate
913
+ if not reason:
914
+ return None
915
+ tracking_ref = remote_tracking_ref(run.snapshot, run.request.remote_name, destination_ref)
916
+ tracking_oid = remote_reference_oid(run.snapshot, tracking_ref) if tracking_ref else ""
917
+ unseen_commits = not tracking_ref or remote_oid != tracking_oid
918
+ return PushRejection(run.request.remote_name, destination_ref, reason, remote_oid, tracking_ref, tracking_oid, unseen_commits)
919
+
920
+ def _record_server_refusals(self, run: TransportRun) -> None:
921
+ """Keep each reference of the batch just sent that the server itself refused, so a failure can say which and why.
922
+
923
+ The batch's own order is used, not the order of Git's output. A refusal because the server holds other commits
924
+ is not one of them: Git reports it locally, before the server sees the reference.
925
+ """
926
+ for full_ref, _oid in run.push_batches[run.batch_index]:
927
+ if run.porcelain_flag_by_ref.get(full_ref) != REFUSED_PORCELAIN_FLAG:
928
+ continue
929
+ summary = run.porcelain_summary_by_ref.get(full_ref, "")
930
+ if not summary.startswith(SERVER_REFUSAL_SUMMARY_PREFIX):
931
+ continue
932
+ match = SERVER_REFUSAL_REASON_PATTERN.search(summary)
933
+ run.server_refusals.append(ServerRefusal(full_ref, match.group("reason") if match is not None else ""))
934
+
935
+ def _update_remote_tracking(self, run: TransportRun, snapshot: RemoteSnapshot) -> Generator:
936
+ """Move the remote-tracking branch of each verified pushed branch to the pushed commit, as `git push <remote>` does.
937
+
938
+ GitPane pushes to a private alias of the remote's URL, so Git cannot do it on its own and the graph marker would
939
+ only move after the next Fetch. No network is used: the new position is the commit the server was just observed
940
+ to hold. Returns whether a reference changed.
941
+ """
942
+ changed = False
943
+ for result in run.ref_results:
944
+ if result.status != OperationStatus.SUCCESS or result.after_oid != result.source_oid:
945
+ continue
946
+ tracking_ref = remote_tracking_ref(snapshot, run.request.remote_name, result.full_ref)
947
+ old_oid = remote_reference_oid(snapshot, tracking_ref) if tracking_ref else ""
948
+ if not tracking_ref or old_oid == result.after_oid:
949
+ continue
950
+ absent_oid = "0" * len(result.after_oid)
951
+ arguments = ["update-ref", "--no-deref", "-m", TRACKING_REFLOG_MESSAGE, tracking_ref, result.after_oid, old_oid or absent_oid]
952
+ changed = (yield from self._write_tracking(run, TRACKING_UPDATE_OPERATION, tracking_ref, old_oid, result.after_oid, arguments)) or changed
953
+ return changed
954
+
955
+ def _link_upstream(self, run: TransportRun, snapshot: RemoteSnapshot) -> Generator:
956
+ """Make the pushed local branch follow the remote branch the server was just observed holding it on, on the configured remote.
957
+
958
+ It runs after the remote-tracking branch of that destination was moved, since Git only links a branch to a
959
+ remote-tracking branch that exists. The full remote-tracking name and the "--" before the branch keep a short
960
+ name that is ambiguous, or one that reads as an option, from linking anything else. An upstream already present
961
+ when the repository is read again after the Push is kept as it is; one that another tool writes between that
962
+ read and `git branch` is replaced, since Git cannot write a setting only while it is still unset. Once Git
963
+ answers, the repository is read again and the link is only said set when Git resolves exactly the asked
964
+ remote-tracking branch: Git can answer 0 and still resolve another upstream, for instance when
965
+ branch.<name>.remote has several values. Whatever happens here, the Push keeps its own result: a link that is
966
+ not set is a failed or unknown step and a red console line, never a failed Push. Returns the capture the rest of
967
+ the Push goes on with.
968
+ """
969
+ request = run.request
970
+ operation_id = request.operation.operation_id
971
+ verified = False
972
+ for result in run.ref_results:
973
+ if result.full_ref == request.destination_ref and result.status == OperationStatus.SUCCESS and result.after_oid == result.source_oid:
974
+ verified = True
975
+ if not verified or not request.source_ref.startswith(LOCAL_PREFIX):
976
+ return snapshot
977
+ branch_name = request.source_ref.removeprefix(LOCAL_PREFIX)
978
+ if tracking_values(snapshot, request.source_ref, MERGE_TRACKING_FIELD):
979
+ run.steps.append(OperationStepResult(SharedText.UPSTREAM_LINK_STEP.value, OperationStatus.NOT_STARTED, effect="upstream already set", full_ref=request.source_ref))
980
+ run.trace.append_event(operation_id, "status", SharedText.UPSTREAM_KEPT.value.format(branch=branch_name))
981
+ return snapshot
982
+ tracking_ref = remote_tracking_ref(snapshot, request.remote_name, request.destination_ref)
983
+ if not tracking_ref:
984
+ tracking_name = request.remote_name + "/" + request.destination_ref.removeprefix(LOCAL_PREFIX)
985
+ cause = SharedText.UPSTREAM_NO_TRACKING_BRANCH.value.format(remote=request.remote_name, destination=request.destination_ref.removeprefix(LOCAL_PREFIX))
986
+ self._report_not_linked(run, OperationStatus.FAILED, branch_name, tracking_name, cause)
987
+ return snapshot
988
+ tracking_name = tracking_ref.removeprefix(REMOTE_PREFIX)
989
+ cause = self._link_refusal(run)
990
+ if cause:
991
+ self._report_not_linked(run, OperationStatus.FAILED, branch_name, tracking_name, cause)
992
+ return snapshot
993
+
994
+ def check_link() -> None:
995
+ """Refuse the link at its launch when the configuration may no longer change, judged right before Git writes it."""
996
+ refusal = self._link_refusal(run)
997
+ if refusal:
998
+ raise ValueError(refusal)
999
+
1000
+ try:
1001
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "branch", "--set-upstream-to=" + tracking_ref, "--", branch_name], profile=LOCAL_WRITE_PROFILE,
1002
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=check_link)
1003
+ except GitCommandError as error:
1004
+ self._report_not_linked(run, OperationStatus.FAILED, branch_name, tracking_name, error.diagnostic.cause, error.diagnostic)
1005
+ return snapshot
1006
+ operation = request.operation
1007
+ try:
1008
+ linked_snapshot = yield from self.remote.capture(operation.context, operation.git_filename, operation.generation)
1009
+ except (ValueError, OSError, RuntimeError, GitCommandError) as error:
1010
+ self._report_not_linked(run, OperationStatus.UNKNOWN, branch_name, tracking_name, SharedText.UPSTREAM_UNREAD.value.format(cause=str(error)))
1011
+ return snapshot
1012
+ tracking = linked_snapshot.tracking_by_ref.get(request.source_ref)
1013
+ upstream = tracking.upstream if tracking is not None else ""
1014
+ if not upstream:
1015
+ self._report_not_linked(run, OperationStatus.UNKNOWN, branch_name, tracking_name, SharedText.UPSTREAM_NO_UPSTREAM.value)
1016
+ return linked_snapshot
1017
+ if upstream != tracking_ref:
1018
+ self._report_not_linked(run, OperationStatus.UNKNOWN, branch_name, tracking_name, SharedText.UPSTREAM_OTHER_UPSTREAM.value.format(upstream=upstream.removeprefix(REMOTE_PREFIX)))
1019
+ return linked_snapshot
1020
+ run.steps.append(OperationStepResult(SharedText.UPSTREAM_LINK_STEP.value, OperationStatus.SUCCESS, after=tracking_ref, effect="linked", full_ref=request.source_ref))
1021
+ run.trace.append_event(operation_id, "status", SharedText.UPSTREAM_LINKED.value.format(branch=branch_name, tracking=tracking_name))
1022
+ return linked_snapshot
1023
+
1024
+ def _link_refusal(self, run: TransportRun) -> str:
1025
+ """Say why the repository's write rules refuse the link as a configuration write, or "" when they allow it.
1026
+
1027
+ The link writes branch.<name>.* into the configuration every worktree of the repository shares, so it follows
1028
+ the rules of a configuration write, for instance no change while a linked worktree has a paused rebase. The
1029
+ Push itself keeps its own action: those rules only judge this one step, so a refused link never stops the Push.
1030
+ """
1031
+ return self.repository_operation.policy_diagnostic(replace(run.request.operation, action=OperationAction.CONFIG_WRITE))
1032
+
1033
+ def _report_not_linked(self, run: TransportRun, status: OperationStatus, branch_name: str, tracking_name: str, cause: str, diagnostic: PreparedDiagnostic | None = None) -> None:
1034
+ """Record a link asked for after a successful Push that is not set, say why in red, and keep the Push's console open for it."""
1035
+ request = run.request
1036
+ run.steps.append(OperationStepResult(SharedText.UPSTREAM_LINK_STEP.value, status, effect="not linked", diagnostic=diagnostic, full_ref=request.source_ref))
1037
+ run.trace.append_event(request.operation.operation_id, "status", SharedText.UPSTREAM_NOT_LINKED.value.format(branch=branch_name, tracking=tracking_name, cause=cause), tone=ConsoleTone.FAILURE)
1038
+ run.upstream_not_linked = True
1039
+
1040
+ def _read_tracking_result(self, run: TransportRun, snapshot: RemoteSnapshot) -> Generator:
1041
+ """Observe the branches a remote-tracking update just moved, with one Git read instead of a second complete capture.
1042
+
1043
+ Configuration and reference signatures are taken before and after that read and must not move: only then do
1044
+ the objects and upstreams Git reports replace the ones of ``snapshot``, whose configuration is still exact.
1045
+ Anything else, a change during the read or an answer that cannot be parsed, leads to a complete capture, whose
1046
+ own failure leaves the result unknown rather than a local success nobody observed.
1047
+ """
1048
+ context = snapshot.context
1049
+ before = yield FileTask(lambda: (config_signature(context, snapshot.values), reference_signature(context)))
1050
+ output = yield GitCommand(["for-each-ref", TRACKING_RESULT_FORMAT, LOCAL_PREFIX, REMOTE_PREFIX], profile=READ_PROFILE)
1051
+ after = yield FileTask(lambda: (config_signature(context, snapshot.values), reference_signature(context)))
1052
+ config_before, reference_before = before
1053
+ config_after, reference_after = after
1054
+ observed = None
1055
+ if config_before == config_after == snapshot.signature and reference_before == reference_after:
1056
+ observed = self._tracking_observation(snapshot, output, reference_after)
1057
+ if observed is not None:
1058
+ return observed
1059
+ operation = run.request.operation
1060
+ run.trace.append_event(operation.operation_id, "status", "Local branches changed while being read; reading every remote setting again.")
1061
+ return (yield from self.remote.capture(operation.context, operation.git_filename, operation.generation))
1062
+
1063
+ def _tracking_observation(self, snapshot: RemoteSnapshot, output: bytes, reference_state: tuple) -> RemoteSnapshot | None:
1064
+ """Rebuild the branch part of a capture from one complete reference read, or nothing when the read does not match it.
1065
+
1066
+ The local branches must be exactly the captured ones, with the same objects and upstreams: only remote-tracking
1067
+ branches may have moved, and whether an upstream exists is computed again from the references Git reported.
1068
+ """
1069
+ remote_records: list[bytes] = []
1070
+ local_by_ref: dict[str, tuple[str, str]] = {}
1071
+ observed_ref_set: set[str] = set()
1072
+ for record in output.splitlines():
1073
+ fields = record.split(b"\0")
1074
+ if len(fields) != TRACKING_RESULT_FIELD_COUNT:
1075
+ return None
1076
+ raw_name, oid, symref, upstream = fields
1077
+ name = raw_name.decode("utf-8", "surrogateescape")
1078
+ if name in observed_ref_set:
1079
+ return None
1080
+ observed_ref_set.add(name)
1081
+ if name.startswith(REMOTE_PREFIX):
1082
+ remote_records.append(raw_name + b"\0" + oid + b"\0" + symref + b"\n")
1083
+ elif name.startswith(LOCAL_PREFIX):
1084
+ local_by_ref[name] = (oid.decode("ascii", "replace"), upstream.decode("utf-8", "surrogateescape"))
1085
+ else:
1086
+ return None
1087
+ if set(local_by_ref) != set(snapshot.tracking_by_ref):
1088
+ return None
1089
+ tracking_by_ref = {}
1090
+ for full_ref, tracking in snapshot.tracking_by_ref.items():
1091
+ if local_by_ref[full_ref] != (tracking.oid, tracking.upstream):
1092
+ return None
1093
+ tracking_by_ref[full_ref] = replace(tracking, observed=bool(tracking.upstream) and tracking.upstream in observed_ref_set)
1094
+ # This read does not observe HEAD, so the rebuilt capture claims no HEAD rather than the older capture's one.
1095
+ return replace(snapshot, references=b"".join(remote_records), tracking_by_ref=tracking_by_ref, reference_state=reference_state, head_oid=None, head_ref=None)
1096
+
1097
+ def _write_tracking(self, run: TransportRun, label: str, tracking_ref: str, old_oid: str, new_oid: str, arguments: list[str]) -> Generator:
1098
+ """Write one remote-tracking branch with its old value checked under Git's reference lock, and record the step.
1099
+
1100
+ A tracking branch changed by someone else in the meantime is left alone: the step reports it and the transport
1101
+ result itself is unchanged, since the server effect was already verified. Returns whether the write succeeded.
1102
+ """
1103
+ operation_id = run.request.operation.operation_id
1104
+ try:
1105
+ yield GitCommand(arguments, profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS)
1106
+ except GitCommandError as error:
1107
+ run.steps.append(OperationStepResult(label, OperationStatus.FAILED, before=old_oid or None, after=new_oid or None, effect="left unchanged", diagnostic=error.diagnostic, full_ref=tracking_ref))
1108
+ run.trace.append_event(operation_id, "status", tracking_ref + ": not updated (" + error.diagnostic.cause + ")")
1109
+ return False
1110
+ run.steps.append(OperationStepResult(label, OperationStatus.SUCCESS, before=old_oid or None, after=new_oid or None, effect="changed", full_ref=tracking_ref))
1111
+ run.trace.append_event(operation_id, "status", tracking_ref + ": " + (new_oid if new_oid else "deleted"))
1112
+ return True
1113
+
1114
+ def _read_porcelain(self, run: TransportRun) -> None:
1115
+ """Read native per-ref status, including the '(delete)' source used for deletion."""
1116
+ run.porcelain_flag_by_ref.clear()
1117
+ run.porcelain_summary_by_ref.clear()
1118
+ field_count = 3
1119
+ for record in bytes(run.output).splitlines():
1120
+ fields = record.split(b"\t")
1121
+ if len(fields) != field_count:
1122
+ continue
1123
+ flag, mapping, summary = fields
1124
+ _source, separator, full_ref = mapping.partition(b":")
1125
+ if separator:
1126
+ name = full_ref.decode("utf-8", "surrogateescape")
1127
+ run.porcelain_flag_by_ref[name] = flag.decode("ascii", "replace")
1128
+ run.porcelain_summary_by_ref[name] = summary.decode("utf-8", "replace")
1129
+
1130
+ def _observed_oid_by_ref(self, run: TransportRun) -> dict[str, str]:
1131
+ """Match complete destination refs independently of server output order and glob matching."""
1132
+ oid_by_ref = {}
1133
+ selected_ref_set = set(dict(run.push_batches[run.batch_index]))
1134
+ for record in bytes(run.output).splitlines():
1135
+ oid, separator, full_ref = record.partition(b"\t")
1136
+ name = full_ref.decode("utf-8", "surrogateescape")
1137
+ if separator and name in selected_ref_set:
1138
+ oid_by_ref[name] = oid.decode("ascii")
1139
+ return oid_by_ref
1140
+
1141
+ def _push_status(self, run: TransportRun, fallback: OperationStatus) -> OperationStatus:
1142
+ """Keep incomplete batches and failures visible while preserving observed successes."""
1143
+ status_set = {result.status for result in run.ref_results}
1144
+ if OperationStatus.UNKNOWN in status_set or fallback == OperationStatus.UNKNOWN:
1145
+ return OperationStatus.UNKNOWN
1146
+ if OperationStatus.PARTIAL in status_set or OperationStatus.SUCCESS in status_set and (status_set != {OperationStatus.SUCCESS} or fallback != OperationStatus.SUCCESS):
1147
+ return OperationStatus.PARTIAL
1148
+ if status_set and status_set != {OperationStatus.SUCCESS}:
1149
+ return next(iter(status_set)) if len(status_set) == 1 else OperationStatus.FAILED
1150
+ return fallback
1151
+
1152
+ def fetch(self, request: TransportRequest) -> TransportRun:
1153
+ """Freeze the requested remotes and retain their shared owner until every reread ends."""
1154
+ request = self._scoped_request(request)
1155
+ operation = request.operation
1156
+ previous = self.run_by_id.get(operation.operation_id)
1157
+ if previous is not None:
1158
+ return previous
1159
+ trace = OperationTrace(operation.operation_id, self.runner, self.log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
1160
+ run = TransportRun(request, trace, helper_override=self.authentication.helper_commands)
1161
+ self.run_by_id[operation.operation_id] = run
1162
+ if self.closed or not self.is_current(operation) or not self.repository_operation.reserve(operation):
1163
+ cause = self._reservation_refusal(operation)
1164
+ QTimer.singleShot(0, self, lambda: self._fail(run, cause, OperationStatus.REFUSED))
1165
+ return run
1166
+ if request.snapshot is not None:
1167
+ QTimer.singleShot(0, self, lambda: self._prepared(run, request.snapshot, None))
1168
+ return run
1169
+ self.remote.worktree_service.run(operation.context, operation.git_filename,
1170
+ self.remote.capture(operation.context, operation.git_filename, operation.generation),
1171
+ lambda snapshot, error: self._prepared(run, snapshot, error), operation_id=operation.operation_id)
1172
+ return run
1173
+
1174
+ def cancel(self, operation_id: str) -> None:
1175
+ """Stop network work while retaining ownership through actual exit and rereading."""
1176
+ run = self.run_by_id.get(operation_id)
1177
+ if run is None or operation_id in self.outcome_by_id:
1178
+ return
1179
+ run.cancelled = True
1180
+ if run.process_id:
1181
+ self.runner.cancel(run.process_id)
1182
+ if run.retry_waiting:
1183
+ QTimer.singleShot(0, self, lambda: self._start_again(run, run.stage, run.launch_arguments))
1184
+ if run.stage == "delete-local":
1185
+ for active in tuple(self.runner.active_by_id.values()):
1186
+ if active.request.operation_id == operation_id:
1187
+ self.runner.cancel(active.request.request_id)
1188
+ for request in tuple(self.runner.pending_requests):
1189
+ if request.operation_id == operation_id:
1190
+ self.runner.cancel(request.request_id)
1191
+
1192
+ def close(self) -> None:
1193
+ """Stop owned work and release temporary output only after its processes drain."""
1194
+ self.closed = True
1195
+ for operation_id, run in tuple(self.run_by_id.items()):
1196
+ self.cancel(operation_id)
1197
+ if operation_id in self.outcome_by_id:
1198
+ run.trace.close()
1199
+
1200
+ def _prepared(self, run: TransportRun, snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
1201
+ """Freeze the resolved endpoint and account before launching any network process."""
1202
+ if error is not None:
1203
+ self._fail(run, str(error))
1204
+ return
1205
+ try:
1206
+ operation = run.request.operation
1207
+ run.snapshot = run.first_snapshot = snapshot
1208
+ self._check_current(run)
1209
+ if snapshot.context != operation.context or snapshot.git_filename != operation.git_filename or snapshot.generation != operation.generation:
1210
+ raise ValueError("The Fetch selection belongs to an older repository view. Open the menu again.")
1211
+ targets: list[FetchTarget] = []
1212
+ for name in sorted(snapshot.entry_by_name):
1213
+ entry = snapshot.entry_by_name[name]
1214
+ if not entry.active:
1215
+ run.trace.append_event(operation.operation_id, "status", name + ": skipped (inactive)")
1216
+ continue
1217
+ if not run.request.prune_all and name != run.request.remote_name:
1218
+ continue
1219
+ if run.request.prune_all and remote_boolean(snapshot, name, "skipdefaultupdate", aliases=("skipfetchall",)):
1220
+ run.trace.append_event(operation.operation_id, "status", name + ": skipped (last skipDefaultUpdate/skipFetchAll value is true)")
1221
+ continue
1222
+ targets.append(self._capture_target(run, name))
1223
+ run.targets = tuple(targets)
1224
+ if not targets:
1225
+ raise ValueError("No active remotes are selected for Fetch. Use Manage remotes to configure a destination or review skipDefaultUpdate/skipFetchAll.")
1226
+ refs = list(operation.scope.refs)
1227
+ for target in targets:
1228
+ refspecs = target.refspecs if not target.error else tuple(value.value for value in field_values(snapshot, target.remote_name, "fetch"))
1229
+ refs.extend(refspec_write_refs(refspecs))
1230
+ scoped = replace(operation, scope=WriteScope.capture(operation.context, tuple(sorted(set(refs)))))
1231
+ if not self.repository_operation.extend(scoped):
1232
+ if self.repository_operation.closed:
1233
+ raise ValueError(GITPANE_CLOSING_TEXT)
1234
+ raise ValueError(self.repository_operation.policy_diagnostic(scoped) or "The Fetch references are no longer available for writing.")
1235
+ run.request = replace(run.request, operation=scoped)
1236
+ run.trace.append_event(operation.operation_id, "status", "Captured remotes: " + ", ".join(target.remote_name for target in targets))
1237
+ self._next_remote(run)
1238
+ except (ValueError, OSError, RuntimeError) as failure:
1239
+ self._fail(run, str(failure), OperationStatus.CANCELLED if run.cancelled else OperationStatus.FAILED)
1240
+
1241
+ def _capture_target(self, run: TransportRun, name: str) -> FetchTarget:
1242
+ """Prepare every target now while retaining an invalid target's own later refusal."""
1243
+ snapshot = run.snapshot
1244
+ endpoint = ""
1245
+ try:
1246
+ entry = snapshot.entry_by_name[name]
1247
+ if entry.collision or len(entry.fetch_urls) != 1:
1248
+ raise ValueError("Remote " + name + " needs one unambiguous Fetch URL.")
1249
+ endpoint, = entry.fetch_urls
1250
+ if rewrite_url(endpoint, snapshot.values) != endpoint:
1251
+ raise ValueError("The Fetch URL has chained rewrite rules. Configure an unambiguous destination before fetching.")
1252
+ refspecs = fetch_branch_refspecs(snapshot, name)
1253
+ if len(refspecs) != len(field_values(snapshot, name, "fetch")):
1254
+ run.trace.append_event(run.request.operation.operation_id, "status", name + ": tag Fetch refspecs excluded; local tags are preserved.")
1255
+ kind = endpoint_kind(endpoint, self.allow_local_endpoint)
1256
+ command_endpoint, arguments, environment_by_name, selection = endpoint, [], {}, None
1257
+ if kind == HTTPS_ENDPOINT_KIND:
1258
+ # A preview with every helper disabled: the helper itself is checked once, just before the first HTTPS Fetch.
1259
+ selection = self.authentication.account_for_endpoint(endpoint, snapshot.context)
1260
+ command_endpoint, arguments = self.authentication.command_configuration(endpoint, snapshot.context, selection=selection, helper_commands=PREVIEW_HELPER_COMMANDS)
1261
+ arguments.extend(isolated_http_arguments(command_endpoint))
1262
+ elif kind == SSH_ENDPOINT_KIND:
1263
+ arguments, environment_by_name = self.ssh.command_configuration()
1264
+ return FetchTarget(name, endpoint, command_endpoint, refspecs, tuple(arguments), environment_by_name, selection, kind)
1265
+ except (ValueError, OSError, RuntimeError) as error:
1266
+ return FetchTarget(name, endpoint, error=str(error))
1267
+
1268
+ def _next_remote(self, run: TransportRun) -> None:
1269
+ """Start exactly the next frozen remote after the preceding effects have been observed.
1270
+
1271
+ A credential manager refusal ends only the HTTPS remotes of the Fetch, each on its own red line, without a second
1272
+ search; SSH and local remotes are still fetched, and the Fetch ends failed, or partial when another remote was
1273
+ fetched.
1274
+ """
1275
+ try:
1276
+ self._check_current(run)
1277
+ if run.target_index == len(run.targets):
1278
+ if run.helper_refusal:
1279
+ self._finish(run, OperationStatus.FAILED, self._cause_diagnostic(run, run.helper_refusal), run.snapshot)
1280
+ return
1281
+ self._finish(run, OperationStatus.SUCCESS, snapshot=run.snapshot)
1282
+ return
1283
+ target = run.targets[run.target_index]
1284
+ run.remote_name, run.endpoint, run.command_endpoint = target.remote_name, target.endpoint, target.command_endpoint
1285
+ run.arguments, run.environment_by_name = [], {**target.environment_by_name, **TRANSPORT_TRACE_ENVIRONMENT_BY_NAME}
1286
+ run.selection, run.kind = target.selection, target.kind
1287
+ if target.error:
1288
+ raise ValueError(target.error)
1289
+ operation = run.request.operation
1290
+ run.trace.append_event(operation.operation_id, "status", "Fetching remote " + run.remote_name)
1291
+ if target.kind == HTTPS_ENDPOINT_KIND and run.helper_refusal:
1292
+ self._refuse_remote(run, target)
1293
+ return
1294
+
1295
+ def launch(_value: object, failure: Exception | None) -> None:
1296
+ """Start the captured endpoint, after the credential manager search when this remote is the first HTTPS one."""
1297
+ try:
1298
+ if failure is not None and run.helper_refusal:
1299
+ self._refuse_remote(run, target)
1300
+ return
1301
+ if failure is not None:
1302
+ raise ValueError(str(failure))
1303
+ # The mappings already stay inside the remote's namespace; their syntax is Git's to judge, and the
1304
+ # native fetch refuses an invalid one itself before contacting the server or writing anything.
1305
+ run.arguments = self._network_arguments(run, target)
1306
+ self._start(run, "fetch", ["-c", "fetch.pruneTags=false", "fetch", "--progress", "--prune" if run.request.prune_all else "--no-prune", "--no-tags", "--no-prune-tags",
1307
+ "--no-recurse-submodules", "--refmap=", "--", run.command_endpoint, *target.refspecs])
1308
+ except (OSError, ValueError, RuntimeError) as launch_error:
1309
+ self._fail(run, str(launch_error), OperationStatus.CANCELLED if run.cancelled else OperationStatus.FAILED)
1310
+
1311
+ if target.kind == HTTPS_ENDPOINT_KIND and run.verified_helper_commands is None:
1312
+ self.remote.worktree_service.run(operation.context, operation.git_filename, self._prepare_network(run), launch, operation_id=operation.operation_id)
1313
+ return
1314
+ launch(None, None)
1315
+ except (ValueError, OSError, RuntimeError) as failure:
1316
+ self._fail(run, str(failure), OperationStatus.CANCELLED if run.cancelled else OperationStatus.FAILED)
1317
+
1318
+ def _refuse_remote(self, run: TransportRun, target: FetchTarget) -> None:
1319
+ """End one HTTPS remote of a Fetch on the credential manager refusal without stopping the other remotes.
1320
+
1321
+ An SSH or local remote never depends on the credential manager, so the Fetch goes on with the next remote. No
1322
+ process ran for the refused remote, so nothing changed for it. The refused remote's red line reads as a refusal
1323
+ before launch, as for Push; its result stays failed.
1324
+ """
1325
+ operation = run.request.operation
1326
+ diagnostic = self._cause_diagnostic(run, run.helper_refusal)
1327
+ run.trace.append_diagnostic(operation.operation_id, "error", diagnostic, OperationStatus.REFUSED)
1328
+ run.remote_results.append(FetchResult(target.remote_name, target.endpoint, OperationStatus.FAILED, "unchanged", diagnostic))
1329
+ run.trace.append_event(operation.operation_id, "status", target.remote_name + ": " + OperationStatus.FAILED.value)
1330
+ run.target_index += 1
1331
+ self._next_remote(run)
1332
+
1333
+ def _reservation_refusal(self, operation: OperationIntent) -> str:
1334
+ """Say why a transport could not reserve its repository, naming a running operation only when one really holds it.
1335
+
1336
+ A window refreshed after the request was captured, for instance, gets that reason rather than a guess between
1337
+ the two.
1338
+ """
1339
+ if self.closed:
1340
+ return TRANSPORT_CLOSED_TEXT
1341
+ return self.repository_operation.refusal_cause(operation)
1342
+
1343
+ def _check_current(self, run: TransportRun) -> None:
1344
+ """Reject queued work when authority or the captured config source changes."""
1345
+ self.repository_operation.validate(run.request.operation)
1346
+ if run.cancelled or self.closed or not self.is_current(run.request.operation):
1347
+ raise ValueError("The transport was cancelled or its repository session is no longer current.")
1348
+ if run.delete_current is not None and not run.delete_current():
1349
+ raise ValueError("The deletion form or its captured local branch is no longer current.")
1350
+ if run.snapshot is not None and config_signature(run.snapshot.context, run.snapshot.values) != run.snapshot.signature:
1351
+ raise ValueError("Remote configuration changed. Reread the destination before sending.")
1352
+ if run.snapshot is not None:
1353
+ current_state = reference_signature(run.snapshot.context)
1354
+ if run.request.tag_refs:
1355
+ # Newly created loose tags must not join or interrupt the frozen selection.
1356
+ # Existing records remain byte-for-byte checked, including packed refs.
1357
+ captured_filename_set = set()
1358
+ for filename, *_metadata in run.snapshot.reference_state:
1359
+ captured_filename_set.add(filename)
1360
+ for full_ref, _oid in run.request.tag_refs:
1361
+ captured_filename_set.add(os.path.join(run.snapshot.context.common_git_dir, *full_ref.split("/")))
1362
+ tag_dir = os.path.join(run.snapshot.context.common_git_dir, "refs", "tags") + os.sep
1363
+ retained_records = []
1364
+ for record in current_state:
1365
+ filename, *_metadata = record
1366
+ if filename in captured_filename_set or not filename.startswith(tag_dir):
1367
+ retained_records.append(record)
1368
+ current_state = tuple(retained_records)
1369
+ if current_state != run.snapshot.reference_state:
1370
+ raise ValueError("Repository references changed. Reread before sending.")
1371
+ if run.request.mode in ("push", "delete") and run.request.push_target is not None:
1372
+ target = self.capture_push_target(run.snapshot, run.request.remote_name, run.request.endpoint)
1373
+ if target != run.request.push_target:
1374
+ raise ValueError("The Push account or tool changed. Reread destination before sending.")
1375
+ target = self._network_target(run)
1376
+ if target is not None and target.kind == HTTPS_ENDPOINT_KIND:
1377
+ self._check_https_launch(run, target)
1378
+
1379
+ def _start(self, run: TransportRun, stage: str, arguments: list[str], *, retry: bool = False) -> None:
1380
+ """Attach the existing supervised SSH bridge to each separately identified process.
1381
+
1382
+ Every start, a retry included, runs the capture checks first and again just before Git starts. A ``retry`` keeps
1383
+ the count of the step's retries; any other start begins a new step.
1384
+ """
1385
+ if run.kind == HTTPS_ENDPOINT_KIND and not run.verified_helper_commands:
1386
+ raise ValueError(CREDENTIAL_MANAGER_NOT_SEARCHED_TEXT)
1387
+ check = self._check_observation if stage in OBSERVATION_STAGE_SET else self._check_current
1388
+ check(run)
1389
+ operation = run.request.operation
1390
+ run.stage = stage
1391
+ run.launch_arguments = list(arguments)
1392
+ if not retry:
1393
+ run.credential_read_retry_count = 0
1394
+ run.output.clear()
1395
+ run.process_id = uuid.uuid4().hex
1396
+
1397
+ def validate_launch() -> None:
1398
+ """Record that native deletion could start only after all capture checks pass."""
1399
+ check(run)
1400
+ if stage == "delete":
1401
+ run.delete_started = True
1402
+ request = ProcessRequest(run.process_id, operation.git_filename, [*run.arguments, *arguments], operation.context.repository_dir,
1403
+ GIT_TRANSPORT_TIMEOUT_MS, session_id=operation.session_id or operation.operation_id,
1404
+ idle_timeout_ms=TRANSPORT_IDLE_TIMEOUT_MS, environment_by_name=run.environment_by_name,
1405
+ operation=(DELETE_REMOTE_TAG_OPERATION if run.request.source_ref.startswith(TAG_PREFIX) else DELETE_REMOTE_BRANCH_OPERATION) if run.request.mode == "delete" else "Push" if run.request.mode == "push" else "Fetch",
1406
+ profile=NETWORK_PROFILE, operation_id=operation.operation_id,
1407
+ before_start=validate_launch)
1408
+ if run.kind == SSH_ENDPOINT_KIND:
1409
+ request = self.ssh.prompt(request, context=operation.context, generation=operation.generation,
1410
+ is_current=lambda: not run.cancelled and self.is_current(operation))
1411
+ self.runner.start(request)
1412
+
1413
+ def _check_observation(self, run: TransportRun) -> None:
1414
+ """Permit post-send reads after closure only while their frozen routing stays exact."""
1415
+ if run.snapshot is None or config_signature(run.snapshot.context, run.snapshot.values) != run.snapshot.signature:
1416
+ raise ValueError("Remote configuration changed after Push. The original endpoint could not be verified.")
1417
+
1418
+ def _process_finished(self, result: ProcessResult) -> None:
1419
+ """Let the trace flush its final fragments before interpreting the native result."""
1420
+ run = self.run_by_id.get(result.request.operation_id)
1421
+ if run is not None and run.process_id == result.request.request_id:
1422
+ run.process_id = ""
1423
+ QTimer.singleShot(0, self, lambda: self._advance(run, result))
1424
+
1425
+ def _advance(self, run: TransportRun, result: ProcessResult) -> None:
1426
+ """Retain the network outcome, including a failure, and reread the local references before the next remote.
1427
+
1428
+ A step that failed only because the credential manager could not read back a secret is started again first, and
1429
+ that attempt is never interpreted as the step's result.
1430
+ """
1431
+ if self._retry_after_credential_read_failure(run, result):
1432
+ return
1433
+ if run.request.mode == "push":
1434
+ self._advance_push(run, result)
1435
+ return
1436
+ if run.request.mode == "delete":
1437
+ self._advance_delete(run, result)
1438
+ return
1439
+ diagnostic = None
1440
+ if result.status != ProcessStatus.SUCCESS:
1441
+ diagnostic = GitCommandError(result).diagnostic
1442
+ run.steps.append(OperationStepResult(run.remote_name + ": " + run.stage, operation_status(result.status), process_status=result.status, diagnostic=diagnostic, return_code=result.return_code))
1443
+ operation = run.request.operation
1444
+ run.stage = "rereading"
1445
+
1446
+ def reread(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
1447
+ """Publish observed local refs even when the network command was interrupted."""
1448
+ if error is not None:
1449
+ self._fail(run, "Fetch finished, but its references could not be reread: " + str(error), OperationStatus.UNKNOWN)
1450
+ return
1451
+ if not self._compatible_reread(run.snapshot, snapshot):
1452
+ self._fail(run, "Remote configuration changed during Fetch; review the observed references.", OperationStatus.UNKNOWN, snapshot)
1453
+ return
1454
+ effect = "changed" if run.snapshot.references != snapshot.references else "unchanged"
1455
+ run.steps.append(OperationStepResult(run.remote_name + ": Reread references", OperationStatus.SUCCESS, before=run.snapshot.references, after=snapshot.references, effect=effect))
1456
+ status = OperationStatus.PARTIAL if result.status == ProcessStatus.FAILED and effect == "changed" else operation_status(result.status)
1457
+ run.remote_results.append(FetchResult(run.remote_name, run.endpoint, status, effect, diagnostic))
1458
+ run.trace.append_event(operation.operation_id, "status", run.remote_name + ": " + status.value + " (" + ("up to date" if effect == "unchanged" else "references changed") + ")")
1459
+ run.snapshot = snapshot
1460
+ run.target_index += 1
1461
+ if status != OperationStatus.SUCCESS or run.cancelled:
1462
+ self._finish(run, OperationStatus.CANCELLED if run.cancelled else status, diagnostic, snapshot)
1463
+ return
1464
+ self._next_remote(run)
1465
+
1466
+ self.remote.worktree_service.run(operation.context, operation.git_filename,
1467
+ self.remote.capture(operation.context, operation.git_filename, operation.generation), reread, operation_id=operation.operation_id)
1468
+
1469
+ def _retry_after_credential_read_failure(self, run: TransportRun, result: ProcessResult) -> bool:
1470
+ """Start the failed step again after a pause when its secret read failed and the step is safe to repeat; tell whether it will.
1471
+
1472
+ The console and the ``--log`` file say each retry; the failed attempt keeps its output there. The pause is a
1473
+ single-shot timer, so the window never waits.
1474
+ """
1475
+ if run.cancelled or self.closed or run.stage not in CREDENTIAL_READ_RETRY_STAGE_SET:
1476
+ return False
1477
+ if not credential_read_retry_due(result, run.credential_read_retry_count):
1478
+ return False
1479
+ run.credential_read_retry_count += 1
1480
+ run.retry_waiting = True
1481
+ run.trace.append_event(run.request.operation.operation_id, "status", credential_read_retry_text(run.credential_read_retry_count))
1482
+ stage, arguments = run.stage, run.launch_arguments
1483
+ QTimer.singleShot(CREDENTIAL_READ_RETRY_DELAY_MS, self, lambda: self._start_again(run, stage, arguments))
1484
+ return True
1485
+
1486
+ def _start_again(self, run: TransportRun, stage: str, arguments: list[str]) -> None:
1487
+ """Start a step again once its pause is over, or end the operation when it was cancelled or GitPane is closing meanwhile.
1488
+
1489
+ Whichever comes first, the end of the pause or the cancel, decides; the other does nothing. A cancelled step that
1490
+ only read the server after a send leaves the send's result unknown.
1491
+ """
1492
+ if not run.retry_waiting:
1493
+ return
1494
+ run.retry_waiting = False
1495
+ if run.cancelled or self.closed:
1496
+ self._fail(run, SharedText.CREDENTIAL_READ_RETRY_CANCELLED.value, OperationStatus.UNKNOWN if stage in OBSERVATION_STAGE_SET else OperationStatus.CANCELLED)
1497
+ return
1498
+ try:
1499
+ self._start(run, stage, arguments, retry=True)
1500
+ except (ValueError, OSError, RuntimeError) as failure:
1501
+ if stage in OBSERVATION_STAGE_SET:
1502
+ self._fail(run, "Outcome unknown. Remote observation failed: " + str(failure), OperationStatus.UNKNOWN)
1503
+ return
1504
+ self._fail(run, str(failure), OperationStatus.CANCELLED if run.cancelled else OperationStatus.REFUSED)
1505
+
1506
+ def _compatible_reread(self, before: RemoteSnapshot, after: RemoteSnapshot) -> bool:
1507
+ """Allow newly added remotes without appending them or accepting changed captured settings."""
1508
+ if before.signature == after.signature:
1509
+ return True
1510
+ before_identity, _before_records = before.signature
1511
+ after_identity, _after_records = after.signature
1512
+ if before_identity != after_identity:
1513
+ return False
1514
+ extra_name_set = set(after.entry_by_name) - set(before.entry_by_name)
1515
+ if not extra_name_set:
1516
+ return False
1517
+ retained_values = []
1518
+ for value in after.values:
1519
+ if value.key.startswith("remote.") and value.key.removeprefix("remote.").rsplit(".", 1)[0] in extra_name_set:
1520
+ continue
1521
+ retained_values.append(value)
1522
+ return tuple(retained_values) == before.values
1523
+
1524
+ def _fail(self, run: TransportRun, cause: str, status: OperationStatus = OperationStatus.FAILED, snapshot: RemoteSnapshot | None = None) -> None:
1525
+ """Retain a filtered refusal or incomplete result; the only automatic retry, of a failed secret read, happens before.
1526
+
1527
+ The ``--log`` file keeps the full diagnostic block; the console shows one line, ``Not started:``, ``Cancelled:`` or
1528
+ ``Failed:`` followed by the cause, according to ``status``.
1529
+ """
1530
+ operation = run.request.operation
1531
+ diagnostic = self._cause_diagnostic(run, cause)
1532
+ run.trace.append_diagnostic(operation.operation_id, "error", diagnostic, status)
1533
+ self._finish(run, status, diagnostic, snapshot)
1534
+
1535
+ def _cause_diagnostic(self, run: TransportRun, cause: str) -> PreparedDiagnostic:
1536
+ """Build the diagnostic of a transport that ended on ``cause`` without a Git process to explain it."""
1537
+ operation = run.request.operation
1538
+ return Diagnostic(transport_console_label(run.request), operation.context.repository_dir, operation.git_filename, [], cause).prepare()
1539
+
1540
+ def _refresh_required(self, run: TransportRun, status: OperationStatus, final_snapshot: RemoteSnapshot | None) -> bool:
1541
+ """Tell whether the views must read the repository again, and answer no only when nothing can have changed.
1542
+
1543
+ That takes a Fetch or Push that fully succeeded with every remote and every reference observed unchanged, no
1544
+ remote-tracking or upstream write of its own, and a final capture identical to the operation's first one: a
1545
+ server that says "up to date" can still leave a repaired tracking branch, or another tool may have changed
1546
+ the repository meanwhile. Deletion, failures, partial or unknown results always read again.
1547
+ """
1548
+ if run.request.mode not in ("fetch", "push") or status != OperationStatus.SUCCESS or final_snapshot is None or run.first_snapshot is None:
1549
+ return True
1550
+ if not run.remote_results:
1551
+ return True
1552
+ for result in run.remote_results:
1553
+ if result.status != OperationStatus.SUCCESS or result.effect != "unchanged":
1554
+ return True
1555
+ if run.request.mode == "push":
1556
+ if not run.ref_results:
1557
+ return True
1558
+ for result in run.ref_results:
1559
+ if result.status != OperationStatus.SUCCESS or result.effect != "unchanged":
1560
+ return True
1561
+ for step in run.steps:
1562
+ if step.operation in (TRACKING_UPDATE_OPERATION, TRACKING_DELETE_OPERATION):
1563
+ return True
1564
+ # A link step that never started Git, or that Git refused, wrote nothing; an unknown one may have written.
1565
+ if step.operation == SharedText.UPSTREAM_LINK_STEP.value and step.status in (OperationStatus.SUCCESS, OperationStatus.UNKNOWN):
1566
+ return True
1567
+ return not unchanged_observation(run.first_snapshot, final_snapshot)
1568
+
1569
+ def _end_line(self, run: TransportRun, status: OperationStatus, diagnostic: PreparedDiagnostic | None) -> ConsoleLine:
1570
+ """Return the console line that ends a transport, such as ``Push: done`` in green, in place of the bare status the log keeps.
1571
+
1572
+ A partial result says which part did not happen, and is red like any result that is not a success.
1573
+ """
1574
+ label = transport_console_label(run.request)
1575
+ tone = run.trace.end_tone(status, failure_reported=diagnostic is not None)
1576
+ if status != OperationStatus.PARTIAL:
1577
+ return ConsoleLine(operation_end_text(label, status), tone)
1578
+ return ConsoleLine(self._partial_end_text(run, label), tone)
1579
+
1580
+ def _partial_end_text(self, run: TransportRun, label: str) -> str:
1581
+ """Say which part of a partial transport failed: the side of a deletion, the references not pushed, the remotes not fetched.
1582
+
1583
+ A deletion done on both sides for some references and on one side only for others has no single side to name,
1584
+ so it keeps the generic partial line.
1585
+ """
1586
+ if run.request.mode == "delete":
1587
+ local_deleted = False
1588
+ for step in run.steps:
1589
+ if step.operation in LOCAL_DELETE_OPERATION_SET and step.status == OperationStatus.SUCCESS:
1590
+ local_deleted = True
1591
+ remote_deleted = unsuccessful_count(run.ref_results) < len(run.ref_results)
1592
+ if local_deleted and not remote_deleted:
1593
+ return DELETED_LOCALLY_ONLY_TEMPLATE.format(label=label)
1594
+ if remote_deleted and not local_deleted:
1595
+ return DELETED_REMOTELY_ONLY_TEMPLATE.format(label=label)
1596
+ return operation_end_text(label, OperationStatus.PARTIAL)
1597
+ if run.request.mode == "push":
1598
+ return NOT_PUSHED_REFS_TEMPLATE.format(label=label, count=unsuccessful_count(run.ref_results), total=len(run.ref_results))
1599
+ return FAILED_REMOTES_TEMPLATE.format(label=label, count=unsuccessful_count(run.remote_results), total=len(run.remote_results))
1600
+
1601
+ def _finish(self, run: TransportRun, status: OperationStatus, diagnostic: PreparedDiagnostic | None = None, snapshot: RemoteSnapshot | None = None) -> None:
1602
+ """Release the common owner only after all native processes and rereads have ended."""
1603
+ operation_id = run.request.operation.operation_id
1604
+ if operation_id in self.outcome_by_id:
1605
+ return
1606
+ server_status = status
1607
+ tag_delete = run.request.mode == "delete" and run.request.source_ref.startswith(TAG_PREFIX)
1608
+ if tag_delete:
1609
+ if not run.local_started:
1610
+ run.steps.append(OperationStepResult(DELETE_LOCAL_TAG_OPERATION, OperationStatus.NOT_STARTED, effect="preparation did not succeed", full_ref=run.request.source_ref))
1611
+ if not any(step.operation == DELETE_REMOTE_TAG_OPERATION for step in run.steps):
1612
+ run.steps.append(OperationStepResult(DELETE_REMOTE_TAG_OPERATION, OperationStatus.UNKNOWN if status == OperationStatus.UNKNOWN else OperationStatus.NOT_STARTED, effect="server deletion did not start", diagnostic=diagnostic,
1613
+ full_ref=run.request.destination_ref))
1614
+ if status != OperationStatus.SUCCESS and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in run.steps):
1615
+ status = OperationStatus.PARTIAL
1616
+ if not tag_delete and run.request.mode == "delete" and run.delete_local is not None and not run.local_started:
1617
+ run.local_started = True
1618
+ if status == OperationStatus.SUCCESS:
1619
+ run.stage = "delete-local"
1620
+
1621
+ def completed(outcome: OperationOutcome | None, error: Exception | None) -> None:
1622
+ """Retain the known remote success even when local deletion fails or is cancelled."""
1623
+ if error is not None:
1624
+ cause = Diagnostic(DELETE_LOCAL_BRANCH_OPERATION, run.request.operation.context.repository_dir, run.request.operation.git_filename, [], str(error)).prepare()
1625
+ run.steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.UNKNOWN, diagnostic=cause))
1626
+ self._finish(run, OperationStatus.PARTIAL, cause)
1627
+ return
1628
+ run.steps.extend(outcome.steps)
1629
+ self._finish(run, outcome.status, outcome.diagnostic or diagnostic)
1630
+ operation = run.request.operation
1631
+ self.remote.worktree_service.run(operation.context, operation.git_filename, run.delete_local(), completed, operation_id=operation_id)
1632
+ return
1633
+ effect = "local deletion could not start" if any(result.status == OperationStatus.SUCCESS for result in run.ref_results) else "remote deletion did not succeed"
1634
+ if run.request.delete_local_refs:
1635
+ success_ref_set = {step.full_ref for step in run.steps if step.operation == DELETE_LOCAL_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS}
1636
+ for full_ref, _oid in run.request.delete_local_refs:
1637
+ if full_ref not in success_ref_set:
1638
+ run.steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.NOT_STARTED, effect=effect, full_ref=full_ref))
1639
+ else:
1640
+ run.steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.NOT_STARTED, effect=effect))
1641
+ if not tag_delete and run.request.mode == "delete" and not run.tracking_checked:
1642
+ run.tracking_checked = True
1643
+ tracking_ref = remote_tracking_ref(run.snapshot, run.request.remote_name, run.request.destination_ref) if run.snapshot is not None else ""
1644
+ old_oid = remote_reference_oid(run.snapshot, tracking_ref) if tracking_ref else ""
1645
+ deleted = any(result.full_ref == run.request.destination_ref and result.status == OperationStatus.SUCCESS for result in run.ref_results)
1646
+ if old_oid and deleted and not self.closed:
1647
+ # Git drops the remote-tracking branch of a branch it deleted on the server; the local deletion steps ran first
1648
+ # because they check the tracking branch they belong to.
1649
+ arguments = ["update-ref", "--no-deref", "-d", tracking_ref, old_oid]
1650
+ operation = run.request.operation
1651
+ self.remote.worktree_service.run(operation.context, operation.git_filename, self._write_tracking(run, TRACKING_DELETE_OPERATION, tracking_ref, old_oid, "", arguments),
1652
+ lambda _value, _error: self._finish(run, status, diagnostic, snapshot), operation_id=operation_id)
1653
+ return
1654
+ if run.request.mode == "delete" and not run.ref_results:
1655
+ run.ref_results.append(PushResult(run.request.destination_ref, "", run.request.endpoint, server_status, run.before_oid, effect="not started" if server_status == OperationStatus.REFUSED else "unknown"))
1656
+ run.stage = "finished"
1657
+ # Every local and network continuation has ended: this run drops its answer, while the search keeps its own success for the next operation with the same Git.
1658
+ run.verified_helper_commands = None
1659
+ run.helper_override = None
1660
+ completed_name_set = {result.remote_name for result in run.remote_results}
1661
+ for target in run.targets:
1662
+ if target.remote_name in completed_name_set:
1663
+ continue
1664
+ target_status = status if target.remote_name == run.remote_name else OperationStatus.NOT_STARTED
1665
+ run.remote_results.append(FetchResult(target.remote_name, target.endpoint, target_status, diagnostic=diagnostic if target_status != OperationStatus.NOT_STARTED else None))
1666
+ run.trace.append_event(operation_id, "status", target.remote_name + ": " + target_status.value)
1667
+ if status != OperationStatus.SUCCESS and any(result.status == OperationStatus.SUCCESS for result in run.remote_results):
1668
+ status = OperationStatus.PARTIAL
1669
+ if run.request.mode == "push":
1670
+ completed_ref_set = {result.full_ref for result in run.ref_results}
1671
+ refs = tuple(sorted(run.request.tag_refs)) if run.request.tag_refs else ((run.request.destination_ref, run.request.source_oid),)
1672
+ for full_ref, oid in refs:
1673
+ if full_ref not in completed_ref_set:
1674
+ current_ref_set = set(dict(run.push_batches[run.batch_index])) if run.batch_index < len(run.push_batches) else set()
1675
+ ref_status = status if full_ref in current_ref_set or not run.push_batches else OperationStatus.NOT_STARTED
1676
+ run.ref_results.append(PushResult(full_ref, oid, run.request.endpoint, ref_status, run.before_oid_by_ref.get(full_ref)))
1677
+ completed_ref_set.add(full_ref)
1678
+ run.trace.append_event(operation_id, "status", full_ref + ": " + ref_status.value)
1679
+ status = self._push_status(run, status)
1680
+ final_snapshot = snapshot or run.snapshot
1681
+ outcome = TransportOutcome(operation_id, status, tuple(run.steps), diagnostic, run.remote_name, run.endpoint, final_snapshot, tuple(run.remote_results), tuple(run.ref_results),
1682
+ run.rejection, self._refresh_required(run, status, final_snapshot), run.selection, tuple(run.server_refusals),
1683
+ credential_manager_refusal=run.credential_manager_refusal, upstream_not_linked=run.upstream_not_linked)
1684
+ self.outcome_by_id[operation_id] = outcome
1685
+ run.trace.append_record(operation_id, "status", status.value, self._end_line(run, status, diagnostic))
1686
+ if operation_id in self.repository_operation.active_by_id:
1687
+ self.repository_operation.reconcile(outcome)
1688
+ self.finished.emit(outcome)
1689
+ if self.closed:
1690
+ run.trace.close()