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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- 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)
|