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