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,229 @@
|
|
|
1
|
+
"""Describe the byte identities used by a private history rewrite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RewriteTargetKind(Enum):
|
|
14
|
+
"""Choose exact-file or directory-descendant matching."""
|
|
15
|
+
|
|
16
|
+
FILE = "file"
|
|
17
|
+
FOLDER = "folder"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RewriteTarget:
|
|
22
|
+
"""Keep a canonical Git path separate from its display label."""
|
|
23
|
+
|
|
24
|
+
path: bytes
|
|
25
|
+
kind: RewriteTargetKind
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
"""Reject ambiguous repository paths before using them in any Git request."""
|
|
29
|
+
validate_path(self.path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_path(path: bytes) -> bytes:
|
|
33
|
+
"""Accept only literal, canonical repository-relative path bytes."""
|
|
34
|
+
if not path or b"\0" in path or any(part in (b"", b".", b"..") for part in path.split(b"/")):
|
|
35
|
+
raise ValueError("Invalid repository-relative path; select a canonical file or folder")
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RewriteStatus(Enum):
|
|
40
|
+
"""Identify the visible contribution of a rewritten commit."""
|
|
41
|
+
|
|
42
|
+
MODIFIED = "M"
|
|
43
|
+
DELETED = "D"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RewriteProtectedKind(Enum):
|
|
47
|
+
"""Name the repository entries that cannot participate in history removal."""
|
|
48
|
+
|
|
49
|
+
SUBMODULE = "a submodule"
|
|
50
|
+
MODULE_CONFIGURATION = "the .gitmodules file"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RewriteRefusalKind(Enum):
|
|
54
|
+
"""Identify technical limits that cannot produce a faithful rewritten history."""
|
|
55
|
+
|
|
56
|
+
NO_SURVIVING_TIP = "no_surviving_tip"
|
|
57
|
+
INVALID_MERGE_PARENTS = "invalid_merge_parents"
|
|
58
|
+
UNSUPPORTED_METADATA = "unsupported_metadata"
|
|
59
|
+
DETACHED_HEAD = "detached_head"
|
|
60
|
+
UNBORN_BRANCH = "unborn_branch"
|
|
61
|
+
NATIVE_OPERATION = "native_operation"
|
|
62
|
+
UNMERGED_INDEX = "unmerged_index"
|
|
63
|
+
STAGED_TARGET = "staged_target"
|
|
64
|
+
PROTECTED_TARGET = "protected_target"
|
|
65
|
+
BEHIND_UPSTREAM = "behind_upstream"
|
|
66
|
+
OTHER_WORKTREE = "other_worktree"
|
|
67
|
+
STALE_CAPTURE = "stale_capture"
|
|
68
|
+
INCOMPLETE_HISTORY = "incomplete_history"
|
|
69
|
+
UNSUPPORTED_INDEX = "unsupported_index"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RewriteRefusal(ValueError):
|
|
73
|
+
"""Carry a typed preparation refusal with an actionable diagnostic."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, kind: RewriteRefusalKind, detail: str, *, path: bytes | None = None, commit_oid: str | None = None, branch: str | None = None) -> None:
|
|
76
|
+
"""Keep the machine-readable cause alongside the user-facing explanation."""
|
|
77
|
+
super().__init__(detail)
|
|
78
|
+
self.kind = kind
|
|
79
|
+
self.detail = detail
|
|
80
|
+
self.path = path
|
|
81
|
+
self.commit_oid = commit_oid
|
|
82
|
+
self.branch = branch
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ChangeKind(Enum):
|
|
86
|
+
"""Identify the supported fast-export file directives."""
|
|
87
|
+
|
|
88
|
+
MODIFY = b"M"
|
|
89
|
+
DELETE = b"D"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class FileChange:
|
|
94
|
+
"""Keep a literal delta without loading its blob."""
|
|
95
|
+
|
|
96
|
+
kind: ChangeKind
|
|
97
|
+
path: bytes
|
|
98
|
+
mode: bytes = b""
|
|
99
|
+
oid: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class ExportCommit:
|
|
104
|
+
"""Retain export order, ancestry references and file deltas."""
|
|
105
|
+
|
|
106
|
+
oid: str
|
|
107
|
+
mark: str
|
|
108
|
+
parent_refs: tuple[str, ...]
|
|
109
|
+
changes: tuple[FileChange, ...]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class RawCommit:
|
|
114
|
+
"""Locate an original message in its spool with the metadata needed to rebuild it."""
|
|
115
|
+
|
|
116
|
+
oid: str
|
|
117
|
+
tree_oid: str
|
|
118
|
+
parent_oids: tuple[str, ...]
|
|
119
|
+
author: bytes
|
|
120
|
+
committer: bytes
|
|
121
|
+
encoding: bytes | None
|
|
122
|
+
message_offset: int
|
|
123
|
+
message_length: int
|
|
124
|
+
header_names: tuple[bytes, ...]
|
|
125
|
+
signed: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ImportCommit:
|
|
130
|
+
"""Describe one generated private commit with remapped parents."""
|
|
131
|
+
|
|
132
|
+
raw: RawCommit
|
|
133
|
+
mark: str
|
|
134
|
+
parent_refs: tuple[str, ...]
|
|
135
|
+
changes: tuple[FileChange, ...]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class RewriteRow:
|
|
140
|
+
"""Project one changed contribution into bounded presentation text."""
|
|
141
|
+
|
|
142
|
+
oid: str
|
|
143
|
+
status: RewriteStatus
|
|
144
|
+
message: str
|
|
145
|
+
author: str
|
|
146
|
+
date: str
|
|
147
|
+
message_offset: int | None = None
|
|
148
|
+
message_length: int = 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class BranchRewrite:
|
|
153
|
+
"""Describe a local reference's proposed destination."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
old_oid: str
|
|
157
|
+
new_oid: str
|
|
158
|
+
upstream: str | None = None
|
|
159
|
+
remote_refs: tuple[str, ...] = ()
|
|
160
|
+
tip_deleted: bool = False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class RewriteCapture:
|
|
165
|
+
"""Keep the source facts to which a rewrite proposal belongs.
|
|
166
|
+
|
|
167
|
+
``git_version`` is the line ``git --version`` wrote for that executable; it says which options that Git accepts.
|
|
168
|
+
It is empty while the check of that Git, at start or in Settings, has not answered yet or after it failed, and
|
|
169
|
+
the preparation then reads it itself before its first export.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
context: RepositoryContext
|
|
173
|
+
git_filename: str
|
|
174
|
+
head_ref: str | None
|
|
175
|
+
local_ref_by_name: dict[str, str] = field(default_factory=dict)
|
|
176
|
+
remote_ref_by_name: dict[str, str] = field(default_factory=dict)
|
|
177
|
+
index_identity: bytes | None = None
|
|
178
|
+
upstream_by_name: dict[str, str] = field(default_factory=dict)
|
|
179
|
+
worktrees: tuple[bytes, ...] = ()
|
|
180
|
+
native_state: tuple[str, ...] = ()
|
|
181
|
+
generation: int = 0
|
|
182
|
+
upstream_config: bytes = b""
|
|
183
|
+
index_stat: tuple[int, ...] | None = None
|
|
184
|
+
complete: bool = False
|
|
185
|
+
shared_index_filename: str = ""
|
|
186
|
+
shared_index_identity: bytes | None = None
|
|
187
|
+
git_version: str = ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class RewriteIndex:
|
|
192
|
+
"""Bind private index files to their exact original and verified candidate digests."""
|
|
193
|
+
|
|
194
|
+
original_filename: str
|
|
195
|
+
candidate_filename: str
|
|
196
|
+
original_identity: bytes | None
|
|
197
|
+
candidate_identity: bytes | None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class RewritePreparation:
|
|
202
|
+
"""Expose only a verified proposal as executable preparation."""
|
|
203
|
+
|
|
204
|
+
capture: RewriteCapture
|
|
205
|
+
target: RewriteTarget
|
|
206
|
+
operation_dir: str = ""
|
|
207
|
+
stream_filename: str = ""
|
|
208
|
+
replacement_by_oid: dict[str, str | None] = field(default_factory=dict)
|
|
209
|
+
branches: tuple[BranchRewrite, ...] = ()
|
|
210
|
+
rows: tuple[RewriteRow, ...] = ()
|
|
211
|
+
signed_count: int = 0
|
|
212
|
+
current_absent: bool = False
|
|
213
|
+
complete: bool = False
|
|
214
|
+
refusal: str = ""
|
|
215
|
+
operation_identity: tuple[int, int] | None = None
|
|
216
|
+
refusal_kind: RewriteRefusalKind | None = None
|
|
217
|
+
index: RewriteIndex | None = None
|
|
218
|
+
message_filename: str = ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class HistoryAnalysis:
|
|
223
|
+
"""Keep graph decisions separate from import serialization."""
|
|
224
|
+
|
|
225
|
+
replacement_by_oid: dict[str, str | None]
|
|
226
|
+
commits: tuple[ImportCommit, ...]
|
|
227
|
+
rows: tuple[RewriteRow, ...]
|
|
228
|
+
branches: tuple[BranchRewrite, ...]
|
|
229
|
+
signed_count: int
|
|
File without changes
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Decide the text an operation console shows of a record, while the ``--log`` file keeps the full detail.
|
|
2
|
+
|
|
3
|
+
The console reads like a terminal: a folder named once at the top, one ``$ `` line per command written as one would type
|
|
4
|
+
it, the raw output of the command below it, and a single line when a process fails. This module holds those rules
|
|
5
|
+
without any Qt type, so the trace, the view and the tests share one definition of each text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from gitpane.operation.console_tone import ConsoleTone
|
|
17
|
+
from gitpane.operation.diagnostic import MASKED_VALUE, mask_sensitive_arguments, terminal_text
|
|
18
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
19
|
+
from gitpane.process.process_runner import EXIT_CODE_CAUSE_TEMPLATE, ProcessStatus
|
|
20
|
+
from gitpane.transport.git_refusal import is_trace_line
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
from gitpane.process.process_runner import ProcessRequest, ProcessResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ConsoleLine:
|
|
30
|
+
"""One console line as it is shown, with its colour; a text holding line breaks spans several lines of one tone."""
|
|
31
|
+
|
|
32
|
+
text: str
|
|
33
|
+
tone: ConsoleTone = ConsoleTone.NEUTRAL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ConsoleHeader:
|
|
38
|
+
"""The folder a console names once on its first line: the repository, or the destination of a clone."""
|
|
39
|
+
|
|
40
|
+
label: str
|
|
41
|
+
directory: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
REPOSITORY_HEADER_LABEL = "Repository"
|
|
45
|
+
DESTINATION_HEADER_LABEL = "Destination"
|
|
46
|
+
HEADER_TEXT_TEMPLATE = "{label}: {directory}"
|
|
47
|
+
COMMAND_PROMPT = "$ "
|
|
48
|
+
COMMAND_WORD_SEPARATOR = " "
|
|
49
|
+
|
|
50
|
+
# cmd.exe splits or interprets these characters, which Git accepts in a reference name (feature&x, for example).
|
|
51
|
+
# subprocess.list2cmdline only quotes a space, a tab or an empty argument, so it would leave them bare.
|
|
52
|
+
WINDOWS_SHELL_SPECIAL_CHARACTER_SET = frozenset(' \t&|<>^()%!"')
|
|
53
|
+
WINDOWS_QUOTE = '"'
|
|
54
|
+
WINDOWS_BACKSLASH = "\\"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def header_text(header: ConsoleHeader) -> str:
|
|
58
|
+
"""Return the first line of a console, such as ``Repository: /home/user/project``."""
|
|
59
|
+
return HEADER_TEXT_TEMPLATE.format(label=header.label, directory=header.directory)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def quote_posix_argument(argument: str) -> str:
|
|
63
|
+
"""Quote one word so a POSIX shell reads it back unchanged; a word with nothing special stays bare."""
|
|
64
|
+
return shlex.quote(argument)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def quote_windows_argument(argument: str) -> str:
|
|
68
|
+
"""Quote one word the way the Microsoft C runtime reads it back, also when cmd.exe would interpret one of its characters.
|
|
69
|
+
|
|
70
|
+
The rules are those of subprocess.list2cmdline: backslashes are doubled only before a quote and at the end of a
|
|
71
|
+
quoted word, and an inner quote is escaped with a backslash.
|
|
72
|
+
"""
|
|
73
|
+
# Workaround: list2cmdline decides alone when to quote, and it leaves cmd.exe's special characters bare, so its
|
|
74
|
+
# escaping loop is written again here with a wider test. A %VAR% stays expanded by cmd.exe even between quotes;
|
|
75
|
+
# there is no way to write it literally on a line pasted into cmd.exe, a limit accepted for a line meant to be read.
|
|
76
|
+
if argument and not any(character in WINDOWS_SHELL_SPECIAL_CHARACTER_SET for character in argument):
|
|
77
|
+
return argument
|
|
78
|
+
pieces: list[str] = [WINDOWS_QUOTE]
|
|
79
|
+
backslash_count = 0
|
|
80
|
+
for character in argument:
|
|
81
|
+
if character == WINDOWS_BACKSLASH:
|
|
82
|
+
backslash_count += 1
|
|
83
|
+
continue
|
|
84
|
+
if character == WINDOWS_QUOTE:
|
|
85
|
+
pieces.append(WINDOWS_BACKSLASH * (backslash_count * 2 + 1) + WINDOWS_QUOTE)
|
|
86
|
+
else:
|
|
87
|
+
pieces.append(WINDOWS_BACKSLASH * backslash_count + character)
|
|
88
|
+
backslash_count = 0
|
|
89
|
+
pieces.append(WINDOWS_BACKSLASH * (backslash_count * 2) + WINDOWS_QUOTE)
|
|
90
|
+
return "".join(pieces)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quote_argument(argument: str, *, windows: bool | None = None) -> str:
|
|
94
|
+
"""Quote one word in the style of the system's usual shell, so the line can be pasted there."""
|
|
95
|
+
windows = os.name == "nt" if windows is None else windows
|
|
96
|
+
if windows:
|
|
97
|
+
return quote_windows_argument(argument)
|
|
98
|
+
return quote_posix_argument(argument)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def command_text(program_filename: str, arguments: list[str], *, redact: Callable[[str], str] | None = None, windows: bool | None = None) -> str:
|
|
102
|
+
"""Return a command as one would type it: the full executable path then each argument, quoted where a shell needs it.
|
|
103
|
+
|
|
104
|
+
A value written after a secret-named option is replaced by its placeholder, left unquoted so it reads as one. Every
|
|
105
|
+
other word goes through ``redact`` first, the filter that already hides the secrets of the trace, and is quoted
|
|
106
|
+
after, so a replacement holding a space is still one word.
|
|
107
|
+
"""
|
|
108
|
+
masked_arguments = mask_sensitive_arguments(arguments).arguments
|
|
109
|
+
words: list[str] = []
|
|
110
|
+
for word in [program_filename, *masked_arguments]:
|
|
111
|
+
if word == MASKED_VALUE:
|
|
112
|
+
words.append(word)
|
|
113
|
+
continue
|
|
114
|
+
if redact is not None:
|
|
115
|
+
word = redact(word)
|
|
116
|
+
words.append(quote_argument(word, windows=windows))
|
|
117
|
+
return COMMAND_WORD_SEPARATOR.join(words)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
WORKING_DIR_SUFFIX_TEMPLATE = " (in {working_dir})"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _comparable_dir(directory: str) -> str:
|
|
124
|
+
"""Return a folder spelled the way two spellings of the same folder compare equal: absolute, normalised, case folded where the system ignores case."""
|
|
125
|
+
return os.path.normcase(os.path.abspath(directory))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def working_dir_suffix(working_dir: str, header_dir: str) -> str:
|
|
129
|
+
"""Return what follows a command run in another folder than the one the header names, or nothing when it runs there.
|
|
130
|
+
|
|
131
|
+
The suffix is about the folder the process really runs in. A folder given to Git with ``-C`` is already read in the
|
|
132
|
+
arguments, so a command that passes it and runs in the header's folder gets no suffix. The folder is written as
|
|
133
|
+
received and without quotes: it is information placed after the command, not part of what one would paste.
|
|
134
|
+
"""
|
|
135
|
+
if _comparable_dir(working_dir) == _comparable_dir(header_dir):
|
|
136
|
+
return ""
|
|
137
|
+
return WORKING_DIR_SUFFIX_TEMPLATE.format(working_dir=working_dir)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def command_line(request: ProcessRequest, header: ConsoleHeader | None, *, redact: Callable[[str], str] | None = None, windows: bool | None = None) -> str:
|
|
141
|
+
"""Return the ``$ `` line of a process: its full command, then its folder when it runs elsewhere than the header says.
|
|
142
|
+
|
|
143
|
+
Without a header there is no folder to compare with, so no suffix. ``redact`` filters every word of the command and
|
|
144
|
+
the suffix's folder, each on its own: filtering the joined line once more would let a hidden header value swallow
|
|
145
|
+
every word after it, since a header runs to the end of its line.
|
|
146
|
+
"""
|
|
147
|
+
line = COMMAND_PROMPT + command_text(request.program_filename, request.arguments, redact=redact, windows=windows)
|
|
148
|
+
if header is None:
|
|
149
|
+
return line
|
|
150
|
+
suffix = working_dir_suffix(request.working_dir, header.directory)
|
|
151
|
+
if redact is not None:
|
|
152
|
+
suffix = redact(suffix)
|
|
153
|
+
return line + suffix
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# A hosting service answers "repository not found" for a private repository the chosen account cannot see, so the
|
|
157
|
+
# console points at the account as the other likely cause.
|
|
158
|
+
REPOSITORY_NOT_FOUND_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?fatal: repository '.*' not found(?=\r?\n?$)", re.IGNORECASE)
|
|
159
|
+
WRONG_ACCOUNT_HINT = " (or wrong account selected in settings)"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def with_account_hint(line: str) -> str:
|
|
163
|
+
"""Return a Git error line as the console shows it: a "repository not found" line also names a wrong account."""
|
|
164
|
+
match = REPOSITORY_NOT_FOUND_LINE_PATTERN.match(line)
|
|
165
|
+
if match is None:
|
|
166
|
+
return line
|
|
167
|
+
return match.group(0) + WRONG_ACCOUNT_HINT + line[match.end():]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
FAILED_TEXT = "Failed"
|
|
171
|
+
FAILED_WITH_EXIT_TEMPLATE = "Failed (exit {return_code})"
|
|
172
|
+
NOT_STARTED_TEXT = "Not started"
|
|
173
|
+
CANCELLED_TEXT = "Cancelled"
|
|
174
|
+
STOPPED_TEXT = "Stopped"
|
|
175
|
+
# Endings of a request that may never have reached its launch: it did not start unless its command line was shown.
|
|
176
|
+
NOT_STARTED_STATUS_SET = frozenset({ProcessStatus.START_FAILED, ProcessStatus.QUEUE_FULL})
|
|
177
|
+
# Endings where GitPane stopped a running command at one of its limits: no output, too much output, total time.
|
|
178
|
+
STOPPED_STATUS_SET = frozenset({ProcessStatus.IDLE_TIMEOUT, ProcessStatus.VOLUME, ProcessStatus.TIMEOUT})
|
|
179
|
+
# Results after which nothing was written or nothing went through, so a form keeps what the user chose or typed for
|
|
180
|
+
# another try.
|
|
181
|
+
UNCHANGED_OUTCOME_STATUS_SET = frozenset((OperationStatus.REFUSED, OperationStatus.FAILED))
|
|
182
|
+
# Results the user may let the console dismiss through Keep dialog open. An Abort is only reported once a requested native
|
|
183
|
+
# Abort has been verified (rebase as well as cherry-pick and revert), so, like a success, nothing is left to inspect. A
|
|
184
|
+
# pause without failure cause is dismissed the same way, though it is not listed here: the Rebase window, the banner or
|
|
185
|
+
# the conflict question waits for the user, not the console.
|
|
186
|
+
COMPLETED_STATUS_SET = frozenset((OperationStatus.SUCCESS, OperationStatus.ABORTED))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
END_TEXT_TEMPLATE = "{label}: {word}"
|
|
190
|
+
# Names a clone's console gives its end line: the clone itself, and the later retry of the submodules it left missing.
|
|
191
|
+
CLONE_LABEL = "Clone"
|
|
192
|
+
RETRY_SUBMODULES_LABEL = "Retry submodules"
|
|
193
|
+
# Name of the Stash pop operation: the title of its console, the start of its end line, and of the one its log keeps.
|
|
194
|
+
STASH_POP_LABEL = "Stash pop"
|
|
195
|
+
# Name of the rebase operation, used the same way by its console and its log.
|
|
196
|
+
REBASE_LABEL = "Rebase"
|
|
197
|
+
# Counts every reference whose result is not a success: refused, unknown or never sent. "Not pushed" rather than
|
|
198
|
+
# "rejected", since only some of them were refused by the server.
|
|
199
|
+
NOT_PUSHED_REFS_TEMPLATE = "{label}: {count} of {total} refs not pushed"
|
|
200
|
+
# Counts every remote whose Fetch did not succeed, including the ones that never started after an earlier failure.
|
|
201
|
+
FAILED_REMOTES_TEMPLATE = "{label}: {count} of {total} remotes failed"
|
|
202
|
+
# A tag is deleted locally first, then on the server: this says the second step did not happen.
|
|
203
|
+
DELETED_LOCALLY_ONLY_TEMPLATE = "{label}: deleted locally, not on the remote"
|
|
204
|
+
# A branch is deleted on the server first, then locally: this says the local step did not happen.
|
|
205
|
+
DELETED_REMOTELY_ONLY_TEMPLATE = "{label}: deleted on the remote, not locally"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def operation_end_text(label: str, status: OperationStatus) -> str:
|
|
209
|
+
"""Return the line that ends an operation which had none a reader could understand, such as ``Push: done``.
|
|
210
|
+
|
|
211
|
+
The log keeps the bare status (``success``); this is what the console shows in its place.
|
|
212
|
+
"""
|
|
213
|
+
return END_TEXT_TEMPLATE.format(label=label, word=status.end_word)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
CAUSE_SEPARATOR = ": "
|
|
217
|
+
# Sets the reason of a refusal apart from the end line it completes, which already holds a ": " of its own.
|
|
218
|
+
END_CAUSE_SEPARATOR = " — "
|
|
219
|
+
LINE_BREAK = "\n"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def line_with_cause(head: str, cause: str) -> str:
|
|
223
|
+
"""Return a head followed by its cause, or the head alone when there is no cause to give."""
|
|
224
|
+
if not cause:
|
|
225
|
+
return head
|
|
226
|
+
return head + CAUSE_SEPARATOR + cause
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def end_line_with_cause(end_text: str, cause: str) -> str:
|
|
230
|
+
"""Return the end line of an operation followed by the reason it ended so, such as ``Create tag: not started — <reason>``.
|
|
231
|
+
|
|
232
|
+
A refusal then reads as one line that says why, instead of an end line and a separate line for its reason. Without a
|
|
233
|
+
reason the end line stays alone.
|
|
234
|
+
"""
|
|
235
|
+
if not cause:
|
|
236
|
+
return end_text
|
|
237
|
+
return end_text + END_CAUSE_SEPARATOR + cause
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _shown_lines(text: str) -> list[str]:
|
|
241
|
+
"""Return the non-empty lines of a text as a terminal leaves them, without trace records and trailing blanks."""
|
|
242
|
+
lines: list[str] = []
|
|
243
|
+
for line in terminal_text(text).split(LINE_BREAK):
|
|
244
|
+
line = line.rstrip()
|
|
245
|
+
if line and not is_trace_line(line):
|
|
246
|
+
lines.append(line)
|
|
247
|
+
return lines
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cause_without_shown_output(cause: str, shown_output: str) -> str:
|
|
251
|
+
"""Return the part of a failure cause the console has not shown yet.
|
|
252
|
+
|
|
253
|
+
The console already showed the error output line by line. A cause that copies it, at its start, in its middle or at
|
|
254
|
+
its end, keeps only what GitPane adds. Both texts are read as a terminal leaves them, without trace records and
|
|
255
|
+
trailing blanks; the first run of cause lines equal to the output's lines is removed, and the rest is returned.
|
|
256
|
+
"""
|
|
257
|
+
cause_lines = _shown_lines(cause)
|
|
258
|
+
output_lines = _shown_lines(shown_output)
|
|
259
|
+
output_line_count = len(output_lines)
|
|
260
|
+
if output_line_count:
|
|
261
|
+
for start in range(len(cause_lines) - output_line_count + 1):
|
|
262
|
+
if cause_lines[start:start + output_line_count] == output_lines:
|
|
263
|
+
cause_lines = cause_lines[:start] + cause_lines[start + output_line_count:]
|
|
264
|
+
break
|
|
265
|
+
return LINE_BREAK.join(cause_lines).strip()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cause_beyond_exit_code(cause: str, return_code: int | None) -> str:
|
|
269
|
+
"""Return a failure cause without the generic sentence the runner writes for a silent failure.
|
|
270
|
+
|
|
271
|
+
That sentence only repeats the exit code the line already gives, so it becomes an empty cause. A cause without an
|
|
272
|
+
exit code, or different from that sentence, is returned as it is.
|
|
273
|
+
"""
|
|
274
|
+
if return_code is not None and cause == EXIT_CODE_CAUSE_TEMPLATE.format(return_code=return_code):
|
|
275
|
+
return ""
|
|
276
|
+
return cause
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def process_end_line(result: ProcessResult, *, command_shown: bool) -> ConsoleLine | None:
|
|
280
|
+
"""Return the line a console writes when a process ends, or None when a terminal would not mark the ending.
|
|
281
|
+
|
|
282
|
+
``command_shown`` tells whether the console already wrote the process's ``$ `` line, which is what separates a
|
|
283
|
+
request that never started from a program that could not run once launched.
|
|
284
|
+
|
|
285
|
+
| Ending | Line | Colour |
|
|
286
|
+
|------------------------------------------------------|--------------------------------------|------------------------------|
|
|
287
|
+
| success, or an exit code the caller accepts | none | |
|
|
288
|
+
| failed | ``Failed (exit N)``, then own cause | red, neutral during a pause |
|
|
289
|
+
| cancelled | ``Cancelled: <cause>`` | orange |
|
|
290
|
+
| stopped at a limit (no output, too much, total time) | ``Stopped: <cause>`` | red |
|
|
291
|
+
| refused before launch or queue full, no ``$`` line | ``Not started: <cause>`` | red |
|
|
292
|
+
| could not run after its ``$`` line, or anything else | ``Failed: <cause>`` | red |
|
|
293
|
+
|
|
294
|
+
An accepted exit code is a success: a lookup that may find nothing ends with 1 when nothing is there, and a red
|
|
295
|
+
line after it would make an operation that went well look broken. The cause loses the error output the console
|
|
296
|
+
already showed, and the runner's generic cause of a silent failure, which only repeats the exit code; an empty
|
|
297
|
+
cause leaves the word alone.
|
|
298
|
+
"""
|
|
299
|
+
if result.status == ProcessStatus.SUCCESS:
|
|
300
|
+
return None
|
|
301
|
+
if result.status == ProcessStatus.FAILED and result.return_code in result.request.accepted_return_codes:
|
|
302
|
+
return None
|
|
303
|
+
cause = cause_beyond_exit_code(cause_without_shown_output(result.cause, result.stderr.decode("utf-8", "replace")), result.return_code)
|
|
304
|
+
if result.status == ProcessStatus.FAILED:
|
|
305
|
+
head = FAILED_TEXT if result.return_code is None else FAILED_WITH_EXIT_TEMPLATE.format(return_code=result.return_code)
|
|
306
|
+
return ConsoleLine(line_with_cause(head, cause), ConsoleTone.COMMAND_FAILURE)
|
|
307
|
+
if result.status == ProcessStatus.CANCELLED:
|
|
308
|
+
return ConsoleLine(line_with_cause(CANCELLED_TEXT, cause), ConsoleTone.CANCELLED)
|
|
309
|
+
if result.status in STOPPED_STATUS_SET:
|
|
310
|
+
return ConsoleLine(line_with_cause(STOPPED_TEXT, cause), ConsoleTone.FAILURE)
|
|
311
|
+
if result.status in NOT_STARTED_STATUS_SET and not command_shown:
|
|
312
|
+
return ConsoleLine(line_with_cause(NOT_STARTED_TEXT, cause), ConsoleTone.FAILURE)
|
|
313
|
+
return ConsoleLine(line_with_cause(FAILED_TEXT, cause), ConsoleTone.FAILURE)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Decide the colour an operation console gives a record.
|
|
2
|
+
|
|
3
|
+
A Git error line is red, a warning orange, and the line that ends an operation takes the colour of its result. This
|
|
4
|
+
module holds those rules without any Qt type, so the trace, the view and the tests share one definition of each tone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
|
|
12
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConsoleTone(IntEnum):
|
|
16
|
+
"""Say how a console line is coloured; the view stores it as the state of the line's Qt block."""
|
|
17
|
+
|
|
18
|
+
# Qt gives -1 to a block that never received a state, so a neutral line needs no state at all.
|
|
19
|
+
NEUTRAL = -1
|
|
20
|
+
# An operation that reached its goal, or an abort that put the repository back as it was.
|
|
21
|
+
SUCCESS = 1
|
|
22
|
+
# An operation stopped to wait for the user, such as a conflict to resolve.
|
|
23
|
+
PAUSED = 2
|
|
24
|
+
# An operation the user stopped.
|
|
25
|
+
CANCELLED = 3
|
|
26
|
+
# An error GitPane reports itself; it stays red whatever the operation's outcome.
|
|
27
|
+
FAILURE = 4
|
|
28
|
+
# A Git process that ended in failure; neutral while the operation waits on a pause, since that is why Git stopped.
|
|
29
|
+
COMMAND_FAILURE = 5
|
|
30
|
+
# Git's own error and fatal lines; neutral while the operation waits on a pause, for the same reason.
|
|
31
|
+
GIT_ERROR = 6
|
|
32
|
+
# Git's own warning lines.
|
|
33
|
+
GIT_WARNING = 7
|
|
34
|
+
# A cause GitPane writes to explain why Git refused something, such as a setting to change: red and bold whatever
|
|
35
|
+
# the operation's outcome, a pause included, so it is read at first glance among Git's lines.
|
|
36
|
+
FAILURE_CAUSE = 8
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# During a pause, what Git says when it stops is the reason for the pause, not a failure.
|
|
40
|
+
PAUSE_NEUTRAL_TONE_SET = frozenset({ConsoleTone.GIT_ERROR, ConsoleTone.COMMAND_FAILURE})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
GIT_ERROR_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?(?:error:|fatal:|Trace incomplete:)", re.IGNORECASE)
|
|
44
|
+
GIT_WARNING_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?warning:", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def git_output_tone(line: str) -> ConsoleTone:
|
|
48
|
+
"""Return the colour of a line Git wrote on its error output: red for an error, orange for a warning.
|
|
49
|
+
|
|
50
|
+
A ``hint:`` line and progress stay neutral: they explain or report, they do not tell that something went wrong.
|
|
51
|
+
"""
|
|
52
|
+
if GIT_ERROR_LINE_PATTERN.match(line):
|
|
53
|
+
return ConsoleTone.GIT_ERROR
|
|
54
|
+
if GIT_WARNING_LINE_PATTERN.match(line):
|
|
55
|
+
return ConsoleTone.GIT_WARNING
|
|
56
|
+
return ConsoleTone.NEUTRAL
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def result_tone(status: OperationStatus, *, failure_reported: bool = False, expected_stop: bool = False) -> ConsoleTone:
|
|
60
|
+
"""Return the colour of the line that ends an operation, the one classification its console window also reads.
|
|
61
|
+
|
|
62
|
+
The end line and the state of the console window (paused or not) both come from here, so they cannot disagree.
|
|
63
|
+
Green is for an operation that reached its goal and for a verified Abort, which put the repository back as it was.
|
|
64
|
+
Orange is kept for what waits on the user: a pause, or a stop the caller expected (``expected_stop``, such as a
|
|
65
|
+
Stash pop stopped on conflicts), and a cancellation. A pause or an Abort that carries a failure cause
|
|
66
|
+
(``failure_reported``, such as a refused hook) is a real failure, red like everything else.
|
|
67
|
+
"""
|
|
68
|
+
if status == OperationStatus.SUCCESS:
|
|
69
|
+
return ConsoleTone.SUCCESS
|
|
70
|
+
if status == OperationStatus.ABORTED and not failure_reported:
|
|
71
|
+
return ConsoleTone.SUCCESS
|
|
72
|
+
if (status == OperationStatus.PAUSED or expected_stop) and not failure_reported:
|
|
73
|
+
return ConsoleTone.PAUSED
|
|
74
|
+
if status == OperationStatus.CANCELLED:
|
|
75
|
+
return ConsoleTone.CANCELLED
|
|
76
|
+
return ConsoleTone.FAILURE
|