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,264 @@
|
|
|
1
|
+
"""Locate the Git installations of this machine, each executable verified before GitPane uses it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
12
|
+
|
|
13
|
+
from gitpane.config import GIT_EXECUTABLE_PROBE_LIMIT_BYTES, GIT_OPEN_TIMEOUT_MS
|
|
14
|
+
from gitpane.history.repository_value import RepositoryFailure, RequestIdentity
|
|
15
|
+
from gitpane.process.process_runner import EXIT_CODE_CAUSE_TEMPLATE, READ_PROFILE, ProcessRequest, ProcessResult, ProcessRunner, ProcessStatus
|
|
16
|
+
from gitpane.tool.git_version import NO_GIT_ANSWER, git_version_answer_line
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect_git_candidates() -> list[str]:
|
|
23
|
+
"""List existing Git candidates in explicit platform order without personal paths."""
|
|
24
|
+
candidate_filenames = []
|
|
25
|
+
path_filename = shutil.which("git")
|
|
26
|
+
if path_filename:
|
|
27
|
+
candidate_filenames.append(path_filename)
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
for key in ("ProgramFiles", "LOCALAPPDATA"):
|
|
30
|
+
base_dir = os.environ.get(key)
|
|
31
|
+
if base_dir:
|
|
32
|
+
for relative_dir in ("bin", "cmd"):
|
|
33
|
+
candidate_filenames.append(os.path.join(base_dir, "Git", relative_dir, "git.exe"))
|
|
34
|
+
else:
|
|
35
|
+
candidate_filenames.extend((os.path.join(os.sep, "usr", "bin", "git"), os.path.join(os.sep, "usr", "local", "bin", "git")))
|
|
36
|
+
result_filenames = []
|
|
37
|
+
identity_set: set[str] = set()
|
|
38
|
+
for candidate_filename in candidate_filenames:
|
|
39
|
+
canonical_filename = os.path.abspath(candidate_filename)
|
|
40
|
+
identity = os.path.normcase(canonical_filename)
|
|
41
|
+
if os.path.isfile(canonical_filename) and identity not in identity_set:
|
|
42
|
+
result_filenames.append(canonical_filename)
|
|
43
|
+
identity_set.add(identity)
|
|
44
|
+
return result_filenames
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def terminal_git_filename(saved_git_filename: str) -> str:
|
|
48
|
+
"""Name the Git whose installation Git Bash and the OpenSSH client are searched in, even before the startup check chose it.
|
|
49
|
+
|
|
50
|
+
At first launch the terminal registry is built before the startup check sets the Git, so the saved path is still
|
|
51
|
+
empty; detect_git_candidates() starts no process and gives the first candidate that check will take.
|
|
52
|
+
"""
|
|
53
|
+
if saved_git_filename:
|
|
54
|
+
return saved_git_filename
|
|
55
|
+
candidate_filenames = detect_git_candidates()
|
|
56
|
+
if not candidate_filenames:
|
|
57
|
+
return ""
|
|
58
|
+
first_candidate_index = 0
|
|
59
|
+
return candidate_filenames[first_candidate_index]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Folders of a Git for Windows installation, read by the tools that look for its bundled programs.
|
|
63
|
+
GIT_BINARY_DIR_BASENAME = "bin"
|
|
64
|
+
GIT_SHELL_DIR_BASENAME = "usr"
|
|
65
|
+
# cmd\git.exe and bin\git.exe sit one folder below the installation root, <architecture>\bin\git.exe two.
|
|
66
|
+
GIT_ROOT_CLIMB_LIMIT = 2
|
|
67
|
+
GIT_VERSION_ARGUMENT = "--version"
|
|
68
|
+
GIT_EXECUTABLE_PROBE_OPERATION = "Verify Git executable"
|
|
69
|
+
PATH_ENVIRONMENT_NAME = "PATH"
|
|
70
|
+
GIT_EXEC_PATH_ARGUMENT = "--exec-path"
|
|
71
|
+
GIT_TOOLS_FOLDER_PROBE_OPERATION = "Read Git tools folder"
|
|
72
|
+
# Longest piece of an unexpected program output quoted in a cause, whether written to the --log file or shown in
|
|
73
|
+
# Settings: a --version output without a version, or a git --exec-path answer that is not a folder.
|
|
74
|
+
UNKNOWN_OUTPUT_LOG_LIMIT = 200
|
|
75
|
+
# Settings shows one of these under the version of a Git it accepts. Git itself works, but the commands it hands to its
|
|
76
|
+
# own helper programs (HTTPS transport, difftool, mergetool) cannot find them. Apple's Git, started through a link,
|
|
77
|
+
# looks for its helpers beside the link instead of beside the real file.
|
|
78
|
+
GIT_TOOLS_FOLDER_MISSING_TEMPLATE = ("Git tools folder not found: {exec_path}. HTTPS remotes, difftool and mergetool cannot work with this Git. "
|
|
79
|
+
"Choose the real Git executable, not a link to it.")
|
|
80
|
+
GIT_TOOLS_FOLDER_UNREADABLE_TEMPLATE = "Git did not name its tools folder ({cause}). HTTPS remotes, difftool and mergetool may not work with this Git."
|
|
81
|
+
NO_TOOLS_FOLDER_WARNING = ""
|
|
82
|
+
UNEXPECTED_ANSWER_PREFIX = "unexpected answer "
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_exec_path_answer(output: bytes, decode: Callable[[bytes], str] = os.fsdecode) -> str:
|
|
86
|
+
"""Return the folder of Git's own programs named by a `git --exec-path` answer, or raise ValueError saying why it is not one.
|
|
87
|
+
|
|
88
|
+
The folder is a file name, so it is decoded as one: a folder whose name is not UTF-8 stays usable on Linux and
|
|
89
|
+
macOS, while Windows decodes file names strictly and refuses such bytes. ``decode`` lets a test take either rule
|
|
90
|
+
on any system. Only one absolute line counts as an answer.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
exec_path = decode(bytes(output)).rstrip("\r\n")
|
|
94
|
+
except UnicodeDecodeError as error:
|
|
95
|
+
raise ValueError(UNEXPECTED_ANSWER_PREFIX + repr(bytes(output)[:UNKNOWN_OUTPUT_LOG_LIMIT])) from error
|
|
96
|
+
if not exec_path or "\n" in exec_path or not os.path.isabs(exec_path):
|
|
97
|
+
raise ValueError(UNEXPECTED_ANSWER_PREFIX + repr(exec_path[:UNKNOWN_OUTPUT_LOG_LIMIT]))
|
|
98
|
+
return os.path.normpath(exec_path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def tools_folder_warning(exec_path: str, isdir: Callable[[str], bool] = os.path.isdir) -> str:
|
|
102
|
+
"""Return the warning for a Git whose tools folder does not exist, or nothing when the folder is there.
|
|
103
|
+
|
|
104
|
+
Nothing is worked around: the warning only tells the user which Git to choose instead.
|
|
105
|
+
"""
|
|
106
|
+
if isdir(exec_path):
|
|
107
|
+
return NO_TOOLS_FOLDER_WARNING
|
|
108
|
+
return GIT_TOOLS_FOLDER_MISSING_TEMPLATE.format(exec_path=exec_path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tools_folder_answer_warning(result: ProcessResult, output: bytes) -> str:
|
|
112
|
+
"""Turn the outcome of a `git --exec-path` read into the warning Settings shows, or nothing when the folder exists.
|
|
113
|
+
|
|
114
|
+
An answer that cannot be read is reported with its cause instead of being kept quiet.
|
|
115
|
+
"""
|
|
116
|
+
cause = ""
|
|
117
|
+
if result.status != ProcessStatus.SUCCESS:
|
|
118
|
+
cause = result.cause or result.status.value
|
|
119
|
+
elif result.return_code != 0:
|
|
120
|
+
cause = EXIT_CODE_CAUSE_TEMPLATE.format(return_code=result.return_code)
|
|
121
|
+
if not cause:
|
|
122
|
+
try:
|
|
123
|
+
return tools_folder_warning(parse_exec_path_answer(output))
|
|
124
|
+
except ValueError as error:
|
|
125
|
+
cause = str(error)
|
|
126
|
+
return GIT_TOOLS_FOLDER_UNREADABLE_TEMPLATE.format(cause=cause)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class GitExecutableCheck:
|
|
131
|
+
"""Follow one validation in progress: the file it is about and the version it reported."""
|
|
132
|
+
|
|
133
|
+
identity: RequestIdentity
|
|
134
|
+
git_filename: str
|
|
135
|
+
version: str = ""
|
|
136
|
+
# Only Settings asks for the tools folder: the startup and launch checks stay a single process.
|
|
137
|
+
read_tools_folder: bool = False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _probe_version(result: ProcessResult, output: bytes) -> str:
|
|
141
|
+
"""Return the version line a probe answered, or nothing when its outcome or its answer cannot be trusted."""
|
|
142
|
+
if result.status != ProcessStatus.SUCCESS or result.return_code != 0:
|
|
143
|
+
return NO_GIT_ANSWER
|
|
144
|
+
return git_version_answer_line(output)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class GitExecutableValidator(QObject):
|
|
148
|
+
"""Validate a draft executable asynchronously with the shared process limits.
|
|
149
|
+
|
|
150
|
+
A validation is one bounded `--version` read of the very file the user chose: its answer alone decides whether
|
|
151
|
+
that Git is usable, and the chosen file is the one every later command launches. A Settings validation then reads
|
|
152
|
+
`--exec-path` once the version is accepted, and publishes on ``tools_folder_checked`` the warning for a tools
|
|
153
|
+
folder that does not exist, just before ``validated``; that warning never refuses the Git.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
validated = Signal(object, str, str, object)
|
|
157
|
+
tools_folder_checked = Signal(object, str, str)
|
|
158
|
+
|
|
159
|
+
def __init__(self, runner: ProcessRunner) -> None:
|
|
160
|
+
"""Inject the runner so validation shares cancellation and shutdown behavior."""
|
|
161
|
+
super().__init__(runner)
|
|
162
|
+
self.runner = runner
|
|
163
|
+
self.check_by_id: dict[str, GitExecutableCheck] = {}
|
|
164
|
+
self.output_by_id: dict[str, bytearray] = {}
|
|
165
|
+
runner.stdout_chunk.connect(self._receive_chunk)
|
|
166
|
+
runner.finished.connect(self._receive_result)
|
|
167
|
+
|
|
168
|
+
def validate(self, git_filename: str, identity: RequestIdentity, *, read_tools_folder: bool = False) -> None:
|
|
169
|
+
"""Check a concrete absolute executable and request its version without a shell; ``read_tools_folder`` also checks the folder of its helper programs."""
|
|
170
|
+
self.cancel(identity.session_id)
|
|
171
|
+
cause = ""
|
|
172
|
+
if not git_filename.strip():
|
|
173
|
+
cause = "The Git path is empty"
|
|
174
|
+
elif not os.path.isabs(git_filename):
|
|
175
|
+
cause = "Choose an absolute path to Git"
|
|
176
|
+
elif not os.path.isfile(git_filename):
|
|
177
|
+
cause = "The file does not exist or is not an executable file"
|
|
178
|
+
elif not os.access(git_filename, os.X_OK):
|
|
179
|
+
cause = "The file is not executable"
|
|
180
|
+
if cause:
|
|
181
|
+
failure = RepositoryFailure("This Git executable cannot be used", "", git_filename, [GIT_VERSION_ARGUMENT], cause)
|
|
182
|
+
QTimer.singleShot(0, lambda: self.validated.emit(identity, git_filename, "", failure))
|
|
183
|
+
return
|
|
184
|
+
git_filename = os.path.abspath(git_filename)
|
|
185
|
+
check = GitExecutableCheck(identity, git_filename, read_tools_folder=read_tools_folder)
|
|
186
|
+
self._start_probe(check, GIT_VERSION_ARGUMENT, GIT_EXECUTABLE_PROBE_OPERATION)
|
|
187
|
+
|
|
188
|
+
def cancel(self, session_id: str) -> None:
|
|
189
|
+
"""Forget the previous draft before any late process result can validate it.
|
|
190
|
+
|
|
191
|
+
What this session was checking is dropped before the runner is asked to cancel, so the outcomes it publishes
|
|
192
|
+
right away, and the late ones too, have nothing left to act on.
|
|
193
|
+
"""
|
|
194
|
+
for request_id, check in tuple(self.check_by_id.items()):
|
|
195
|
+
if check.identity.session_id == session_id:
|
|
196
|
+
self.check_by_id.pop(request_id, None)
|
|
197
|
+
self.output_by_id.pop(request_id, None)
|
|
198
|
+
self.runner.cancel_session(session_id)
|
|
199
|
+
|
|
200
|
+
def _start_probe(self, check: GitExecutableCheck, argument: str, operation: str) -> None:
|
|
201
|
+
"""Launch one bounded read of the very file being checked, from its own folder and with GitPane's usual environment."""
|
|
202
|
+
request_id = uuid.uuid4().hex
|
|
203
|
+
self.check_by_id[request_id] = check
|
|
204
|
+
self.output_by_id[request_id] = bytearray()
|
|
205
|
+
self.runner.start(ProcessRequest(request_id, check.git_filename, [argument], os.path.dirname(check.git_filename), GIT_OPEN_TIMEOUT_MS,
|
|
206
|
+
check.identity.session_id, stdout_limit_bytes=GIT_EXECUTABLE_PROBE_LIMIT_BYTES, operation=operation,
|
|
207
|
+
profile=READ_PROFILE))
|
|
208
|
+
|
|
209
|
+
def _finish_check(self, check: GitExecutableCheck, failure: RepositoryFailure | None = None) -> None:
|
|
210
|
+
"""Publish the outcome of one validation: the checked file with its version, or the reason it cannot be used."""
|
|
211
|
+
self.validated.emit(check.identity, check.git_filename, check.version if failure is None else "", failure)
|
|
212
|
+
|
|
213
|
+
def _receive_chunk(self, request: ProcessRequest, output: bytes) -> None:
|
|
214
|
+
if request.request_id in self.output_by_id:
|
|
215
|
+
self.output_by_id[request.request_id].extend(output)
|
|
216
|
+
|
|
217
|
+
def _receive_result(self, result: ProcessResult) -> None:
|
|
218
|
+
"""End the validation this outcome belongs to, unless it was cancelled or replaced meanwhile."""
|
|
219
|
+
check = self.check_by_id.pop(result.request.request_id, None)
|
|
220
|
+
output = self.output_by_id.pop(result.request.request_id, bytearray())
|
|
221
|
+
if check is None or result.status == ProcessStatus.CANCELLED:
|
|
222
|
+
return
|
|
223
|
+
if check.version:
|
|
224
|
+
self._receive_tools_folder(check, result, output)
|
|
225
|
+
return
|
|
226
|
+
self._receive_launcher_version(check, result, output)
|
|
227
|
+
|
|
228
|
+
def _receive_launcher_version(self, check: GitExecutableCheck, result: ProcessResult, output: bytes) -> None:
|
|
229
|
+
"""Decide whether the chosen Git is usable: a single clean Git version line accepts it, anything else refuses it."""
|
|
230
|
+
version = _probe_version(result, output)
|
|
231
|
+
if not version:
|
|
232
|
+
cause = result.cause or "The executable did not return a Git version"
|
|
233
|
+
self._finish_check(check, RepositoryFailure("This Git executable cannot be used", result.request.working_dir, result.request.program_filename,
|
|
234
|
+
result.request.arguments, cause, result.stderr, result.return_code))
|
|
235
|
+
return
|
|
236
|
+
check.version = version
|
|
237
|
+
if check.read_tools_folder:
|
|
238
|
+
self._start_probe(check, GIT_EXEC_PATH_ARGUMENT, GIT_TOOLS_FOLDER_PROBE_OPERATION)
|
|
239
|
+
return
|
|
240
|
+
self._finish_check(check)
|
|
241
|
+
|
|
242
|
+
def _receive_tools_folder(self, check: GitExecutableCheck, result: ProcessResult, output: bytes) -> None:
|
|
243
|
+
"""Publish what the `--exec-path` read says about the helper programs, then the accepted Git it belongs to."""
|
|
244
|
+
self.tools_folder_checked.emit(check.identity, check.git_filename, _tools_folder_answer_warning(result, output))
|
|
245
|
+
self._finish_check(check)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def git_root_dirs(git_filename: str) -> list[str]:
|
|
249
|
+
"""List the folders that may be the root of the Git installation GitPane runs, nearest first.
|
|
250
|
+
|
|
251
|
+
Git Bash and the OpenSSH client shipped with Git for Windows are both searched from these folders. No
|
|
252
|
+
architecture folder name is written here: the root is simply one or two folders above git.exe.
|
|
253
|
+
"""
|
|
254
|
+
if not git_filename:
|
|
255
|
+
return []
|
|
256
|
+
root_dirs = []
|
|
257
|
+
current_dir = os.path.dirname(os.path.abspath(git_filename))
|
|
258
|
+
for _level in range(GIT_ROOT_CLIMB_LIMIT):
|
|
259
|
+
parent_dir = os.path.dirname(current_dir)
|
|
260
|
+
if parent_dir == current_dir:
|
|
261
|
+
break
|
|
262
|
+
root_dirs.append(parent_dir)
|
|
263
|
+
current_dir = parent_dir
|
|
264
|
+
return root_dirs
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Read the version of a Git from what its `git --version` command answers.
|
|
2
|
+
|
|
3
|
+
The answer gives the version line, by the one rule Settings and Remove from history share, and that line gives the
|
|
4
|
+
release numbers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
GIT_VERSION_PREFIX = "git version "
|
|
12
|
+
NO_GIT_ANSWER = ""
|
|
13
|
+
# Major and minor numbers at the start of the version text, then an optional patch number; builds append their own
|
|
14
|
+
# suffix after them (".windows.2", " (Apple Git-157)"), and a Git built between two releases writes "2.45.GIT".
|
|
15
|
+
GIT_VERSION_NUMBER_PATTERN = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
|
|
16
|
+
# A Git built between two releases is built after the major.minor.0 release, so it is at least that release.
|
|
17
|
+
MISSING_PATCH_NUMBER = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _probe_line(text: str) -> str:
|
|
21
|
+
"""Return the single line an answer holds, or nothing when it holds none or several.
|
|
22
|
+
|
|
23
|
+
Only the trailing line ending is removed; an answer spread over several lines is not answering the question that
|
|
24
|
+
was asked.
|
|
25
|
+
"""
|
|
26
|
+
text = text.removesuffix("\n").removesuffix("\r")
|
|
27
|
+
if not text or "\n" in text or "\r" in text:
|
|
28
|
+
return NO_GIT_ANSWER
|
|
29
|
+
return text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def git_version_answer_line(output: bytes | bytearray) -> str:
|
|
33
|
+
"""Return the version line of a `git --version` answer, or nothing when the answer is not one clean version line.
|
|
34
|
+
|
|
35
|
+
This is the only rule by which GitPane takes an answer for a Git version: Settings applies it to accept a Git, and
|
|
36
|
+
Remove from history to read the version of its Git again, so the two never disagree on the same answer. The answer
|
|
37
|
+
must be a single line of valid UTF-8 that starts with the version prefix; what follows the prefix is not judged here.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
line = _probe_line(bytes(output).decode("utf-8"))
|
|
41
|
+
except UnicodeDecodeError:
|
|
42
|
+
return NO_GIT_ANSWER
|
|
43
|
+
if not line.startswith(GIT_VERSION_PREFIX):
|
|
44
|
+
return NO_GIT_ANSWER
|
|
45
|
+
return line
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def git_version_numbers(version_line: str) -> tuple[int, int, int] | None:
|
|
49
|
+
"""Read major, minor and patch from a `git version …` line, a missing patch number counting as 0.
|
|
50
|
+
|
|
51
|
+
None for a line without the exact prefix or without major and minor numbers right after it.
|
|
52
|
+
"""
|
|
53
|
+
line = version_line.rstrip()
|
|
54
|
+
if not line.startswith(GIT_VERSION_PREFIX):
|
|
55
|
+
return None
|
|
56
|
+
match = GIT_VERSION_NUMBER_PATTERN.match(line[len(GIT_VERSION_PREFIX):])
|
|
57
|
+
if match is None:
|
|
58
|
+
return None
|
|
59
|
+
major, minor, patch = match.groups()
|
|
60
|
+
if patch is None:
|
|
61
|
+
return int(major), int(minor), MISSING_PATCH_NUMBER
|
|
62
|
+
return int(major), int(minor), int(patch)
|
gitpane/tool/kdiff3.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Find the standard KDiff3 installation that external comparison shares with conflict resolution.
|
|
2
|
+
|
|
3
|
+
Windows and Linux install KDiff3 at a fixed file; macOS installs it as an application bundle, whose declared program is
|
|
4
|
+
offered. It lives apart from both tools so the preferences can use it without importing the comparison service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from gitpane.config import KDIFF3_BUNDLE_BASENAMES_BY_PLATFORM, KDIFF3_SUGGESTED_FILENAMES_BY_PLATFORM
|
|
12
|
+
from gitpane.tool.app_bundle import APPLICATION_SEARCH_DIRS, find_application
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_kdiff3_filename(platform: str, search_dirs: tuple[str, ...] = APPLICATION_SEARCH_DIRS) -> str:
|
|
16
|
+
"""Find the standard KDiff3 installation of ``platform`` without starting it or saving preferences.
|
|
17
|
+
|
|
18
|
+
The fixed files of the system come first, and the first executable one is returned. Then the KDiff3 bundles the
|
|
19
|
+
system lists are searched in ``search_dirs``, in that order, and the program the first readable one declares is
|
|
20
|
+
returned. A system with neither gets an empty result. The system and the folders are received rather than read here
|
|
21
|
+
so the three systems can be tested from any of them, without ever reading the real applications folders.
|
|
22
|
+
"""
|
|
23
|
+
for candidate_filename in KDIFF3_SUGGESTED_FILENAMES_BY_PLATFORM.get(platform, ()):
|
|
24
|
+
if os.path.isfile(candidate_filename) and os.access(candidate_filename, os.X_OK):
|
|
25
|
+
return candidate_filename
|
|
26
|
+
bundle_basenames = KDIFF3_BUNDLE_BASENAMES_BY_PLATFORM.get(platform, ())
|
|
27
|
+
if not bundle_basenames:
|
|
28
|
+
return ""
|
|
29
|
+
bundle = find_application(bundle_basenames, search_dirs)
|
|
30
|
+
if bundle is None:
|
|
31
|
+
return ""
|
|
32
|
+
return bundle.executable_filename
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tool_program_usable(program_filename: str) -> bool:
|
|
36
|
+
"""Tell whether a diff or merge tool field leads to a program GitPane can start: an absolute name of an executable file.
|
|
37
|
+
|
|
38
|
+
Both tool recipes refuse anything else, and Settings shows how to install KDiff3 under a field that fails it.
|
|
39
|
+
"""
|
|
40
|
+
return os.path.isabs(program_filename) and os.path.isfile(program_filename) and os.access(program_filename, os.X_OK)
|
|
41
|
+
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Say how to install KDiff3 on a system.
|
|
2
|
+
|
|
3
|
+
The line of each system comes from GitPane's own tables, never from anything read on the machine, so a text the user
|
|
4
|
+
copies into a terminal can only be one GitPane wrote. Under Linux, the distribution family and the precise distribution
|
|
5
|
+
read from os-release only pick members of ``Kdiff3InstallText``, and a family never receives the command of another
|
|
6
|
+
family's package manager: a command that does not exist on the user's system does not work and teaches a wrong command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum, unique
|
|
12
|
+
|
|
13
|
+
from gitpane.config import KDIFF3_INSTALL_LINE_BY_PLATFORM, LINUX_PLATFORM
|
|
14
|
+
from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
|
|
15
|
+
|
|
16
|
+
KDIFF3_RUN_PREFIX = "To install KDiff3, run:"
|
|
17
|
+
DNF_INSTALL_COMMAND = "sudo dnf install kdiff3"
|
|
18
|
+
# Fedora's official address of the EPEL release package; ``$(rpm -E %rhel)`` is left for the user's shell to expand,
|
|
19
|
+
# so GitPane never reads the major version of the system.
|
|
20
|
+
EPEL_RELEASE_COMMAND = "sudo dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-$(rpm -E %rhel).noarch.rpm"
|
|
21
|
+
# Joins the lines of a distribution that needs several commands, so that each can be copied alone.
|
|
22
|
+
INSTALL_LINE_SEPARATOR = "\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@unique
|
|
26
|
+
class Kdiff3InstallText(Enum):
|
|
27
|
+
"""A line of the KDiff3 install help under Linux, as Settings and a refused comparison or merge show it.
|
|
28
|
+
|
|
29
|
+
``@unique`` because two members with the same text would silently become one member under two names.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
DEBIAN = KDIFF3_RUN_PREFIX + " sudo apt install kdiff3"
|
|
33
|
+
"""The line of the Debian family, Ubuntu and its derivatives included."""
|
|
34
|
+
FEDORA = KDIFF3_RUN_PREFIX + " " + DNF_INSTALL_COMMAND
|
|
35
|
+
"""The line of the Fedora family."""
|
|
36
|
+
SUSE = KDIFF3_RUN_PREFIX + " sudo zypper install kdiff3"
|
|
37
|
+
"""The line of the SUSE family, openSUSE and SUSE Linux Enterprise alike."""
|
|
38
|
+
ARCH = KDIFF3_RUN_PREFIX + " sudo pacman -S kdiff3"
|
|
39
|
+
"""The line of the Arch family."""
|
|
40
|
+
GENTOO = KDIFF3_RUN_PREFIX + " sudo emerge --ask kde-misc/kdiff3"
|
|
41
|
+
"""The line of the Gentoo family, which builds KDiff3 from its category of KDE tools."""
|
|
42
|
+
VOID = KDIFF3_RUN_PREFIX + " sudo xbps-install -S kdiff3"
|
|
43
|
+
"""The line of Void Linux, both variants."""
|
|
44
|
+
ALPINE = "To install KDiff3, enable the community repository in /etc/apk/repositories, then run as root: apk add kdiff3"
|
|
45
|
+
"""The line of Alpine, whose KDiff3 package lives in the community repository and which may have no sudo."""
|
|
46
|
+
SOLUS = KDIFF3_RUN_PREFIX + " sudo eopkg install kdiff3"
|
|
47
|
+
"""The line of Solus."""
|
|
48
|
+
NIXOS = ("To install KDiff3, add kdiff3 to environment.systemPackages in /etc/nixos/configuration.nix, run sudo nixos-rebuild switch, "
|
|
49
|
+
"then choose /run/current-system/sw/bin/kdiff3 with Browse…")
|
|
50
|
+
"""The line of NixOS, where KDiff3 is declared in the system configuration and never lands at the path GitPane looks at first."""
|
|
51
|
+
UNKNOWN_HOW = "You need to install KDiff3. GitPane does not know how to do it for your distribution: ask your favorite AI."
|
|
52
|
+
"""The line of a distribution GitPane has no way to install KDiff3 on: Guix, which has no kdiff3 package, and any distribution GitPane does not know.
|
|
53
|
+
|
|
54
|
+
It names no package manager and no address, so it never teaches a command that does not exist on the user's system.
|
|
55
|
+
"""
|
|
56
|
+
RUN_INTRO = KDIFF3_RUN_PREFIX
|
|
57
|
+
"""Introduces the commands of a distribution that needs several, each on its own line."""
|
|
58
|
+
EPEL_RELEASE = EPEL_RELEASE_COMMAND
|
|
59
|
+
"""Installs EPEL, the repository of Fedora packages rebuilt for the RHEL family, where KDiff3 comes from there."""
|
|
60
|
+
CRB_ENABLE = "sudo crb enable"
|
|
61
|
+
"""Turns on the CRB repository through the script epel-release installs on Rocky Linux and AlmaLinux."""
|
|
62
|
+
RHEL_CODEREADY = "sudo subscription-manager repos --enable codeready-builder-for-rhel-$(rpm -E %rhel)-$(arch)-rpms"
|
|
63
|
+
"""Turns on Red Hat's CodeReady Builder repository, which EPEL packages need, through the subscription."""
|
|
64
|
+
CENTOS_CRB = "sudo dnf config-manager --set-enabled crb"
|
|
65
|
+
"""Turns on the CRB repository of CentOS Stream, which EPEL packages need."""
|
|
66
|
+
ORACLE_EPEL = "sudo dnf install oracle-epel-release-el$(rpm -E %rhel)"
|
|
67
|
+
"""Installs EPEL from Oracle's own repositories."""
|
|
68
|
+
ORACLE_CODEREADY = "sudo dnf config-manager --set-enabled ol$(rpm -E %rhel)_codeready_builder"
|
|
69
|
+
"""Turns on Oracle's CodeReady Builder repository, which EPEL packages need."""
|
|
70
|
+
DNF_INSTALL = DNF_INSTALL_COMMAND
|
|
71
|
+
"""Installs KDiff3 through dnf, once the repositories before it are turned on."""
|
|
72
|
+
SEE_SETTINGS_TOOLS = "To install KDiff3, see the commands in Settings › Tools."
|
|
73
|
+
"""Replaces several install commands where only one line can be shown, as in the status bar of the repository window."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Rocky Linux and AlmaLinux get their CRB through the script of epel-release, so EPEL comes first there; this order is
|
|
77
|
+
# also given to any RHEL derivative without a row of its own.
|
|
78
|
+
ROCKY_OR_ALMA_INSTALL = (Kdiff3InstallText.RUN_INTRO, Kdiff3InstallText.EPEL_RELEASE, Kdiff3InstallText.CRB_ENABLE, Kdiff3InstallText.DNF_INSTALL)
|
|
79
|
+
|
|
80
|
+
# Keyed by the family read from os-release; the value is text of Settings and of the refusal messages, another layer
|
|
81
|
+
# than the family, like the install table of the Git Credential Manager. A family missing from the table is told GitPane
|
|
82
|
+
# does not know how.
|
|
83
|
+
LINUX_KDIFF3_INSTALL_BY_FAMILY: dict[DistributionFamily, tuple[Kdiff3InstallText, ...]] = {
|
|
84
|
+
DistributionFamily.DEBIAN: (Kdiff3InstallText.DEBIAN,),
|
|
85
|
+
# Kept for the whole family by choice, although dnf cannot install into the read-only system image of Kinoite,
|
|
86
|
+
# Silverblue or Bazzite: telling them apart would mean reading VARIANT_ID, and those users know their own way
|
|
87
|
+
# (rpm-ostree, a toolbox), while any other line would name a tool the rest of the family does not use.
|
|
88
|
+
DistributionFamily.FEDORA: (Kdiff3InstallText.FEDORA,),
|
|
89
|
+
DistributionFamily.RHEL: ROCKY_OR_ALMA_INSTALL,
|
|
90
|
+
# One line for SUSE Linux Enterprise too, where kdiff3 only comes from SUSE Package Hub once it is turned on.
|
|
91
|
+
DistributionFamily.SUSE: (Kdiff3InstallText.SUSE,),
|
|
92
|
+
DistributionFamily.ARCH: (Kdiff3InstallText.ARCH,),
|
|
93
|
+
DistributionFamily.GENTOO: (Kdiff3InstallText.GENTOO,),
|
|
94
|
+
DistributionFamily.VOID: (Kdiff3InstallText.VOID,),
|
|
95
|
+
DistributionFamily.ALPINE: (Kdiff3InstallText.ALPINE,),
|
|
96
|
+
DistributionFamily.NIXOS: (Kdiff3InstallText.NIXOS,),
|
|
97
|
+
DistributionFamily.SOLUS: (Kdiff3InstallText.SOLUS,),
|
|
98
|
+
DistributionFamily.UNKNOWN: (Kdiff3InstallText.UNKNOWN_HOW,),
|
|
99
|
+
}
|
|
100
|
+
# Keyed by the precise distribution read from os-release's exact ID, and read before the family table: these
|
|
101
|
+
# distributions install differently from the rest of their family.
|
|
102
|
+
LINUX_KDIFF3_INSTALL_BY_RELEASE: dict[DistributionRelease, tuple[Kdiff3InstallText, ...]] = {
|
|
103
|
+
DistributionRelease.RHEL: (Kdiff3InstallText.RUN_INTRO, Kdiff3InstallText.RHEL_CODEREADY, Kdiff3InstallText.EPEL_RELEASE, Kdiff3InstallText.DNF_INSTALL),
|
|
104
|
+
DistributionRelease.CENTOS_STREAM: (Kdiff3InstallText.RUN_INTRO, Kdiff3InstallText.CENTOS_CRB, Kdiff3InstallText.EPEL_RELEASE, Kdiff3InstallText.DNF_INSTALL),
|
|
105
|
+
DistributionRelease.ROCKY_OR_ALMA: ROCKY_OR_ALMA_INSTALL,
|
|
106
|
+
DistributionRelease.ORACLE_LINUX: (Kdiff3InstallText.RUN_INTRO, Kdiff3InstallText.ORACLE_EPEL, Kdiff3InstallText.ORACLE_CODEREADY, Kdiff3InstallText.DNF_INSTALL),
|
|
107
|
+
# Guix shares the NixOS family but has no kdiff3 package and no nixos-rebuild.
|
|
108
|
+
DistributionRelease.GUIX: (Kdiff3InstallText.UNKNOWN_HOW,),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def linux_kdiff3_install_lines(family: DistributionFamily, release: DistributionRelease) -> tuple[str, ...]:
|
|
113
|
+
"""Write the KDiff3 install lines of a Linux distribution: its own when it has a row, otherwise its family's, otherwise the line saying GitPane does not know how.
|
|
114
|
+
|
|
115
|
+
The family and the distribution are received rather than read here, so the lines of each can be tested from any
|
|
116
|
+
system.
|
|
117
|
+
"""
|
|
118
|
+
install_texts = LINUX_KDIFF3_INSTALL_BY_RELEASE.get(release)
|
|
119
|
+
if install_texts is None:
|
|
120
|
+
install_texts = LINUX_KDIFF3_INSTALL_BY_FAMILY.get(family, (Kdiff3InstallText.UNKNOWN_HOW,))
|
|
121
|
+
lines = []
|
|
122
|
+
for install_text in install_texts:
|
|
123
|
+
lines.append(install_text.value)
|
|
124
|
+
return tuple(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def kdiff3_install_line(platform: str, family: DistributionFamily = DistributionFamily.UNKNOWN, release: DistributionRelease = DistributionRelease.OTHER) -> str:
|
|
128
|
+
"""Say how to install KDiff3 on ``platform``, or return an empty text for a system that has no line.
|
|
129
|
+
|
|
130
|
+
Under Linux the text depends on the distribution ``family`` and ``release``; elsewhere they are not read. The system,
|
|
131
|
+
the family and the distribution are received rather than read here, so the line of each can be tested from any system.
|
|
132
|
+
"""
|
|
133
|
+
if platform == LINUX_PLATFORM:
|
|
134
|
+
return INSTALL_LINE_SEPARATOR.join(linux_kdiff3_install_lines(family, release))
|
|
135
|
+
return KDIFF3_INSTALL_LINE_BY_PLATFORM.get(platform, "")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def with_kdiff3_install_line(text: str, platform: str, family: DistributionFamily = DistributionFamily.UNKNOWN, release: DistributionRelease = DistributionRelease.OTHER) -> str:
|
|
139
|
+
"""Follow a message about a missing comparison or merge tool with the way to install KDiff3 on ``platform``, when the system has one.
|
|
140
|
+
|
|
141
|
+
A single line follows the message after a space; several lines start on a line of their own, so each command can be
|
|
142
|
+
copied alone.
|
|
143
|
+
"""
|
|
144
|
+
install_line = kdiff3_install_line(platform, family, release)
|
|
145
|
+
if not install_line:
|
|
146
|
+
return text
|
|
147
|
+
if INSTALL_LINE_SEPARATOR in install_line:
|
|
148
|
+
return text + INSTALL_LINE_SEPARATOR + install_line
|
|
149
|
+
return text + " " + install_line
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def status_bar_refusal_text(cause: str) -> str:
|
|
153
|
+
"""Fit a refusal into the single line of a status bar: several install commands become a pointer to Settings › Tools.
|
|
154
|
+
|
|
155
|
+
The status bar paints one line and cannot be copied from, so commands meant to be copied one by one would only
|
|
156
|
+
show as a cut line there; Settings lists them in a selectable label. A cause with a single line is kept as it is.
|
|
157
|
+
"""
|
|
158
|
+
if INSTALL_LINE_SEPARATOR not in cause:
|
|
159
|
+
return cause
|
|
160
|
+
first_line_index = 0
|
|
161
|
+
first_line = cause.split(INSTALL_LINE_SEPARATOR)[first_line_index]
|
|
162
|
+
return first_line + " " + Kdiff3InstallText.SEE_SETTINGS_TOOLS.value
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Name the application macOS opens for a plain text file, read from the user's LaunchServices handlers only.
|
|
2
|
+
|
|
3
|
+
LaunchServices keeps the default applications the user chose (Finder's Get Info, "Open with", "Change All…") in one
|
|
4
|
+
property list of the user's preferences: a list of handlers, each naming a content type or a URL scheme, with the
|
|
5
|
+
identifier of the application that opens it for a role, written in lower case. Only the handler of plain text
|
|
6
|
+
(``public.plain-text``) is looked at, the type of the default text editor that ``open -t`` opens. When no handler names
|
|
7
|
+
plain text, or the list cannot be read, macOS opens plain text with TextEdit, so TextEdit's identifier is answered. The
|
|
8
|
+
identifier found only picks one of GitPane's known editor presets; nothing of the application it names is read or
|
|
9
|
+
started, and no process is started to read it.
|
|
10
|
+
|
|
11
|
+
The rule reads only the data, the home folder and the file reader it is given, so it can be checked from any system
|
|
12
|
+
without touching the real preferences; only the last function reads the session's own preferences.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import plistlib
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from gitpane.tool.app_bundle import PLIST_READ_ERRORS, TEXTEDIT_BUNDLE_IDENTIFIER
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
# Where LaunchServices keeps the user's default applications, under the home folder.
|
|
27
|
+
LAUNCH_SERVICES_HANDLER_PARTS = ("Library", "Preferences", "com.apple.LaunchServices", "com.apple.launchservices.secure.plist")
|
|
28
|
+
HANDLER_LIST_KEY = "LSHandlers"
|
|
29
|
+
HANDLER_CONTENT_TYPE_KEY = "LSHandlerContentType"
|
|
30
|
+
PLAIN_TEXT_CONTENT_TYPE = "public.plain-text"
|
|
31
|
+
# The roles a handler may name an application for, the editor's first: the editor role is the closer answer to "which
|
|
32
|
+
# application edits plain text", the role for all uses is what Finder's "Change All…" writes. The viewer role is no
|
|
33
|
+
# editor and is not read.
|
|
34
|
+
EDITOR_HANDLER_ROLE_KEYS = ("LSHandlerRoleEditor", "LSHandlerRoleAll")
|
|
35
|
+
# The application macOS opens plain text with when the user chose none.
|
|
36
|
+
SYSTEM_DEFAULT_TEXT_EDITOR_IDENTIFIER = TEXTEDIT_BUNDLE_IDENTIFIER
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def launch_services_handler_filename(home_dir: str) -> str:
|
|
40
|
+
"""Answer the path of the property list where LaunchServices keeps the user's default applications."""
|
|
41
|
+
return os.path.join(home_dir, *LAUNCH_SERVICES_HANDLER_PARTS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _handler_application_identifier(handler: dict[object, object]) -> str:
|
|
45
|
+
"""Answer the application one plain text handler names for editing, or an empty text when it names none."""
|
|
46
|
+
for role_key in EDITOR_HANDLER_ROLE_KEYS:
|
|
47
|
+
identifier = handler.get(role_key)
|
|
48
|
+
if isinstance(identifier, str) and identifier.strip():
|
|
49
|
+
return identifier.strip()
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def plain_text_handler_identifier(preference_data: object) -> str:
|
|
54
|
+
"""Answer the identifier of the application the user's handlers name for plain text, or an empty text when none does.
|
|
55
|
+
|
|
56
|
+
``preference_data`` is the property list as plistlib reads it. The first handler of plain text that names an
|
|
57
|
+
application for editing decides, in the order of the list; the content type is compared without case, as macOS
|
|
58
|
+
compares type identifiers. Data of another shape names nothing instead of raising.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(preference_data, dict):
|
|
61
|
+
return ""
|
|
62
|
+
handlers = preference_data.get(HANDLER_LIST_KEY)
|
|
63
|
+
if not isinstance(handlers, list):
|
|
64
|
+
return ""
|
|
65
|
+
for handler in handlers:
|
|
66
|
+
if not isinstance(handler, dict):
|
|
67
|
+
continue
|
|
68
|
+
content_type = handler.get(HANDLER_CONTENT_TYPE_KEY)
|
|
69
|
+
if not isinstance(content_type, str) or content_type.casefold() != PLAIN_TEXT_CONTENT_TYPE:
|
|
70
|
+
continue
|
|
71
|
+
identifier = _handler_application_identifier(handler)
|
|
72
|
+
if identifier:
|
|
73
|
+
return identifier
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def launch_services_text_editor_id(home_dir: str, read_property_list: Callable[[str], object | None]) -> str:
|
|
78
|
+
"""Answer the identifier of the application macOS opens plain text with for this user.
|
|
79
|
+
|
|
80
|
+
The user's own choice when the handlers name one, otherwise TextEdit's, which is what macOS itself opens then.
|
|
81
|
+
``read_property_list`` answers None for a file it cannot read, which counts as no choice.
|
|
82
|
+
"""
|
|
83
|
+
identifier = plain_text_handler_identifier(read_property_list(launch_services_handler_filename(home_dir)))
|
|
84
|
+
if identifier:
|
|
85
|
+
return identifier
|
|
86
|
+
return SYSTEM_DEFAULT_TEXT_EDITOR_IDENTIFIER
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_property_list(filename: str) -> object | None:
|
|
90
|
+
"""Read an XML or binary property list, or answer None when it is missing, unreadable or broken."""
|
|
91
|
+
try:
|
|
92
|
+
with open(filename, "rb") as stream:
|
|
93
|
+
return plistlib.load(stream)
|
|
94
|
+
except PLIST_READ_ERRORS:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def session_launch_services_text_editor_id() -> str:
|
|
99
|
+
"""Answer the application macOS opens plain text with for this session's user, read from the real preferences.
|
|
100
|
+
|
|
101
|
+
The home folder may sit on a network share that stops answering: call it on a thread of its own only, never on the
|
|
102
|
+
window's thread.
|
|
103
|
+
"""
|
|
104
|
+
return launch_services_text_editor_id(os.path.expanduser("~"), read_property_list)
|