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,1273 @@
1
+ """Run native clones whose result survives whatever happens to the opening of the repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ntpath
6
+ import os
7
+ import posixpath
8
+ import re
9
+ import stat
10
+ import sys
11
+ import tempfile
12
+ import unicodedata
13
+ import uuid
14
+ from dataclasses import dataclass, field, replace
15
+ from typing import TYPE_CHECKING
16
+ from urllib.parse import unquote, urlsplit
17
+
18
+ from PySide6.QtCore import QObject, QTimer, Signal
19
+
20
+ from gitpane.account.authentication import PREVIEW_HELPER_COMMANDS, RecursiveTransportContext, endpoint_origin, helper_after_refusal, isolated_http_arguments
21
+ from gitpane.account.credential_manager import CredentialManagerRefusal
22
+ from gitpane.config import (
23
+ CONTROL_CHARACTER_CATEGORY,
24
+ CREDENTIAL_READ_RETRY_DELAY_MS,
25
+ GIT_BRANCH_LIST_TIMEOUT_MS,
26
+ GIT_CLONE_TIMEOUT_MS,
27
+ GIT_TRANSPORT_TIMEOUT_MS,
28
+ LOCAL_PREFIX,
29
+ WINDOWS_PLATFORM,
30
+ ProcessPriority,
31
+ SharedText,
32
+ )
33
+ from gitpane.history.repository_value import RepositoryContext
34
+ from gitpane.operation.console_line import (
35
+ CLONE_LABEL,
36
+ DESTINATION_HEADER_LABEL,
37
+ RETRY_SUBMODULES_LABEL,
38
+ ConsoleHeader,
39
+ ConsoleLine,
40
+ operation_end_text,
41
+ )
42
+ from gitpane.operation.diagnostic import Diagnostic
43
+ from gitpane.operation.operation_status import OperationStatus, operation_status
44
+ from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
45
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult
46
+ from gitpane.process.git_output_text import git_error_text
47
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, NETWORK_PROFILE, ProcessRequest, ProcessStatus
48
+ from gitpane.sequencer.replay_state import HEAD_BASENAME
49
+ from gitpane.submodule.checkout_refusal import checkout_refusals, refused_checkout_submodule_paths, refused_submodule_dirs
50
+ from gitpane.submodule.checkout_refusal_notice import checkout_refusal_text
51
+ from gitpane.submodule.submodule_discovery import OperationCredentialHelper, SubmoduleAuthenticationTarget, SubmoduleInitializationError, SubmoduleService, initialization_signature
52
+ from gitpane.submodule.submodule_loss import GIT_ENTRY_BASENAME
53
+ from gitpane.system.path_identity import canonical_path, link_resolved_path
54
+ from gitpane.transport.credential_read_retry import credential_read_retry_due, credential_read_retry_text
55
+ from gitpane.transport.git_refusal import file_transport_refused
56
+ from gitpane.transport.refusal_notice import FILE_TRANSPORT_REFUSED_TEXT
57
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
58
+
59
+ if TYPE_CHECKING:
60
+ from collections.abc import Callable, Generator
61
+
62
+ from gitpane.account.authentication import AccountSelection, AuthenticationService, CredentialHelper
63
+ from gitpane.account.credential_manager import CredentialManagerDetection
64
+ from gitpane.account.ssh import SshService
65
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
66
+ from gitpane.operation.repository_operation import RepositoryOperationService
67
+ from gitpane.process.process_runner import ProcessResult, ProcessRunner
68
+
69
+
70
+ # Put in front of why the inherited authentication settings of a clone's parent folder could not be read.
71
+ INHERITED_INSPECTION_FAILED_TEXT = "Could not inspect inherited transport configuration. "
72
+ # Said when the parent repository was cloned but some of its submodules were not; the clone form shows it only once,
73
+ # whether it comes from the clone result or from the form itself.
74
+ SUBMODULE_PARTIAL_TEXT = "The repository was cloned, but some submodules could not be initialized. Review the details before continuing."
75
+ # Ending a bare host:path text must have, with no scheme and no user, before the clipboard text is taken as a repository URL.
76
+ GIT_URL_SUFFIX = ".git"
77
+ # What endpoint_kind answers for an address. A URL's kind is its scheme: HTTPS, SSH and file:// are the accepted ones.
78
+ HTTPS_ENDPOINT_KIND = "https"
79
+ SSH_ENDPOINT_KIND = "ssh"
80
+ LOCAL_ENDPOINT_KIND = "local"
81
+ # A file:// URL names a repository on this computer or on a network share. Only its shape is checked: Git says at run
82
+ # time whether the folder exists, and it never takes an account.
83
+ FILE_URL_ENDPOINT_KIND = "file"
84
+ FILE_URL_PREFIX = FILE_URL_ENDPOINT_KIND + "://"
85
+ # Start of the refusal endpoint_kind gives, without and with plain local folders accepted; the reason is added after it.
86
+ ENDPOINT_EXPECTED_TEXT = "Enter an HTTPS, SSH or file:// repository URL"
87
+ LOCAL_ENDPOINT_EXPECTED_TEXT = "Enter an HTTPS, SSH or file:// repository URL or an absolute local folder path"
88
+ URL_ENDPOINT_KIND_SET = frozenset({HTTPS_ENDPOINT_KIND, SSH_ENDPOINT_KIND})
89
+ # Kinds of URL the clone form takes from the clipboard at once, without the disk: every accepted URL. A plain folder path
90
+ # is never taken on its text alone: it is only taken once a background check finds a Git repository there.
91
+ CLIPBOARD_ENDPOINT_KIND_SET = frozenset({HTTPS_ENDPOINT_KIND, SSH_ENDPOINT_KIND, FILE_URL_ENDPOINT_KIND})
92
+ # Folders a bare repository, or a copied .git folder itself, holds beside its HEAD file.
93
+ BARE_OBJECTS_BASENAME = "objects"
94
+ BARE_REFS_BASENAME = "refs"
95
+ # Windows reads a path that starts with two separators, in either direction, as a network share (\\server\share) or a
96
+ # device path (\\?\, \\.\): only a drive path such as C:\repo names a disk of this PC.
97
+ WINDOWS_SEPARATOR_CHARACTERS = "\\/"
98
+ WINDOWS_NETWORK_PREFIX_LENGTH = 2
99
+ # The automounter of macOS (and many Linux setups) mounts /net/<host> from any host named in the path the moment it is
100
+ # looked up, so a path under /net reaches a server that the path itself chooses.
101
+ AUTOMOUNT_HOST_ROOT_BASENAME = "net"
102
+ # Said when a clone, or its submodule retry, is refused because the clone form that asked for it was closed or changed since.
103
+ CLONE_REQUEST_NOT_CURRENT_TEXT = "The clone request is no longer current."
104
+ DESTINATION_REMOVED_TEXT = "Removed the empty destination folder GitPane created: "
105
+ DESTINATION_KEPT_TEXT = "Kept the destination folder: "
106
+ # Written to the clone's console after a bare clone, which is never opened in a repository window.
107
+ BARE_CLONE_DONE_TEXT = "Bare repository cloned to: "
108
+ # Commits fetched per branch when the full history is not asked for: only the tip of each branch.
109
+ SHALLOW_CLONE_DEPTH = 1
110
+ BRANCH_NAME_REFUSED_TEXT = 'Enter a branch name that does not start with "-" and has no control characters.'
111
+ # The branch list of the clone form runs Git in an empty temporary folder named with this prefix, removed afterwards.
112
+ BRANCH_LIST_DIR_PREFIX = "gitpane-branches-"
113
+ BRANCH_LIST_OPERATION = "List remote branches"
114
+ # Branch lists queue apart from the clone itself, so typing a URL never fills the clone's own queue.
115
+ BRANCH_LIST_SESSION_ID = "clone-branch-list"
116
+ BRANCH_LIST_CLOSED_TEXT = "The clone service is closed."
117
+ LS_REMOTE_FIELD_SEPARATOR = "\t"
118
+
119
+
120
+ def parse_remote_branches(output: bytes) -> tuple[str, ...]:
121
+ """Read the branch names of a ``git ls-remote --heads`` answer, sorted by name regardless of case.
122
+
123
+ A remote lists its branches under the same namespace as local branches, so the local branch prefix selects them.
124
+ Tags and any other reference are skipped, a name given twice is kept once, and the order Git printed them in is
125
+ never relied upon.
126
+ """
127
+ branch_name_set: set[str] = set()
128
+ for line in output.decode("utf-8", "replace").splitlines():
129
+ _object_name, separator, reference = line.partition(LS_REMOTE_FIELD_SEPARATOR)
130
+ if not separator or not reference.startswith(LOCAL_PREFIX):
131
+ continue
132
+ branch_name = reference.removeprefix(LOCAL_PREFIX)
133
+ if branch_name:
134
+ branch_name_set.add(branch_name)
135
+ return tuple(sorted(branch_name_set, key=lambda branch_name: (branch_name.casefold(), branch_name)))
136
+
137
+
138
+ def remove_branch_list_dir(neutral_dir: str) -> None:
139
+ """Remove the empty folder a branch list ran in; Git writes nothing there, so a folder that is not empty is kept."""
140
+ if not neutral_dir:
141
+ return
142
+ try:
143
+ os.rmdir(neutral_dir)
144
+ except OSError:
145
+ pass
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class BranchList:
150
+ """Answer one branch list request: the remote branches, or the reason they could not be read (for the --log file only)."""
151
+
152
+ request_id: str
153
+ endpoint: str
154
+ branches: tuple[str, ...] = ()
155
+ cause: str = ""
156
+
157
+
158
+ @dataclass
159
+ class PendingBranchList:
160
+ """Keep what one started branch list needs until its Git process has exited."""
161
+
162
+ request_id: str
163
+ endpoint: str
164
+ neutral_dir: str
165
+ output: bytearray = field(default_factory=bytearray)
166
+ # The started request, kept so a list whose secret read failed can be started again unchanged in the same folder.
167
+ request: ProcessRequest | None = None
168
+ # How many times this list was started again after a failed secret read, and whether it waits to be started again.
169
+ credential_read_retry_count: int = 0
170
+ retry_waiting: bool = False
171
+
172
+
173
+ def suggested_name(endpoint: str) -> str:
174
+ """Suggest the final URL component without altering an explicitly edited name."""
175
+ path = urlsplit(endpoint).path if "://" in endpoint else endpoint.replace("\\", "/").rsplit(":", 1)[-1]
176
+ return unquote(path.rstrip("/").rsplit("/", 1)[-1]).removesuffix(".git")
177
+
178
+
179
+ def file_url_shape_refusal(endpoint: str) -> str:
180
+ """Answer why a ``file://`` URL is malformed, or an empty string when its shape is accepted.
181
+
182
+ Only the shape is checked, never the folder: Git says at run time whether it exists. The URL must start with exactly
183
+ ``file://`` (Git reads any other spelling as another transport), carry no user, password or port since a local
184
+ repository never takes an account, no query or fragment Git would read as part of the path, no backslash, and an
185
+ absolute path. The host is left to Git, so a Windows network share ``file://server/share`` is accepted.
186
+ """
187
+ parsed = urlsplit(endpoint)
188
+ if not endpoint.startswith(FILE_URL_PREFIX) or "\\" in endpoint:
189
+ return " starting with file:// and written with forward slashes."
190
+ if "@" in parsed.netloc or ":" in parsed.netloc or "?" in endpoint or "#" in endpoint:
191
+ return " without a user, password, port, query or fragment."
192
+ if not parsed.path.startswith("/") or parsed.path == "/":
193
+ return ". The file:// URL needs an absolute path."
194
+ return ""
195
+
196
+
197
+ def endpoint_kind(endpoint: str, allow_local: bool = False) -> str:
198
+ """Accept explicit supported transports without allowing arbitrary Git helper syntax.
199
+
200
+ HTTPS, SSH and ``file://`` URLs are accepted; a ``file://`` URL is checked on its shape only, whether or not local folders
201
+ are accepted, so every screen that reads a URL accepts it the same way. With allow_local an absolute folder path is
202
+ accepted as a plain local repository; option-looking values, control characters, relative paths and other URL schemes
203
+ stay refused either way.
204
+ """
205
+ expected = LOCAL_ENDPOINT_EXPECTED_TEXT if allow_local else ENDPOINT_EXPECTED_TEXT
206
+ if not endpoint or endpoint.startswith("-") or any(ord(character) < 32 or ord(character) == 127 for character in endpoint):
207
+ raise ValueError(expected + " without control characters.")
208
+ if urlsplit(endpoint).scheme == FILE_URL_ENDPOINT_KIND:
209
+ refusal = file_url_shape_refusal(endpoint)
210
+ if refusal:
211
+ raise ValueError(expected + refusal)
212
+ return FILE_URL_ENDPOINT_KIND
213
+ if "\\" in endpoint and not allow_local:
214
+ raise ValueError(expected + " without control characters.")
215
+ if allow_local and os.path.isabs(endpoint):
216
+ return LOCAL_ENDPOINT_KIND
217
+ if "://" in endpoint:
218
+ parsed = urlsplit(endpoint)
219
+ if parsed.scheme not in URL_ENDPOINT_KIND_SET or not parsed.hostname or parsed.password is not None or parsed.query or parsed.fragment:
220
+ raise ValueError(expected + " without a password, query or fragment.")
221
+ if not parsed.path or parsed.port == 0:
222
+ raise ValueError("The repository URL needs a path and a valid port.")
223
+ return parsed.scheme
224
+ if re.fullmatch(r"(?:[^\s/@:]+@)?(?:\[[0-9a-fA-F:]+\]|[^\s/:]+):[^:].*", endpoint) and not re.match(r"^[A-Za-z]:", endpoint):
225
+ return SSH_ENDPOINT_KIND
226
+ raise ValueError(expected + ".")
227
+
228
+
229
+ def clipboard_repository_url(text: str) -> str:
230
+ """Return the clipboard text when it is clearly the URL of a repository, otherwise an empty string.
231
+
232
+ Clipboard text comes from any application, so it is only taken when it is one word with no blank once the blanks
233
+ around it are dropped, and that word is an HTTPS, SSH or well-formed ``file://`` address without a password, with or
234
+ without a final ``.git``. Another transport or a URL with a password is never taken. A folder path is not taken
235
+ here either: ``clipboard_local_repository_candidate`` and ``looks_like_git_repository`` decide it, the second on a
236
+ background thread. The URL is only written into the form; nothing is started from it.
237
+ """
238
+ candidate = text.strip()
239
+ # A copied repository address never holds a blank, so a text that still has one (space, tab or line break) is
240
+ # ordinary text, such as "bob@example.com: thanks for the review".
241
+ if re.search(r"\s", candidate):
242
+ return ""
243
+ try:
244
+ kind = endpoint_kind(candidate)
245
+ except ValueError:
246
+ return ""
247
+ if kind not in CLIPBOARD_ENDPOINT_KIND_SET:
248
+ return ""
249
+ # A bare host:path with no scheme and no user also has the shape of short everyday text with no blank, such as a
250
+ # time ("12:30"), that nobody copied to clone, so on top of having no blank it needs a final .git to be taken.
251
+ host, _separator, _path = candidate.partition(":")
252
+ if "://" not in candidate and "@" not in host and not candidate.endswith(GIT_URL_SUFFIX):
253
+ return ""
254
+ return candidate
255
+
256
+
257
+ def is_network_path(path: str, platform: str) -> bool:
258
+ """Tell, from the text alone, whether looking up this absolute path would make the system contact another computer.
259
+
260
+ On Windows that is any path starting with two separators: a network share, where the system connects and signs in
261
+ with the user's Windows account to the server the path names, or a device path, which no copied repository path needs.
262
+ Elsewhere it is a path under /net, which the automounter mounts from the host named right after it. The letter case
263
+ of /net is ignored, since the macOS disk ignores it too. ``platform`` is a ``sys.platform`` value, so each system's
264
+ rule can be tested from any other.
265
+ """
266
+ if platform == WINDOWS_PLATFORM:
267
+ prefix = path[:WINDOWS_NETWORK_PREFIX_LENGTH]
268
+ return len(prefix) == WINDOWS_NETWORK_PREFIX_LENGTH and all(character in WINDOWS_SEPARATOR_CHARACTERS for character in prefix)
269
+ root_basename = posixpath.normpath(path).lstrip("/").split("/")[0]
270
+ return root_basename.casefold() == AUTOMOUNT_HOST_ROOT_BASENAME
271
+
272
+
273
+ def clipboard_local_repository_candidate(text: str, platform: str = sys.platform) -> str:
274
+ """Return the clipboard text when it may be the path of a local repository, otherwise an empty string; never reads the disk.
275
+
276
+ Clipboard text comes from any application, so only an absolute path on a single line is kept, once the blanks around
277
+ it are dropped, and only when it holds no control character; blanks inside are allowed, since folder names have
278
+ them. An absolute path never starts with "-", so it can never be read as an option. A network path is never kept:
279
+ the form checks the candidate on its own, without the user asking, and looking up such a path would contact (and on
280
+ Windows sign in to) a server chosen by whoever wrote the copied text. Whether a repository is really there is for
281
+ ``looks_like_git_repository`` to say, on a background thread. ``platform`` is a ``sys.platform`` value, so each
282
+ system's rule of absolute and network paths can be tested from any other.
283
+ """
284
+ candidate = text.strip()
285
+ if not candidate or len(candidate.splitlines()) != 1:
286
+ return ""
287
+ for character in candidate:
288
+ if unicodedata.category(character) == CONTROL_CHARACTER_CATEGORY:
289
+ return ""
290
+ is_absolute = ntpath.isabs if platform == WINDOWS_PLATFORM else posixpath.isabs
291
+ if not is_absolute(candidate) or is_network_path(candidate, platform):
292
+ return ""
293
+ return candidate
294
+
295
+
296
+ def looks_like_git_repository(directory: str) -> bool:
297
+ """Tell, from the disk alone and without starting Git, whether the folder is a Git repository. May block on a slow disk.
298
+
299
+ A folder with a .git entry is a working tree, a submodule or a linked worktree; a folder with a HEAD file and objects
300
+ and refs folders is a bare repository, or a copied .git folder. A folder inside a repository is none of these, nor is
301
+ a path that is absent or cannot be read: those all answer False, never an error, so the text is simply not taken.
302
+ """
303
+ try:
304
+ if os.path.lexists(os.path.join(directory, GIT_ENTRY_BASENAME)):
305
+ return True
306
+ return (os.path.isfile(os.path.join(directory, HEAD_BASENAME)) and os.path.isdir(os.path.join(directory, BARE_OBJECTS_BASENAME))
307
+ and os.path.isdir(os.path.join(directory, BARE_REFS_BASENAME)))
308
+ except (OSError, ValueError):
309
+ return False
310
+
311
+
312
+ def directory_identity(directory: str) -> tuple[int, int]:
313
+ """Identify a real directory while rejecting links and Windows junctions."""
314
+ metadata = os.lstat(directory)
315
+ if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", 0):
316
+ raise ValueError("Choose a real directory, not a link or junction: " + directory)
317
+ return metadata.st_dev, metadata.st_ino
318
+
319
+
320
+ def validate_destination(parent_dir: str, name: str) -> None:
321
+ """Reject ambiguous portable filenames before creating any destination on disk."""
322
+ if not parent_dir or not os.path.isabs(parent_dir):
323
+ raise ValueError("Choose an existing absolute parent directory.")
324
+ if (not name or name in {".", ".."} or name.endswith((" ", ".")) or any(character in '<>:"/\\|?*' or ord(character) < 32 for character in name)
325
+ or re.fullmatch(r"(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?", name, re.IGNORECASE)):
326
+ raise ValueError("Enter a repository name without separators, traversal or reserved filename characters.")
327
+ directory_identity(parent_dir)
328
+ # Links are found by resolving them without the disk spelling: on macOS a parent typed with another letter case
329
+ # is the same folder, not a link.
330
+ if link_resolved_path(parent_dir) != os.path.normcase(os.path.abspath(parent_dir)):
331
+ raise ValueError("Choose a parent directory without links or junctions.")
332
+ if os.path.lexists(os.path.join(parent_dir, name)):
333
+ raise ValueError("Destination already exists. Choose another repository name or parent directory.")
334
+
335
+
336
+ def validate_branch_name(branch: str) -> None:
337
+ """Refuse a branch name Git could read as an option or that hides a control character.
338
+
339
+ An empty name means the remote HEAD. A name that does not exist on the server is left to Git, which refuses it
340
+ with its own message.
341
+ """
342
+ if branch.startswith("-") or any(ord(character) < 32 or ord(character) == 127 for character in branch):
343
+ raise ValueError(BRANCH_NAME_REFUSED_TEXT)
344
+
345
+
346
+ @dataclass(frozen=True)
347
+ class CloneOptions:
348
+ """What the clone form asks Git to do besides copying the repository."""
349
+
350
+ # An empty branch clones the branch the server's HEAD points to.
351
+ branch: str = ""
352
+ initialize_submodules: bool = True
353
+ full_history: bool = True
354
+ bare: bool = False
355
+
356
+
357
+ def clone_arguments(options: CloneOptions, endpoint: str, destination_dir: str, *, local: bool = False) -> list[str]:
358
+ """Build the clone command for the chosen options, in the order Git Extensions uses.
359
+
360
+ A short history keeps every remote branch visible (``--no-single-branch``) so the user can still switch to another
361
+ branch afterwards. Git ignores ``--depth`` when it copies a local folder directly, so a short history of a ``local``
362
+ folder also asks for ``--no-local``, which makes Git fetch through its normal transport and honour the depth. A bare
363
+ repository has no working tree, so its submodules are never initialized. The URL and the destination always come
364
+ after ``--`` and the branch travels as one ``--branch=<name>`` argument, so no option is ever read from user text.
365
+ """
366
+ arguments = ["clone"]
367
+ if options.bare:
368
+ arguments.append("--bare")
369
+ if options.initialize_submodules and not options.bare:
370
+ arguments.append("--recurse-submodules")
371
+ if not options.full_history:
372
+ arguments.extend(["--depth", str(SHALLOW_CLONE_DEPTH), "--no-single-branch"])
373
+ if local:
374
+ arguments.append("--no-local")
375
+ arguments.append("--progress")
376
+ if options.branch:
377
+ arguments.append("--branch=" + options.branch)
378
+ arguments.extend(["--", endpoint, destination_dir])
379
+ return arguments
380
+
381
+
382
+ def clone_context(destination_dir: str, bare: bool) -> RepositoryContext:
383
+ """Describe the repository a clone writes: a bare clone is its own Git folder, any other clone keeps it in ``.git``."""
384
+ if bare:
385
+ return RepositoryContext(None, destination_dir, destination_dir, True)
386
+ git_dir = os.path.join(destination_dir, ".git")
387
+ return RepositoryContext(destination_dir, git_dir, git_dir, False)
388
+
389
+
390
+ def repository_written(destination_dir: str, bare: bool) -> bool:
391
+ """Tell whether Git already wrote a repository into the clone folder, so a failure keeps it instead of reading it as missing."""
392
+ if bare:
393
+ return os.path.isfile(os.path.join(destination_dir, "HEAD"))
394
+ return os.path.isdir(os.path.join(destination_dir, ".git"))
395
+
396
+
397
+ @dataclass(frozen=True)
398
+ class CloneIntent:
399
+ """Capture a welcome request and the exact destination the user approved."""
400
+
401
+ endpoint: str
402
+ parent_dir: str
403
+ name: str
404
+ session_id: str
405
+ generation: int
406
+ selection: AccountSelection | None = None
407
+ options: CloneOptions = field(default_factory=CloneOptions)
408
+
409
+
410
+ @dataclass(frozen=True)
411
+ class OwnedDestination:
412
+ """Remember the exclusively created directory and its original resolved parent."""
413
+
414
+ destination_dir: str
415
+ parent_dir: str
416
+ resolved_parent_dir: str
417
+ identity: tuple[int, int]
418
+ parent_identity: tuple[int, int]
419
+
420
+ @classmethod
421
+ def create(cls, parent_dir: str, name: str) -> OwnedDestination:
422
+ """Claim an absent destination without adopting any pre-existing directory."""
423
+ validate_destination(parent_dir, name)
424
+ parent_dir = os.path.abspath(parent_dir)
425
+ parent_identity = directory_identity(parent_dir)
426
+ resolved_parent_dir = canonical_path(parent_dir)
427
+ destination_dir = os.path.join(parent_dir, name)
428
+ os.mkdir(destination_dir)
429
+ return cls(destination_dir, parent_dir, resolved_parent_dir, directory_identity(destination_dir), parent_identity)
430
+
431
+ def verify(self) -> None:
432
+ """Refuse another path identity before any later command or opening."""
433
+ if (directory_identity(self.parent_dir) != self.parent_identity or canonical_path(self.parent_dir) != self.resolved_parent_dir
434
+ or directory_identity(self.destination_dir) != self.identity):
435
+ raise ValueError("The clone destination or its parent changed. Inspect the directory: " + self.destination_dir)
436
+
437
+ def remove_if_empty(self) -> bool:
438
+ """Remove the folder this clone created when it is still that same folder and holds nothing; return whether it was removed.
439
+
440
+ A clone that failed or was cancelled before Git wrote anything would otherwise leave an empty folder behind, and
441
+ the same name would then be refused as an existing destination. The folder and its parent must still be the
442
+ ones recorded at creation, so a replaced folder, a link or a junction is never removed. Only ``os.rmdir`` is
443
+ used: it refuses a folder that is not empty by itself, so a file that appears between the emptiness check and
444
+ the removal is kept. Nothing is ever removed recursively, and any doubt keeps the folder.
445
+ """
446
+ try:
447
+ self.verify()
448
+ with os.scandir(self.destination_dir) as entries:
449
+ if next(entries, None) is not None:
450
+ return False
451
+ os.rmdir(self.destination_dir)
452
+ except (OSError, ValueError):
453
+ return False
454
+ return True
455
+
456
+
457
+ def kept_destination_reason(destination: OwnedDestination) -> str:
458
+ """Say in plain words why a clone's own folder was kept after a failure, for the clone console."""
459
+ try:
460
+ destination.verify()
461
+ with os.scandir(destination.destination_dir) as entries:
462
+ if next(entries, None) is not None:
463
+ return "it is not empty"
464
+ except ValueError:
465
+ return "it changed since GitPane created it"
466
+ except OSError as error:
467
+ return "it could not be read: " + (error.strerror or str(error))
468
+ return "it could not be removed"
469
+
470
+
471
+ @dataclass
472
+ class CloneRun:
473
+ """Keep the process result separate from the subsequent window publication."""
474
+
475
+ operation_id: str
476
+ intent: CloneIntent
477
+ trace: OperationTrace
478
+ destination: OwnedDestination | None = None
479
+ clone_status: str = "not_started"
480
+ open_status: str = "not_started"
481
+ diagnostic: PreparedDiagnostic | None = None
482
+ cancelled: bool = False
483
+ validation_status: str = "not_started"
484
+ selection_saved: bool = False
485
+ origin_restored: bool = False
486
+ incomplete_repository: bool = False
487
+ recursive_status: str = "not_started"
488
+ retry_operation_id: str = ""
489
+ authentication_target: SubmoduleAuthenticationTarget | None = None
490
+ child_selection: AccountSelection | None = None
491
+ # The account resolved for an HTTPS clone (None without one), kept so a failure can name it; never a secret.
492
+ selection: AccountSelection | None = None
493
+ # True once the empty folder this clone created was removed after a failure or a cancellation.
494
+ destination_removed: bool = False
495
+ # Why the credential manager was refused when that refusal stopped this clone or its last Retry submodules, so the
496
+ # window explaining how to install it can open; None otherwise.
497
+ credential_manager_refusal: CredentialManagerDetection | None = None
498
+ # The sentence that explains a submodule whose checkout Git refused, set when the clone ends and emptied when a failed
499
+ # Retry submodules brings its own cause; empty otherwise. The clone form then shows it first instead of the generic
500
+ # partial-clone sentence.
501
+ checkout_refusal_text: str = ""
502
+ # The folders of the submodules whose checkout Git refused, during the clone or a later Retry submodules: the
503
+ # submodules Git cloned after them without checking them out are checked out, these are left as Git left them.
504
+ refused_submodule_dir_set: set[str] = field(default_factory=set)
505
+ # The step that checks out those submodules right after a clone Git stopped on a refused checkout: NOT_STARTED until
506
+ # it begins, PENDING while it runs, then the status it ended with.
507
+ cloned_checkout_status: OperationStatus = OperationStatus.NOT_STARTED
508
+ # The clone's own request with its launch checks, before the SSH prompt owner is attached, so the clone can be started
509
+ # again unchanged after a failed secret read; None until it is first started.
510
+ native_request: ProcessRequest | None = None
511
+ # How many times the clone was started again after a failed secret read, and the failed result it waits on meanwhile.
512
+ credential_read_retry_count: int = 0
513
+ retry_result: ProcessResult | None = None
514
+
515
+ def has_running_step(self) -> bool:
516
+ """Tell whether a step of the clone still runs: the clone, its submodules, its validation or the checkout of the submodules cloned without files."""
517
+ return self.cloned_checkout_status == OperationStatus.PENDING or "running" in (self.clone_status, self.recursive_status, self.validation_status)
518
+
519
+
520
+ class CloneService(QObject):
521
+ """Reserve native clone work until actual exit and preserve completed directories."""
522
+
523
+ finished = Signal(object)
524
+
525
+ def __init__(self, runner: ProcessRunner, repository_operation: RepositoryOperationService, authentication: AuthenticationService,
526
+ ssh: SshService, parent: QObject | None = None, *, is_current: Callable[[CloneIntent], bool], log: DiagnosticLog | None = None) -> None:
527
+ """Inject process, authentication and authority owners without tying them to a dialog."""
528
+ super().__init__(parent)
529
+ self.runner = runner
530
+ self.repository_operation = repository_operation
531
+ self.authentication = authentication
532
+ self.ssh = ssh
533
+ self.is_current = is_current
534
+ self.log = log
535
+ # The clone form accepts a local folder, typed or chosen with Browse; a test can still turn this off to check the refusal.
536
+ self.allow_local_endpoint = True
537
+ self.closed = False
538
+ self.run_by_id: dict[str, CloneRun] = {}
539
+ # Branch lists whose Git process has not exited yet, and the callbacks still waiting for a branch list result.
540
+ self.branch_list_by_id: dict[str, PendingBranchList] = {}
541
+ self.branch_complete_by_id: dict[str, Callable[[BranchList], None]] = {}
542
+ self.submodule = SubmoduleService(repository_operation.worktree_service, repository_operation, authentication, ssh, log)
543
+ runner.stdout_chunk.connect(self._branch_list_output)
544
+ runner.finished.connect(self._finished)
545
+
546
+ def submit(self, intent: CloneIntent, git_filename: str) -> CloneRun:
547
+ """Start one captured clone or retain a safe explicit refusal for its form.
548
+
549
+ A refused reservation of the destination takes the repository owner's reason, which names another operation only
550
+ when a write really holds the destination folder.
551
+ """
552
+ for previous in self.run_by_id.values():
553
+ if previous.intent.session_id == intent.session_id and previous.clone_status == "running":
554
+ return previous
555
+ operation_id = uuid.uuid4().hex
556
+ # The destination does not exist yet, so the console names it rather than a repository; it is known before any check.
557
+ header = ConsoleHeader(DESTINATION_HEADER_LABEL, os.path.join(intent.parent_dir, intent.name))
558
+ run = CloneRun(operation_id, intent, OperationTrace(operation_id, self.runner, self.log, header=header))
559
+ self.run_by_id[operation_id] = run
560
+ try:
561
+ if self.closed or not self.is_current(intent):
562
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
563
+ kind = endpoint_kind(intent.endpoint, self.allow_local_endpoint)
564
+ validate_destination(intent.parent_dir, intent.name)
565
+ validate_branch_name(intent.options.branch)
566
+ endpoint, arguments, environment_by_name = intent.endpoint, [], {}
567
+ destinations = self.authentication.recursive_context()
568
+ if kind == HTTPS_ENDPOINT_KIND:
569
+ selection = intent.selection or self.authentication.account_for_endpoint(endpoint)
570
+ run.selection = selection
571
+ destinations = self.authentication.recursive_context(endpoint, selection)
572
+ if selection is not None:
573
+ endpoint, _selected_arguments = self.authentication.command_configuration(endpoint, selection=selection, helper_commands=PREVIEW_HELPER_COMMANDS)
574
+ if kind == SSH_ENDPOINT_KIND or self.ssh.configuration().ssh_filename:
575
+ arguments, environment_by_name = self.ssh.command_configuration()
576
+ destination_dir = os.path.join(intent.parent_dir, intent.name)
577
+ context = clone_context(destination_dir, intent.options.bare)
578
+ owner = OperationIntent.capture(operation_id, context, git_filename, operation="Clone repository", generation=intent.generation,
579
+ reserved_repository_dirs=(canonical_path(destination_dir),), action=OperationAction.SUBMODULE_INIT)
580
+ if not self.repository_operation.reserve(owner):
581
+ raise ValueError(self.repository_operation.refusal_cause(owner))
582
+ run.destination = OwnedDestination.create(intent.parent_dir, intent.name)
583
+ destination = run.destination
584
+ destination.verify()
585
+ def before_start() -> None:
586
+ """Recheck the final destination after any queue delay and before native launch."""
587
+ self.repository_operation.validate(owner)
588
+ destination.verify()
589
+ if run.cancelled or not self.is_current(intent):
590
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
591
+ self.authentication.recursive_configuration(destinations, helper_commands=PREVIEW_HELPER_COMMANDS)
592
+
593
+ # No protocol.file.allow is added: whatever URL the parent came from, Git's own setting decides whether a
594
+ # submodule whose URL is a local folder or a file:// URL may be cloned, and a refusal is explained at the end.
595
+ request = ProcessRequest(operation_id, git_filename, [*arguments,
596
+ *clone_arguments(intent.options, endpoint, destination.destination_dir, local=kind == LOCAL_ENDPOINT_KIND)],
597
+ destination.parent_dir,
598
+ GIT_CLONE_TIMEOUT_MS, session_id="clone:" + intent.session_id, idle_timeout_ms=GIT_TRANSPORT_TIMEOUT_MS,
599
+ environment_by_name=environment_by_name, operation="Clone repository", profile=NETWORK_PROFILE, operation_id=operation_id, before_start=before_start)
600
+ run.clone_status = "running"
601
+ run.recursive_status = "running"
602
+
603
+ def prepare_transport() -> Generator:
604
+ """Find the credential manager once for the whole recursive clone, then inspect inherited authentication.
605
+
606
+ Runs in the background: the search may start short processes. Every submodule's Git then starts the same
607
+ helper, by the same absolute path. Without any account to compose, nothing is searched; a refusal stops
608
+ only a clone whose own URL uses an account (see helper_after_refusal).
609
+ """
610
+ helper_commands = PREVIEW_HELPER_COMMANDS
611
+ if any(recursive_destination.selection is not None for recursive_destination in destinations):
612
+ try:
613
+ helper = yield FileTask(lambda: self.authentication.credential_helper(git_filename))
614
+ helper_commands = helper.commands
615
+ except ValueError as refusal:
616
+ helper_commands = helper_after_refusal(refusal, operation_uses_account=run.selection is not None, log=self.log)
617
+ try:
618
+ configuration = yield from self.authentication.recursive_inherited_keys((destination.parent_dir,))
619
+ except (OSError, ValueError, RuntimeError) as failure:
620
+ raise ValueError(INHERITED_INSPECTION_FAILED_TEXT + str(failure)) from failure
621
+ return helper_commands, configuration
622
+
623
+ def prepared(value: object, error: Exception | None) -> None:
624
+ """Launch only after the credential manager was found and inherited authentication names were read safely.
625
+
626
+ A refused credential manager reaches here as its own sentence, which becomes the cause of the clone's red line.
627
+ """
628
+ try:
629
+ if error is not None:
630
+ if isinstance(error, CredentialManagerRefusal) and not run.cancelled:
631
+ run.credential_manager_refusal = error.detection
632
+ raise ValueError(str(error))
633
+ helper_commands, configuration = value
634
+ before_start()
635
+ recursive_arguments = self.authentication.recursive_configuration(destinations, configuration, helper_commands=helper_commands)
636
+
637
+ def verified_start() -> None:
638
+ """Bind queued launch to the inspected conditional authentication files and to the helper found for this clone."""
639
+ before_start()
640
+ self.authentication.recursive_configuration(destinations, configuration, helper_commands=helper_commands)
641
+
642
+ native_request = replace(request, arguments=[*recursive_arguments, *request.arguments], before_start=verified_start)
643
+ run.native_request = native_request
644
+ native_request = self.ssh.prompt(native_request, is_current=lambda: not run.cancelled and self.is_current(intent))
645
+ self.runner.start(native_request)
646
+ except (OSError, ValueError, RuntimeError) as failure:
647
+ run.clone_status = run.recursive_status = OperationStatus.CANCELLED.value if run.cancelled else OperationStatus.FAILED.value
648
+ run.diagnostic = Diagnostic("Clone repository", destination.destination_dir, git_filename, [], str(failure)).prepare()
649
+ # Git's clone never ran: the console says it did not start, or that the user cancelled it.
650
+ run.trace.append_diagnostic(operation_id, "error", run.diagnostic, OperationStatus.CANCELLED if run.cancelled else OperationStatus.REFUSED)
651
+ self._release_empty_destination(run)
652
+ self._complete(run, (OperationStepResult("Prepare recursive transport", OperationStatus(run.recursive_status), diagnostic=run.diagnostic),))
653
+
654
+ self.repository_operation.worktree_service.run(context, git_filename, prepare_transport(), prepared, operation_id=operation_id)
655
+ except (OSError, ValueError, RuntimeError) as error:
656
+ run.diagnostic = Diagnostic("Clone repository", intent.parent_dir, git_filename, [], str(error)).prepare()
657
+ run.clone_status = run.recursive_status = OperationStatus.CANCELLED.value if run.cancelled else OperationStatus.FAILED.value
658
+ # A refusal before launch is its single red line: no end line repeats it, since nothing was attempted.
659
+ run.trace.append_diagnostic(operation_id, "error", run.diagnostic, OperationStatus.CANCELLED if run.cancelled else OperationStatus.REFUSED)
660
+ self._release_empty_destination(run)
661
+ self.repository_operation.release(operation_id)
662
+ QTimer.singleShot(0, self, lambda: self.finished.emit(run))
663
+ return run
664
+
665
+ def accepts_endpoint(self, endpoint: str) -> bool:
666
+ """Tell whether a clone would accept this repository address, so an unfinished URL starts no branch list."""
667
+ try:
668
+ endpoint_kind(endpoint, self.allow_local_endpoint)
669
+ except ValueError:
670
+ return False
671
+ return True
672
+
673
+ def list_remote_branches(self, endpoint: str, git_filename: str, selection: AccountSelection | None, complete: Callable[[BranchList], None]) -> str:
674
+ """Read a remote repository's branches in the background with the account a clone would use, and return the request's id.
675
+
676
+ The list is read while the user types, so it must never ask anything: inherited credential helpers are cleared,
677
+ the credential helper may not prompt, no askpass program is given and Git runs from an empty temporary folder
678
+ where it may not look for a repository above, so no repository's settings apply. With an account, the
679
+ credential manager is first found away from the window's thread; its refusal is the list's failure. Without
680
+ one, nothing is searched. ``complete`` is called once, later, unless ``cancel_branch_list`` forgot the request first.
681
+ """
682
+ request_id = uuid.uuid4().hex
683
+ self.branch_complete_by_id[request_id] = complete
684
+ try:
685
+ if self.closed:
686
+ raise ValueError(BRANCH_LIST_CLOSED_TEXT)
687
+ selection = self._branch_list_selection(endpoint, selection)
688
+ except (OSError, ValueError, RuntimeError) as error:
689
+ self._publish_branch_list_later(BranchList(request_id, endpoint, cause=self._branch_list_cause(git_filename, [], str(error))))
690
+ return request_id
691
+ if selection is None:
692
+ self._start_branch_list(request_id, endpoint, git_filename, None, PREVIEW_HELPER_COMMANDS)
693
+ return request_id
694
+
695
+ def helper_found(helper: CredentialHelper | None, error: Exception | None) -> None:
696
+ """Read the branches with the credential manager found, unless the request was cancelled or the service closed meanwhile."""
697
+ if self.closed or request_id not in self.branch_complete_by_id:
698
+ return
699
+ if error is not None:
700
+ self._publish_branch_list_later(BranchList(request_id, endpoint, cause=self._branch_list_cause(git_filename, [], str(error))))
701
+ return
702
+ self._start_branch_list(request_id, endpoint, git_filename, selection, helper.commands)
703
+
704
+ self.authentication.resolve_credential_helper(git_filename, helper_found)
705
+ return request_id
706
+
707
+ def _branch_list_selection(self, endpoint: str, selection: AccountSelection | None) -> AccountSelection | None:
708
+ """Return the account a branch list of this address reads with: the chosen one, else the address's default, never one for a non-HTTPS address."""
709
+ if endpoint_kind(endpoint, self.allow_local_endpoint) != HTTPS_ENDPOINT_KIND:
710
+ return None
711
+ return selection or self.authentication.account_for_endpoint(endpoint)
712
+
713
+ def _start_branch_list(self, request_id: str, endpoint: str, git_filename: str, selection: AccountSelection | None, helper_commands: tuple[str, ...]) -> None:
714
+ """Start ``git ls-remote`` in an empty temporary folder, or publish why it could not start."""
715
+ neutral_dir = ""
716
+ arguments: list[str] = []
717
+ try:
718
+ arguments, environment_by_name = self._branch_list_command(endpoint, selection, helper_commands)
719
+ neutral_dir = tempfile.mkdtemp(prefix=BRANCH_LIST_DIR_PREFIX)
720
+ environment_by_name["GIT_CEILING_DIRECTORIES"] = os.path.dirname(neutral_dir)
721
+ request = ProcessRequest(request_id, git_filename, arguments, neutral_dir, GIT_BRANCH_LIST_TIMEOUT_MS, session_id=BRANCH_LIST_SESSION_ID,
722
+ idle_timeout_ms=GIT_BRANCH_LIST_TIMEOUT_MS, environment_by_name=environment_by_name, operation=BRANCH_LIST_OPERATION,
723
+ profile=NETWORK_PROFILE, operation_id=request_id, priority=ProcessPriority.BACKGROUND)
724
+ self.branch_list_by_id[request_id] = PendingBranchList(request_id, endpoint, neutral_dir, request=request)
725
+ self.runner.start(request)
726
+ except (OSError, ValueError, RuntimeError) as error:
727
+ self.branch_list_by_id.pop(request_id, None)
728
+ remove_branch_list_dir(neutral_dir)
729
+ cause = self._branch_list_cause(git_filename, arguments, str(error))
730
+ self._publish_branch_list_later(BranchList(request_id, endpoint, cause=cause))
731
+
732
+ def cancel_branch_list(self, request_id: str) -> None:
733
+ """Forget a branch list's callback and stop its Git process, whether it still waits in the queue, already runs or waits to be started again."""
734
+ self.branch_complete_by_id.pop(request_id, None)
735
+ pending = self.branch_list_by_id.get(request_id)
736
+ if pending is not None and pending.retry_waiting:
737
+ del self.branch_list_by_id[request_id]
738
+ remove_branch_list_dir(pending.neutral_dir)
739
+ return
740
+ if pending is not None:
741
+ self.runner.cancel(request_id)
742
+
743
+ def _branch_list_command(self, endpoint: str, selection: AccountSelection | None, helper_commands: tuple[str, ...]) -> tuple[list[str], dict[str, str]]:
744
+ """Build the ``git ls-remote --heads`` command and its environment with the same account and SSH client a clone of this address would use.
745
+
746
+ ``selection`` is the account already chosen for this address, if any, and ``helper_commands`` the credential
747
+ manager already found for it; nothing is searched here.
748
+ """
749
+ kind = endpoint_kind(endpoint, self.allow_local_endpoint)
750
+ arguments = self.authentication.recursive_configuration((), helper_commands=PREVIEW_HELPER_COMMANDS)
751
+ arguments.extend(["-c", "core.askPass="])
752
+ environment_by_name: dict[str, str] = {}
753
+ command_url = endpoint
754
+ if kind == HTTPS_ENDPOINT_KIND and selection is not None:
755
+ command_url, account_arguments = self.authentication.command_configuration(endpoint, selection=selection, helper_commands=helper_commands)
756
+ arguments.extend(account_arguments)
757
+ arguments.extend(isolated_http_arguments(command_url))
758
+ # Batch mode and no question helper: an unknown host key or a passphrase makes the list fail instead of asking.
759
+ # Without any SSH client the configuration is refused here and Git is never started.
760
+ if kind == SSH_ENDPOINT_KIND or self.ssh.configuration().ssh_filename:
761
+ ssh_arguments, ssh_environment_by_name = self.ssh.command_configuration(batch_mode=True)
762
+ arguments.extend(ssh_arguments)
763
+ environment_by_name.update(ssh_environment_by_name)
764
+ # "--heads" rather than "--branches": older Git versions only know the first. Never its one-letter form, which asks Git for help.
765
+ arguments.extend(["ls-remote", "--heads", "--", command_url])
766
+ return arguments, environment_by_name
767
+
768
+ def _branch_list_cause(self, git_filename: str, arguments: list[str], cause: str, stderr: bytes = b"", return_code: int | None = None) -> str:
769
+ """Prepare why a branch list failed without any secret, and write it to the --log file; the form only says the branches could not be read."""
770
+ diagnostic = Diagnostic(BRANCH_LIST_OPERATION, "", git_filename, arguments, cause, stderr, return_code).prepare()
771
+ if self.log is not None:
772
+ self.log.write(diagnostic)
773
+ return diagnostic.cause
774
+
775
+ def _branch_list_output(self, request: ProcessRequest, data: bytes) -> None:
776
+ """Collect the standard output of branch lists only; every other command's output is left to its own owner."""
777
+ pending = self.branch_list_by_id.get(request.request_id)
778
+ if pending is not None:
779
+ pending.output.extend(data)
780
+
781
+ def _branch_list_finished(self, pending: PendingBranchList, result: ProcessResult) -> None:
782
+ """Remove the branch list's empty folder once Git has exited, then publish the branches or the failure.
783
+
784
+ A list whose secret read failed is started again a moment later in the same folder instead, while it is still
785
+ wanted; the failed attempt is written to the --log file with the retry line as its cause.
786
+ """
787
+ if (not self.closed and pending.request is not None and pending.request_id in self.branch_complete_by_id
788
+ and credential_read_retry_due(result, pending.credential_read_retry_count)):
789
+ pending.credential_read_retry_count += 1
790
+ pending.retry_waiting = True
791
+ pending.output.clear()
792
+ self.branch_list_by_id[pending.request_id] = pending
793
+ request = result.request
794
+ self._branch_list_cause(request.program_filename, request.arguments, credential_read_retry_text(pending.credential_read_retry_count), result.stderr, result.return_code)
795
+ QTimer.singleShot(CREDENTIAL_READ_RETRY_DELAY_MS, self, lambda: self._list_branches_again(pending))
796
+ return
797
+ remove_branch_list_dir(pending.neutral_dir)
798
+ if result.status == ProcessStatus.SUCCESS:
799
+ branch_list = BranchList(pending.request_id, pending.endpoint, parse_remote_branches(bytes(pending.output)))
800
+ else:
801
+ request = result.request
802
+ cause = self._branch_list_cause(request.program_filename, request.arguments, result.cause or result.status.value, result.stderr, result.return_code)
803
+ branch_list = BranchList(pending.request_id, pending.endpoint, cause=cause or result.status.value)
804
+ self._publish_branch_list_later(branch_list)
805
+
806
+ def _list_branches_again(self, pending: PendingBranchList) -> None:
807
+ """Start a branch list again once its pause is over, unless it was cancelled or the service closed meanwhile."""
808
+ if self.branch_list_by_id.get(pending.request_id) is not pending or not pending.retry_waiting:
809
+ return
810
+ pending.retry_waiting = False
811
+ if self.closed or pending.request_id not in self.branch_complete_by_id:
812
+ del self.branch_list_by_id[pending.request_id]
813
+ remove_branch_list_dir(pending.neutral_dir)
814
+ return
815
+ try:
816
+ self.runner.start(pending.request)
817
+ except (OSError, ValueError, RuntimeError) as error:
818
+ self.branch_list_by_id.pop(pending.request_id, None)
819
+ remove_branch_list_dir(pending.neutral_dir)
820
+ request = pending.request
821
+ self._publish_branch_list_later(BranchList(pending.request_id, pending.endpoint, cause=self._branch_list_cause(request.program_filename, request.arguments, str(error))))
822
+
823
+ def _publish_branch_list_later(self, branch_list: BranchList) -> None:
824
+ """Hand a branch list to its callback on a later turn, so the caller always knows the request's id before the answer arrives."""
825
+ QTimer.singleShot(0, self, lambda: self._publish_branch_list(branch_list))
826
+
827
+ def _publish_branch_list(self, branch_list: BranchList) -> None:
828
+ """Call the branch list's callback unless the request was cancelled meanwhile."""
829
+ complete = self.branch_complete_by_id.pop(branch_list.request_id, None)
830
+ if complete is not None:
831
+ complete(branch_list)
832
+
833
+ def close(self) -> None:
834
+ """Cancel all clone work and every branch list while retaining traces until the native owner drains."""
835
+ self.closed = True
836
+ for request_id in (*self.branch_complete_by_id, *self.branch_list_by_id):
837
+ self.cancel_branch_list(request_id)
838
+ for run in tuple(self.run_by_id.values()):
839
+ self.cancel(run.operation_id)
840
+ if not run.has_running_step():
841
+ run.trace.close()
842
+
843
+ def cancel(self, operation_id: str) -> None:
844
+ """Invalidate later publication while supervised Git drains its descendants."""
845
+ run = self.run_by_id.get(operation_id)
846
+ if run is None:
847
+ return
848
+ run.cancelled = True
849
+ if not run.has_running_step():
850
+ return
851
+ if run.retry_result is not None:
852
+ QTimer.singleShot(0, self, lambda: self._clone_again(run))
853
+ return
854
+ owner_id = run.retry_operation_id or operation_id
855
+ requests = list(self.runner.pending_requests)
856
+ for active in self.runner.active_by_id.values():
857
+ requests.append(active.request)
858
+ for request in requests:
859
+ if request.request_id == owner_id or request.operation_id == owner_id:
860
+ self.runner.cancel(request.request_id)
861
+
862
+ def open_result(self, operation_id: str, success: bool, diagnostic: PreparedDiagnostic | None = None) -> None:
863
+ """Record the opening result without replaying a successful clone."""
864
+ run = self.run_by_id[operation_id]
865
+ run.open_status = OperationStatus.SUCCESS.value if success else OperationStatus.FAILED.value
866
+ if diagnostic is not None:
867
+ run.diagnostic = diagnostic
868
+
869
+ def _finished(self, result: ProcessResult) -> None:
870
+ """Finish a branch list, or recheck a clone's destination ownership after native exit before allowing a new session."""
871
+ pending = self.branch_list_by_id.pop(result.request.request_id, None)
872
+ if pending is not None:
873
+ self._branch_list_finished(pending, result)
874
+ return
875
+ run = self.run_by_id.get(result.request.request_id)
876
+ if run is None or run.clone_status != "running":
877
+ return
878
+ publish_after_trace(lambda: self._clone_exited(run, result), self)
879
+
880
+ def _clone_exited(self, run: CloneRun, result: ProcessResult) -> None:
881
+ """Recheck a clone's destination ownership after native exit, then validate what Git wrote or end the clone.
882
+
883
+ A clone whose secret read failed before Git wrote any repository is started again a moment later instead.
884
+ """
885
+ if self._retry_after_credential_read_failure(run, result):
886
+ return
887
+ run.clone_status = operation_status(result.status).value
888
+ run.recursive_status = operation_status(result.status).value
889
+ try:
890
+ run.destination.verify()
891
+ except (OSError, ValueError) as error:
892
+ run.clone_status = OperationStatus.UNKNOWN.value
893
+ run.diagnostic = Diagnostic("Clone repository", run.intent.parent_dir, result.request.program_filename, [], str(error)).prepare()
894
+ if run.clone_status != OperationStatus.SUCCESS.value and run.diagnostic is None:
895
+ bare = run.intent.options.bare
896
+ written = repository_written(run.destination.destination_dir, bare)
897
+ # A bare clone has no submodules and is never opened, so what an interrupted one leaves is only a kept folder,
898
+ # never a partial clone to open or to retry submodules in. Git writes a bare HEAD before any transfer.
899
+ run.incomplete_repository = written and not bare
900
+ cause = result.cause
901
+ if written:
902
+ error_text = git_error_text(result.stderr)
903
+ refusals = checkout_refusals(error_text) if not bare else ()
904
+ refusal_text = checkout_refusal_text(refusals, sys.platform)
905
+ run.checkout_refusal_text = refusal_text
906
+ refused_paths = refused_checkout_submodule_paths(error_text) if not bare else ()
907
+ run.refused_submodule_dir_set.update(refused_submodule_dirs(refused_paths, run.destination.destination_dir))
908
+ if refusal_text:
909
+ cause = refusal_text + "\n" + cause
910
+ elif not bare and (b"submodule" in result.stderr.lower() or b"failed to clone" in result.stderr.lower()):
911
+ cause = SUBMODULE_PARTIAL_TEXT + "\n" + cause
912
+ else:
913
+ cause = "The partial repository was preserved. Review the details before continuing.\n" + cause
914
+ # Git refuses local submodules with its default protocol.file.allow, whatever URL the parent came from.
915
+ # GitPane gives no permission for that and only says why, after Git's own lines.
916
+ if file_transport_refused(error_text):
917
+ run.trace.append_failure_cause(run.operation_id, FILE_TRANSPORT_REFUSED_TEXT)
918
+ # Git cites the first name it refuses in a submodule, then stops its checkout. GitPane only explains those
919
+ # lines, after them, without another process and without file name rules of its own.
920
+ # A cause is one console line, so each refused submodule gets its own.
921
+ for refusal in refusals:
922
+ run.trace.append_failure_cause(run.operation_id, checkout_refusal_text((refusal,), sys.platform))
923
+ run.diagnostic = Diagnostic("Clone repository", run.destination.destination_dir, result.request.program_filename, result.request.arguments,
924
+ cause, result.stderr, result.return_code).prepare()
925
+ step = OperationStepResult("Clone repository", OperationStatus(run.clone_status), process_status=result.status, effect=run.clone_status, diagnostic=run.diagnostic, return_code=result.return_code)
926
+ if run.clone_status == OperationStatus.UNKNOWN.value or not repository_written(run.destination.destination_dir, run.intent.options.bare):
927
+ if run.clone_status != OperationStatus.SUCCESS.value:
928
+ self._release_empty_destination(run)
929
+ self._complete(run, (step,))
930
+ return
931
+ git_filename = result.request.program_filename
932
+
933
+ def validated() -> None:
934
+ """End the clone, after checking out the submodules Git cloned but left without files when it refused another's checkout."""
935
+ steps = (step, OperationStepResult("Validate cloned repository", OperationStatus(run.validation_status), diagnostic=run.diagnostic))
936
+ if not run.refused_submodule_dir_set or run.validation_status != OperationStatus.SUCCESS.value or run.cancelled or not self.is_current(run.intent):
937
+ self._complete(run, steps)
938
+ return
939
+ self._check_out_cloned_submodules(run, git_filename, lambda checkout_step: self._complete(run, (*steps, checkout_step)))
940
+
941
+ self.validate_repository(run, git_filename, validated, reconcile_only=True)
942
+
943
+ def _retry_after_credential_read_failure(self, run: CloneRun, result: ProcessResult) -> bool:
944
+ """Start the clone again after a pause when its secret read failed and Git left nothing; tell whether it will.
945
+
946
+ Only a clone that left its folder as GitPane created it, empty and without any repository, is started again:
947
+ Git removes what it wrote when a clone fails, so the next attempt starts from the same state. A clone that already
948
+ wrote its repository, such as one whose submodule failed, keeps the partial clone path and Retry submodules.
949
+ """
950
+ if run.cancelled or self.closed or run.native_request is None or run.destination is None:
951
+ return False
952
+ if not credential_read_retry_due(result, run.credential_read_retry_count):
953
+ return False
954
+ try:
955
+ run.destination.verify()
956
+ if repository_written(run.destination.destination_dir, run.intent.options.bare):
957
+ return False
958
+ with os.scandir(run.destination.destination_dir) as entries:
959
+ if next(entries, None) is not None:
960
+ return False
961
+ except (OSError, ValueError):
962
+ return False
963
+ run.credential_read_retry_count += 1
964
+ run.retry_result = result
965
+ run.trace.append_event(run.operation_id, "status", credential_read_retry_text(run.credential_read_retry_count))
966
+ QTimer.singleShot(CREDENTIAL_READ_RETRY_DELAY_MS, self, lambda: self._clone_again(run))
967
+ return True
968
+
969
+ def _clone_again(self, run: CloneRun) -> None:
970
+ """Start the clone again once its pause is over, or end it as cancelled when the user cancelled or GitPane is closing.
971
+
972
+ A request that stopped being current during the pause ends with the refusal its first start gives, not as cancelled.
973
+
974
+ Whichever comes first, the end of the pause or the cancel, decides; the other does nothing. The launch checks of
975
+ the first start run again: the destination, the request's authority and the composed credential settings.
976
+ """
977
+ failed_result = run.retry_result
978
+ if failed_result is None:
979
+ return
980
+ run.retry_result = None
981
+ if run.cancelled or self.closed:
982
+ self._clone_exited(run, replace(failed_result, status=ProcessStatus.CANCELLED, cause=SharedText.CREDENTIAL_READ_RETRY_CANCELLED.value))
983
+ return
984
+ if not self.is_current(run.intent):
985
+ # Nobody cancelled: the request lost its authority during the pause, which the first start refuses with its own words.
986
+ self._clone_exited(run, replace(failed_result, status=ProcessStatus.FAILED, cause=CLONE_REQUEST_NOT_CURRENT_TEXT, stderr=b""))
987
+ return
988
+ try:
989
+ self.runner.start(self.ssh.prompt(run.native_request, is_current=lambda: not run.cancelled and self.is_current(run.intent)))
990
+ except (OSError, ValueError, RuntimeError) as failure:
991
+ # Git did not start again: its earlier output is already in the console, and keeping it here would start
992
+ # another retry on the same words.
993
+ self._clone_exited(run, replace(failed_result, status=ProcessStatus.FAILED, cause=str(failure), stderr=b""))
994
+
995
+ def _check_out_cloned_submodules(self, run: CloneRun, git_filename: str, complete: Callable[[OperationStepResult], None]) -> None:
996
+ """Check out, one at a time, the submodules Git cloned but never checked out because it stopped on a refused checkout.
997
+
998
+ Git clones every submodule first, then checks them out in order, and stops at the first checkout it refuses: the
999
+ submodules after that one are cloned, with their history, but their folder holds only the ``.git`` file. They
1000
+ are checked out here under the clone's own reservation, with the account the clone used for its own address,
1001
+ the way Retry submodules does; the submodules whose checkout Git refused are left exactly as Git left them. The
1002
+ clone's own result is kept: Git did refuse a checkout. A failure of this step is written in the console only, and
1003
+ so is the reason the step did not run when the clone no longer holds its repository.
1004
+ """
1005
+ destination = run.destination
1006
+ owner = self.repository_operation.active_by_id.get(run.operation_id)
1007
+ if owner is None:
1008
+ run.trace.append_failure_cause(run.operation_id, SharedText.CLONED_SUBMODULE_CHECKOUT_NOT_STARTED.value)
1009
+ complete(OperationStepResult(SharedText.CLONED_SUBMODULE_CHECKOUT_STEP.value, OperationStatus.REFUSED))
1010
+ return
1011
+ context = clone_context(destination.destination_dir, False)
1012
+ destinations = ()
1013
+ if run.selection is not None and endpoint_kind(run.intent.endpoint, self.allow_local_endpoint) == HTTPS_ENDPOINT_KIND:
1014
+ destinations = (RecursiveTransportContext(run.intent.endpoint, endpoint_origin(run.intent.endpoint), run.selection),)
1015
+
1016
+ def current() -> bool:
1017
+ """Check destination identity and publication authority before every checkout."""
1018
+ destination.verify()
1019
+ return not run.cancelled and self.is_current(run.intent)
1020
+
1021
+ def checked_out(error: Exception | None) -> None:
1022
+ """Write why the step failed, if it did, once the trace has heard the last process, then end the clone."""
1023
+ status = OperationStatus.CANCELLED if run.cancelled else OperationStatus.FAILED if error is not None else OperationStatus.SUCCESS
1024
+ run.cloned_checkout_status = status
1025
+ diagnostic = None
1026
+ if error is not None:
1027
+ if isinstance(error, (GitCommandError, SubmoduleInitializationError)):
1028
+ diagnostic = error.diagnostic
1029
+ else:
1030
+ diagnostic = Diagnostic(SharedText.CLONED_SUBMODULE_CHECKOUT_STEP.value, destination.destination_dir, git_filename, [], str(error)).prepare()
1031
+ run.trace.append_diagnostic(run.operation_id, "error", diagnostic, status)
1032
+ complete(OperationStepResult(SharedText.CLONED_SUBMODULE_CHECKOUT_STEP.value, status, diagnostic=diagnostic))
1033
+
1034
+ def ended(value: object, error: Exception | None) -> None:
1035
+ """Finish the step one turn later, after the trace has heard the last process."""
1036
+ publish_after_trace(lambda: checked_out(error), self)
1037
+
1038
+ run.cloned_checkout_status = OperationStatus.PENDING
1039
+ sequence = self.submodule.resume_missing(owner, run.trace, current, destinations, credential_helper=OperationCredentialHelper(),
1040
+ refused_dir_set=run.refused_submodule_dir_set, initialize_missing=False)
1041
+ self.repository_operation.worktree_service.run(context, git_filename, sequence, ended, operation_id=run.operation_id)
1042
+
1043
+ def retry_submodules(self, run: CloneRun, git_filename: str) -> None:
1044
+ """Resume only missing declared children while preserving the original native clone result.
1045
+
1046
+ A retry refused at its launch says why: a clone request that is no longer current says so, and a refused
1047
+ reservation takes the repository owner's reason, which names another operation only when a write really holds
1048
+ the cloned repository.
1049
+ """
1050
+ if (run.retry_operation_id or run.operation_id) in self.repository_operation.active_by_id or run.destination is None:
1051
+ return
1052
+ # The same run serves every Retry submodules: a refusal from an earlier retry must not reopen the install window
1053
+ # after a later one.
1054
+ run.credential_manager_refusal = None
1055
+ destination = run.destination
1056
+ context = clone_context(destination.destination_dir, run.intent.options.bare)
1057
+ run.retry_operation_id = uuid.uuid4().hex
1058
+ run.trace.operation_id = run.retry_operation_id
1059
+ owner = OperationIntent.capture(run.retry_operation_id, context, git_filename, operation="Retry submodules", generation=run.intent.generation,
1060
+ reserved_repository_dirs=(canonical_path(destination.destination_dir),), action=OperationAction.SUBMODULE_INIT)
1061
+
1062
+ def current() -> bool:
1063
+ """Check destination identity and publication authority before every resumed write."""
1064
+ destination.verify()
1065
+ return not run.cancelled and self.is_current(run.intent)
1066
+
1067
+ def sequence() -> Generator:
1068
+ """Use the same initialization reader and native supervision as declared child activation."""
1069
+ if not current():
1070
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
1071
+ target = run.authentication_target
1072
+ destinations = ()
1073
+ if target is not None and run.child_selection is not None:
1074
+ if initialization_signature(target.parent, target.entry.repository_dir) != target.signature:
1075
+ raise ValueError("The child destination or configuration changed. Review the details before retrying.")
1076
+ current_target = yield from self.submodule.authentication_target(context, "Authentication failed for " + target.endpoint)
1077
+ if current_target != target:
1078
+ raise ValueError("The child endpoint changed. Review the details before retrying.")
1079
+ destinations = (RecursiveTransportContext(target.endpoint, endpoint_origin(target.endpoint), run.child_selection),)
1080
+ try:
1081
+ # The submodules Git cloned but left without files are checked out too, except those whose checkout it refused.
1082
+ yield from self.submodule.resume_missing(owner, run.trace, current, destinations, credential_helper=OperationCredentialHelper(),
1083
+ refused_dir_set=run.refused_submodule_dir_set)
1084
+ except (OSError, ValueError, RuntimeError) as error:
1085
+ if current():
1086
+ # Only Git's own error output may say a submodule was refused: the full block also lists the command
1087
+ # line, whose empty credential helper would make every failure look like an authentication refusal.
1088
+ refusal_text = error.diagnostic.stderr if isinstance(error, SubmoduleInitializationError) else str(error)
1089
+ run.authentication_target = yield from self.submodule.authentication_target(context, refusal_text)
1090
+ raise
1091
+ if not current():
1092
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
1093
+ if target is not None and run.child_selection is not None:
1094
+ child_context = yield from self.submodule._context(target.entry.repository_dir)
1095
+ if not current():
1096
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
1097
+ self.authentication.save_selection(run.child_selection.origin, run.child_selection, child_context)
1098
+
1099
+ def finish_retry(error: Exception | None) -> None:
1100
+ """End the retry once the trace has heard the last process end, keeping earlier diagnostics.
1101
+
1102
+ Called one turn after that end, so a failed retry writes its diagnostic and its end line at once; a
1103
+ successful one first validates the repository.
1104
+ """
1105
+ run.recursive_status = OperationStatus.CANCELLED.value if run.cancelled else OperationStatus.FAILED.value if error else OperationStatus.SUCCESS.value
1106
+ if error:
1107
+ if isinstance(error, (GitCommandError, SubmoduleInitializationError)):
1108
+ run.diagnostic = error.diagnostic
1109
+ else:
1110
+ run.diagnostic = Diagnostic("Retry submodules", destination.destination_dir, git_filename, [], str(error)).prepare()
1111
+ # Only the error output of the Git process that failed may say why, decoded once when it ended.
1112
+ error_text = error.git_error_text if isinstance(error, SubmoduleInitializationError) else ""
1113
+ # The retry's own cause replaces the clone's, so the clone's checkout refusal no longer heads what the form shows.
1114
+ run.checkout_refusal_text = ""
1115
+ # Retrying does not change Git's protocol.file.allow setting, so a refused local submodule says why again.
1116
+ if file_transport_refused(error_text):
1117
+ run.trace.append_failure_cause(run.operation_id, FILE_TRANSPORT_REFUSED_TEXT)
1118
+ # Retrying does not change the names the repository records either, so a submodule whose checkout Git
1119
+ # refused says why again, after Git's lines, one console line per refused submodule.
1120
+ for refusal in checkout_refusals(error_text):
1121
+ run.trace.append_failure_cause(run.operation_id, checkout_refusal_text((refusal,), sys.platform))
1122
+ line_status = OperationStatus(run.recursive_status)
1123
+ # The credential manager was refused before Git started, so the line says the retry did not start, as
1124
+ # every other refused operation does; the retry's own status stays failed.
1125
+ if isinstance(error, CredentialManagerRefusal) and not run.cancelled:
1126
+ line_status = OperationStatus.REFUSED
1127
+ run.credential_manager_refusal = error.detection
1128
+ run.trace.append_diagnostic(run.operation_id, "error", run.diagnostic, line_status)
1129
+ steps = (OperationStepResult("Retry submodules", OperationStatus(run.recursive_status), diagnostic=run.diagnostic),)
1130
+ if error is None and not run.cancelled:
1131
+ self.validate_repository(run, git_filename, lambda: self._complete(run, steps))
1132
+ else:
1133
+ self._end(run, self._reconcile_reservation(run, steps))
1134
+
1135
+ def complete(value: object, error: Exception | None) -> None:
1136
+ """Finish the retry one turn later, after all initialization reads have finished."""
1137
+ publish_after_trace(lambda: finish_retry(error), self)
1138
+
1139
+ try:
1140
+ if not current():
1141
+ raise ValueError(CLONE_REQUEST_NOT_CURRENT_TEXT)
1142
+ if not self.repository_operation.reserve(owner):
1143
+ raise ValueError(self.repository_operation.refusal_cause(owner))
1144
+ run.recursive_status = "running"
1145
+ self.repository_operation.worktree_service.run(context, git_filename, sequence(), complete, operation_id=run.retry_operation_id)
1146
+ except (OSError, ValueError) as error:
1147
+ complete(None, error)
1148
+
1149
+ def validate_repository(self, run: CloneRun, git_filename: str, complete: Callable[[], None], *, reconcile_only: bool = False) -> None:
1150
+ """Inspect real HEAD and references so an invalid server HEAD cannot look empty.
1151
+
1152
+ A check that cannot reserve the cloned repository fails with the repository owner's reason, which names another
1153
+ operation only when a write really holds it.
1154
+ """
1155
+ run.validation_status = "running"
1156
+ destination = run.destination
1157
+ context = clone_context(destination.destination_dir, run.intent.options.bare)
1158
+ verification_id = ""
1159
+ operation_id = run.retry_operation_id or run.operation_id
1160
+ if operation_id not in self.repository_operation.active_by_id:
1161
+ verification_id = uuid.uuid4().hex
1162
+ owner = OperationIntent.capture(verification_id, context, git_filename, operation="Validate cloned repository",
1163
+ reserved_repository_dirs=(canonical_path(destination.destination_dir),), action=OperationAction.CONFIG_WRITE)
1164
+ if not self.repository_operation.reserve(owner):
1165
+ run.validation_status = OperationStatus.FAILED.value
1166
+ run.diagnostic = Diagnostic("Validate cloned repository", destination.destination_dir, git_filename, [], self.repository_operation.refusal_cause(owner)).prepare()
1167
+ QTimer.singleShot(0, self, complete)
1168
+ return
1169
+
1170
+ def before_start() -> None:
1171
+ """Refuse queued validation commands after directory replacement or cancellation."""
1172
+ current_owner = self.repository_operation.active_by_id.get(verification_id or operation_id)
1173
+ if current_owner is None:
1174
+ raise ValueError("Clone validation no longer owns its destination")
1175
+ self.repository_operation.validate(current_owner)
1176
+ destination.verify()
1177
+ if not reconcile_only and (run.cancelled or not self.is_current(run.intent)):
1178
+ raise ValueError("Clone validation was cancelled.")
1179
+
1180
+ def sequence() -> Generator[GitCommand, bytes]:
1181
+ """Recheck ownership before each native validation and exact origin restoration."""
1182
+ destination.verify()
1183
+ if not reconcile_only and (run.cancelled or not self.is_current(run.intent)):
1184
+ raise ValueError("Clone validation was cancelled.")
1185
+ if not run.origin_restored and endpoint_kind(run.intent.endpoint, self.allow_local_endpoint) == HTTPS_ENDPOINT_KIND:
1186
+ yield GitCommand(["config", "--local", "remote.origin.url", run.intent.endpoint], profile=LOCAL_WRITE_PROFILE, before_start=before_start)
1187
+ run.origin_restored = True
1188
+ destination.verify()
1189
+ if not reconcile_only and (run.cancelled or not self.is_current(run.intent)):
1190
+ raise ValueError("Clone validation was cancelled.")
1191
+ head = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1), before_start=before_start)
1192
+ destination.verify()
1193
+ if not reconcile_only and (run.cancelled or not self.is_current(run.intent)):
1194
+ raise ValueError("Clone validation was cancelled.")
1195
+ refs = yield GitCommand(["for-each-ref", "--format=%(objectname)"], before_start=before_start)
1196
+ destination.verify()
1197
+ if refs.strip() and not head.strip():
1198
+ raise ValueError("Repository cloned, but the server HEAD does not identify a checked-out branch. Repair HEAD before opening this directory.")
1199
+ # A bare repository has no working tree and therefore no submodule to authenticate.
1200
+ if (not run.intent.options.bare and run.incomplete_repository and run.recursive_status != OperationStatus.SUCCESS.value and run.diagnostic is not None
1201
+ and not run.cancelled and self.is_current(run.intent)):
1202
+ run.authentication_target = yield from self.submodule.authentication_target(context, run.diagnostic.stderr)
1203
+
1204
+ def validated(value: object, error: Exception | None) -> None:
1205
+ """Retain the native clone success even when readiness cannot be established."""
1206
+ run.validation_status = OperationStatus.SUCCESS.value if error is None else OperationStatus.FAILED.value
1207
+ if error is not None:
1208
+ run.diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Validate cloned repository", destination.destination_dir, git_filename, [], str(error)).prepare()
1209
+ if verification_id:
1210
+ self.repository_operation.release(verification_id)
1211
+ complete()
1212
+
1213
+ self.repository_operation.worktree_service.run(context, git_filename, sequence(), validated, operation_id=operation_id)
1214
+
1215
+ def _release_empty_destination(self, run: CloneRun) -> None:
1216
+ """Remove the empty folder a failed or cancelled clone created, so the same name can be cloned again.
1217
+
1218
+ Called only when this clone's Git has exited, or never started, and left no repository in the folder. The
1219
+ folder is kept while another queued or running Git command still uses it, when it changed, or when it holds
1220
+ anything at all; the clone console says which happened.
1221
+ """
1222
+ destination = run.destination
1223
+ if destination is None or run.destination_removed:
1224
+ return
1225
+ if self._destination_in_use(run, destination.destination_dir):
1226
+ reason = "another Git command still uses it"
1227
+ elif destination.remove_if_empty():
1228
+ run.destination_removed = True
1229
+ run.trace.append_event(run.operation_id, "status", DESTINATION_REMOVED_TEXT + destination.destination_dir)
1230
+ return
1231
+ else:
1232
+ reason = kept_destination_reason(destination)
1233
+ run.trace.append_event(run.operation_id, "status", DESTINATION_KEPT_TEXT + destination.destination_dir + " (" + reason + ")")
1234
+
1235
+ def _destination_in_use(self, run: CloneRun, destination_dir: str) -> bool:
1236
+ """Tell whether a queued or running command of this clone, or any command working inside its folder, is still there."""
1237
+ requests = list(self.runner.pending_requests)
1238
+ for active in self.runner.active_by_id.values():
1239
+ requests.append(active.request)
1240
+ owned_dir = os.path.normcase(os.path.abspath(destination_dir))
1241
+ for request in requests:
1242
+ if run.operation_id in (request.request_id, request.operation_id):
1243
+ return True
1244
+ working_dir = os.path.normcase(os.path.abspath(request.working_dir))
1245
+ if working_dir == owned_dir or working_dir.startswith(owned_dir + os.sep):
1246
+ return True
1247
+ return False
1248
+
1249
+ def _complete(self, run: CloneRun, steps: tuple[OperationStepResult, ...]) -> None:
1250
+ """Release the reservation only after clone and readiness reads have terminated, then end the clone once its trace has caught up."""
1251
+ status = self._reconcile_reservation(run, steps)
1252
+ publish_after_trace(lambda: self._end(run, status), self)
1253
+
1254
+ def _reconcile_reservation(self, run: CloneRun, steps: tuple[OperationStepResult, ...]) -> OperationStatus:
1255
+ """Release the clone's or retry's repository reservation with its outcome, and return the status its end line shows."""
1256
+ status = OperationStatus(run.recursive_status) if run.validation_status in {"not_started", OperationStatus.SUCCESS.value} else OperationStatus.PARTIAL
1257
+ operation_id = run.retry_operation_id or run.operation_id
1258
+ if operation_id in self.repository_operation.active_by_id:
1259
+ self.repository_operation.reconcile(OperationOutcome(operation_id, status, steps, run.diagnostic))
1260
+ return status
1261
+
1262
+ def _end(self, run: CloneRun, status: OperationStatus) -> None:
1263
+ """Write the clone's end line in its console, then publish the finished clone.
1264
+
1265
+ The end line is console text only: the ``--log`` file and the records already hold every step's own status. A
1266
+ clone the user cancelled says so, whatever the checks of what Git had already written found.
1267
+ """
1268
+ end_status = OperationStatus.CANCELLED if run.cancelled else status
1269
+ label = RETRY_SUBMODULES_LABEL if run.retry_operation_id else CLONE_LABEL
1270
+ run.trace.append_console_line(ConsoleLine(operation_end_text(label, end_status), run.trace.end_tone(end_status, failure_reported=run.diagnostic is not None)))
1271
+ self.finished.emit(run)
1272
+ if self.closed:
1273
+ run.trace.close()