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