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,35 @@
|
|
|
1
|
+
"""Decide when a failed Git network step is started again because the credential manager could not read back a secret.
|
|
2
|
+
|
|
3
|
+
Git Credential Manager sometimes fails to read a secret it keeps, right after storing it, while the reads before and
|
|
4
|
+
after succeed. It fails before Git sends any authenticated request, so a step that is safe to repeat is started again
|
|
5
|
+
a moment later, a bounded number of times, instead of asking the user to click Retry. Each service decides which of its
|
|
6
|
+
own steps are safe to repeat; this module holds the rule they share and the line the console shows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from gitpane.config import CREDENTIAL_READ_RETRY_DELAY_MS, CREDENTIAL_READ_RETRY_LIMIT, MILLISECONDS_PER_SECOND, SharedText
|
|
14
|
+
from gitpane.process.process_runner import ProcessStatus
|
|
15
|
+
from gitpane.transport.git_refusal import credential_store_read_failed
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from gitpane.process.process_runner import ProcessResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def credential_read_retry_due(result: ProcessResult, retry_count: int) -> bool:
|
|
22
|
+
"""Tell whether a finished step must be started again: it failed on the credential manager's own words and has retries left.
|
|
23
|
+
|
|
24
|
+
Only a command that ran to a failing end counts: a stop, a timeout or a launch that never happened is not a failed
|
|
25
|
+
secret read. ``retry_count`` is how many times this same step was already started again.
|
|
26
|
+
"""
|
|
27
|
+
if result.status != ProcessStatus.FAILED or retry_count >= CREDENTIAL_READ_RETRY_LIMIT:
|
|
28
|
+
return False
|
|
29
|
+
return credential_store_read_failed(result.stderr.decode("utf-8", "replace"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def credential_read_retry_text(attempt: int) -> str:
|
|
33
|
+
"""Return the console and ``--log`` line that announces the retry numbered ``attempt``, counted from 1."""
|
|
34
|
+
seconds = f"{CREDENTIAL_READ_RETRY_DELAY_MS / MILLISECONDS_PER_SECOND:g}"
|
|
35
|
+
return SharedText.CREDENTIAL_READ_RETRY.value.format(seconds=seconds, attempt=attempt, limit=CREDENTIAL_READ_RETRY_LIMIT)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Read what Git says when a transfer fails.
|
|
2
|
+
|
|
3
|
+
Git prints the server's own explanation as ``remote:`` lines, then its ``fatal:`` line. A form shows that output as Git
|
|
4
|
+
wrote it, only without debug traces, with each progress line reduced to its last value and with the credentials part of
|
|
5
|
+
every URL removed. The usual authentication refusals are recognised from the words of the answer, never from the name
|
|
6
|
+
of a service, so a self-hosted server that explains itself the same way is read the same way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from gitpane.config import HTTPS_PREFIX
|
|
16
|
+
from gitpane.operation.diagnostic import ANSI_PATTERN, URL_USERINFO_PATTERN, terminal_text
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RefusalKind(Enum):
|
|
23
|
+
"""Say why a server refused to let Git in."""
|
|
24
|
+
|
|
25
|
+
PASSWORD_NOT_ACCEPTED = "password_not_accepted"
|
|
26
|
+
CREDENTIALS_REFUSED = "credentials_refused"
|
|
27
|
+
CREDENTIALS_MISSING = "credentials_missing"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
REMOTE_LINE_PREFIX = "remote:"
|
|
31
|
+
# Git, curl (through GIT_TRACE_CURL) and Git Credential Manager all start their trace records with the time of day.
|
|
32
|
+
TRACE_TIME_PATTERN_TEXT = r"\d{2}:\d{2}:\d{2}\.\d{6} "
|
|
33
|
+
TRACE_RECORD_PATTERN = re.compile("^" + TRACE_TIME_PATTERN_TEXT)
|
|
34
|
+
# A local remote runs the server side of a Push on this machine with the trace settings it inherits from the pushing Git,
|
|
35
|
+
# and Git sends what that side writes back to the client after "remote:", so its trace records arrive with that prefix.
|
|
36
|
+
REMOTE_TRACE_RECORD_PATTERN = re.compile("^" + re.escape(REMOTE_LINE_PREFIX) + r"\s+" + TRACE_TIME_PATTERN_TEXT)
|
|
37
|
+
# curl's header, data and information markers, for a trace line that reaches the output without its time of day.
|
|
38
|
+
CURL_TRACE_PATTERN = re.compile(r"^(?:=> Send|<= Recv|== Info)\b")
|
|
39
|
+
# Git Credential Manager writes the .NET stack of an exception on its standard error, which Git passes on unchanged: one
|
|
40
|
+
# indented "at <method>" line per frame, and "--- End of ... stack trace ..." separators between the parts.
|
|
41
|
+
STACK_TRACE_LINE_PATTERN = re.compile(r"^\s+at \S|^\s*--- End of .*stack trace")
|
|
42
|
+
# Git could not ask anyone for a username or a password: no account answered and prompts are disabled. Older Git says it
|
|
43
|
+
# could not read them, Git with credential.interactive=false says it could not get the password, and Git Credential
|
|
44
|
+
# Manager says it may not prompt.
|
|
45
|
+
CREDENTIALS_MISSING_PATTERN = re.compile(
|
|
46
|
+
r"could not read (?:Username|Password)\b"
|
|
47
|
+
r"|unable to get password from user"
|
|
48
|
+
r"|Cannot prompt because user interactivity has been disabled",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
# Git Credential Manager's words when the system's credential store cannot be reached or stays locked: no desktop
|
|
52
|
+
# session, no session bus, a keyring nobody unlocked, or an unlock the user cancelled. Such a message never proves that
|
|
53
|
+
# a secret is missing; the store simply did not answer.
|
|
54
|
+
CREDENTIAL_STORE_UNAVAILABLE_PATTERN = re.compile(r"secret\s?service|d-?bus|keyring|unlock", re.IGNORECASE)
|
|
55
|
+
# Git Credential Manager's words when it could not read back a secret it keeps, although the store answered: seen now and
|
|
56
|
+
# then right after it stored that secret, while the reads before and after succeed. It fails on its "get", before Git
|
|
57
|
+
# sends any authenticated request, so nothing reached the server. It is neither a refusal of the server nor a locked or
|
|
58
|
+
# unreachable store, and the same step usually succeeds a moment later.
|
|
59
|
+
CREDENTIAL_STORE_READ_FAILURE_PATTERN = re.compile(r"Received invalid secret from the secret storage|Failed to load the secret", re.IGNORECASE)
|
|
60
|
+
# A server line saying plainly that account passwords are refused for Git. "password" must be followed in the same
|
|
61
|
+
# sentence by the refusal word, so a message that only mentions a password among other causes does not match.
|
|
62
|
+
PASSWORD_NOT_ACCEPTED_PATTERN = re.compile(
|
|
63
|
+
r"password[^.!?\n]*\b(?:removed|not available|not supported|no longer|not accepted|disabled)\b"
|
|
64
|
+
r"|stopped supporting[^.!?\n]*password"
|
|
65
|
+
r"|must use a personal access token",
|
|
66
|
+
re.IGNORECASE,
|
|
67
|
+
)
|
|
68
|
+
CREDENTIALS_REFUSED_PATTERN = re.compile(r"Authentication failed for|returned error: 401\b", re.IGNORECASE)
|
|
69
|
+
# Git's own line when the server answers that the repository does not exist: a private repository hidden from the
|
|
70
|
+
# account used gets the same answer. It is not an authentication refusal, so it is kept apart from refusal_kind.
|
|
71
|
+
REPOSITORY_NOT_FOUND_PATTERN = re.compile(r"^fatal: repository '[^']*' not found$")
|
|
72
|
+
# Git's own line when protocol.file.allow refuses the file transport to a clone Git starts by itself, such as the clone
|
|
73
|
+
# of a submodule whose URL is a local folder or a file:// URL. Only a whole line counts, so a server cannot fake it inside
|
|
74
|
+
# another sentence.
|
|
75
|
+
FILE_TRANSPORT_REFUSED_PATTERN = re.compile(r"^fatal: transport 'file' not allowed$", re.MULTILINE)
|
|
76
|
+
# The form shows only the end of a long output: the refusal and the fatal line are always last.
|
|
77
|
+
GIT_MESSAGE_LINE_LIMIT = 20
|
|
78
|
+
EARLIER_OUTPUT_TEXT = "… earlier lines are in Git's full output."
|
|
79
|
+
# The URL Git quotes in its own refusal line. A submodule initialization keeps no URL of its own, so the notice reads the
|
|
80
|
+
# address from there.
|
|
81
|
+
REFUSED_URL_PATTERN = re.compile(r"(?:Authentication failed for|could not read (?:Username|Password) for) '(?P<url>[^']+)'"
|
|
82
|
+
r"|unable to access '(?P<status_url>[^']+)': The requested URL returned error: 401\b", re.IGNORECASE)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_trace_line(line: str) -> bool:
|
|
86
|
+
"""Tell whether a line is a debug trace record or a .NET stack line rather than something Git says to the user.
|
|
87
|
+
|
|
88
|
+
The forms and the operation consoles both use it, so a line left out of one is left out of the other.
|
|
89
|
+
"""
|
|
90
|
+
return bool(TRACE_RECORD_PATTERN.match(line) or REMOTE_TRACE_RECORD_PATTERN.match(line) or CURL_TRACE_PATTERN.match(line)
|
|
91
|
+
or STACK_TRACE_LINE_PATTERN.match(line))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def git_output_lines(stderr_text: str) -> list[str]:
|
|
95
|
+
"""Return every line of Git's standard error worth showing, in order.
|
|
96
|
+
|
|
97
|
+
Trace records and stack lines are dropped, each progress line keeps its last value, terminal colour and control
|
|
98
|
+
sequences are removed as a terminal would not show them, the credentials part of any URL is removed (including a
|
|
99
|
+
part already replaced by a mask) and blank lines around the output are dropped.
|
|
100
|
+
"""
|
|
101
|
+
lines: list[str] = []
|
|
102
|
+
for line in terminal_text(ANSI_PATTERN.sub("", stderr_text)).split("\n"):
|
|
103
|
+
if is_trace_line(line):
|
|
104
|
+
continue
|
|
105
|
+
lines.append(URL_USERINFO_PATTERN.sub(r"\1", line.rstrip()))
|
|
106
|
+
while lines and not lines[-1]:
|
|
107
|
+
lines.pop()
|
|
108
|
+
first_index = 0
|
|
109
|
+
while first_index < len(lines) and not lines[first_index]:
|
|
110
|
+
first_index += 1
|
|
111
|
+
return lines[first_index:]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def git_message_text(stderr_text: str) -> str:
|
|
115
|
+
"""Return Git's standard error as a form shows it: readable, without secrets, and short enough to fit.
|
|
116
|
+
|
|
117
|
+
Only the last lines are kept, where Git puts the server's refusal and its own fatal line; a first line then says
|
|
118
|
+
that the beginning is in Git's full output. Dropped trace and stack lines are never counted as cut lines.
|
|
119
|
+
"""
|
|
120
|
+
lines = git_output_lines(stderr_text)
|
|
121
|
+
if len(lines) <= GIT_MESSAGE_LINE_LIMIT:
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
return "\n".join([EARLIER_OUTPUT_TEXT, *lines[-GIT_MESSAGE_LINE_LIMIT:]])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def credential_store_unavailable(text: str) -> bool:
|
|
127
|
+
"""Tell whether a credential manager's message says the system's credential store could not be reached or unlocked.
|
|
128
|
+
|
|
129
|
+
Git Credential Manager wraps its messages at about 80 columns, so line breaks and repeated spaces are merged first,
|
|
130
|
+
and words cut across two lines are still found.
|
|
131
|
+
"""
|
|
132
|
+
return CREDENTIAL_STORE_UNAVAILABLE_PATTERN.search(" ".join(text.split())) is not None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def credential_store_read_failed(stderr_text: str) -> bool:
|
|
136
|
+
"""Tell whether Git's standard error says the credential manager could not read back a secret it keeps.
|
|
137
|
+
|
|
138
|
+
Only the credential manager's own words count, never "any failure": a real refusal or a wrong account must fail at
|
|
139
|
+
once, while this read failure passes by itself and calls for no decision of the user. The lines are read as a form
|
|
140
|
+
shows them, so a trace record never counts, and a server's ``remote:`` line that quotes the same words is left out.
|
|
141
|
+
Git Credential Manager wraps its messages at about 80 columns, so the remaining lines are joined first.
|
|
142
|
+
"""
|
|
143
|
+
own_lines: list[str] = []
|
|
144
|
+
for line in git_output_lines(stderr_text):
|
|
145
|
+
if line.lstrip().startswith(REMOTE_LINE_PREFIX):
|
|
146
|
+
continue
|
|
147
|
+
own_lines.append(line)
|
|
148
|
+
return CREDENTIAL_STORE_READ_FAILURE_PATTERN.search(" ".join(" ".join(own_lines).split())) is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def refusal_kind(text: str) -> RefusalKind | None:
|
|
152
|
+
"""Recognise an authentication refusal from Git's output, or return None for any other failure.
|
|
153
|
+
|
|
154
|
+
Only the words of the answer are read. An ambiguous answer, such as one that says the password was wrong or that
|
|
155
|
+
a token may be required, is read as a plain refusal: the same text is sent for a mistyped password.
|
|
156
|
+
"""
|
|
157
|
+
if CREDENTIALS_MISSING_PATTERN.search(text):
|
|
158
|
+
return RefusalKind.CREDENTIALS_MISSING
|
|
159
|
+
for line in text.split("\n"):
|
|
160
|
+
if line.lstrip().startswith(REMOTE_LINE_PREFIX) and PASSWORD_NOT_ACCEPTED_PATTERN.search(line):
|
|
161
|
+
return RefusalKind.PASSWORD_NOT_ACCEPTED
|
|
162
|
+
if CREDENTIALS_REFUSED_PATTERN.search(text):
|
|
163
|
+
return RefusalKind.CREDENTIALS_REFUSED
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def authentication_refused(diagnostic: PreparedDiagnostic) -> bool:
|
|
168
|
+
"""Tell whether a failed transfer was refused for authentication, reading Git's output as a form shows it."""
|
|
169
|
+
return refusal_kind("\n".join(git_output_lines(diagnostic.stderr))) is not None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def repository_not_found(lines: list[str]) -> bool:
|
|
173
|
+
"""Tell whether Git said the repository does not exist, reading the lines already cleaned by ``git_output_lines``.
|
|
174
|
+
|
|
175
|
+
Kept apart from ``refusal_kind`` on purpose: a missing repository is not an authentication refusal, so checking a
|
|
176
|
+
new account must not start recognising the server for it.
|
|
177
|
+
"""
|
|
178
|
+
for line in lines:
|
|
179
|
+
if REPOSITORY_NOT_FOUND_PATTERN.match(line):
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def file_transport_refused(stderr_text: str) -> bool:
|
|
185
|
+
"""Tell whether Git refused the file transport to a submodule clone, reading its whole standard error.
|
|
186
|
+
|
|
187
|
+
The console then explains the refusal in plain words, whatever kind of URL the parent was cloned from. Windows line
|
|
188
|
+
ends and the carriage returns that end Git's progress lines become plain line ends first, so the line is matched
|
|
189
|
+
whole on every system.
|
|
190
|
+
"""
|
|
191
|
+
return FILE_TRANSPORT_REFUSED_PATTERN.search(stderr_text.replace("\r\n", "\n").replace("\r", "\n")) is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def refused_https_url(stderr_text: str) -> str:
|
|
195
|
+
"""Return the HTTPS URL Git named in its authentication refusal, without any credentials part, or an empty text.
|
|
196
|
+
|
|
197
|
+
The lines are read as a form shows them, so a username or a secret written in the URL is already gone. A refusal
|
|
198
|
+
quoting another kind of URL (plain HTTP, SSH, a local path) gives nothing, since no account applies there.
|
|
199
|
+
"""
|
|
200
|
+
for line in git_output_lines(stderr_text):
|
|
201
|
+
match = REFUSED_URL_PATTERN.search(line)
|
|
202
|
+
if match is None:
|
|
203
|
+
continue
|
|
204
|
+
url = match.group("url") or match.group("status_url")
|
|
205
|
+
if url.lower().startswith(HTTPS_PREFIX):
|
|
206
|
+
return url
|
|
207
|
+
return ""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Decide whether a pushed branch is linked to its remote branch.
|
|
2
|
+
|
|
3
|
+
A Push asks whether to link the branch only when Git would not already follow a remote branch for it, and the answer
|
|
4
|
+
the user keeps for a repository is saved in the preferences file, never in the repository's Git configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Self
|
|
12
|
+
|
|
13
|
+
from gitpane.config import LOCAL_PREFIX, PUSH_TRACKING_PREFERENCE_KEY, SharedText
|
|
14
|
+
from gitpane.system.path_identity import canonical_path
|
|
15
|
+
from gitpane.transport.remote_config import MERGE_TRACKING_FIELD, remote_tracking_ref, tracking_values
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
21
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PushTrackingChoice(Enum):
|
|
25
|
+
"""What a Push does about linking a branch that follows no remote branch: ask each time, always link, or never link.
|
|
26
|
+
|
|
27
|
+
The value is the word saved in the preferences file, which every system and older GitPane versions read; the label
|
|
28
|
+
is the text Settings shows. ``ASK`` is never written: it is the absence of an entry for the repository, so going
|
|
29
|
+
back to it removes the entry.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
ASK = ("ask", "Ask")
|
|
33
|
+
ALWAYS_ADD = ("always-add", "Always add")
|
|
34
|
+
NEVER_ADD = ("never-add", "Never add")
|
|
35
|
+
|
|
36
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
37
|
+
"""Keep the saved word as the value and the Settings text beside it."""
|
|
38
|
+
member = object.__new__(cls)
|
|
39
|
+
member._value_ = value
|
|
40
|
+
member.label = label
|
|
41
|
+
return member
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracking_question_applies(snapshot: RemoteSnapshot, remote_name: str, source_ref: str, destination_ref: str, tag_refs: Sequence[tuple[str, str]]) -> bool:
|
|
45
|
+
"""Tell whether a push of ``source_ref`` to ``destination_ref`` on ``remote_name`` may link the branch to its remote branch.
|
|
46
|
+
|
|
47
|
+
Only a local branch pushed alone to an active configured remote qualifies, and only when it has no
|
|
48
|
+
``branch.<name>.merge`` at all: a branch whose upstream is gone, one that follows a local branch (remote ".") and one
|
|
49
|
+
whose setting comes from a global file already have an upstream that Git uses. A push to a typed address has no
|
|
50
|
+
remote name, and Git does not know an inactive remote as a remote, so neither can be linked. A destination that no
|
|
51
|
+
Fetch refspec of the remote maps to a remote-tracking branch (a push-only remote, a mirror, a narrowed refspec)
|
|
52
|
+
cannot be linked either, since Git only links a branch to a remote-tracking branch: asking would only end on a link
|
|
53
|
+
that is not set, at every Push of that branch.
|
|
54
|
+
"""
|
|
55
|
+
if not remote_name or tag_refs or not source_ref.startswith(LOCAL_PREFIX):
|
|
56
|
+
return False
|
|
57
|
+
entry = snapshot.entry_by_name.get(remote_name)
|
|
58
|
+
if entry is None or not entry.active:
|
|
59
|
+
return False
|
|
60
|
+
if not remote_tracking_ref(snapshot, remote_name, destination_ref):
|
|
61
|
+
return False
|
|
62
|
+
return not tracking_values(snapshot, source_ref, MERGE_TRACKING_FIELD)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def push_tracking_entry_by_repository(value: object) -> dict[str, object]:
|
|
66
|
+
"""Read the saved JSON object mapping a repository's shared Git folder to its kept entry, each entry as stored.
|
|
67
|
+
|
|
68
|
+
This version writes a word per repository. A word it does not know, or an entry of another shape (a number, an
|
|
69
|
+
object a later GitPane may write), is kept as it is and only reads as no choice: refusing it would make the whole
|
|
70
|
+
key unreadable and block every later "Don't ask again" and Settings save. Only a value that is not a JSON object is
|
|
71
|
+
refused, TypeError when it is JSON of another shape and ValueError when it is not JSON at all, with a text that
|
|
72
|
+
names the key, since only removing it from the preferences file brings the choices back.
|
|
73
|
+
"""
|
|
74
|
+
if not isinstance(value, str):
|
|
75
|
+
raise TypeError(SharedText.PUSH_TRACKING_UNREADABLE.value)
|
|
76
|
+
try:
|
|
77
|
+
entry_by_repository = json.loads(value)
|
|
78
|
+
except ValueError as error:
|
|
79
|
+
raise ValueError(SharedText.PUSH_TRACKING_UNREADABLE.value) from error
|
|
80
|
+
if not isinstance(entry_by_repository, dict):
|
|
81
|
+
raise TypeError(SharedText.PUSH_TRACKING_UNREADABLE.value)
|
|
82
|
+
return entry_by_repository
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_push_tracking_json(value: object) -> str:
|
|
86
|
+
"""Accept any JSON object keyed by repository, whatever its entries hold, and keep its text as saved."""
|
|
87
|
+
push_tracking_entry_by_repository(value)
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def push_tracking_choice(preference: PreferenceStore, common_git_dir: str) -> PushTrackingChoice:
|
|
92
|
+
"""Return the choice kept for the repository whose shared Git folder is ``common_git_dir``, or ``ASK`` when none is kept.
|
|
93
|
+
|
|
94
|
+
Linked worktrees share that folder, so they share the choice. A word this version does not know, and an entry that
|
|
95
|
+
is not a word, read as ``ASK``.
|
|
96
|
+
"""
|
|
97
|
+
entry_by_repository = push_tracking_entry_by_repository(preference.read(PUSH_TRACKING_PREFERENCE_KEY))
|
|
98
|
+
word = entry_by_repository.get(canonical_path(common_git_dir))
|
|
99
|
+
if not isinstance(word, str):
|
|
100
|
+
return PushTrackingChoice.ASK
|
|
101
|
+
try:
|
|
102
|
+
return PushTrackingChoice(word)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return PushTrackingChoice.ASK
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def save_push_tracking_choice(preference: PreferenceStore, common_git_dir: str, choice: PushTrackingChoice) -> None:
|
|
108
|
+
"""Keep ``choice`` for the repository whose shared Git folder is ``common_git_dir``, leaving every other entry as it is.
|
|
109
|
+
|
|
110
|
+
The write starts from the value stored right now, never from the copy in memory: another GitPane open at the same
|
|
111
|
+
time may have kept the choice of another repository meanwhile. The entries of other repositories are written back
|
|
112
|
+
exactly as stored, whatever they hold. ``ASK`` removes the entry. OSError, ValueError and TypeError are left to the
|
|
113
|
+
caller, and nothing is written then.
|
|
114
|
+
"""
|
|
115
|
+
entry_by_repository = push_tracking_entry_by_repository(preference.read_saved(PUSH_TRACKING_PREFERENCE_KEY))
|
|
116
|
+
repository_key = canonical_path(common_git_dir)
|
|
117
|
+
if choice == PushTrackingChoice.ASK:
|
|
118
|
+
entry_by_repository.pop(repository_key, None)
|
|
119
|
+
else:
|
|
120
|
+
entry_by_repository[repository_key] = choice.value
|
|
121
|
+
preference.update({PUSH_TRACKING_PREFERENCE_KEY: json.dumps(entry_by_repository, ensure_ascii=False)})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Say a transfer refusal of Git in plain words.
|
|
2
|
+
|
|
3
|
+
A failed transfer starts with one factual sentence naming the address and the account used, when one applies, and a
|
|
4
|
+
refusal that needs an account is described for the Accounts page without any secret.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from gitpane.transport.git_refusal import REMOTE_LINE_PREFIX, credential_store_unavailable, git_message_text, git_output_lines, refusal_kind, repository_not_found
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
17
|
+
from gitpane.transport.branch_exchange import ServerRefusal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TransferAccount:
|
|
22
|
+
"""Say which address a transfer contacted and with which account, so a failure can name both without any secret.
|
|
23
|
+
|
|
24
|
+
``address`` is the host and port only, without scheme or path; an empty ``label`` means no account was used.
|
|
25
|
+
``needs_reconnect`` says a check of the system credential store found that account's secret gone.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
address: str
|
|
29
|
+
label: str = ""
|
|
30
|
+
needs_reconnect: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AccountNotice:
|
|
35
|
+
"""Say which remote needs an account, at which address, and which account it was refused with, without any secret.
|
|
36
|
+
|
|
37
|
+
``subject`` names what was contacted (a remote name, a submodule, the repository to clone); ``address`` is the host
|
|
38
|
+
and port only; an empty ``account_label`` means no account was used. It only describes the refusal: the caller
|
|
39
|
+
decides where to show it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
subject: str
|
|
43
|
+
address: str
|
|
44
|
+
account_label: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# GitPane's own explanation, shown once after Git's lines: Git decides, the user changes the setting if they want to.
|
|
48
|
+
FILE_TRANSPORT_REFUSED_TEXT = ("Git's protocol.file.allow setting does not allow cloning submodules from local folders or file:// URLs, so the submodule"
|
|
49
|
+
" clone was refused. Change this Git setting if you want to allow it.")
|
|
50
|
+
NO_GIT_MESSAGE_TEXT = "Git gave no other explanation."
|
|
51
|
+
# Cause shown under a failed Fetch, Push or clone when the server refused and no account is connected for its address.
|
|
52
|
+
NO_CONNECTED_ACCOUNT_NOTICE_TEXT = "No account is connected for {address}. Click Add account… to add one."
|
|
53
|
+
# GitPane's own refusal to start an HTTPS Fetch or Push when no account is chosen for its address: Git never runs, so
|
|
54
|
+
# this cause is the only sign that the transfer failed for lack of an account.
|
|
55
|
+
NO_ACCOUNT_CONFIGURED_TEXT = "No account configured. Choose an account in Settings before continuing."
|
|
56
|
+
AUTHENTICATION_FAILED_TEXT = "Authentication failed for {address} with account {account}. Reconnect it in Settings › Accounts."
|
|
57
|
+
# Added under that sentence once a check confirmed the account's secret is gone: Git and Git Credential Manager erase a
|
|
58
|
+
# secret by themselves when the server refuses it.
|
|
59
|
+
NEEDS_RECONNECT_TEXT = "{account}: Needs reconnect. Its saved sign-in is no longer in the system credential store."
|
|
60
|
+
REPOSITORY_HIDDEN_TEXT = "The account used ({account}) cannot see this repository. Check the URL, or choose another account."
|
|
61
|
+
# The orange notice shown at the top of Settings › Accounts after a refusal; the address part is left out when unknown.
|
|
62
|
+
ACCOUNT_NOTICE_MISSING_TEXT = "{subject}{address_part} needs an account. Add one with Add account…"
|
|
63
|
+
ACCOUNT_NOTICE_REFUSED_TEXT = "{subject}{address_part} refused account {account}. Select it in Connected accounts and click Reconnect."
|
|
64
|
+
ACCOUNT_NOTICE_ADDRESS_TEXT = " ({address})"
|
|
65
|
+
SUBMODULE_NOTICE_SUBJECT_TEXT = "Submodule {path}"
|
|
66
|
+
# A clone has no remote name yet, so the notice names what the user typed the address for.
|
|
67
|
+
CLONE_NOTICE_SUBJECT_TEXT = "The repository to clone"
|
|
68
|
+
# The first sentence of a Push the server itself refused (a hook, for instance), naming every refused branch or tag.
|
|
69
|
+
SERVER_REFUSAL_TEXT = "The server refused the push of {names}."
|
|
70
|
+
SERVER_REFUSAL_NAME_SEPARATOR = ", "
|
|
71
|
+
SERVER_REFUSAL_REASON_TEXT = "({reason})"
|
|
72
|
+
# A progress line the server sends during a Push ("Resolving deltas: 100% (3/3), done."): it says nothing about a refusal.
|
|
73
|
+
PROGRESS_LINE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z ]*:\s+\d+% \(\d+/\d+\)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _own_cause_text(cause: str, stderr_text: str) -> str:
|
|
77
|
+
"""Return the part of a failure cause that is not Git's standard error itself.
|
|
78
|
+
|
|
79
|
+
A process that fails without its own explanation takes its whole standard error as cause, sometimes after a sentence
|
|
80
|
+
of GitPane's (a preserved partial clone). A cause that does not end like the standard error (a timeout, a modified
|
|
81
|
+
folder) is entirely GitPane's own and is returned whole.
|
|
82
|
+
"""
|
|
83
|
+
cause_lines = cause.strip().split("\n")
|
|
84
|
+
stderr_lines: list[str] = []
|
|
85
|
+
for line in stderr_text.strip().split("\n"):
|
|
86
|
+
stderr_lines.append(line.rstrip())
|
|
87
|
+
if not stderr_lines or cause_lines[-1].rstrip() != stderr_lines[-1]:
|
|
88
|
+
return cause.strip()
|
|
89
|
+
own_lines: list[str] = []
|
|
90
|
+
for line in cause_lines:
|
|
91
|
+
if line.rstrip() == stderr_lines[0]:
|
|
92
|
+
break
|
|
93
|
+
own_lines.append(line)
|
|
94
|
+
return "\n".join(own_lines).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def failure_headline(diagnostic: PreparedDiagnostic, account: TransferAccount | None) -> str:
|
|
98
|
+
"""Return the one factual sentence that explains a failed transfer, or an empty text when none applies.
|
|
99
|
+
|
|
100
|
+
Without a known address (an SSH or local transfer) nothing is said. An authentication refusal names the account
|
|
101
|
+
that was used and points to Reconnect; without an account it says none is connected and how to add one. The
|
|
102
|
+
sentence never guesses a cause from the server's advice, since the same words come back for a revoked token. A
|
|
103
|
+
repository reported missing after an account was used may just be hidden from that account, so the account is
|
|
104
|
+
named; without an account Git's own line already says the repository does not exist. When the account's secret is
|
|
105
|
+
known to be gone, a second line says so right under the refusal sentence. A credential store that could not be
|
|
106
|
+
reached gets no sentence: the credential manager's own message says it.
|
|
107
|
+
"""
|
|
108
|
+
if account is None:
|
|
109
|
+
return ""
|
|
110
|
+
lines = git_output_lines(diagnostic.stderr)
|
|
111
|
+
git_output = "\n".join(lines)
|
|
112
|
+
# The credential manager's own message, already in the console, says the system's credential store did not answer;
|
|
113
|
+
# the account's secret may well be intact, so no account is named as refused and no reconnect is suggested.
|
|
114
|
+
if credential_store_unavailable(git_output):
|
|
115
|
+
return ""
|
|
116
|
+
if refusal_kind(git_output) is not None:
|
|
117
|
+
if not account.label:
|
|
118
|
+
return NO_CONNECTED_ACCOUNT_NOTICE_TEXT.format(address=account.address)
|
|
119
|
+
headline = AUTHENTICATION_FAILED_TEXT.format(address=account.address, account=account.label)
|
|
120
|
+
if account.needs_reconnect:
|
|
121
|
+
headline += "\n" + NEEDS_RECONNECT_TEXT.format(account=account.label)
|
|
122
|
+
return headline
|
|
123
|
+
if account.label and repository_not_found(lines):
|
|
124
|
+
return REPOSITORY_HIDDEN_TEXT.format(account=account.label)
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_no_account_refusal(text: str) -> bool:
|
|
129
|
+
"""Tell whether a shown text holds, as one whole line, GitPane's refusal to start an HTTPS transfer without an account.
|
|
130
|
+
|
|
131
|
+
Every window recognises the refusal here instead of copying its sentence; the sentence only counts as a whole line,
|
|
132
|
+
so a server or Git line that merely quotes it is not taken for it.
|
|
133
|
+
"""
|
|
134
|
+
return NO_ACCOUNT_CONFIGURED_TEXT in text.split("\n")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def account_notice(diagnostic: PreparedDiagnostic, account: TransferAccount | None, subject: str) -> AccountNotice | None:
|
|
138
|
+
"""Describe the account a refused HTTPS transfer needs, or return None when the failure is not about an account.
|
|
139
|
+
|
|
140
|
+
A transfer GitPane refused to start because no account is chosen for its address counts as a refusal too. Without
|
|
141
|
+
an address (an SSH or local transfer) or without an authentication refusal (a missing repository, a refused
|
|
142
|
+
connection), nothing is asked of the user, so Settings is left alone; a credential store that could not be reached
|
|
143
|
+
says nothing about the account either.
|
|
144
|
+
"""
|
|
145
|
+
if account is None:
|
|
146
|
+
return None
|
|
147
|
+
if not account.label and is_no_account_refusal(diagnostic.cause):
|
|
148
|
+
return AccountNotice(subject, account.address)
|
|
149
|
+
git_output = "\n".join(git_output_lines(diagnostic.stderr))
|
|
150
|
+
if credential_store_unavailable(git_output) or refusal_kind(git_output) is None:
|
|
151
|
+
return None
|
|
152
|
+
return AccountNotice(subject, account.address, account.label)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def account_notice_text(notice: AccountNotice) -> str:
|
|
156
|
+
"""Say who needs an account and where, or which account was refused, in the words shown on the Accounts page."""
|
|
157
|
+
address_part = ACCOUNT_NOTICE_ADDRESS_TEXT.format(address=notice.address) if notice.address else ""
|
|
158
|
+
if notice.account_label:
|
|
159
|
+
return ACCOUNT_NOTICE_REFUSED_TEXT.format(subject=notice.subject, address_part=address_part, account=notice.account_label)
|
|
160
|
+
return ACCOUNT_NOTICE_MISSING_TEXT.format(subject=notice.subject, address_part=address_part)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def server_refusal_headline(refusals: tuple[ServerRefusal, ...], stderr_text: str) -> str:
|
|
164
|
+
"""Say in a few lines which references the server refused and why, or return an empty text when it refused none.
|
|
165
|
+
|
|
166
|
+
The sentence names every refused branch or tag, then come the server's own lines without their "remote:" prefix
|
|
167
|
+
(trace records and progress left out), then Git's reasons in parentheses, each said once. ``stderr_text`` is Git's
|
|
168
|
+
standard error as already filtered for secrets, so a window can show these lines as they are.
|
|
169
|
+
"""
|
|
170
|
+
if not refusals:
|
|
171
|
+
return ""
|
|
172
|
+
names: list[str] = []
|
|
173
|
+
reasons: list[str] = []
|
|
174
|
+
for refusal in refusals:
|
|
175
|
+
names.append(refusal.short_name())
|
|
176
|
+
if refusal.reason and refusal.reason not in reasons:
|
|
177
|
+
reasons.append(refusal.reason)
|
|
178
|
+
lines = [SERVER_REFUSAL_TEXT.format(names=SERVER_REFUSAL_NAME_SEPARATOR.join(names))]
|
|
179
|
+
for line in git_output_lines(stderr_text):
|
|
180
|
+
if not line.startswith(REMOTE_LINE_PREFIX):
|
|
181
|
+
continue
|
|
182
|
+
message = line.removeprefix(REMOTE_LINE_PREFIX).strip()
|
|
183
|
+
if message and not PROGRESS_LINE_PATTERN.match(message):
|
|
184
|
+
lines.append(message)
|
|
185
|
+
for reason in reasons:
|
|
186
|
+
lines.append(SERVER_REFUSAL_REASON_TEXT.format(reason=reason))
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def headline_first(headline: str, text: str) -> str:
|
|
191
|
+
"""Put a failure headline above the rest of a summary, leaving out whichever part is empty.
|
|
192
|
+
|
|
193
|
+
A window that filters what it shows filters the rest on its own and keeps the headline out of the filter: the
|
|
194
|
+
headline holds only an address and an account label, or the server's lines already filtered with the rest of Git's
|
|
195
|
+
output, and a filter that has learned the Git username from a URL would otherwise hide the account it names.
|
|
196
|
+
"""
|
|
197
|
+
parts: list[str] = []
|
|
198
|
+
for part in (headline, text):
|
|
199
|
+
if part:
|
|
200
|
+
parts.append(part)
|
|
201
|
+
return "\n".join(parts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def failure_summary(diagnostic: PreparedDiagnostic, account: TransferAccount | None = None) -> str:
|
|
205
|
+
"""Return what a form shows for a failed Git transfer: its own cause, the factual headline, then Git's output.
|
|
206
|
+
|
|
207
|
+
``account`` is the address and account the transfer used, so the headline can name them; None when the transfer
|
|
208
|
+
was not an HTTPS one. Without any standard error the cause is all there is to say. The complete, filtered detail
|
|
209
|
+
stays available to the user through the details view.
|
|
210
|
+
"""
|
|
211
|
+
if not diagnostic.stderr.strip():
|
|
212
|
+
return diagnostic.cause
|
|
213
|
+
parts: list[str] = []
|
|
214
|
+
own_cause = _own_cause_text(diagnostic.cause, diagnostic.stderr)
|
|
215
|
+
if own_cause:
|
|
216
|
+
parts.append(own_cause)
|
|
217
|
+
headline = failure_headline(diagnostic, account)
|
|
218
|
+
if headline:
|
|
219
|
+
parts.append(headline)
|
|
220
|
+
message = git_message_text(diagnostic.stderr)
|
|
221
|
+
if message:
|
|
222
|
+
parts.append(message)
|
|
223
|
+
elif not own_cause:
|
|
224
|
+
parts.append(NO_GIT_MESSAGE_TEXT)
|
|
225
|
+
return "\n".join(parts)
|