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,218 @@
|
|
|
1
|
+
"""Check that nothing would be lost before a cherry-pick or revert replaces the folder of a submodule.
|
|
2
|
+
|
|
3
|
+
Git, launched without descending into submodules, removes the whole folder of a submodule whose entry the replayed
|
|
4
|
+
commit turns into a file, and writes the files of a folder or a link that takes its place. That is only safe when
|
|
5
|
+
every file in the folder, nested submodules included, is already recorded in the submodule's own history, and when
|
|
6
|
+
that history lives in the parent's Git folder rather than inside the folder about to go. Every check here only reads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from gitpane.config import GIT_PATH_SEPARATOR, SUBMODULE_LOSS_PATH_DISPLAY_LIMIT
|
|
16
|
+
from gitpane.reference.reference_edit import GITLINK_MODE
|
|
17
|
+
from gitpane.system.path_identity import canonical_path
|
|
18
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, ReadBatch
|
|
19
|
+
from gitpane.worktree.worktree_snapshot import parse_index_listing
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Generator
|
|
23
|
+
|
|
24
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
25
|
+
|
|
26
|
+
GIT_ENTRY_BASENAME = ".git"
|
|
27
|
+
# The folder, inside a repository's Git folder, where Git keeps the history of each of its absorbed submodules.
|
|
28
|
+
SUBMODULE_GIT_DIRS_BASENAME = "modules"
|
|
29
|
+
INDEX_ENTRY_SEPARATOR = b"\0"
|
|
30
|
+
INDEX_PATH_SEPARATOR = b"\t"
|
|
31
|
+
INDEX_FIELD_SEPARATOR = b" "
|
|
32
|
+
INDEX_MODE_POSITION = 0
|
|
33
|
+
# A porcelain v1 status record is two status letters, a space, then the path.
|
|
34
|
+
STATUS_SEPARATOR_POSITION = 2
|
|
35
|
+
STATUS_SEPARATOR = b" "
|
|
36
|
+
STATUS_PATH_OFFSET = 3
|
|
37
|
+
# The two lines rev-parse answers for the location of a submodule repository: its root, then its Git folder.
|
|
38
|
+
LOCATION_FIELD_COUNT = 2
|
|
39
|
+
LOCATION_ROOT_POSITION = 0
|
|
40
|
+
LOCATION_GIT_DIR_POSITION = 1
|
|
41
|
+
# Every file that would be lost is looked for until one more than the display limit is found, which is enough to say
|
|
42
|
+
# that the list goes on.
|
|
43
|
+
LOST_PATH_SEARCH_LIMIT = SUBMODULE_LOSS_PATH_DISPLAY_LIMIT + 1
|
|
44
|
+
SUBMODULE_UNREADABLE_CAUSE = "Git gave back an unexpected answer about a submodule. Refresh before applying a commit."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SubmoduleFolder(Enum):
|
|
48
|
+
"""What the folder recorded for a submodule holds on disk, read without following links."""
|
|
49
|
+
|
|
50
|
+
ABSENT = "absent"
|
|
51
|
+
EMPTY = "empty"
|
|
52
|
+
NOT_A_FOLDER = "not a folder"
|
|
53
|
+
UNRECORDED_FILES = "unrecorded files"
|
|
54
|
+
EMBEDDED_HISTORY = "embedded history"
|
|
55
|
+
REPOSITORY = "repository"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def gitlink_paths(index_entries: bytes) -> list[bytes]:
|
|
59
|
+
"""Name the submodule entries of NUL-framed `ls-files --stage` records, in the order Git lists them."""
|
|
60
|
+
paths = []
|
|
61
|
+
for record in index_entries.split(INDEX_ENTRY_SEPARATOR):
|
|
62
|
+
if not record:
|
|
63
|
+
continue
|
|
64
|
+
metadata, separator, path = record.partition(INDEX_PATH_SEPARATOR)
|
|
65
|
+
if not separator or not path:
|
|
66
|
+
raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
|
|
67
|
+
if metadata.split(INDEX_FIELD_SEPARATOR)[INDEX_MODE_POSITION] == GITLINK_MODE:
|
|
68
|
+
paths.append(path)
|
|
69
|
+
return paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def submodule_git_dirs(context: RepositoryContext) -> tuple[str, str]:
|
|
73
|
+
"""Name the folders where Git keeps the history of the absorbed submodules of a worktree: its own Git folder's, then the common one's."""
|
|
74
|
+
return os.path.join(context.git_dir, SUBMODULE_GIT_DIRS_BASENAME), os.path.join(context.common_git_dir, SUBMODULE_GIT_DIRS_BASENAME)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def join_git_path(parent_path: str, child_path: str) -> str:
|
|
78
|
+
"""Join two repository paths with Git's own separator, whatever the system's."""
|
|
79
|
+
return parent_path + GIT_PATH_SEPARATOR + child_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def inspect_submodule_folder(directory: str) -> tuple[SubmoduleFolder, list[str]]:
|
|
83
|
+
"""Say what the folder of a submodule holds, without following links, and name the first files of a folder that holds no repository.
|
|
84
|
+
|
|
85
|
+
A folder with no ".git" entry holds nothing any history records, so each of its files (up to one more than the
|
|
86
|
+
display limit, as paths relative to the folder with Git's separator) would be lost with it. Empty sub-folders
|
|
87
|
+
lose nothing.
|
|
88
|
+
"""
|
|
89
|
+
if not os.path.lexists(directory):
|
|
90
|
+
return SubmoduleFolder.ABSENT, []
|
|
91
|
+
if os.path.islink(directory) or os.path.isjunction(directory) or not os.path.isdir(directory):
|
|
92
|
+
return SubmoduleFolder.NOT_A_FOLDER, []
|
|
93
|
+
git_entry_filename = os.path.join(directory, GIT_ENTRY_BASENAME)
|
|
94
|
+
if os.path.lexists(git_entry_filename):
|
|
95
|
+
if os.path.isfile(git_entry_filename) and not os.path.islink(git_entry_filename):
|
|
96
|
+
return SubmoduleFolder.REPOSITORY, []
|
|
97
|
+
return SubmoduleFolder.EMBEDDED_HISTORY, []
|
|
98
|
+
found_paths: list[str] = []
|
|
99
|
+
pending_dirs = [directory]
|
|
100
|
+
while pending_dirs and len(found_paths) < LOST_PATH_SEARCH_LIMIT:
|
|
101
|
+
current_dir = pending_dirs.pop()
|
|
102
|
+
with os.scandir(current_dir) as entries:
|
|
103
|
+
for entry in entries:
|
|
104
|
+
if entry.is_dir(follow_symlinks=False) and not entry.is_junction():
|
|
105
|
+
pending_dirs.append(entry.path)
|
|
106
|
+
continue
|
|
107
|
+
found_paths.append(os.path.relpath(entry.path, directory).replace(os.sep, GIT_PATH_SEPARATOR))
|
|
108
|
+
if len(found_paths) >= LOST_PATH_SEARCH_LIMIT:
|
|
109
|
+
break
|
|
110
|
+
if not found_paths:
|
|
111
|
+
return SubmoduleFolder.EMPTY, []
|
|
112
|
+
return SubmoduleFolder.UNRECORDED_FILES, sorted(found_paths)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_repository_of(directory: str, root_dir: str, git_dir: str, modules_dirs: tuple[str, ...]) -> tuple[bool, bool]:
|
|
116
|
+
"""Tell whether Git found the folder's own repository rather than an enclosing one, then whether its history lives in the parent's Git folder."""
|
|
117
|
+
own_root = canonical_path(root_dir) == canonical_path(directory)
|
|
118
|
+
canonical_git_dir = canonical_path(git_dir)
|
|
119
|
+
absorbed = False
|
|
120
|
+
for modules_dir in modules_dirs:
|
|
121
|
+
if canonical_git_dir.startswith(canonical_path(modules_dir) + os.sep):
|
|
122
|
+
absorbed = True
|
|
123
|
+
return own_root, absorbed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def status_paths(output: bytes) -> list[bytes]:
|
|
127
|
+
"""Name every path of a NUL-framed porcelain v1 status listed without rename detection, in Git's order."""
|
|
128
|
+
paths = []
|
|
129
|
+
for record in output.split(INDEX_ENTRY_SEPARATOR):
|
|
130
|
+
if not record:
|
|
131
|
+
continue
|
|
132
|
+
if record[STATUS_SEPARATOR_POSITION:STATUS_PATH_OFFSET] != STATUS_SEPARATOR or len(record) <= STATUS_PATH_OFFSET:
|
|
133
|
+
raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
|
|
134
|
+
paths.append(record[STATUS_PATH_OFFSET:])
|
|
135
|
+
return paths
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def existing_paths(directory: str, paths: list[bytes]) -> list[bytes]:
|
|
139
|
+
"""Keep the paths, relative to a folder, that name something on disk."""
|
|
140
|
+
present_paths = []
|
|
141
|
+
for path in paths:
|
|
142
|
+
if os.path.lexists(os.path.join(directory, *os.fsdecode(path).split(GIT_PATH_SEPARATOR))):
|
|
143
|
+
present_paths.append(path)
|
|
144
|
+
return present_paths
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def submodule_loss_text(submodule_path: str, lost_paths: list[str]) -> str:
|
|
148
|
+
"""Describe a refused replay that would lose files of a submodule, naming the submodule and the first of those files."""
|
|
149
|
+
shown_paths = lost_paths[:SUBMODULE_LOSS_PATH_DISPLAY_LIMIT]
|
|
150
|
+
text = f'The selected commit replaces the submodule "{submodule_path}", and these files in it would be lost: ' + ", ".join(shown_paths)
|
|
151
|
+
if len(lost_paths) > SUBMODULE_LOSS_PATH_DISPLAY_LIMIT:
|
|
152
|
+
text += ", and more"
|
|
153
|
+
return text + ". Commit, move or delete them first. It was not applied."
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def submodule_history_loss_text(submodule_path: str, repository_path: str) -> str:
|
|
157
|
+
"""Describe a refused replay that would delete a Git history kept inside the folder of a submodule."""
|
|
158
|
+
return (f'The selected commit replaces the submodule "{submodule_path}", but the Git history of "{repository_path}" is kept inside that folder and '
|
|
159
|
+
'would be lost. Run "git submodule absorbgitdirs" in the parent repository first. It was not applied.')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def submodule_loss_refusal(context: RepositoryContext, submodule_path: bytes) -> Generator:
|
|
163
|
+
"""Answer why replacing the folder of one submodule of this worktree would lose something, or "" when nothing would be lost.
|
|
164
|
+
|
|
165
|
+
The folder and every populated submodule nested in it are checked one repository at a time, without Git
|
|
166
|
+
descending on its own: an absent or empty folder loses nothing; a link, a file, or files without a repository
|
|
167
|
+
would be lost; a history kept inside the folder would be lost; otherwise every changed, staged, untracked or
|
|
168
|
+
ignored file is lost, and so is any file whose changes Git was told to hide. A malformed answer raises ValueError.
|
|
169
|
+
"""
|
|
170
|
+
display_path = os.fsdecode(submodule_path)
|
|
171
|
+
modules_dirs = submodule_git_dirs(context)
|
|
172
|
+
lost_paths: list[str] = []
|
|
173
|
+
pending_paths = [display_path]
|
|
174
|
+
while pending_paths and len(lost_paths) < LOST_PATH_SEARCH_LIMIT:
|
|
175
|
+
repository_path = pending_paths.pop(0)
|
|
176
|
+
directory = os.path.join(context.worktree_dir, *repository_path.split(GIT_PATH_SEPARATOR))
|
|
177
|
+
folder, found_paths = yield FileTask(lambda directory=directory: inspect_submodule_folder(directory))
|
|
178
|
+
if folder in (SubmoduleFolder.ABSENT, SubmoduleFolder.EMPTY):
|
|
179
|
+
continue
|
|
180
|
+
if folder == SubmoduleFolder.NOT_A_FOLDER:
|
|
181
|
+
lost_paths.append(repository_path)
|
|
182
|
+
continue
|
|
183
|
+
if folder == SubmoduleFolder.UNRECORDED_FILES:
|
|
184
|
+
for found_path in found_paths:
|
|
185
|
+
lost_paths.append(join_git_path(repository_path, found_path))
|
|
186
|
+
continue
|
|
187
|
+
if folder == SubmoduleFolder.EMBEDDED_HISTORY:
|
|
188
|
+
return submodule_history_loss_text(display_path, repository_path)
|
|
189
|
+
location = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel", "--absolute-git-dir"])
|
|
190
|
+
fields = os.fsdecode(location.removesuffix(b"\n")).split("\n")
|
|
191
|
+
if len(fields) != LOCATION_FIELD_COUNT:
|
|
192
|
+
raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
|
|
193
|
+
own_root, absorbed = yield FileTask(lambda directory=directory, fields=fields: is_repository_of(directory, fields[LOCATION_ROOT_POSITION],
|
|
194
|
+
fields[LOCATION_GIT_DIR_POSITION], modules_dirs))
|
|
195
|
+
if not own_root:
|
|
196
|
+
raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
|
|
197
|
+
if not absorbed:
|
|
198
|
+
return submodule_history_loss_text(display_path, repository_path)
|
|
199
|
+
# Git's own nested submodule check is turned on whatever the configuration says, so a nested submodule with
|
|
200
|
+
# local work shows up here too; its files are then listed by its own check.
|
|
201
|
+
status_output, index_output = yield ReadBatch((
|
|
202
|
+
GitCommand(["-C", directory, "status", "--porcelain=v1", "-z", "--no-renames", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]),
|
|
203
|
+
GitCommand(["-C", directory, "ls-files", "--stage", "-v", "-z"]),
|
|
204
|
+
))
|
|
205
|
+
listing = parse_index_listing(index_output)
|
|
206
|
+
nested_paths = gitlink_paths(listing.index_entries)
|
|
207
|
+
nested_path_set = set(nested_paths)
|
|
208
|
+
for path in status_paths(status_output):
|
|
209
|
+
if path not in nested_path_set:
|
|
210
|
+
lost_paths.append(join_git_path(repository_path, os.fsdecode(path)))
|
|
211
|
+
hidden_paths = yield FileTask(lambda directory=directory, listing=listing: existing_paths(directory, list(listing.hidden_index_paths)))
|
|
212
|
+
for path in hidden_paths:
|
|
213
|
+
lost_paths.append(join_git_path(repository_path, os.fsdecode(path)))
|
|
214
|
+
for nested_path in nested_paths:
|
|
215
|
+
pending_paths.append(join_git_path(repository_path, os.fsdecode(nested_path)))
|
|
216
|
+
if not lost_paths:
|
|
217
|
+
return ""
|
|
218
|
+
return submodule_loss_text(display_path, lost_paths)
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Read the light or dark preference the desktop's Settings portal declares, at start or when it changes.
|
|
2
|
+
|
|
3
|
+
The freedesktop Settings portal publishes the preference the user picked in the desktop's settings as the
|
|
4
|
+
``color-scheme`` key of the ``org.freedesktop.appearance`` namespace: 0 for no preference, 1 for dark, 2 for light,
|
|
5
|
+
and any other value counts as no preference. Qt reads that portal too, but asynchronously, so GitPane reads it itself
|
|
6
|
+
once, blocking and bounded, before its first window. Every failure of that read (no bus, no portal, an error, no answer
|
|
7
|
+
in time, a value of another type) gives no preference, which keeps what Qt shows without it.
|
|
8
|
+
|
|
9
|
+
The connection is received from the caller, so the tests read a private bus and never reach the user's session bus.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import SLOT, QObject, Signal, Slot
|
|
18
|
+
from PySide6.QtDBus import QDBus, QDBusConnection, QDBusMessage, QDBusVariant
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
PORTAL_SERVICE_NAME = "org.freedesktop.portal.Desktop"
|
|
24
|
+
PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop"
|
|
25
|
+
PORTAL_SETTINGS_INTERFACE = "org.freedesktop.portal.Settings"
|
|
26
|
+
APPEARANCE_NAMESPACE = "org.freedesktop.appearance"
|
|
27
|
+
COLOR_SCHEME_KEY = "color-scheme"
|
|
28
|
+
READ_ONE_METHOD = "ReadOne"
|
|
29
|
+
SETTING_CHANGED_SIGNAL = "SettingChanged"
|
|
30
|
+
# A ReadOne reply carries exactly one value.
|
|
31
|
+
READ_ONE_REPLY_ARGUMENT_COUNT = 1
|
|
32
|
+
READ_ONE_REPLY_VALUE_INDEX = 0
|
|
33
|
+
# ReadOne wraps the value in one variant; the older Read method, which a portal without ReadOne still answers, wraps it in two.
|
|
34
|
+
MAXIMUM_VARIANT_LAYER_COUNT = 2
|
|
35
|
+
# SettingChanged carries the namespace, the key, then the value in a variant.
|
|
36
|
+
SETTING_CHANGED_ARGUMENT_COUNT = 3
|
|
37
|
+
SETTING_CHANGED_NAMESPACE_INDEX = 0
|
|
38
|
+
SETTING_CHANGED_KEY_INDEX = 1
|
|
39
|
+
SETTING_CHANGED_VALUE_INDEX = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PortalColorScheme(Enum):
|
|
43
|
+
"""The light or dark preference the Settings portal declares, with the value the portal sends for each."""
|
|
44
|
+
|
|
45
|
+
NO_PREFERENCE = 0
|
|
46
|
+
PREFER_DARK = 1
|
|
47
|
+
PREFER_LIGHT = 2
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def prefers_dark(self) -> bool:
|
|
51
|
+
"""Tell whether the desktop asks applications for their dark appearance."""
|
|
52
|
+
return self == PortalColorScheme.PREFER_DARK
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def portal_color_scheme(value: object) -> PortalColorScheme:
|
|
56
|
+
"""Turn a value read from the portal into a preference, unwrapping its variants first.
|
|
57
|
+
|
|
58
|
+
Only a whole number names a preference: a boolean or a text is not one, even when it would compare equal to one.
|
|
59
|
+
The specification says that an unknown value means no preference, and so does anything that is not a number.
|
|
60
|
+
"""
|
|
61
|
+
for _layer in range(MAXIMUM_VARIANT_LAYER_COUNT):
|
|
62
|
+
if type(value) is not QDBusVariant:
|
|
63
|
+
break
|
|
64
|
+
value = value.variant()
|
|
65
|
+
if type(value) is not int:
|
|
66
|
+
return PortalColorScheme.NO_PREFERENCE
|
|
67
|
+
try:
|
|
68
|
+
return PortalColorScheme(value)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return PortalColorScheme.NO_PREFERENCE
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_portal_color_scheme(connection: QDBusConnection, timeout_ms: int) -> PortalColorScheme:
|
|
74
|
+
"""Ask the portal on ``connection`` for the colour scheme and wait at most ``timeout_ms`` for its answer.
|
|
75
|
+
|
|
76
|
+
The call blocks, so the preference is known before the first window is shown, when Qt's own asynchronous read has
|
|
77
|
+
not answered yet. A closed connection, an error reply (no portal, a portal without that key, no answer in time) or a
|
|
78
|
+
reply of another shape gives no preference.
|
|
79
|
+
"""
|
|
80
|
+
if not connection.isConnected():
|
|
81
|
+
return PortalColorScheme.NO_PREFERENCE
|
|
82
|
+
message = QDBusMessage.createMethodCall(PORTAL_SERVICE_NAME, PORTAL_OBJECT_PATH, PORTAL_SETTINGS_INTERFACE, READ_ONE_METHOD)
|
|
83
|
+
message.setArguments([APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY])
|
|
84
|
+
# D-Bus activation is left allowed, unlike the keyring read: Qt's own theme already starts the portal when the
|
|
85
|
+
# application starts, and the portal holds no secret, so starting it changes nothing the user would not expect.
|
|
86
|
+
reply = connection.call(message, QDBus.CallMode.Block, timeout_ms)
|
|
87
|
+
if reply.type() != QDBusMessage.MessageType.ReplyMessage:
|
|
88
|
+
return PortalColorScheme.NO_PREFERENCE
|
|
89
|
+
values = reply.arguments()
|
|
90
|
+
if len(values) != READ_ONE_REPLY_ARGUMENT_COUNT:
|
|
91
|
+
return PortalColorScheme.NO_PREFERENCE
|
|
92
|
+
return portal_color_scheme(values[READ_ONE_REPLY_VALUE_INDEX])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PortalColorSchemeWatcher(QObject):
|
|
96
|
+
"""Report each change of the colour scheme the portal signals, as a ``PortalColorScheme``.
|
|
97
|
+
|
|
98
|
+
The portal signals every setting it publishes through the one ``SettingChanged`` signal; only the colour scheme of
|
|
99
|
+
the appearance namespace is reported. The change is reported inside the D-Bus delivery itself, with no timer, so a
|
|
100
|
+
receiver acts on it before Qt's own listeners of the same signal, which were subscribed earlier, rebuild anything.
|
|
101
|
+
``connected`` says whether the subscription was made: when it is false, no change will ever be reported.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
changed = Signal(object)
|
|
105
|
+
|
|
106
|
+
def __init__(self, connect: Callable[[], QDBusConnection], parent: QObject | None = None) -> None:
|
|
107
|
+
super().__init__(parent)
|
|
108
|
+
self.connection = connect()
|
|
109
|
+
self.connected = False
|
|
110
|
+
if not self.connection.isConnected():
|
|
111
|
+
return
|
|
112
|
+
# PySide6 6.11 drops the first character of a slot signature given as a bare text ("etting_changed(QDBusMessage)"),
|
|
113
|
+
# and the subscription fails; written through SLOT(), the signature reaches QtDBus whole.
|
|
114
|
+
self.connected = self.connection.connect(PORTAL_SERVICE_NAME, PORTAL_OBJECT_PATH, PORTAL_SETTINGS_INTERFACE, SETTING_CHANGED_SIGNAL, self,
|
|
115
|
+
SLOT("setting_changed(QDBusMessage)"))
|
|
116
|
+
|
|
117
|
+
@Slot(QDBusMessage)
|
|
118
|
+
def setting_changed(self, message: QDBusMessage) -> None:
|
|
119
|
+
"""Report the new colour scheme when the signalled setting is the appearance namespace's colour scheme, and ignore any other."""
|
|
120
|
+
arguments = message.arguments()
|
|
121
|
+
if len(arguments) != SETTING_CHANGED_ARGUMENT_COUNT:
|
|
122
|
+
return
|
|
123
|
+
if arguments[SETTING_CHANGED_NAMESPACE_INDEX] != APPEARANCE_NAMESPACE or arguments[SETTING_CHANGED_KEY_INDEX] != COLOR_SCHEME_KEY:
|
|
124
|
+
return
|
|
125
|
+
self.changed.emit(portal_color_scheme(arguments[SETTING_CHANGED_VALUE_INDEX]))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Give GitPane its own identity in the Windows taskbar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.config import APPLICATION_USER_MODEL_ID, WINDOWS_PLATFORM
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
# S_OK: the shell accepted the identifier; any other result is a refusal.
|
|
16
|
+
SUCCESS_RESULT = 0
|
|
17
|
+
# Shows a negative result as the unsigned hexadecimal code Microsoft documents, such as 0x80070057.
|
|
18
|
+
RESULT_CODE_MASK = 0xFFFFFFFF
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _shell_identity_setter() -> Callable[[str], int]:
|
|
22
|
+
"""Load the Windows shell call that names the running process, typed so the identifier is passed as a wide string."""
|
|
23
|
+
shell = ctypes.WinDLL("shell32")
|
|
24
|
+
setter = shell.SetCurrentProcessExplicitAppUserModelID
|
|
25
|
+
setter.argtypes = [ctypes.c_wchar_p]
|
|
26
|
+
setter.restype = ctypes.c_int32
|
|
27
|
+
return setter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def declare_application_identity(set_identity: Callable[[str], int] | None = None, platform: str = sys.platform) -> bool:
|
|
31
|
+
"""Tell Windows that this process is GitPane, and return whether the shell accepted it.
|
|
32
|
+
|
|
33
|
+
Without it the taskbar groups GitPane under the Python interpreter and shows the Python icon. Windows only takes the
|
|
34
|
+
identifier into account when it is set before the process shows any window, so this runs before the Qt application
|
|
35
|
+
exists. A refusal only costs the taskbar icon: it is logged and start-up goes on. Other systems have no such call.
|
|
36
|
+
"""
|
|
37
|
+
if platform != WINDOWS_PLATFORM:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
setter = set_identity if set_identity is not None else _shell_identity_setter()
|
|
41
|
+
result = setter(APPLICATION_USER_MODEL_ID)
|
|
42
|
+
except (AttributeError, OSError) as error:
|
|
43
|
+
logging.getLogger(__name__).warning("Cannot declare the Windows application identity; the taskbar may show the Python icon: %s", error)
|
|
44
|
+
return False
|
|
45
|
+
if result != SUCCESS_RESULT:
|
|
46
|
+
logging.getLogger(__name__).warning("Windows refused the application identity (result %#x); the taskbar may show the Python icon", result & RESULT_CODE_MASK)
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Describe the command-line options of the gitpane command.
|
|
2
|
+
|
|
3
|
+
The parser answers --version and --help, and refuses an unknown option, before any Qt object exists, so these answers
|
|
4
|
+
reach the terminal even when GitPane would later leave it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from gitpane.config import APPLICATION_VERSION_LINE
|
|
13
|
+
|
|
14
|
+
ATTACH_OPTION = "--attach"
|
|
15
|
+
LOG_OPTION = "--log"
|
|
16
|
+
VERSION_OPTION = "--version"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandLineText(Enum):
|
|
20
|
+
"""Texts the gitpane command shows in its help, one member per text so a misspelt name is an error."""
|
|
21
|
+
|
|
22
|
+
DESCRIPTION = "GitPane — browse a Git repository"
|
|
23
|
+
"""The line under the usage of --help."""
|
|
24
|
+
LOG_HELP = "Write a diagnostic log with one line per Git process"
|
|
25
|
+
"""The help of the --log option."""
|
|
26
|
+
ATTACH_HELP = "Stay attached to the terminal: GitPane's output and errors are shown there, and Ctrl+C stops GitPane"
|
|
27
|
+
"""The help of the --attach option."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def command_line_parser() -> argparse.ArgumentParser:
|
|
31
|
+
"""Build the parser of the gitpane command, which answers --version with the application's name and version and exits."""
|
|
32
|
+
parser = argparse.ArgumentParser(description=CommandLineText.DESCRIPTION.value)
|
|
33
|
+
parser.add_argument(LOG_OPTION, action="store_true", help=CommandLineText.LOG_HELP.value)
|
|
34
|
+
parser.add_argument(ATTACH_OPTION, action="store_true", help=CommandLineText.ATTACH_HELP.value)
|
|
35
|
+
parser.add_argument(VERSION_OPTION, action="version", version=APPLICATION_VERSION_LINE)
|
|
36
|
+
return parser
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read from the running session the facts the desktop icon decision needs, off the window's thread.
|
|
2
|
+
|
|
3
|
+
The state of a GNOME Shell extension is read through ``gnome-extensions list``, which names the extensions the Shell
|
|
4
|
+
knows, and ``gnome-extensions list --active``, which names those it really runs, each bounded by a delay of
|
|
5
|
+
``config.py``; an extension the Shell does not know yet but whose ``metadata.json`` lies in an XDG data folder was
|
|
6
|
+
installed during the session. A read with no answer in time, a missing tool or a refusal leave the state unknown,
|
|
7
|
+
which gives no note rather than a wrong one. The program, the files and os-release are all received as parameters,
|
|
8
|
+
so a test never reaches the real session.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from platform import freedesktop_os_release
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QStandardPaths
|
|
20
|
+
|
|
21
|
+
from gitpane.config import LINUX_PLATFORM, MILLISECONDS_PER_SECOND, SHELL_EXTENSION_READ_TIMEOUT_MS
|
|
22
|
+
from gitpane.system.desktop_icon_support import GNOME_EXTENSIONS_PROGRAM, DesktopIconFacts, ExtensionState, desktop_icon_rule
|
|
23
|
+
from gitpane.system.desktop_shortcut import named_desktop_dir
|
|
24
|
+
from gitpane.system.linux_distribution import read_os_release_or_nothing
|
|
25
|
+
from gitpane.system.qt_folder import native_folder_path
|
|
26
|
+
from gitpane.system.xdg_session import desktop_names, xdg_data_dirs, xdg_data_home
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
30
|
+
|
|
31
|
+
ExtensionRun = Callable[..., subprocess.CompletedProcess[bytes]]
|
|
32
|
+
|
|
33
|
+
GNOME_EXTENSIONS_LIST_ARGUMENT = "list"
|
|
34
|
+
GNOME_EXTENSIONS_ACTIVE_OPTION = "--active"
|
|
35
|
+
SHELL_EXTENSION_DIR_PARTS = ("gnome-shell", "extensions")
|
|
36
|
+
EXTENSION_METADATA_BASENAME = "metadata.json"
|
|
37
|
+
GNOME_EXTENSIONS_OUTPUT_ENCODING = "utf-8"
|
|
38
|
+
MOST_SPECIFIC_DESKTOP_NAME_INDEX = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def shell_extension_read_timeout_seconds() -> float:
|
|
42
|
+
"""Return the bound of one gnome-extensions read, in the seconds ``subprocess`` counts in."""
|
|
43
|
+
return SHELL_EXTENSION_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def listed_extension_uuid_set(run: ExtensionRun, options: Sequence[str], timeout_seconds: float) -> frozenset[str] | None:
|
|
47
|
+
"""Return the extension identifiers ``gnome-extensions list`` names with ``options``, one per line, or None when it cannot answer in time.
|
|
48
|
+
|
|
49
|
+
The tool runs without a shell and reads no input; a missing tool, a delay passed or a non-zero exit code all give
|
|
50
|
+
None. Its output is only compared with identifiers of GitPane's own table, never shown or run.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
completed = run([GNOME_EXTENSIONS_PROGRAM, GNOME_EXTENSIONS_LIST_ARGUMENT, *options], shell=False, stdin=subprocess.DEVNULL, capture_output=True, check=False,
|
|
54
|
+
timeout=timeout_seconds)
|
|
55
|
+
except (OSError, subprocess.SubprocessError):
|
|
56
|
+
return None
|
|
57
|
+
if completed.returncode != 0:
|
|
58
|
+
return None
|
|
59
|
+
uuid_set = set()
|
|
60
|
+
for line in completed.stdout.decode(GNOME_EXTENSIONS_OUTPUT_ENCODING, errors="replace").splitlines():
|
|
61
|
+
uuid = line.strip()
|
|
62
|
+
if uuid:
|
|
63
|
+
uuid_set.add(uuid)
|
|
64
|
+
return frozenset(uuid_set)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extension_on_disk(extension_uuid: str, environment_by_name: Mapping[str, str], home_dir: str, is_file: Callable[[str], bool]) -> bool:
|
|
68
|
+
"""Tell whether the extension's ``metadata.json`` lies in the user's XDG data folder or in one of the system's."""
|
|
69
|
+
for data_dir in (xdg_data_home(environment_by_name, home_dir), *xdg_data_dirs(environment_by_name)):
|
|
70
|
+
if is_file(os.path.join(data_dir, *SHELL_EXTENSION_DIR_PARTS, extension_uuid, EXTENSION_METADATA_BASENAME)):
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_extension_state(extension_uuid: str, environment_by_name: Mapping[str, str], home_dir: str, run: ExtensionRun = subprocess.run,
|
|
76
|
+
is_file: Callable[[str], bool] = os.path.isfile) -> ExtensionState:
|
|
77
|
+
"""Read what the running GNOME Shell knows of an extension: active, known but not running, on the disk only, or missing.
|
|
78
|
+
|
|
79
|
+
Runs gnome-extensions twice, so it belongs on a worker thread only. Either read without an answer gives UNKNOWN.
|
|
80
|
+
"""
|
|
81
|
+
timeout_seconds = shell_extension_read_timeout_seconds()
|
|
82
|
+
known_uuid_set = listed_extension_uuid_set(run, (), timeout_seconds)
|
|
83
|
+
if known_uuid_set is None:
|
|
84
|
+
return ExtensionState.UNKNOWN
|
|
85
|
+
active_uuid_set = listed_extension_uuid_set(run, (GNOME_EXTENSIONS_ACTIVE_OPTION,), timeout_seconds)
|
|
86
|
+
if active_uuid_set is None:
|
|
87
|
+
return ExtensionState.UNKNOWN
|
|
88
|
+
if extension_uuid in active_uuid_set:
|
|
89
|
+
return ExtensionState.ACTIVE
|
|
90
|
+
if extension_uuid in known_uuid_set:
|
|
91
|
+
return ExtensionState.INACTIVE
|
|
92
|
+
if extension_on_disk(extension_uuid, environment_by_name, home_dir, is_file):
|
|
93
|
+
return ExtensionState.INSTALLED_NOT_LOADED
|
|
94
|
+
return ExtensionState.NOT_INSTALLED
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_desktop_icon_facts(platform: str, environment_by_name: Mapping[str, str], home_dir: str, desktop_dir: str, run: ExtensionRun = subprocess.run,
|
|
98
|
+
is_file: Callable[[str], bool] = os.path.isfile, is_dir: Callable[[str], bool] = os.path.isdir,
|
|
99
|
+
read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DesktopIconFacts:
|
|
100
|
+
"""Read the facts the desktop icon decision judges for ``platform``, the session ``environment_by_name`` and ``desktop_dir``.
|
|
101
|
+
|
|
102
|
+
The desktop and os-release are only read on Linux, and the extension state only for a desktop that has a row in
|
|
103
|
+
the desktop table: any other desktop or system starts no program and keeps the state unknown. A desktop folder
|
|
104
|
+
that is ``home_dir`` itself reads as no desktop folder, by the same rule the shortcut writer follows, so the note
|
|
105
|
+
says the system names none.
|
|
106
|
+
"""
|
|
107
|
+
desktop_dir = named_desktop_dir(desktop_dir, home_dir)
|
|
108
|
+
desktop_name = ""
|
|
109
|
+
os_release_by_key: dict[str, str] = {}
|
|
110
|
+
extension_state = ExtensionState.UNKNOWN
|
|
111
|
+
if platform == LINUX_PLATFORM:
|
|
112
|
+
names = desktop_names(environment_by_name)
|
|
113
|
+
desktop_name = names[MOST_SPECIFIC_DESKTOP_NAME_INDEX] if names else ""
|
|
114
|
+
os_release_by_key = read_os_release_or_nothing(read_os_release)
|
|
115
|
+
rule = desktop_icon_rule(platform, desktop_name)
|
|
116
|
+
if rule is not None:
|
|
117
|
+
extension_state = read_extension_state(rule.extension_uuid, environment_by_name, home_dir, run, is_file)
|
|
118
|
+
return DesktopIconFacts(platform, desktop_name, os_release_by_key, extension_state, desktop_dir, bool(desktop_dir) and is_dir(desktop_dir))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_session_desktop_icon_facts() -> DesktopIconFacts:
|
|
122
|
+
"""Read the facts of the running session, with the desktop folder Qt names for the system, written with the system's own separator.
|
|
123
|
+
|
|
124
|
+
Starts gnome-extensions on GNOME, so it runs only on a worker thread; Qt only reads the system's folder settings
|
|
125
|
+
there.
|
|
126
|
+
"""
|
|
127
|
+
desktop_dir = native_folder_path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), sys.platform)
|
|
128
|
+
return read_desktop_icon_facts(sys.platform, dict(os.environ), os.path.expanduser("~"), desktop_dir)
|