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,747 @@
1
+ """Apply explicit native Git edits to the remotes of a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass, replace
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, LOCAL_PREFIX, REMOTE_PREFIX
11
+ from gitpane.operation.diagnostic import Diagnostic
12
+ from gitpane.operation.operation_status import OperationStatus
13
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult, WriteScope
14
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
15
+ from gitpane.reference.reference_edit import reference_signature
16
+ from gitpane.reference.reference_snapshot import REFERENCE_FORMAT
17
+ from gitpane.system.path_identity import canonical_path
18
+ from gitpane.transport.remote_config import (
19
+ INACTIVE_REMOTE_SECTION,
20
+ MERGE_TRACKING_FIELD,
21
+ REMOTE_TRACKING_FIELD,
22
+ BranchTracking,
23
+ GitConfigScope,
24
+ RemoteEntry,
25
+ RemoteSnapshot,
26
+ active_remote_urls,
27
+ config_signature,
28
+ field_values,
29
+ parse_config,
30
+ parse_remote_reference_output,
31
+ remote_section,
32
+ rewrite_url,
33
+ tracking_values,
34
+ )
35
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Callable, Generator
39
+
40
+ from gitpane.history.repository_value import RepositoryContext
41
+ from gitpane.operation.repository_operation import RepositoryOperationService
42
+ from gitpane.transport.remote_config import RemoteValue
43
+ from gitpane.worktree.worktree_service import WorktreeService
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RemoteChange:
48
+ """Describe one explicitly edited field without replacing neighboring values."""
49
+
50
+ field: str
51
+ value: str
52
+ selected: RemoteValue | None = None
53
+ local_override: bool = False
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RemoteOutcome(OperationOutcome):
58
+ """Publish the reread target and individual field results after actual process exit."""
59
+
60
+ snapshot: RemoteSnapshot | None = None
61
+ selected_name: str = ""
62
+
63
+
64
+ REMOTE_FIELD_LABEL_BY_NAME = {"add": "Add remote", "name": "Name", "url": "Fetch Url", "pushurl": "Push Url"}
65
+ TRACKING_LABEL_BY_FIELD = {REMOTE_TRACKING_FIELD: "Remote repository", MERGE_TRACKING_FIELD: "Default merge with"}
66
+ EFFECTIVE_CONFIG_LIST_ARGUMENTS = ("config", "--null", "--show-origin", "--show-scope", "--includes", "--list")
67
+ CONFIG_FILE_ORIGIN_PREFIX = "file:"
68
+
69
+
70
+ def config_origin_filename(context: RepositoryContext, origin: str) -> str | None:
71
+ """Return the canonical file behind a ``file:`` origin, relative ones resolved from the repository, or None."""
72
+ if not origin.startswith(CONFIG_FILE_ORIGIN_PREFIX):
73
+ return None
74
+ filename = origin.removeprefix(CONFIG_FILE_ORIGIN_PREFIX)
75
+ if not filename:
76
+ return None
77
+ if not os.path.isabs(filename):
78
+ filename = os.path.join(context.repository_dir, filename)
79
+ return canonical_path(filename)
80
+
81
+
82
+ def direct_config_values(context: RepositoryContext, values: tuple[RemoteValue, ...]) -> tuple[RemoteValue, ...]:
83
+ """Select, in order and with every repetition, the values written in the repository's own config file.
84
+
85
+ These are the only values a local write edits in place: an included file, a worktree file or a global file also
86
+ reads as local or effective configuration but is never the file ``git config --local`` changes. A value whose
87
+ origin cannot be resolved to that exact file is not direct, so it never authorizes an edit.
88
+ """
89
+ direct_filename = canonical_path(os.path.join(context.common_git_dir, "config"))
90
+ direct_values: list[RemoteValue] = []
91
+ for value in values:
92
+ if value.scope == GitConfigScope.LOCAL and config_origin_filename(context, value.origin) == direct_filename:
93
+ direct_values.append(value)
94
+ return tuple(direct_values)
95
+
96
+
97
+ def signature_covers(signature: tuple, earlier: tuple) -> bool:
98
+ """Tell whether every source recorded by an earlier config signature is still identical in a later, wider one."""
99
+ identity_records, records = signature
100
+ earlier_identity_records, earlier_records = earlier
101
+ record_set = set(records)
102
+ return identity_records == earlier_identity_records and all(record in record_set for record in earlier_records)
103
+
104
+
105
+ def url_edit_reason(snapshot: RemoteSnapshot, name: str, field: str, selected: RemoteValue | None) -> str:
106
+ """Explain when native set-url cannot safely edit precisely one selected value."""
107
+ values = field_values(snapshot, name, field)
108
+ if selected is None:
109
+ return "Choose the exact URL to edit." if values else ""
110
+ if any(character in selected.value for character in ("\0", "\n", "\r")):
111
+ return "This URL contains control characters. Review its configuration source before editing."
112
+ if selected not in field_values(snapshot, name, field, direct=True):
113
+ return "This URL is inherited. Editing it locally would retain the inherited destination; edit its source explicitly outside this form."
114
+ expanded = rewrite_url(selected.value, snapshot.values)
115
+ candidates = 0
116
+ for value in values:
117
+ if value.value in (selected.value, expanded):
118
+ candidates += 1
119
+ if candidates != 1:
120
+ return "This URL is repeated or overlaps a rewritten value. Git cannot select only this occurrence safely."
121
+ return ""
122
+
123
+
124
+ def validate_name(name: str) -> None:
125
+ """Reject empty names and control characters before Git validates ref naming."""
126
+ if not name or name.startswith("-") or any(character in name for character in ("\0", "\n", "\r")):
127
+ raise ValueError("Enter a valid remote name without options or control characters")
128
+
129
+
130
+ class RemoteService:
131
+ """Use the existing common repository owner and native asynchronous runner."""
132
+
133
+ def __init__(self, worktree_service: WorktreeService, repository_operation: RepositoryOperationService) -> None:
134
+ """Inject write ownership so remotes cannot race staging or other config changes."""
135
+ self.worktree_service = worktree_service
136
+ self.repository_operation = repository_operation
137
+ self.outcome_by_id: dict[str, RemoteOutcome] = {}
138
+
139
+ def capture(self, context: RepositoryContext, git_filename: str, generation: int = 0) -> Generator:
140
+ """Read every config value, branch, remote-tracking branch and HEAD with two Git processes.
141
+
142
+ The reference and configuration sources are fingerprinted before and after the reads; a capture that saw any
143
+ of them move is refused rather than published. Destinations follow Git's own remote rules from the values read,
144
+ and only a remote whose destinations Git alone can establish is asked of Git separately.
145
+ """
146
+ opening_state = yield FileTask(lambda: (reference_signature(context), config_signature(context, ())))
147
+ reference_state, base_signature = opening_state
148
+ reference_output, output = yield ReadBatch((GitCommand(["for-each-ref", "--format=" + REFERENCE_FORMAT, LOCAL_PREFIX, REMOTE_PREFIX], profile=READ_PROFILE),
149
+ GitCommand(list(EFFECTIVE_CONFIG_LIST_ARGUMENTS), profile=READ_PROFILE)))
150
+ values = parse_config(output)
151
+ direct_values = direct_config_values(context, values)
152
+ signature = yield FileTask(lambda: config_signature(context, values))
153
+ if not signature_covers(signature, base_signature):
154
+ raise ValueError("Remote configuration changed while reading; reopen Manage remotes")
155
+ references, branch_state_by_ref, head_oid, head_ref = parse_remote_reference_output(reference_output)
156
+ if head_ref is None:
157
+ # The listing marks HEAD only when it is on an existing branch; a detached or unborn HEAD is asked of Git.
158
+ head_output, symbolic_output = yield ReadBatch((GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1), profile=READ_PROFILE),
159
+ GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1), profile=READ_PROFILE)))
160
+ head_oid = head_output.decode("ascii", "replace").strip() or None
161
+ head_ref = symbolic_output.decode("utf-8", "surrogateescape").strip()
162
+ value_by_name: dict[str, list[RemoteValue]] = {}
163
+ inactive_value_by_name: dict[str, list[RemoteValue]] = {}
164
+ for value in values:
165
+ for prefix, target_by_name in (("remote.", value_by_name), (INACTIVE_REMOTE_SECTION, inactive_value_by_name)):
166
+ if value.key.startswith(prefix) and "." in value.key.removeprefix(prefix):
167
+ name, _key = value.key.removeprefix(prefix).rsplit(".", 1)
168
+ target_by_name.setdefault(name, []).append(value)
169
+ entry_by_name: dict[str, RemoteEntry] = {}
170
+ for name in sorted(value_by_name.keys() | inactive_value_by_name.keys(), key=lambda name: (name.casefold(), name)):
171
+ active = name in value_by_name
172
+ entry_values = value_by_name[name] if active else inactive_value_by_name[name]
173
+ if not active:
174
+ # Git's remote reader ignores command-scope definitions. Inactive sections retain
175
+ # their configured destinations with the same longest-prefix rewrite rules.
176
+ fetch_urls: list[str] = []
177
+ push_urls: list[str] = []
178
+ raw_urls: list[str] = []
179
+ for value in entry_values:
180
+ if value.key == INACTIVE_REMOTE_SECTION + name + ".url":
181
+ raw_urls.append(value.value)
182
+ fetch_urls.append(rewrite_url(value.value, values))
183
+ elif value.key == INACTIVE_REMOTE_SECTION + name + ".pushurl":
184
+ push_urls.append(rewrite_url(value.value, values))
185
+ if not push_urls:
186
+ for url in raw_urls:
187
+ rewritten = rewrite_url(url, values, ".pushinsteadof")
188
+ if rewritten != url:
189
+ push_urls.append(rewritten)
190
+ if not push_urls:
191
+ push_urls.extend(fetch_urls)
192
+ else:
193
+ native_urls = active_remote_urls(name, tuple(entry_values), values)
194
+ if native_urls is None:
195
+ fetch, push = yield ReadBatch((GitCommand(["remote", "get-url", "--all", "--", name], accepted_codes=(0, 2), profile=READ_PROFILE),
196
+ GitCommand(["remote", "get-url", "--push", "--all", "--", name], accepted_codes=(0, 2), profile=READ_PROFILE)))
197
+ native_urls = (tuple(fetch.decode("utf-8", "surrogateescape").splitlines()), tuple(push.decode("utf-8", "surrogateescape").splitlines()))
198
+ fetch_urls, push_urls = native_urls
199
+ entry_by_name[name] = RemoteEntry(name, tuple(entry_values), tuple(fetch_urls), tuple(push_urls), active, active and name in inactive_value_by_name)
200
+ final_signature = yield FileTask(lambda: config_signature(context, values))
201
+ final_reference_state = yield FileTask(lambda: reference_signature(context))
202
+ if final_signature != signature or reference_state != final_reference_state:
203
+ raise ValueError("Remote configuration changed while reading; reopen Manage remotes")
204
+ observed_ref_set: set[str] = set(branch_state_by_ref)
205
+ for record in references.splitlines():
206
+ full_ref, _separator, _rest = record.partition(b"\0")
207
+ observed_ref_set.add(full_ref.decode("utf-8", "surrogateescape"))
208
+ tracking_by_ref: dict[str, BranchTracking] = {}
209
+ for full_ref, (oid, upstream) in branch_state_by_ref.items():
210
+ prefix = "branch." + full_ref.removeprefix(LOCAL_PREFIX) + "."
211
+ branch_values = tuple(value for value in values if value.key in (prefix + REMOTE_TRACKING_FIELD, prefix + MERGE_TRACKING_FIELD))
212
+ tracking_by_ref[full_ref] = BranchTracking(full_ref, oid, branch_values, upstream, bool(upstream) and upstream in observed_ref_set)
213
+ return RemoteSnapshot(context, git_filename, generation, entry_by_name, values, direct_values, signature, references, tracking_by_ref, reference_state,
214
+ head_oid, head_ref)
215
+
216
+ def read(self, context: RepositoryContext, git_filename: str, generation: int, callback: Callable) -> None:
217
+ """Read without taking a mutation reservation or changing the repository."""
218
+ self.worktree_service.run(context, git_filename, self.capture(context, git_filename, generation), callback)
219
+
220
+ def save(self, snapshot: RemoteSnapshot, name: str, changes: tuple[RemoteChange, ...], *, session_id: str,
221
+ is_current: Callable[[], bool] | None = None, operation_id: str | None = None) -> str:
222
+ """Reserve and submit the selected target once, retaining its completed result."""
223
+ refs = [REMOTE_PREFIX + name + "/*"]
224
+ for change in changes:
225
+ if change.field == "name":
226
+ refs.append(REMOTE_PREFIX + change.value + "/*")
227
+ operation = OperationIntent.capture(operation_id or uuid.uuid4().hex, snapshot.context, snapshot.git_filename,
228
+ generation=snapshot.generation, targets=(name,), session_id=session_id, operation="Save remote",
229
+ action=OperationAction.CONFIG_WRITE, scope=WriteScope.capture(snapshot.context, tuple(refs)), from_form=True)
230
+ return self.repository_operation.submit(operation, self._save(operation, snapshot, name, changes, is_current))
231
+
232
+ def set_active(self, snapshot: RemoteSnapshot, name: str, active: bool, *, session_id: str,
233
+ is_current: Callable[[], bool] | None = None, operation_id: str | None = None) -> str:
234
+ """Move a local remote section once while retaining every reference and tracking value."""
235
+ operation = OperationIntent.capture(operation_id or uuid.uuid4().hex, snapshot.context, snapshot.git_filename,
236
+ generation=snapshot.generation, targets=(name,), session_id=session_id,
237
+ operation="Activate remote" if active else "Deactivate remote", action=OperationAction.CONFIG_WRITE, from_form=True)
238
+ return self.repository_operation.submit(operation, self._set_active(operation, snapshot, name, active, is_current))
239
+
240
+ def remove(self, snapshot: RemoteSnapshot, name: str, *, session_id: str,
241
+ is_current: Callable[[], bool] | None = None, operation_id: str | None = None) -> str:
242
+ """Remove exactly the confirmed local remote after checking its captured definition."""
243
+ operation = OperationIntent.capture(operation_id or uuid.uuid4().hex, snapshot.context, snapshot.git_filename,
244
+ generation=snapshot.generation, targets=(name,), session_id=session_id, operation="Remove remote",
245
+ action=OperationAction.CONFIG_WRITE, scope=WriteScope.capture(snapshot.context, (REMOTE_PREFIX + name + "/*",)), from_form=True)
246
+ return self.repository_operation.submit(operation, self._remove(operation, snapshot, name, is_current))
247
+
248
+ def set_tracking(self, snapshot: RemoteSnapshot, full_ref: str, changes: tuple[RemoteChange, ...], *, session_id: str,
249
+ is_current: Callable[[], bool] | None = None, operation_id: str | None = None) -> str:
250
+ """Save only the selected branch's explicit config fields under common ownership."""
251
+ operation = OperationIntent.capture(operation_id or uuid.uuid4().hex, snapshot.context, snapshot.git_filename,
252
+ generation=snapshot.generation, targets=(full_ref,), session_id=session_id, operation="Save branch tracking", action=OperationAction.CONFIG_WRITE, from_form=True)
253
+ return self.repository_operation.submit(operation, self._set_tracking(operation, snapshot, full_ref, changes, is_current))
254
+
255
+ def _set_tracking(self, operation: OperationIntent, snapshot: RemoteSnapshot, full_ref: str, changes: tuple[RemoteChange, ...],
256
+ is_current: Callable[[], bool] | None) -> Generator:
257
+ """Recheck the captured branch and record each native field write separately."""
258
+ expected = snapshot
259
+ refreshed = snapshot
260
+ steps: list[OperationStepResult] = []
261
+ diagnostic = None
262
+ field_set: set[str] = set()
263
+ for change in changes:
264
+ native_started = False
265
+ process_status = None
266
+ cause = None
267
+ status = OperationStatus.REFUSED
268
+ try:
269
+ if (not operation.session_id or full_ref not in expected.tracking_by_ref or change.field not in TRACKING_LABEL_BY_FIELD
270
+ or change.field in field_set or is_current is not None and not is_current()):
271
+ raise ValueError("The branch tracking form is invalid or no longer authorized")
272
+ field_set.add(change.field)
273
+ current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
274
+ branch = current.tracking_by_ref.get(full_ref)
275
+ if branch != expected.tracking_by_ref[full_ref] or current.values != expected.values:
276
+ raise ValueError("Branch or configuration changed. Reread tracking before saving this branch.")
277
+ if not change.value or any(character in change.value for character in ("\0", "\n", "\r")):
278
+ raise ValueError("Enter a nonempty tracking value without control characters")
279
+ if change.field == MERGE_TRACKING_FIELD:
280
+ if not change.value.startswith(LOCAL_PREFIX):
281
+ raise ValueError("Default merge with must be a full refs/heads/ branch name")
282
+ yield GitCommand(["check-ref-format", change.value], profile=READ_PROFILE)
283
+ elif change.value != "." and change.value not in current.entry_by_name:
284
+ raise ValueError("Choose a configured remote repository")
285
+ values = tracking_values(current, full_ref, change.field)
286
+ selected = change.selected
287
+ if selected is not None and selected not in values:
288
+ raise ValueError("The selected value does not belong to this captured branch field")
289
+ if values and (selected not in values or sum(value.value == selected.value for value in values) != 1):
290
+ raise ValueError("Select one unique configured tracking value; repeated values require review in their source")
291
+ if selected is not None and selected not in current.direct_values:
292
+ raise ValueError("This tracking value is inherited; edit its original configuration source")
293
+ key = "branch." + full_ref.removeprefix(LOCAL_PREFIX) + "." + change.field
294
+ arguments = (["config", "--local", "--fixed-value", "--replace-all", "--", key, change.value, selected.value]
295
+ if selected else ["config", "--local", "--add", "--", key, change.value])
296
+ def validate_launch(captured: RemoteSnapshot = current) -> None:
297
+ """Keep queued writes tied to the same branch, config sources and authority."""
298
+ nonlocal native_started
299
+ self.repository_operation.validate(operation)
300
+ allows = self.repository_operation.allows
301
+ if (is_current is not None and not is_current() or allows is not None and not allows(operation)
302
+ or config_signature(snapshot.context, captured.values) != captured.signature
303
+ or reference_signature(snapshot.context) != captured.reference_state):
304
+ raise ValueError("Branch, configuration or authority changed before launch; reread tracking")
305
+ native_started = True
306
+
307
+ if selected is None or change.value != selected.value:
308
+ # Saving tracking writes local configuration, so it stays supervised even when the caller
309
+ # launched the surrounding sequence as a plain read, as an upstream setup after a push does.
310
+ yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS,
311
+ before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
312
+ process_status = ProcessStatus.SUCCESS
313
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
314
+ expected_texts: list[str] = []
315
+ for value in values:
316
+ expected_texts.append(change.value if value == selected else value.value)
317
+ if selected is None:
318
+ expected_texts.append(change.value)
319
+ if tuple(value.value for value in tracking_values(refreshed, full_ref, change.field)) != tuple(expected_texts):
320
+ raise ValueError("Git completed but the saved tracking field could not be verified")
321
+ refreshed_branch = refreshed.tracking_by_ref.get(full_ref)
322
+ if (refreshed_branch is None or refreshed_branch.oid != branch.oid
323
+ or tuple(value for value in refreshed.values if value.key != key) != tuple(value for value in current.values if value.key != key)):
324
+ raise ValueError("Branch or another configuration field changed during save. Reread tracking before continuing.")
325
+ expected = refreshed
326
+ status = OperationStatus.SUCCESS
327
+ except (ValueError, OSError, GitCommandError) as error:
328
+ cause = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation.operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
329
+ diagnostic = diagnostic or cause
330
+ if isinstance(error, GitCommandError):
331
+ process_status = error.process_status
332
+ status = OperationStatus.UNKNOWN if process_status == ProcessStatus.SUCCESS else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
333
+ try:
334
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
335
+ if native_started and tracking_values(refreshed, full_ref, change.field) != tracking_values(current, full_ref, change.field):
336
+ status = OperationStatus.UNKNOWN
337
+ except (ValueError, OSError, GitCommandError):
338
+ refreshed = None
339
+ if native_started:
340
+ status = OperationStatus.UNKNOWN
341
+ steps.append(OperationStepResult(TRACKING_LABEL_BY_FIELD.get(change.field, "Tracking field"), status, change, refreshed, process_status,
342
+ "saved" if status == OperationStatus.SUCCESS else "verify effect" if status == OperationStatus.UNKNOWN else "not saved", cause))
343
+ if status != OperationStatus.SUCCESS:
344
+ break
345
+ status = OperationStatus.SUCCESS if steps and all(step.status == OperationStatus.SUCCESS for step in steps) else OperationStatus.PARTIAL if any(step.status == OperationStatus.SUCCESS for step in steps) else next(reversed(steps)).status if steps else OperationStatus.REFUSED
346
+ outcome = RemoteOutcome(operation.operation_id, status, tuple(steps), diagnostic, refreshed, full_ref)
347
+ self.outcome_by_id[operation.operation_id] = outcome
348
+ return outcome
349
+
350
+ def _remove(self, operation: OperationIntent, snapshot: RemoteSnapshot, name: str, is_current: Callable[[], bool] | None) -> Generator:
351
+ """Observe native removal without transporting or deleting any reference on a server."""
352
+ refreshed = None
353
+ current = None
354
+ diagnostic = None
355
+ process_status = None
356
+ return_code = None
357
+ native_started = False
358
+ status, effect = OperationStatus.REFUSED, "not removed"
359
+ try:
360
+ validate_name(name)
361
+ if not operation.session_id or is_current is not None and not is_current():
362
+ raise ValueError("The remote form is no longer authorized")
363
+ current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
364
+ entry = current.entry_by_name.get(name)
365
+ if entry is None or name not in snapshot.entry_by_name:
366
+ raise ValueError("The selected remote no longer exists; reopen Manage remotes")
367
+ if entry.collision:
368
+ raise ValueError("Active and inactive remote definitions collide; review their configuration sources")
369
+ if current.values != snapshot.values or current.references != snapshot.references:
370
+ raise ValueError("Remote configuration or references changed; review the selected remote before removal")
371
+ if any(value not in current.direct_values for value in entry.values):
372
+ raise ValueError("An inherited remote cannot be removed locally; edit its original configuration source")
373
+ if entry.active:
374
+ for value in current.values:
375
+ if value.key.startswith("branch.") and value.key.endswith((".remote", ".pushremote")) and value.value == name and value not in current.direct_values:
376
+ raise ValueError("Inherited branch tracking cannot be updated by local remote removal; review its original source")
377
+ yield GitCommand(["check-ref-format", REMOTE_PREFIX + name + "/check"], profile=READ_PROFILE)
378
+ reference_state = yield FileTask(lambda: reference_signature(snapshot.context))
379
+
380
+ def validate_launch() -> None:
381
+ """Recheck the confirmed target after waiting for a native process slot."""
382
+ nonlocal native_started
383
+ self.repository_operation.validate(operation)
384
+ allows = self.repository_operation.allows
385
+ if (is_current is not None and not is_current() or allows is not None and not allows(operation)
386
+ or config_signature(snapshot.context, current.values) != current.signature
387
+ or reference_signature(snapshot.context) != reference_state):
388
+ raise ValueError("Remote configuration, references or authority changed before launch")
389
+ native_started = True
390
+
391
+ arguments = ["remote", "remove", "--", name] if entry.active else ["config", "--local", "--remove-section", "--", remote_section(current, name)]
392
+ yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
393
+ process_status, return_code = ProcessStatus.SUCCESS, 0
394
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
395
+ if name in refreshed.entry_by_name:
396
+ raise ValueError("Git completed but the selected remote still has a configuration definition")
397
+ if not entry.active:
398
+ expected_values = tuple(value for value in current.values if value not in entry.values)
399
+ if refreshed.references != current.references or refreshed.values != expected_values:
400
+ raise ValueError("The inactive section was removed but other configuration or references also changed")
401
+ status, effect = OperationStatus.SUCCESS, "removed"
402
+ except (ValueError, OSError, GitCommandError) as error:
403
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation.operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
404
+ if isinstance(error, GitCommandError):
405
+ process_status, return_code = error.process_status, error.return_code
406
+ status = OperationStatus.UNKNOWN if process_status == ProcessStatus.SUCCESS else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
407
+ try:
408
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
409
+ if native_started and current is not None and (refreshed.values != current.values or refreshed.references != current.references):
410
+ status, effect = OperationStatus.UNKNOWN, "configuration or references changed; review before retrying"
411
+ except (ValueError, OSError, GitCommandError):
412
+ if native_started:
413
+ status, effect = OperationStatus.UNKNOWN, "verify removal"
414
+ step = OperationStepResult(operation.operation, status, snapshot, refreshed, process_status, effect, diagnostic, return_code)
415
+ selected_name = name if refreshed is None or name in refreshed.entry_by_name else ""
416
+ outcome = RemoteOutcome(operation.operation_id, status, (step,), diagnostic, refreshed, selected_name)
417
+ self.outcome_by_id[operation.operation_id] = outcome
418
+ return outcome
419
+
420
+ def _set_active(self, operation: OperationIntent, snapshot: RemoteSnapshot, name: str, active: bool, is_current: Callable[[], bool] | None) -> Generator:
421
+ """Compare exact definitions before moving a section and observe its final native state."""
422
+ refreshed = None
423
+ diagnostic = None
424
+ process_status = None
425
+ native_started = False
426
+ effect = "not changed"
427
+ status = OperationStatus.REFUSED
428
+ try:
429
+ validate_name(name)
430
+ if not operation.session_id or type(active) is not bool or is_current is not None and not is_current():
431
+ raise ValueError("The remote form is no longer authorized")
432
+ current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
433
+ entry = current.entry_by_name.get(name)
434
+ expected = snapshot.entry_by_name.get(name)
435
+ if entry is None or expected is None:
436
+ raise ValueError("The selected remote no longer exists; reopen Manage remotes")
437
+ if entry.collision:
438
+ raise ValueError("Active and inactive remote definitions collide; review their configuration sources")
439
+ if current.values != snapshot.values or current.references != snapshot.references:
440
+ raise ValueError("Remote configuration or references changed; review the selected remote")
441
+ if entry.active == active:
442
+ refreshed, status, effect = current, OperationStatus.SUCCESS, "already active" if active else "already inactive"
443
+ else:
444
+ if any(value not in current.direct_values for value in entry.values):
445
+ raise ValueError("An inherited remote cannot be moved locally; edit its original configuration source")
446
+ yield GitCommand(["check-ref-format", REMOTE_PREFIX + name + "/check"], profile=READ_PROFILE)
447
+ reference_state = yield FileTask(lambda: reference_signature(snapshot.context))
448
+
449
+ def validate_launch() -> None:
450
+ """Keep queued writes bound to the same config, refs and live authority."""
451
+ nonlocal native_started
452
+ self.repository_operation.validate(operation)
453
+ allows = self.repository_operation.allows
454
+ if (is_current is not None and not is_current() or allows is not None and not allows(operation)
455
+ or config_signature(snapshot.context, current.values) != current.signature
456
+ or reference_signature(snapshot.context) != reference_state):
457
+ raise ValueError("Remote configuration, references or authority changed before launch")
458
+ native_started = True
459
+
460
+ source = remote_section(current, name)
461
+ destination = ("remote." if active else INACTIVE_REMOTE_SECTION) + name
462
+ yield GitCommand(["config", "--local", "--rename-section", "--", source, destination], timeout_ms=GIT_MUTATION_TIMEOUT_MS,
463
+ idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
464
+ process_status = ProcessStatus.SUCCESS
465
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
466
+ expected_values = tuple(replace(value, key=destination + value.key.removeprefix(source)) if value in entry.values else value for value in current.values)
467
+ if (refreshed.values != expected_values or refreshed.references != current.references
468
+ or refreshed.entry_by_name.get(name) is None or refreshed.entry_by_name[name].active != active):
469
+ raise ValueError("Git completed but the remote section, references or tracking could not be verified")
470
+ status, effect = OperationStatus.SUCCESS, "activated" if active else "deactivated"
471
+ except (ValueError, OSError, GitCommandError) as error:
472
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation.operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
473
+ if isinstance(error, GitCommandError):
474
+ process_status = error.process_status
475
+ status = OperationStatus.UNKNOWN if process_status == ProcessStatus.SUCCESS else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
476
+ try:
477
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
478
+ if native_started and refreshed.entry_by_name.get(name) != snapshot.entry_by_name.get(name):
479
+ status, effect = OperationStatus.UNKNOWN, "verify changed remote"
480
+ except (ValueError, OSError, GitCommandError):
481
+ if native_started:
482
+ status, effect = OperationStatus.UNKNOWN, "verify changed remote"
483
+ step = OperationStepResult(operation.operation, status, snapshot, refreshed, process_status, effect, diagnostic)
484
+ outcome = RemoteOutcome(operation.operation_id, status, (step,), diagnostic, refreshed, name)
485
+ self.outcome_by_id[operation.operation_id] = outcome
486
+ return outcome
487
+
488
+ def _save(self, operation: OperationIntent, snapshot: RemoteSnapshot, name: str, changes: tuple[RemoteChange, ...], is_current: Callable[[], bool] | None) -> Generator:
489
+ """Route a captured addition or field edits without silently widening the intent."""
490
+ if any(change.field == "add" for change in changes):
491
+ additions = tuple(change for change in changes if change.field == "add")
492
+ edits = tuple(change for change in changes if change.field != "add")
493
+ if any(change.field != "pushurl" for change in edits):
494
+ return (yield from self._add(operation, snapshot, name, changes, is_current))
495
+ outcome = yield from self._add(operation, snapshot, name, additions, is_current)
496
+ if outcome.status != OperationStatus.SUCCESS or not edits:
497
+ return outcome
498
+ result = yield from self._edit(operation, outcome.snapshot, name, edits, is_current)
499
+ status = result.status if result.status == OperationStatus.SUCCESS else OperationStatus.PARTIAL
500
+ outcome = replace(result, status=status, steps=outcome.steps + result.steps)
501
+ self.outcome_by_id[operation.operation_id] = outcome
502
+ return outcome
503
+ return (yield from self._edit(operation, snapshot, name, changes, is_current))
504
+
505
+ def _add(self, operation: OperationIntent, snapshot: RemoteSnapshot, name: str, changes: tuple[RemoteChange, ...], is_current: Callable[[], bool] | None) -> Generator:
506
+ """Revalidate the add target at submission and again when Git actually launches."""
507
+ diagnostic = None
508
+ refreshed = None
509
+ status = OperationStatus.REFUSED
510
+ process_status = None
511
+ native_started = False
512
+ try:
513
+ if not operation.session_id:
514
+ raise ValueError("A live management session is required")
515
+ validate_name(name)
516
+ if is_current is not None and not is_current():
517
+ raise ValueError("The remote form is no longer current")
518
+ if len(changes) != 1 or next(iter(changes)).field != "add":
519
+ raise ValueError("Choose Add remote to create a new remote")
520
+ change = next(iter(changes))
521
+ if not change.value or any(character in change.value for character in ("\0", "\n", "\r")):
522
+ raise ValueError("Enter a URL without control characters")
523
+ current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
524
+ if name in snapshot.entry_by_name or name in current.entry_by_name:
525
+ raise ValueError("A remote with this name already exists")
526
+ yield GitCommand(["check-ref-format", REMOTE_PREFIX + name + "/check"], profile=READ_PROFILE)
527
+
528
+ def validate_launch() -> None:
529
+ """Reject a queued request after its form or captured configuration changed."""
530
+ nonlocal native_started
531
+ self.repository_operation.validate(operation)
532
+ allows = self.repository_operation.allows
533
+ if (is_current is not None and not is_current() or allows is not None and not allows(operation)
534
+ or config_signature(snapshot.context, current.values) != current.signature):
535
+ raise ValueError("Remote configuration or authority changed before launch; review and save again")
536
+ native_started = True
537
+
538
+ yield GitCommand(["remote", "add", "--", name, change.value], timeout_ms=GIT_MUTATION_TIMEOUT_MS,
539
+ idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
540
+ process_status = ProcessStatus.SUCCESS
541
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
542
+ entry = refreshed.entry_by_name.get(name)
543
+ if entry is None or not any(value.key == "remote." + name + ".url" and value.value == change.value for value in entry.values):
544
+ raise ValueError("Git completed but the saved remote could not be verified")
545
+ status = OperationStatus.SUCCESS
546
+ except (ValueError, OSError, GitCommandError) as error:
547
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Save remote", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
548
+ status = OperationStatus.PARTIAL if process_status == ProcessStatus.SUCCESS else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
549
+ if native_started:
550
+ try:
551
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
552
+ if name not in snapshot.entry_by_name and name in refreshed.entry_by_name:
553
+ status = OperationStatus.PARTIAL
554
+ except (ValueError, OSError, GitCommandError):
555
+ status = OperationStatus.UNKNOWN
556
+ step = OperationStepResult("Add remote", OperationStatus.SUCCESS if process_status == ProcessStatus.SUCCESS else status, snapshot, refreshed, process_status,
557
+ "created" if status == OperationStatus.SUCCESS else "verify saved remote" if process_status == ProcessStatus.SUCCESS else "not observed", diagnostic)
558
+ outcome = RemoteOutcome(operation.operation_id, status, (step,), diagnostic, refreshed, name)
559
+ self.outcome_by_id[operation.operation_id] = outcome
560
+ return outcome
561
+
562
+ def _edit(self, operation: OperationIntent, snapshot: RemoteSnapshot, name: str, changes: tuple[RemoteChange, ...], is_current: Callable[[], bool] | None) -> Generator:
563
+ """Save independent fields with fresh comparisons and observed partial results."""
564
+ expected = snapshot
565
+ refreshed = snapshot
566
+ steps: list[OperationStepResult] = []
567
+ diagnostic = None
568
+ field_set: set[str] = set()
569
+ for change in changes:
570
+ current = None
571
+ process_status = None
572
+ status = OperationStatus.REFUSED
573
+ cause = None
574
+ selected_name = name
575
+ selected = None
576
+ native_started = False
577
+ try:
578
+ if (not operation.session_id or change.field not in ("name", "url", "pushurl") or change.field in field_set
579
+ or is_current is not None and not is_current()):
580
+ raise ValueError("The remote change is invalid or no longer authorized")
581
+ field_set.add(change.field)
582
+ validate_name(name)
583
+ if not change.value or any(character in change.value for character in ("\0", "\n", "\r")):
584
+ raise ValueError("Enter a nonempty value without control characters")
585
+ current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
586
+ if name not in current.entry_by_name or name not in expected.entry_by_name:
587
+ raise ValueError("The selected remote no longer exists; reopen Manage remotes")
588
+ if current.entry_by_name[name].collision or current.entry_by_name[name].active != expected.entry_by_name[name].active:
589
+ raise ValueError("Remote activation changed or definitions collide; reopen Manage remotes")
590
+ reference_state = None
591
+ if change.field == "name":
592
+ if not current.entry_by_name[name].active:
593
+ raise ValueError("Activate this remote before renaming it so Git can update its references and tracking")
594
+ validate_name(change.value)
595
+ if change.value == name:
596
+ continue
597
+ if change.value in current.entry_by_name:
598
+ raise ValueError("A remote with this name already exists")
599
+ if current.entry_by_name[name] != expected.entry_by_name[name] or current.references != expected.references:
600
+ raise ValueError("Remote configuration or references changed; review the name change")
601
+ for value in current.entry_by_name[name].values:
602
+ if value not in current.direct_values:
603
+ raise ValueError("An inherited remote cannot be renamed locally without leaving its original definition")
604
+ tracking_values = tuple(value for value in current.values if value.key.startswith("branch.") and value.key.endswith((".remote", ".pushremote")) and value.value == name)
605
+ expected_tracking_values = tuple(value for value in expected.values if value.key.startswith("branch.") and value.key.endswith((".remote", ".pushremote")) and value.value == name)
606
+ if tracking_values != expected_tracking_values or any(value not in current.direct_values for value in tracking_values):
607
+ raise ValueError("Branch tracking changed or is inherited; a local rename would not update it safely")
608
+ yield GitCommand(["check-ref-format", REMOTE_PREFIX + change.value + "/check"], profile=READ_PROFILE)
609
+ reference_state = yield FileTask(lambda: reference_signature(snapshot.context))
610
+ arguments = ["remote", "rename", "--", name, change.value]
611
+ selected_name = change.value
612
+ else:
613
+ expected_values = field_values(expected, name, change.field)
614
+ if field_values(current, name, change.field) != expected_values:
615
+ raise ValueError("This field changed outside the form. Review the refreshed value before retrying.")
616
+ rewrite_values = tuple(value for value in current.values if value.key.startswith("url."))
617
+ expected_rewrite_values = tuple(value for value in expected.values if value.key.startswith("url."))
618
+ if rewrite_values != expected_rewrite_values:
619
+ raise ValueError("URL rewrite configuration changed. Review the effective destination before retrying.")
620
+ selected = replace(change.selected, key=remote_section(current, name) + "." + change.field) if change.selected is not None else None
621
+ if selected is not None and selected not in expected_values:
622
+ raise ValueError("The selected URL does not belong to this captured field")
623
+ reason = url_edit_reason(current, name, change.field, selected)
624
+ if reason:
625
+ raise ValueError(reason)
626
+ inherited = any(value not in current.direct_values for value in current.entry_by_name[name].values)
627
+ if selected is None and inherited and not change.local_override:
628
+ raise ValueError("Choose Create local Push URL override to replace the inherited fetch destination for pushes")
629
+ if selected is None and (change.field != "pushurl" or expected_values):
630
+ raise ValueError("Choose the exact configured URL to edit")
631
+ arguments = ["remote", "set-url"]
632
+ if change.field == "pushurl":
633
+ arguments.append("--push")
634
+ if selected is None:
635
+ arguments.append("--add")
636
+ arguments.extend(["--", name, change.value])
637
+ if selected is not None:
638
+ # Git validates against expanded URLs, then uses the same regex on raw config.
639
+ # Match both identities only after proving that no other raw value overlaps.
640
+ expanded = rewrite_url(selected.value, current.values)
641
+ patterns: list[str] = []
642
+ for text in (selected.value, expanded):
643
+ escaped = "".join("\\" + character if character in r"\.^$*+?{}[]()|" else character for character in text)
644
+ patterns.append("^" + escaped + "$")
645
+ arguments.append("(" + "|".join(patterns) + ")")
646
+ elif inherited:
647
+ # remote set-url refuses a remote defined only in global scope. A new local
648
+ # pushurl overrides fetch fallback without rewriting any inherited file.
649
+ arguments = ["config", "--local", "--add", "remote." + name + ".pushurl", change.value]
650
+ if not current.entry_by_name[name].active:
651
+ key = remote_section(current, name) + "." + change.field
652
+ if selected is not None:
653
+ arguments = ["config", "--local", "--fixed-value", "--replace-all", "--", key, change.value, selected.value]
654
+ else:
655
+ arguments = ["config", "--local", "--add", "--", key, change.value]
656
+
657
+ def validate_launch(current: RemoteSnapshot = current, reference_state: tuple | None = reference_state) -> None:
658
+ """Recheck live authority and all read sources immediately before native launch."""
659
+ nonlocal native_started
660
+ self.repository_operation.validate(operation)
661
+ allows = self.repository_operation.allows
662
+ if (is_current is not None and not is_current() or allows is not None and not allows(operation)
663
+ or config_signature(snapshot.context, current.values) != current.signature
664
+ or reference_state is not None and reference_signature(snapshot.context) != reference_state):
665
+ raise ValueError("Remote configuration, references or authority changed before launch")
666
+ native_started = True
667
+
668
+ yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
669
+ process_status = ProcessStatus.SUCCESS
670
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
671
+ if change.field == "name":
672
+ if name in refreshed.entry_by_name or selected_name not in refreshed.entry_by_name:
673
+ raise ValueError("Git completed the rename but its final remote definition is uncertain")
674
+ old_prefix, new_prefix = REMOTE_PREFIX + name + "/", REMOTE_PREFIX + selected_name + "/"
675
+ # for-each-ref sorts by full name, so compare records after a rename changes sort order.
676
+ if sorted(refreshed.references.splitlines()) != sorted(current.references.replace(old_prefix.encode("utf-8"), new_prefix.encode("utf-8")).splitlines()):
677
+ raise ValueError("The renamed remote references differ from their captured targets")
678
+ expected = self._renamed_snapshot(expected, name, selected_name)
679
+ name = selected_name
680
+ else:
681
+ expected_strings: list[str] = []
682
+ for value in field_values(current, name, change.field):
683
+ expected_strings.append(change.value if value == selected else value.value)
684
+ if selected is None:
685
+ expected_strings.append(change.value)
686
+ actual_strings = tuple(value.value for value in field_values(refreshed, name, change.field))
687
+ if actual_strings != tuple(expected_strings):
688
+ raise ValueError("Git completed but the exact URL values could not be verified")
689
+ expected = self._updated_field(expected, refreshed, name, change.field)
690
+ status = OperationStatus.SUCCESS
691
+ except (ValueError, OSError, GitCommandError) as error:
692
+ cause = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Save remote", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
693
+ diagnostic = diagnostic or cause
694
+ if isinstance(error, GitCommandError):
695
+ process_status = error.process_status
696
+ status = OperationStatus.UNKNOWN if process_status == ProcessStatus.SUCCESS else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
697
+ try:
698
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
699
+ if change.field == "name" and name not in refreshed.entry_by_name and selected_name in refreshed.entry_by_name:
700
+ expected = self._renamed_snapshot(expected, name, selected_name)
701
+ name = selected_name
702
+ status = OperationStatus.UNKNOWN
703
+ elif native_started and change.field in ("url", "pushurl") and any(value.value == change.value for value in field_values(refreshed, name, change.field)):
704
+ status = OperationStatus.UNKNOWN
705
+ except (ValueError, OSError, GitCommandError):
706
+ refreshed = None
707
+ steps.append(OperationStepResult(REMOTE_FIELD_LABEL_BY_NAME.get(change.field, "Remote field"), status, change, refreshed, process_status,
708
+ "saved" if status == OperationStatus.SUCCESS else "verify effect" if status == OperationStatus.UNKNOWN else "not saved", cause))
709
+ if status == OperationStatus.UNKNOWN:
710
+ break
711
+ success = any(step.status == OperationStatus.SUCCESS for step in steps)
712
+ incomplete = any(step.status != OperationStatus.SUCCESS for step in steps)
713
+ if success:
714
+ status = OperationStatus.PARTIAL if incomplete else OperationStatus.SUCCESS
715
+ else:
716
+ status = OperationStatus.UNKNOWN if any(step.status == OperationStatus.UNKNOWN for step in steps) else OperationStatus.FAILED if any(step.status == OperationStatus.FAILED for step in steps) else OperationStatus.REFUSED
717
+ outcome = RemoteOutcome(operation.operation_id, status, tuple(steps), diagnostic, refreshed, name)
718
+ self.outcome_by_id[operation.operation_id] = outcome
719
+ return outcome
720
+
721
+ def _renamed_snapshot(self, snapshot: RemoteSnapshot, old: str, new: str) -> RemoteSnapshot:
722
+ """Remap only native rename effects while keeping unrelated field expectations stale."""
723
+ def renamed(value: RemoteValue) -> RemoteValue:
724
+ """Follow the native section, refspec and tracking association rename."""
725
+ key = value.key
726
+ text = value.value
727
+ if key.startswith("remote." + old + "."):
728
+ key = "remote." + new + "." + key.removeprefix("remote." + old + ".")
729
+ if key.endswith(".fetch"):
730
+ text = text.replace(REMOTE_PREFIX + old + "/", REMOTE_PREFIX + new + "/")
731
+ if key.startswith("branch.") and key.endswith((".remote", ".pushremote")) and text == old:
732
+ text = new
733
+ return replace(value, key=key, value=text)
734
+
735
+ values = tuple(renamed(value) for value in snapshot.values)
736
+ direct_values = tuple(renamed(value) for value in snapshot.direct_values)
737
+ entry_by_name = dict(snapshot.entry_by_name)
738
+ entry = entry_by_name.pop(old)
739
+ entry_by_name[new] = replace(entry, name=new, values=tuple(renamed(value) for value in entry.values))
740
+ return replace(snapshot, values=values, direct_values=direct_values, entry_by_name=entry_by_name)
741
+
742
+ def _updated_field(self, expected: RemoteSnapshot, current: RemoteSnapshot, name: str, field: str) -> RemoteSnapshot:
743
+ """Accept one successful field without accepting unrelated concurrent changes."""
744
+ key = remote_section(current, name) + "." + field
745
+ values = tuple(value for value in expected.values if value.key != key) + field_values(current, name, field)
746
+ direct_values = tuple(value for value in expected.direct_values if value.key != key) + field_values(current, name, field, direct=True)
747
+ return replace(expected, values=values, direct_values=direct_values)