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,480 @@
|
|
|
1
|
+
"""Locate a usable official Git Credential Manager on this machine, with the reason each refused candidate is refused.
|
|
2
|
+
|
|
3
|
+
Everything here is blocking and free of Qt objects: the search starts short processes, so it runs away from the
|
|
4
|
+
window's thread.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from gitpane.account.credential_manager_install import distribution_gcm_install_lines
|
|
20
|
+
from gitpane.account.ssh import GIT_FOR_WINDOWS_SOURCE_TEXT, PATH_SOURCE_TEXT, absolute_executable_available
|
|
21
|
+
from gitpane.config import (
|
|
22
|
+
CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
|
|
23
|
+
GCM_ENVIRONMENT_PREFIX,
|
|
24
|
+
GCM_MINIMUM_VERSION,
|
|
25
|
+
GCM_REQUIRED_TEXT,
|
|
26
|
+
GCM_VERSION_TIMEOUT_MS,
|
|
27
|
+
GIT_OPEN_TIMEOUT_MS,
|
|
28
|
+
MILLISECONDS_PER_SECOND,
|
|
29
|
+
WINDOWS_PLATFORM,
|
|
30
|
+
)
|
|
31
|
+
from gitpane.system.linux_distribution import DistributionFamily
|
|
32
|
+
from gitpane.system.network_folder import network_directory
|
|
33
|
+
from gitpane.tool.git_executable import GIT_BINARY_DIR_BASENAME, GIT_EXEC_PATH_ARGUMENT, PATH_ENVIRONMENT_NAME, UNKNOWN_OUTPUT_LOG_LIMIT, parse_exec_path_answer
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
|
|
38
|
+
from gitpane.account.credential_manager_install import GcmArchiveArchitecture
|
|
39
|
+
from gitpane.config import CredentialManagerPlatform
|
|
40
|
+
|
|
41
|
+
GCM_VERSION_ARGUMENT = "--version"
|
|
42
|
+
GIT_EXEC_PATH_ENVIRONMENT_NAME = "GIT_EXEC_PATH"
|
|
43
|
+
GCM_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
|
44
|
+
STANDARD_LOCATION_SOURCE_TEXT = "Standard location"
|
|
45
|
+
# Result written to --log for a candidate that is listed but never started.
|
|
46
|
+
NETWORK_SHARE_SKIP_TEXT = "network share"
|
|
47
|
+
OTHER_TOOLCHAIN_SKIP_TEXT = "another toolchain folder of the Git in use"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CredentialManagerProblem(Enum):
|
|
51
|
+
"""Say why no usable Git Credential Manager was found; NONE when the detection names a usable one."""
|
|
52
|
+
|
|
53
|
+
NONE = "none"
|
|
54
|
+
MISSING = "missing"
|
|
55
|
+
TOO_OLD = "too_old"
|
|
56
|
+
UNREADABLE_VERSION = "unreadable_version"
|
|
57
|
+
NOT_STARTING = "not_starting"
|
|
58
|
+
UNSUPPORTED_PLATFORM = "unsupported_platform"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# When no candidate is usable, the reason kept is the one that tells the user best what to do: an old version names
|
|
62
|
+
# what to update, an unreadable version or a program that does not start says the installation itself is wrong, and
|
|
63
|
+
# absence comes last.
|
|
64
|
+
MOST_USEFUL_FIRST_REFUSAL_PROBLEMS = (CredentialManagerProblem.TOO_OLD, CredentialManagerProblem.UNREADABLE_VERSION,
|
|
65
|
+
CredentialManagerProblem.NOT_STARTING, CredentialManagerProblem.MISSING)
|
|
66
|
+
|
|
67
|
+
# Each refusal has one constant sentence, defined here only: the refused operation shows it in red, and the window that
|
|
68
|
+
# explains how to install the credential manager recognises it. Each sentence starts with words of its own, so a reader
|
|
69
|
+
# of a failure cause can tell them apart line by line. The path of the refused credential manager ends the sentence when
|
|
70
|
+
# there is one, so the user knows which file to update or remove.
|
|
71
|
+
CREDENTIAL_MANAGER_MISSING_TEXT = "Git credential manager not found."
|
|
72
|
+
CREDENTIAL_MANAGER_TOO_OLD_TEXT = "Git credential manager {version} is too old, {minimum} or later is required: {filename}"
|
|
73
|
+
CREDENTIAL_MANAGER_UNREADABLE_VERSION_TEXT = "Could not read the Git credential manager version: {filename}"
|
|
74
|
+
CREDENTIAL_MANAGER_NOT_STARTING_TEXT = "Git credential manager did not start: {filename}"
|
|
75
|
+
CREDENTIAL_MANAGER_UNSUPPORTED_SYSTEM_TEXT = "Git credential manager is not supported on this system: {platform}"
|
|
76
|
+
REFUSAL_TEXT_BY_PROBLEM: dict[CredentialManagerProblem, str] = {
|
|
77
|
+
CredentialManagerProblem.MISSING: CREDENTIAL_MANAGER_MISSING_TEXT,
|
|
78
|
+
CredentialManagerProblem.TOO_OLD: CREDENTIAL_MANAGER_TOO_OLD_TEXT,
|
|
79
|
+
CredentialManagerProblem.UNREADABLE_VERSION: CREDENTIAL_MANAGER_UNREADABLE_VERSION_TEXT,
|
|
80
|
+
CredentialManagerProblem.NOT_STARTING: CREDENTIAL_MANAGER_NOT_STARTING_TEXT,
|
|
81
|
+
CredentialManagerProblem.UNSUPPORTED_PLATFORM: CREDENTIAL_MANAGER_UNSUPPORTED_SYSTEM_TEXT,
|
|
82
|
+
}
|
|
83
|
+
# The refusals that leave the user something to install, so they open the window explaining how. A system missing from
|
|
84
|
+
# the platform table has no official credential manager to offer, so its refusal only shows its red line.
|
|
85
|
+
INSTALL_WINDOW_PROBLEM_SET: frozenset[CredentialManagerProblem] = frozenset({CredentialManagerProblem.MISSING, CredentialManagerProblem.TOO_OLD,
|
|
86
|
+
CredentialManagerProblem.UNREADABLE_VERSION, CredentialManagerProblem.NOT_STARTING})
|
|
87
|
+
# First line of the install window for a credential manager that is too old. The path stays in the red line of the
|
|
88
|
+
# refused operation and in Settings, so this line names only the versions.
|
|
89
|
+
CREDENTIAL_MANAGER_TOO_OLD_HEADER_TEXT = "Your Git credential manager ({version}) is too old, {minimum} or later is required"
|
|
90
|
+
# Short line of the install window when a new search finds the same refusal the window already explains, a missing
|
|
91
|
+
# credential manager or the same version still too old alike.
|
|
92
|
+
CREDENTIAL_MANAGER_STILL_REFUSED_TEXT = "Still not good. (what did you expect? 😂)"
|
|
93
|
+
# Read-only line of Settings: the version and path of the credential manager in use, or its absence.
|
|
94
|
+
CREDENTIAL_MANAGER_FOUND_TEXT = "{version} — {filename}"
|
|
95
|
+
CREDENTIAL_MANAGER_NOT_FOUND_TEXT = "not found"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class CredentialManagerDetection:
|
|
100
|
+
"""Name the credential manager to use with the version read and where it was found, or say why none is usable.
|
|
101
|
+
|
|
102
|
+
``exec_path`` is the folder of the Git in use; every direct start of the credential manager must receive it so the
|
|
103
|
+
credential manager finds Git without the PATH. It is empty when Git did not tell it.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
filename: str = ""
|
|
107
|
+
version: str = ""
|
|
108
|
+
source: str = ""
|
|
109
|
+
problem: CredentialManagerProblem = CredentialManagerProblem.MISSING
|
|
110
|
+
exec_path: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class CredentialManagerCandidate:
|
|
115
|
+
"""Name one file to try and where it comes from.
|
|
116
|
+
|
|
117
|
+
``skip_reason`` is not empty when the file is only listed to be written to --log as skipped: it is never started.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
filename: str
|
|
121
|
+
source: str
|
|
122
|
+
skip_reason: str = ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _other_toolchain_bin_dir(path_dir: str, installation_dir: str, git_bin_dir: str) -> bool:
|
|
126
|
+
"""Say whether ``path_dir`` is a second-level folder of the Git installation other than the bin folder of the Git in use.
|
|
127
|
+
|
|
128
|
+
All three paths are compared normalized and case-folded the way the system compares them.
|
|
129
|
+
"""
|
|
130
|
+
path_key = os.path.normcase(os.path.normpath(path_dir))
|
|
131
|
+
grandparent_key = os.path.dirname(os.path.dirname(path_key))
|
|
132
|
+
if grandparent_key != os.path.normcase(os.path.normpath(installation_dir)):
|
|
133
|
+
return False
|
|
134
|
+
return path_key != os.path.normcase(os.path.normpath(git_bin_dir))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class BoundedCommandAnswer:
|
|
139
|
+
"""Keep what a short command answered: its exit code and standard output, or why it gave no answer at all."""
|
|
140
|
+
|
|
141
|
+
exit_code: int | None = None
|
|
142
|
+
output: bytes = b""
|
|
143
|
+
failure: str = ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_bounded_command(arguments: list[str], environment_by_name: Mapping[str, str], working_dir: str, timeout_ms: int) -> BoundedCommandAnswer:
|
|
147
|
+
"""Run one short read-only command of an external program within a time limit, and never raise.
|
|
148
|
+
|
|
149
|
+
Its input is closed, so a program that waits for input fails instead of hanging, and on Windows no console window
|
|
150
|
+
opens. The console flag is chosen by os.name and not by a simulated platform, because it only exists on Windows.
|
|
151
|
+
The error output is dropped without being read: a warning written there never decides the version. The time limit
|
|
152
|
+
holds even when the program leaves children behind.
|
|
153
|
+
"""
|
|
154
|
+
# Workaround: a pipe stays open as long as a child of the program keeps it. On Windows, subprocess.run reads the
|
|
155
|
+
# pipes again after killing the program at the time limit, so it waits for such a child (a launcher, or the warning
|
|
156
|
+
# stand-in that starts the real credential manager); on every system a program that answered and then left a child
|
|
157
|
+
# would look like it never answered. A file has no end to wait for, so the time limit bounds the call everywhere
|
|
158
|
+
# and only the started program is killed and waited for.
|
|
159
|
+
try:
|
|
160
|
+
with tempfile.TemporaryFile() as output_file:
|
|
161
|
+
completed = subprocess.run(arguments, stdin=subprocess.DEVNULL, stdout=output_file, stderr=subprocess.DEVNULL, cwd=working_dir,
|
|
162
|
+
env=dict(environment_by_name), timeout=timeout_ms / MILLISECONDS_PER_SECOND, check=False,
|
|
163
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
164
|
+
output_file.seek(0)
|
|
165
|
+
output = output_file.read()
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
return BoundedCommandAnswer(failure=f"no answer within {timeout_ms / MILLISECONDS_PER_SECOND:g} s")
|
|
168
|
+
except (OSError, ValueError) as error:
|
|
169
|
+
return BoundedCommandAnswer(failure=type(error).__name__ + ": " + str(error))
|
|
170
|
+
return BoundedCommandAnswer(completed.returncode, output)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ignore_line(text: str) -> None:
|
|
174
|
+
"""Write nothing: the log used when the caller keeps no --log file."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class ProbeVerdict:
|
|
179
|
+
"""Say what one --version answer means: the problem it shows (NONE when usable), the version read and the --log result."""
|
|
180
|
+
|
|
181
|
+
problem: CredentialManagerProblem
|
|
182
|
+
version: str
|
|
183
|
+
result_text: str
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _version_numbers(output: bytes) -> tuple[int, int, int] | None:
|
|
187
|
+
"""Read the first three numbers of the first non-empty line of a --version output, or None when there are none."""
|
|
188
|
+
for line in output.decode("utf-8", errors="replace").splitlines():
|
|
189
|
+
if not line.strip():
|
|
190
|
+
continue
|
|
191
|
+
match = GCM_VERSION_PATTERN.search(line)
|
|
192
|
+
if match is None:
|
|
193
|
+
return None
|
|
194
|
+
major, minor, patch = match.groups()
|
|
195
|
+
return int(major), int(minor), int(patch)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _version_text(numbers: tuple[int, int, int]) -> str:
|
|
200
|
+
"""Write a version as X.Y.Z, the form shown to the user whatever build suffix the program printed."""
|
|
201
|
+
major, minor, patch = numbers
|
|
202
|
+
return f"{major}.{minor}.{patch}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def credential_manager_refusal_text(detection: CredentialManagerDetection, platform: str) -> str:
|
|
206
|
+
"""Write the sentence of one refusal, ending with the path of the refused credential manager when there is one.
|
|
207
|
+
|
|
208
|
+
``platform`` names the system only for the refusal of a system the search does not support.
|
|
209
|
+
"""
|
|
210
|
+
return REFUSAL_TEXT_BY_PROBLEM[detection.problem].format(filename=detection.filename, version=detection.version,
|
|
211
|
+
minimum=_version_text(GCM_MINIMUM_VERSION), platform=platform)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def credential_manager_check_again_text(shown: CredentialManagerDetection, found: CredentialManagerDetection, platform: str) -> str:
|
|
215
|
+
"""Write what the install window says after a new search that still refuses.
|
|
216
|
+
|
|
217
|
+
The short line only when the window would explain the refusal ``found`` with the very lines it already shows for
|
|
218
|
+
``shown``; any other refusal, including the same reason with another version or file, gives its own sentence, path
|
|
219
|
+
included, and the window then shows that refusal's text.
|
|
220
|
+
"""
|
|
221
|
+
if _install_header_lines(found, platform) == _install_header_lines(shown, platform):
|
|
222
|
+
return CREDENTIAL_MANAGER_STILL_REFUSED_TEXT
|
|
223
|
+
return credential_manager_refusal_text(found, platform)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def credential_manager_setting_text(detection: CredentialManagerDetection, platform: str) -> str:
|
|
227
|
+
"""Write the Settings line: the version and path of the credential manager in use, ``not found`` for an absence, the refusal sentence otherwise."""
|
|
228
|
+
if detection.problem == CredentialManagerProblem.NONE:
|
|
229
|
+
return CREDENTIAL_MANAGER_FOUND_TEXT.format(version=detection.version, filename=detection.filename)
|
|
230
|
+
if detection.problem == CredentialManagerProblem.MISSING:
|
|
231
|
+
return CREDENTIAL_MANAGER_NOT_FOUND_TEXT
|
|
232
|
+
return credential_manager_refusal_text(detection, platform)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class CredentialManagerRefusal(ValueError):
|
|
236
|
+
"""Raised when the credential manager GitPane looked for cannot be used; its message is the constant sentence of that refusal.
|
|
237
|
+
|
|
238
|
+
An operation that stops on it started no Git process for the refused destination, so its console reads it as a
|
|
239
|
+
refusal before launch. It keeps the detection too, so the window explaining how to install the credential manager
|
|
240
|
+
knows the reason and the version without reading the sentence back.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, detection: CredentialManagerDetection, platform: str) -> None:
|
|
244
|
+
"""Build the refusal sentence of ``detection`` and keep the detection itself."""
|
|
245
|
+
super().__init__(credential_manager_refusal_text(detection, platform))
|
|
246
|
+
self.detection = detection
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _install_header_lines(detection: CredentialManagerDetection, platform: str) -> tuple[str, ...]:
|
|
250
|
+
"""Write the lines that open the install window for this refusal, saying why it is refused when there is more to say than its absence.
|
|
251
|
+
|
|
252
|
+
The too-old line names only the version, so the same old version found in another file opens the window the same way.
|
|
253
|
+
"""
|
|
254
|
+
if detection.problem == CredentialManagerProblem.TOO_OLD:
|
|
255
|
+
return (CREDENTIAL_MANAGER_TOO_OLD_HEADER_TEXT.format(version=detection.version, minimum=_version_text(GCM_MINIMUM_VERSION)),)
|
|
256
|
+
if detection.problem == CredentialManagerProblem.MISSING:
|
|
257
|
+
return ()
|
|
258
|
+
return (credential_manager_refusal_text(detection, platform),)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def platform_install_lines(entry: CredentialManagerPlatform, problem: CredentialManagerProblem) -> tuple[str, ...]:
|
|
262
|
+
"""Pick the install lines of one refusal: the system's lines for that reason when it has its own, else its general lines.
|
|
263
|
+
|
|
264
|
+
A missing credential manager and one that is too old may need different advice (install versus upgrade); a system
|
|
265
|
+
that gives no lines of its own for a reason shows its general install lines for it.
|
|
266
|
+
"""
|
|
267
|
+
reason_lines_by_problem = {CredentialManagerProblem.MISSING: entry.missing_install_lines, CredentialManagerProblem.TOO_OLD: entry.too_old_install_lines}
|
|
268
|
+
reason_lines = reason_lines_by_problem.get(problem, ())
|
|
269
|
+
if reason_lines:
|
|
270
|
+
return reason_lines
|
|
271
|
+
return entry.install_lines
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _probe_verdict(answer: BoundedCommandAnswer) -> ProbeVerdict:
|
|
275
|
+
"""Classify one --version answer.
|
|
276
|
+
|
|
277
|
+
A non-zero exit code means the program does not start properly even when it printed a version. An output without
|
|
278
|
+
a version is kept apart from a program that does not start, and only a cut, single-line form of it is logged,
|
|
279
|
+
since it comes from an unknown program.
|
|
280
|
+
"""
|
|
281
|
+
if answer.failure:
|
|
282
|
+
return ProbeVerdict(CredentialManagerProblem.NOT_STARTING, "", "did not start: " + answer.failure)
|
|
283
|
+
if answer.exit_code != 0:
|
|
284
|
+
return ProbeVerdict(CredentialManagerProblem.NOT_STARTING, "", f"did not start: exit code {answer.exit_code}")
|
|
285
|
+
numbers = _version_numbers(answer.output)
|
|
286
|
+
if numbers is None:
|
|
287
|
+
output_text = answer.output.decode("utf-8", errors="replace")[:UNKNOWN_OUTPUT_LOG_LIMIT]
|
|
288
|
+
return ProbeVerdict(CredentialManagerProblem.UNREADABLE_VERSION, "", "unreadable version: " + repr(output_text))
|
|
289
|
+
version = _version_text(numbers)
|
|
290
|
+
if numbers < GCM_MINIMUM_VERSION:
|
|
291
|
+
return ProbeVerdict(CredentialManagerProblem.TOO_OLD, version, f"too old ({version})")
|
|
292
|
+
return ProbeVerdict(CredentialManagerProblem.NONE, version, f"accepted ({version})")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class CredentialManagerLocator:
|
|
296
|
+
"""Find the official Git Credential Manager for a Git executable and keep only usable answers for the session.
|
|
297
|
+
|
|
298
|
+
Blocking and free of Qt objects: it starts short processes and may wait up to its time limit for each candidate,
|
|
299
|
+
so callers run it away from the window's thread. Its search never raises. A credential manager installed after a
|
|
300
|
+
refusal is found by the next search, since refusals are never kept.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def __init__(self, platform: str, environment_by_name: Mapping[str, str], *, log_line: Callable[[str], None] = ignore_line,
|
|
304
|
+
timeout_ms: int = GCM_VERSION_TIMEOUT_MS, platform_entry_by_name: Mapping[str, CredentialManagerPlatform] = CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
|
|
305
|
+
available: Callable[[str], bool] = absolute_executable_available, network: Callable[[str], bool] = network_directory,
|
|
306
|
+
distribution_family: DistributionFamily = DistributionFamily.UNKNOWN, architecture: GcmArchiveArchitecture | None = None) -> None:
|
|
307
|
+
"""Keep what the search needs; the distribution family and the processor only choose the Linux install lines.
|
|
308
|
+
|
|
309
|
+
Their defaults read nothing from the machine and give the lines of an unknown distribution on an unknown
|
|
310
|
+
processor; the composition root passes the ones of the running session.
|
|
311
|
+
"""
|
|
312
|
+
self.platform = platform
|
|
313
|
+
self.distribution_family = distribution_family
|
|
314
|
+
self.architecture = architecture
|
|
315
|
+
self.environment_by_name = environment_by_name
|
|
316
|
+
self.log_line = log_line
|
|
317
|
+
self.timeout_ms = timeout_ms
|
|
318
|
+
self.platform_entry_by_name = platform_entry_by_name
|
|
319
|
+
self.available = available
|
|
320
|
+
self.network = network
|
|
321
|
+
self.detection_by_git_filename: dict[str, CredentialManagerDetection] = {}
|
|
322
|
+
# Several background tasks may need the credential manager at the same time (fetch, push, clone, accounts):
|
|
323
|
+
# one search at a time keeps them from starting the same probes twice and from mixing their --log lines.
|
|
324
|
+
self.lock = threading.Lock()
|
|
325
|
+
|
|
326
|
+
def refusal_install_lines(self, problem: CredentialManagerProblem) -> tuple[str, ...]:
|
|
327
|
+
"""Write what to install for a refusal: the lines of this search's distribution and processor where the system's entry says they depend on them, else the entry's lines for that refusal.
|
|
328
|
+
|
|
329
|
+
On Linux, a credential manager too old, unreadable or not starting gets the same lines as a missing one: a
|
|
330
|
+
second extraction of the archive, or a second install of the package, replaces its files.
|
|
331
|
+
"""
|
|
332
|
+
entry = self.platform_entry_by_name[self.platform]
|
|
333
|
+
if entry.install_lines_by_distribution:
|
|
334
|
+
return distribution_gcm_install_lines(self.distribution_family, self.architecture)
|
|
335
|
+
return platform_install_lines(entry, problem)
|
|
336
|
+
|
|
337
|
+
def install_lines(self, detection: CredentialManagerDetection) -> tuple[str, ...]:
|
|
338
|
+
"""Write the lines of the install window for a refusal of this search: why it is refused when there is more to say than its absence, then what to install.
|
|
339
|
+
|
|
340
|
+
The install lines come from refusal_install_lines, read from this search's own platform table, the one it
|
|
341
|
+
searched with, and follow the reason of the refusal. Called only for a refusal of INSTALL_WINDOW_PROBLEM_SET,
|
|
342
|
+
which a system missing from that table never gives.
|
|
343
|
+
"""
|
|
344
|
+
return (*_install_header_lines(detection, self.platform), GCM_REQUIRED_TEXT, *self.refusal_install_lines(detection.problem))
|
|
345
|
+
|
|
346
|
+
def _git_exec_path(self, git_filename: str) -> str:
|
|
347
|
+
"""Ask the Git in use for the folder of its own programs, or return "" and log why it is unknown.
|
|
348
|
+
|
|
349
|
+
An inherited GIT_EXEC_PATH is removed first: Git would answer that variable instead of its own folder. Only one
|
|
350
|
+
absolute line counts as an answer; the search goes on without it otherwise.
|
|
351
|
+
"""
|
|
352
|
+
environment_by_name = dict(self.environment_by_name)
|
|
353
|
+
environment_by_name.pop(GIT_EXEC_PATH_ENVIRONMENT_NAME, None)
|
|
354
|
+
answer = run_bounded_command([git_filename, GIT_EXEC_PATH_ARGUMENT], environment_by_name, os.path.dirname(git_filename), GIT_OPEN_TIMEOUT_MS)
|
|
355
|
+
cause = answer.failure
|
|
356
|
+
if not cause and answer.exit_code != 0:
|
|
357
|
+
cause = f"exit code {answer.exit_code}"
|
|
358
|
+
if not cause:
|
|
359
|
+
# Read as a file name, a folder whose name is not UTF-8 reaches the credential manager unchanged in
|
|
360
|
+
# GIT_EXEC_PATH.
|
|
361
|
+
try:
|
|
362
|
+
return parse_exec_path_answer(answer.output)
|
|
363
|
+
except ValueError as error:
|
|
364
|
+
cause = str(error)
|
|
365
|
+
self.log_line(f"Git exec path not read for {git_filename}: {cause}")
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
def _probe_environment(self, exec_path: str) -> dict[str, str]:
|
|
369
|
+
"""Return the environment of a --version probe: the user's credential manager settings removed, Git's folder given.
|
|
370
|
+
|
|
371
|
+
A setting such as a debugger wait would make a sound credential manager look broken, and without Git's folder
|
|
372
|
+
the credential manager cannot find Git when Git is not on the PATH.
|
|
373
|
+
"""
|
|
374
|
+
environment_by_name = dict(self.environment_by_name)
|
|
375
|
+
for name in tuple(environment_by_name):
|
|
376
|
+
if name.startswith(GCM_ENVIRONMENT_PREFIX):
|
|
377
|
+
environment_by_name.pop(name)
|
|
378
|
+
if exec_path:
|
|
379
|
+
environment_by_name[GIT_EXEC_PATH_ENVIRONMENT_NAME] = exec_path
|
|
380
|
+
return environment_by_name
|
|
381
|
+
|
|
382
|
+
def _candidates(self, entry: CredentialManagerPlatform, exec_path: str) -> list[CredentialManagerCandidate]:
|
|
383
|
+
"""List the files to try with where each comes from, each file once, in search order.
|
|
384
|
+
|
|
385
|
+
The installation of the Git in use comes first when the system has one to search, then the PATH, then the
|
|
386
|
+
fixed locations of the system. Skipped candidates stay in the list at their place, so the search writes them in
|
|
387
|
+
order and stops before them once a credential manager is accepted.
|
|
388
|
+
"""
|
|
389
|
+
windows = self.platform == WINDOWS_PLATFORM
|
|
390
|
+
listed_candidates: list[CredentialManagerCandidate] = []
|
|
391
|
+
installation_dir = ""
|
|
392
|
+
git_bin_dir = ""
|
|
393
|
+
if entry.searches_git_installation and exec_path:
|
|
394
|
+
# Git for Windows answers --exec-path with <prefix>/libexec/git-core, where <prefix> is the toolchain folder
|
|
395
|
+
# of this installation; that folder changes with the version and the architecture. Its credential manager
|
|
396
|
+
# sits in <prefix>/bin. No folder name is written here, so a later move is followed by itself, and the
|
|
397
|
+
# warning stand-in left in another toolchain folder is never taken, neither here nor from the PATH below.
|
|
398
|
+
prefix_dir = os.path.dirname(os.path.dirname(exec_path))
|
|
399
|
+
git_bin_dir = os.path.join(prefix_dir, GIT_BINARY_DIR_BASENAME)
|
|
400
|
+
installation_dir = os.path.dirname(prefix_dir)
|
|
401
|
+
listed_candidates.append(CredentialManagerCandidate(os.path.join(git_bin_dir, entry.executable_basename), GIT_FOR_WINDOWS_SOURCE_TEXT))
|
|
402
|
+
# The PATH is read here instead of with shutil.which, which on Windows looks in the current folder first: a
|
|
403
|
+
# program dropped there, or in a relative PATH entry, must never be started and handed account secrets.
|
|
404
|
+
for path_dir in self.environment_by_name.get(PATH_ENVIRONMENT_NAME, "").split(os.pathsep):
|
|
405
|
+
if not os.path.isabs(path_dir):
|
|
406
|
+
continue
|
|
407
|
+
filename = os.path.join(path_dir, entry.executable_basename)
|
|
408
|
+
if windows and self.network(path_dir):
|
|
409
|
+
listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT, skip_reason=NETWORK_SHARE_SKIP_TEXT))
|
|
410
|
+
continue
|
|
411
|
+
# The Git for Windows install option that adds its Unix tools to the PATH puts there the bin folder of
|
|
412
|
+
# another toolchain folder of the same installation. The credential manager found there is the warning
|
|
413
|
+
# stand-in that starts the real one and adds a warning to every call. It is skipped even when the credential
|
|
414
|
+
# manager of the Git in use is refused or missing, otherwise it would be accepted and kept for the session.
|
|
415
|
+
if installation_dir and _other_toolchain_bin_dir(path_dir, installation_dir, git_bin_dir):
|
|
416
|
+
listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT, skip_reason=OTHER_TOOLCHAIN_SKIP_TEXT))
|
|
417
|
+
continue
|
|
418
|
+
listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT))
|
|
419
|
+
for fixed_filename in entry.fixed_filenames:
|
|
420
|
+
listed_candidates.append(CredentialManagerCandidate(fixed_filename, STANDARD_LOCATION_SOURCE_TEXT))
|
|
421
|
+
candidates: list[CredentialManagerCandidate] = []
|
|
422
|
+
identity_set: set[str] = set()
|
|
423
|
+
for candidate in listed_candidates:
|
|
424
|
+
identity = os.path.normcase(os.path.abspath(candidate.filename))
|
|
425
|
+
if identity in identity_set:
|
|
426
|
+
continue
|
|
427
|
+
identity_set.add(identity)
|
|
428
|
+
candidates.append(candidate)
|
|
429
|
+
return candidates
|
|
430
|
+
|
|
431
|
+
def locate(self, git_filename: str) -> CredentialManagerDetection:
|
|
432
|
+
"""Return the usable credential manager for this Git, or the most useful reason none is usable; never raises.
|
|
433
|
+
|
|
434
|
+
Candidates are tried in search order and the first accepted one wins. When none is accepted, the refusal kept
|
|
435
|
+
is the most useful one; between equal reasons the first candidate seen keeps its file and version. A success
|
|
436
|
+
is kept for this Git and returned again without starting anything while its file and Git's folder still exist.
|
|
437
|
+
"""
|
|
438
|
+
with self.lock:
|
|
439
|
+
entry = self.platform_entry_by_name.get(self.platform)
|
|
440
|
+
if entry is None:
|
|
441
|
+
self.log_line(f"Credential manager not searched: unsupported system {self.platform}")
|
|
442
|
+
return CredentialManagerDetection(problem=CredentialManagerProblem.UNSUPPORTED_PLATFORM)
|
|
443
|
+
kept = self.detection_by_git_filename.get(git_filename)
|
|
444
|
+
if kept is not None:
|
|
445
|
+
# An update of Git for Windows can move its programs folder while the credential manager file stays.
|
|
446
|
+
if self.available(kept.filename) and (not kept.exec_path or os.path.isdir(kept.exec_path)):
|
|
447
|
+
return kept
|
|
448
|
+
del self.detection_by_git_filename[git_filename]
|
|
449
|
+
self.log_line(f"Kept credential manager is gone, searching again: {kept.filename}")
|
|
450
|
+
detection = self._search(entry, git_filename)
|
|
451
|
+
if detection.problem == CredentialManagerProblem.NONE:
|
|
452
|
+
self.detection_by_git_filename[git_filename] = detection
|
|
453
|
+
return detection
|
|
454
|
+
|
|
455
|
+
def _search(self, entry: CredentialManagerPlatform, git_filename: str) -> CredentialManagerDetection:
|
|
456
|
+
"""Try every candidate for this Git and return the first accepted one, or the most useful refusal."""
|
|
457
|
+
exec_path = self._git_exec_path(git_filename)
|
|
458
|
+
probe_environment_by_name = self._probe_environment(exec_path)
|
|
459
|
+
refusal = CredentialManagerDetection(exec_path=exec_path)
|
|
460
|
+
for candidate in self._candidates(entry, exec_path):
|
|
461
|
+
if candidate.skip_reason:
|
|
462
|
+
self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): skipped, {candidate.skip_reason}")
|
|
463
|
+
continue
|
|
464
|
+
if not self.available(candidate.filename):
|
|
465
|
+
self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): not found")
|
|
466
|
+
continue
|
|
467
|
+
answer = run_bounded_command([candidate.filename, GCM_VERSION_ARGUMENT], probe_environment_by_name, os.path.dirname(candidate.filename), self.timeout_ms)
|
|
468
|
+
verdict = _probe_verdict(answer)
|
|
469
|
+
self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): {verdict.result_text}")
|
|
470
|
+
detection = CredentialManagerDetection(os.path.abspath(candidate.filename), verdict.version, candidate.source, verdict.problem, exec_path)
|
|
471
|
+
if verdict.problem == CredentialManagerProblem.NONE:
|
|
472
|
+
self.log_line(f"Credential manager detected: {detection.filename} ({candidate.source}, {verdict.version})")
|
|
473
|
+
return detection
|
|
474
|
+
if MOST_USEFUL_FIRST_REFUSAL_PROBLEMS.index(verdict.problem) < MOST_USEFUL_FIRST_REFUSAL_PROBLEMS.index(refusal.problem):
|
|
475
|
+
refusal = detection
|
|
476
|
+
conclusion = "Credential manager not usable: " + refusal.problem.value
|
|
477
|
+
if refusal.filename:
|
|
478
|
+
conclusion += " " + refusal.filename + (f" ({refusal.version})" if refusal.version else "")
|
|
479
|
+
self.log_line(conclusion)
|
|
480
|
+
return refusal
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Write the commands that install the Git Credential Manager on a Linux distribution.
|
|
2
|
+
|
|
3
|
+
Each family of distributions has its own row in the table, and a family never receives the command of another family's
|
|
4
|
+
package manager: a command that does not exist on the user's system does not work and teaches a wrong command. Only
|
|
5
|
+
constants of GitPane and the table of processors fill the commands: nothing read from the machine (os-release, the
|
|
6
|
+
processor name) ever enters a command, it only picks a family or an architecture, members of an enum.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
from gitpane.config import GCM_ARCHIVE_VERSION, GCM_CORE_DIR, GCM_RELEASES_URL
|
|
16
|
+
from gitpane.system.linux_distribution import DistributionFamily
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GcmArchiveArchitecture(Enum):
|
|
20
|
+
"""A processor the official Linux archive of the Git Credential Manager is built for; the value is the word of the archive's file name."""
|
|
21
|
+
|
|
22
|
+
X64 = "x64"
|
|
23
|
+
ARM64 = "arm64"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Keyed by platform.machine() on Linux. A processor missing from the table has no official archive.
|
|
27
|
+
GCM_ARCHIVE_ARCHITECTURE_BY_MACHINE: dict[str, GcmArchiveArchitecture] = {
|
|
28
|
+
"x86_64": GcmArchiveArchitecture.X64,
|
|
29
|
+
"aarch64": GcmArchiveArchitecture.ARM64,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
GCM_ARCHIVE_BASENAME_TEMPLATE = "gcm-linux-{architecture}-{version}.tar.gz"
|
|
33
|
+
GCM_ARCHIVE_URL_TEMPLATE = "https://github.com/git-ecosystem/git-credential-manager/releases/download/v{version}/{basename}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gcm_archive_architecture(machine: str) -> GcmArchiveArchitecture | None:
|
|
37
|
+
"""Return the archive architecture of a processor name as ``platform.machine()`` writes it, or None when no official archive exists for it."""
|
|
38
|
+
return GCM_ARCHIVE_ARCHITECTURE_BY_MACHINE.get(machine)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def session_gcm_archive_architecture() -> GcmArchiveArchitecture | None:
|
|
42
|
+
"""Return the archive architecture of the running machine's processor, or None when no official archive exists for it."""
|
|
43
|
+
return gcm_archive_architecture(platform.machine())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GcmInstallText(Enum):
|
|
47
|
+
"""A line of the Linux install text of the Git Credential Manager, as the install window shows it."""
|
|
48
|
+
|
|
49
|
+
RUN_IN_TERMINAL = " - in a terminal, run :"
|
|
50
|
+
"""Introduces the commands, each alone on its line so it can be copied as is."""
|
|
51
|
+
RELEASES = " - releases : " + GCM_RELEASES_URL
|
|
52
|
+
"""Ends the archive commands with the page of the releases, the only link of the text."""
|
|
53
|
+
UNKNOWN_DISTRIBUTION = " - GitPane does not know this distribution; the official archive runs on most distributions that use glibc : " + GCM_RELEASES_URL
|
|
54
|
+
"""Opens the text of a distribution GitPane does not know, the link first, before the archive commands."""
|
|
55
|
+
NO_BUILD_FOR_PROCESSOR = " - no official Git credential manager build exists for this processor : " + GCM_RELEASES_URL
|
|
56
|
+
"""The only line of a processor the official archive is not built for."""
|
|
57
|
+
DEBIAN_DOWNLOAD = " - download a .deb here : " + GCM_RELEASES_URL
|
|
58
|
+
"""First line of the Debian family, which installs the official .deb."""
|
|
59
|
+
DEBIAN_INSTALL = " - install it with : sudo apt install ./gcm-linux-<arch>-<version>.deb"
|
|
60
|
+
"""Second line of the Debian family: apt installs the downloaded .deb."""
|
|
61
|
+
DEBIAN_OTHER_DISTRIBUTIONS = " - or for other distros, a .tar.gz here : " + GCM_RELEASES_URL
|
|
62
|
+
"""Third line of the Debian family, kept word for word from the lines Debian and Ubuntu already showed."""
|
|
63
|
+
# -L follows GitHub's redirection to the file, -f refuses to write an error page under the archive's name.
|
|
64
|
+
ARCHIVE_DOWNLOAD = 'cd "$(mktemp -d)" && curl -fLO {url}'
|
|
65
|
+
"""Downloads the official archive into a new temporary folder."""
|
|
66
|
+
# The archive gives its ./ folder to uid 1000: extracted as root without --no-same-owner --no-overwrite-dir, it would
|
|
67
|
+
# give a system folder to the first user. The folder is created first so it belongs to root. No configuration step
|
|
68
|
+
# follows, since GitPane gives its own helper to every command it starts.
|
|
69
|
+
ARCHIVE_INSTALL = "sudo mkdir -p {core_dir} && sudo tar --no-same-owner --no-overwrite-dir -xzf {basename} -C {core_dir}"
|
|
70
|
+
"""Extracts the official archive into the folder GitPane searches after the PATH."""
|
|
71
|
+
FEDORA_PACKAGES = "sudo dnf install libsecret libicu"
|
|
72
|
+
"""Installs the libraries the archive needs, which a minimal or server install may lack, on the whole Fedora and RHEL families.
|
|
73
|
+
|
|
74
|
+
The Fedora family also holds Mageia, Bazzite and the other image-based Fedora, and Amazon Linux, where this command
|
|
75
|
+
can fail (other package names, a read-only system image, yum alone). It is kept for the whole family on purpose: those
|
|
76
|
+
systems already carry both libraries or name them otherwise, and the archive commands after it still work.
|
|
77
|
+
"""
|
|
78
|
+
LIBRARIES_IF_NOT_STARTING = " - if the Git credential manager does not start, install libsecret and ICU with the package manager"
|
|
79
|
+
"""Names the libraries the archive needs where GitPane gives no package command, since their package names were not checked."""
|
|
80
|
+
ARCH_PACKAGES = "sudo pacman -S --needed libsecret icu"
|
|
81
|
+
"""Installs on Arch the libraries the archive needs, which a system without a desktop may lack."""
|
|
82
|
+
GENTOO_PACKAGES = "sudo emerge --ask --noreplace app-crypt/libsecret dev-libs/icu net-misc/curl"
|
|
83
|
+
"""Installs on Gentoo the libraries the archive needs and curl, which a system without a desktop may lack."""
|
|
84
|
+
VOID_PACKAGES = "sudo xbps-install -S curl libsecret icu"
|
|
85
|
+
"""Installs on Void curl and the libraries the archive needs, which a system without a desktop may lack."""
|
|
86
|
+
VOID_MUSL = " - the musl variant of Void cannot run the official archive : " + GCM_RELEASES_URL
|
|
87
|
+
"""Opens Void's lines: both variants share one os-release ID, and the archive is built against glibc."""
|
|
88
|
+
VOID_GLIBC_RUN_IN_TERMINAL = " - on the glibc variant of Void, in a terminal, run :"
|
|
89
|
+
"""Introduces Void's commands, which only the glibc variant can run."""
|
|
90
|
+
ALPINE = " - no official Git credential manager build runs on Alpine (musl libc) : " + GCM_RELEASES_URL
|
|
91
|
+
"""The only line of Alpine, whose musl C library runs no program built against glibc."""
|
|
92
|
+
NIXOS = " - the official Git credential manager archive does not run as is on NixOS or GNU Guix : " + GCM_RELEASES_URL
|
|
93
|
+
"""The only line of NixOS and Guix, which have no dynamic loader at the standard path a prebuilt program expects."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class FamilyGcmInstall:
|
|
98
|
+
"""How one family of distributions installs the Git Credential Manager: plain lines, or the archive commands framed by its own lines."""
|
|
99
|
+
|
|
100
|
+
# Lines given as they are, without any archive command; when set, the other fields are not read.
|
|
101
|
+
plain_lines: tuple[GcmInstallText, ...] = ()
|
|
102
|
+
# True when the plain lines point to a file of the releases page, which only exists for the processors of the
|
|
103
|
+
# archive: another processor then gets the single line saying no official build exists for it.
|
|
104
|
+
plain_lines_need_archive_processor: bool = False
|
|
105
|
+
# Lines before the archive commands.
|
|
106
|
+
lead_lines: tuple[GcmInstallText, ...] = ()
|
|
107
|
+
run_line: GcmInstallText = GcmInstallText.RUN_IN_TERMINAL
|
|
108
|
+
# Command of the family's own package manager that installs what the archive needs, before the download.
|
|
109
|
+
package_command: GcmInstallText | None = None
|
|
110
|
+
trailing_lines: tuple[GcmInstallText, ...] = (GcmInstallText.RELEASES,)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Keyed by the family read from os-release; the value is text of the install window, another layer than the family.
|
|
114
|
+
GCM_INSTALL_BY_FAMILY: dict[DistributionFamily, FamilyGcmInstall] = {
|
|
115
|
+
DistributionFamily.DEBIAN: FamilyGcmInstall(plain_lines=(GcmInstallText.DEBIAN_DOWNLOAD, GcmInstallText.DEBIAN_INSTALL, GcmInstallText.DEBIAN_OTHER_DISTRIBUTIONS),
|
|
116
|
+
plain_lines_need_archive_processor=True),
|
|
117
|
+
# Kept for the whole family by choice, although the command can fail on Mageia, on image-based Fedora such as Bazzite
|
|
118
|
+
# and where only yum exists: see the docstring of the command.
|
|
119
|
+
DistributionFamily.FEDORA: FamilyGcmInstall(package_command=GcmInstallText.FEDORA_PACKAGES),
|
|
120
|
+
DistributionFamily.RHEL: FamilyGcmInstall(package_command=GcmInstallText.FEDORA_PACKAGES),
|
|
121
|
+
# The package names of these families were not checked, so the libraries are named rather than given as a command.
|
|
122
|
+
DistributionFamily.SUSE: FamilyGcmInstall(trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING, GcmInstallText.RELEASES)),
|
|
123
|
+
DistributionFamily.SOLUS: FamilyGcmInstall(trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING, GcmInstallText.RELEASES)),
|
|
124
|
+
DistributionFamily.ARCH: FamilyGcmInstall(package_command=GcmInstallText.ARCH_PACKAGES),
|
|
125
|
+
# The official Gentoo package of the credential manager only exists under a testing keyword, hence the archive.
|
|
126
|
+
DistributionFamily.GENTOO: FamilyGcmInstall(package_command=GcmInstallText.GENTOO_PACKAGES),
|
|
127
|
+
DistributionFamily.VOID: FamilyGcmInstall(lead_lines=(GcmInstallText.VOID_MUSL,), run_line=GcmInstallText.VOID_GLIBC_RUN_IN_TERMINAL,
|
|
128
|
+
package_command=GcmInstallText.VOID_PACKAGES, trailing_lines=()),
|
|
129
|
+
# The official program asks for glibc's dynamic loader, which musl does not provide.
|
|
130
|
+
DistributionFamily.ALPINE: FamilyGcmInstall(plain_lines=(GcmInstallText.ALPINE,)),
|
|
131
|
+
DistributionFamily.NIXOS: FamilyGcmInstall(plain_lines=(GcmInstallText.NIXOS,)),
|
|
132
|
+
# The link comes first, since nothing says the archive runs on a distribution GitPane does not know.
|
|
133
|
+
DistributionFamily.UNKNOWN: FamilyGcmInstall(lead_lines=(GcmInstallText.UNKNOWN_DISTRIBUTION,), trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING,)),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _archive_command_lines(architecture: GcmArchiveArchitecture) -> tuple[str, str]:
|
|
138
|
+
"""Write the download and the extraction commands of the official archive for one processor."""
|
|
139
|
+
basename = GCM_ARCHIVE_BASENAME_TEMPLATE.format(architecture=architecture.value, version=GCM_ARCHIVE_VERSION)
|
|
140
|
+
url = GCM_ARCHIVE_URL_TEMPLATE.format(version=GCM_ARCHIVE_VERSION, basename=basename)
|
|
141
|
+
download_line = GcmInstallText.ARCHIVE_DOWNLOAD.value.format(url=url)
|
|
142
|
+
install_line = GcmInstallText.ARCHIVE_INSTALL.value.format(core_dir=GCM_CORE_DIR, basename=basename)
|
|
143
|
+
return download_line, install_line
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def distribution_gcm_install_lines(family: DistributionFamily, architecture: GcmArchiveArchitecture | None) -> tuple[str, ...]:
|
|
147
|
+
"""Write the install lines of a distribution family on a processor.
|
|
148
|
+
|
|
149
|
+
A family with plain lines gets them on every processor, unless those lines point to an official file, which only
|
|
150
|
+
exists for the processors of the architecture table. Any other family installs the official archive, built for
|
|
151
|
+
the same processors. A processor without an official file gets a single line saying so, never a command that
|
|
152
|
+
cannot work.
|
|
153
|
+
"""
|
|
154
|
+
family_install = GCM_INSTALL_BY_FAMILY[family]
|
|
155
|
+
if family_install.plain_lines and (architecture is not None or not family_install.plain_lines_need_archive_processor):
|
|
156
|
+
lines: list[str] = []
|
|
157
|
+
for text in family_install.plain_lines:
|
|
158
|
+
lines.append(text.value)
|
|
159
|
+
return tuple(lines)
|
|
160
|
+
if architecture is None:
|
|
161
|
+
return (GcmInstallText.NO_BUILD_FOR_PROCESSOR.value,)
|
|
162
|
+
lines = []
|
|
163
|
+
for text in family_install.lead_lines:
|
|
164
|
+
lines.append(text.value)
|
|
165
|
+
lines.append(family_install.run_line.value)
|
|
166
|
+
if family_install.package_command is not None:
|
|
167
|
+
lines.append(family_install.package_command.value)
|
|
168
|
+
lines.extend(_archive_command_lines(architecture))
|
|
169
|
+
for text in family_install.trailing_lines:
|
|
170
|
+
lines.append(text.value)
|
|
171
|
+
return tuple(lines)
|