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,138 @@
|
|
|
1
|
+
"""Watch over one released helper, stopping it, never what it started, as soon as the process that started it ends.
|
|
2
|
+
|
|
3
|
+
A browser sign-in starts Git Credential Manager in a session of its own, so that stopping the sign-in never closes the
|
|
4
|
+
browser it opens. macOS gives a process no signal when its parent dies, and stopping the worker's group does not reach
|
|
5
|
+
a helper that leads a session of its own: without help, a helper whose worker was stopped would keep its local sign-in
|
|
6
|
+
port open until it ends by itself. This watcher is the helper's parent, and the kernel tells it when its own parent,
|
|
7
|
+
the worker, ends; it then stops the helper alone. The browser the helper opened is never signalled.
|
|
8
|
+
|
|
9
|
+
This file is run on its own: the base interpreter runs it in isolated mode, with neither the application's packages
|
|
10
|
+
nor any site directory, so only the standard library can be imported. The application imports it only to find this
|
|
11
|
+
file. The helper inherits the watcher's standard streams and environment, and the watcher ends with the helper's own
|
|
12
|
+
exit code, so the worker reads the helper's answer as if it had started the helper itself.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import select
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
# Workaround: copies of process_group's exit codes. This file runs in isolated mode, where it cannot import the
|
|
24
|
+
# application package, so it keeps its own; a test checks that both stay equal.
|
|
25
|
+
PROCESS_STOP_EXIT_CODE = 125
|
|
26
|
+
PROCESS_START_FAILED_EXIT_CODE = 127
|
|
27
|
+
WATCH_OWNER_ARGUMENT_INDEX = 1
|
|
28
|
+
WATCH_COMMAND_ARGUMENT_OFFSET = 2
|
|
29
|
+
# Workaround: copy of the shell's signal exit base in config.py, kept here for the same isolated-mode reason; a test
|
|
30
|
+
# checks that both stay equal. The watcher reports its helper ended by signal n with this base plus n.
|
|
31
|
+
SIGNAL_EXIT_CODE_BASE = 128
|
|
32
|
+
# At most the owner's end and the helper's end are pending at once.
|
|
33
|
+
WATCH_EVENT_BATCH = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HelperState:
|
|
37
|
+
"""Hold the helper once started, and whether a stop was asked before or while it starts.
|
|
38
|
+
|
|
39
|
+
The stop request comes as a signal, which may arrive before the helper exists: it is then remembered, so that
|
|
40
|
+
nothing is started, or the helper just started is stopped at once.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
"""Start with no helper and no stop asked."""
|
|
45
|
+
self.helper: subprocess.Popen | None = None
|
|
46
|
+
self.stop_requested = False
|
|
47
|
+
|
|
48
|
+
def request_stop(self, signal_number: int, frame: object) -> None:
|
|
49
|
+
"""Answer the stop signal: stop the helper alone if it runs, and remember the request."""
|
|
50
|
+
self.stop_requested = True
|
|
51
|
+
self.stop_helper()
|
|
52
|
+
|
|
53
|
+
def stop_helper(self) -> None:
|
|
54
|
+
"""Stop the helper, never its group: the browser it opened is a member of that group.
|
|
55
|
+
|
|
56
|
+
The helper is this watcher's child and is reaped only after this, so its process ID cannot name another process.
|
|
57
|
+
"""
|
|
58
|
+
if self.helper is not None and self.helper.returncode is None:
|
|
59
|
+
self.helper.kill()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_owner_id(arguments: list[str]) -> int:
|
|
63
|
+
"""Read the process ID of the worker that started this watcher, refusing anything but a positive number and a command after it."""
|
|
64
|
+
if len(arguments) <= WATCH_COMMAND_ARGUMENT_OFFSET:
|
|
65
|
+
raise ValueError("The released helper watcher needs an owner process ID and a command")
|
|
66
|
+
owner_id = int(arguments[WATCH_OWNER_ARGUMENT_INDEX])
|
|
67
|
+
if owner_id <= 0:
|
|
68
|
+
raise ValueError("The released helper watcher owner process ID must be positive")
|
|
69
|
+
return owner_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def exit_watch(process_id: int) -> select.kevent:
|
|
73
|
+
"""Describe a one-shot subscription to the end of ``process_id``."""
|
|
74
|
+
return select.kevent(process_id, select.KQ_FILTER_PROC, select.KQ_EV_ADD | select.KQ_EV_ONESHOT, select.KQ_NOTE_EXIT)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def helper_exit_code(return_code: int) -> int:
|
|
78
|
+
"""Turn the helper's status into this watcher's exit code: its own code, or the shell's form for a signal."""
|
|
79
|
+
if return_code >= 0:
|
|
80
|
+
return return_code
|
|
81
|
+
return SIGNAL_EXIT_CODE_BASE - return_code
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def watch(owner_id: int, command: list[str]) -> int:
|
|
85
|
+
"""Start the helper only while its owner lives, stop it when the owner ends or on SIGTERM, and return its exit code.
|
|
86
|
+
|
|
87
|
+
The owner's end is subscribed to before anything starts, so the helper never runs unguarded. A watcher whose owner
|
|
88
|
+
is already gone, or is no longer its parent, starts nothing.
|
|
89
|
+
"""
|
|
90
|
+
queue = select.kqueue()
|
|
91
|
+
try:
|
|
92
|
+
try:
|
|
93
|
+
queue.control([exit_watch(owner_id)], 0)
|
|
94
|
+
except ProcessLookupError:
|
|
95
|
+
return PROCESS_STOP_EXIT_CODE
|
|
96
|
+
if os.getppid() != owner_id:
|
|
97
|
+
return PROCESS_STOP_EXIT_CODE
|
|
98
|
+
state = HelperState()
|
|
99
|
+
signal.signal(signal.SIGTERM, state.request_stop)
|
|
100
|
+
if state.stop_requested:
|
|
101
|
+
return PROCESS_STOP_EXIT_CODE
|
|
102
|
+
try:
|
|
103
|
+
state.helper = subprocess.Popen(command)
|
|
104
|
+
except OSError as error:
|
|
105
|
+
print("Cannot start the credential helper: " + str(error), file=sys.stderr, flush=True)
|
|
106
|
+
return PROCESS_START_FAILED_EXIT_CODE
|
|
107
|
+
if state.stop_requested:
|
|
108
|
+
state.stop_helper()
|
|
109
|
+
try:
|
|
110
|
+
queue.control([exit_watch(state.helper.pid)], 0)
|
|
111
|
+
except ProcessLookupError:
|
|
112
|
+
# A stop asked while the helper started may already have reaped it, so the kernel no longer knows its ID;
|
|
113
|
+
# the helper has ended and waiting only reads the code recorded then.
|
|
114
|
+
return helper_exit_code(state.helper.wait())
|
|
115
|
+
helper_ended = False
|
|
116
|
+
while not helper_ended:
|
|
117
|
+
for event in queue.control(None, WATCH_EVENT_BATCH):
|
|
118
|
+
if event.ident == state.helper.pid:
|
|
119
|
+
helper_ended = True
|
|
120
|
+
elif event.ident == owner_id:
|
|
121
|
+
state.stop_helper()
|
|
122
|
+
return helper_exit_code(state.helper.wait())
|
|
123
|
+
finally:
|
|
124
|
+
queue.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> int:
|
|
128
|
+
"""Read the owner and the command, then watch the helper; a refused argument starts nothing."""
|
|
129
|
+
try:
|
|
130
|
+
owner_id = read_owner_id(sys.argv)
|
|
131
|
+
except ValueError as error:
|
|
132
|
+
print(str(error), file=sys.stderr, flush=True)
|
|
133
|
+
return PROCESS_START_FAILED_EXIT_CODE
|
|
134
|
+
return watch(owner_id, sys.argv[WATCH_COMMAND_ARGUMENT_OFFSET:])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Group the recent folders by what their answer waits on, from the mount table of Linux or macOS.
|
|
2
|
+
|
|
3
|
+
Asking whether a folder exists waits on the file system that holds it: a sleeping disk, a frozen FUSE mount or a dead
|
|
4
|
+
network share. Folders of one mount, or of one disk or server, are asked in order on one thread, so a slow one only
|
|
5
|
+
delays its own folders. Reading the table itself touches no mounted disk: it is a list the system keeps in memory.
|
|
6
|
+
|
|
7
|
+
This module only reads and compares text; it knows nothing about threads or windows.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import errno
|
|
13
|
+
import os
|
|
14
|
+
import posixpath
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import unicodedata
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from gitpane.config import DECOMPOSED_UNICODE_FORM, LINUX_PLATFORM, MACOS_PLATFORM, MOUNT_TABLE_TIMEOUT_SECONDS
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
# Linux keeps the table of this process's mounts here, one mount per line: source, mount point, type, options.
|
|
28
|
+
PROC_MOUNTS_FILENAME = "/proc/self/mounts"
|
|
29
|
+
# The kernel writes a space, tab, newline, backslash and, in the source, a hash as a backslash and three octal digits,
|
|
30
|
+
# so that the fields stay separated by spaces.
|
|
31
|
+
MOUNT_FIELD_ESCAPE_PATTERN = re.compile(rb"\\([0-7]{3})")
|
|
32
|
+
# The escaped digits are octal.
|
|
33
|
+
OCTAL_BASE = 8
|
|
34
|
+
# Positions of the fields a line of /proc/mounts starts with.
|
|
35
|
+
SOURCE_FIELD, MOUNT_DIR_FIELD, FS_TYPE_FIELD = 0, 1, 2
|
|
36
|
+
# A line shorter than this is not a mount: it is skipped rather than failing the whole table.
|
|
37
|
+
MOUNT_LINE_MIN_FIELD_COUNT = 3
|
|
38
|
+
# Start of a source that is a disk or a partition: one disk mounted at several places is one group.
|
|
39
|
+
DEVICE_SOURCE_PREFIX = "/dev/"
|
|
40
|
+
# Starts of the group keys: the prefix keeps a server name from ever meeting a device or a mount point with the same text.
|
|
41
|
+
SERVER_GROUP_PREFIX = "server:"
|
|
42
|
+
DEVICE_GROUP_PREFIX = "device:"
|
|
43
|
+
MOUNT_GROUP_PREFIX = "mount:"
|
|
44
|
+
# NFS writes its source as host:/export, or [address]:/export for an IPv6 address.
|
|
45
|
+
NFS_FS_TYPE_SET = frozenset({"nfs", "nfs4"})
|
|
46
|
+
# SMB writes its source as //[user@]host/share: macOS writes the user, Linux does not.
|
|
47
|
+
SMB_FS_TYPE_SET = frozenset({"cifs", "smb3", "smbfs"})
|
|
48
|
+
# sshfs writes its source as [user@]host:[path].
|
|
49
|
+
SSHFS_FS_TYPE_SET = frozenset({"fuse.sshfs"})
|
|
50
|
+
# Every network file system; those whose source names no server GitPane can read are grouped by mount point.
|
|
51
|
+
NETWORK_FS_TYPE_SET = NFS_FS_TYPE_SET | SMB_FS_TYPE_SET | frozenset({"afpfs", "webdav", "9p"})
|
|
52
|
+
# FUSE mounts answer through a program that may hang; on Linux their type is also written fuse.<program>.
|
|
53
|
+
FUSE_FS_TYPE_SET = frozenset({"fuse", "fuseblk", "macfuse", "osxfuse"})
|
|
54
|
+
# fusectl starts with "fuse" but is only the control folder of FUSE, not a FUSE mount: the dot keeps it out.
|
|
55
|
+
FUSE_FS_TYPE_PREFIX = "fuse."
|
|
56
|
+
# Reading inside an automount point triggers the mount, so it may wait on the network.
|
|
57
|
+
AUTOMOUNT_FS_TYPE_SET = frozenset({"autofs"})
|
|
58
|
+
# Separators of a network source: //host/share, user@host, host:path and [IPv6 address].
|
|
59
|
+
NETWORK_SHARE_SOURCE_PREFIX = "//"
|
|
60
|
+
NETWORK_SHARE_PATH_SEPARATOR = "/"
|
|
61
|
+
USER_SEPARATOR = "@"
|
|
62
|
+
HOST_PATH_SEPARATOR = ":"
|
|
63
|
+
BRACKETED_HOST_START = "["
|
|
64
|
+
BRACKETED_HOST_END = "]"
|
|
65
|
+
# POSIX lets a path keep exactly two leading slashes, so normpath keeps them; Linux and macOS read them as one.
|
|
66
|
+
DOUBLE_SLASH_ROOT = posixpath.sep * 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class MountEntry:
|
|
71
|
+
"""One mounted file system: what is mounted, where, and its type."""
|
|
72
|
+
|
|
73
|
+
source: str
|
|
74
|
+
mount_dir: str
|
|
75
|
+
fs_type: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decode_mount_field(field: bytes) -> str:
|
|
79
|
+
"""Undo the kernel's octal escapes, then decode as Python decodes every path, so the result compares with recent paths.
|
|
80
|
+
|
|
81
|
+
Names that are not UTF-8 keep the same form as in Python's own paths.
|
|
82
|
+
"""
|
|
83
|
+
return os.fsdecode(MOUNT_FIELD_ESCAPE_PATTERN.sub(lambda match: bytes([int(match.group(1), OCTAL_BASE)]), field))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_proc_mounts(data: bytes) -> list[MountEntry]:
|
|
87
|
+
"""Read the lines of /proc/mounts; a malformed line is skipped rather than failing the whole table."""
|
|
88
|
+
entries: list[MountEntry] = []
|
|
89
|
+
for line in data.splitlines():
|
|
90
|
+
fields = line.split()
|
|
91
|
+
if len(fields) < MOUNT_LINE_MIN_FIELD_COUNT:
|
|
92
|
+
continue
|
|
93
|
+
entries.append(MountEntry(decode_mount_field(fields[SOURCE_FIELD]), decode_mount_field(fields[MOUNT_DIR_FIELD]), decode_mount_field(fields[FS_TYPE_FIELD])))
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_file_bytes(filename: str) -> bytes:
|
|
98
|
+
"""Read a whole file as bytes."""
|
|
99
|
+
with open(filename, "rb") as file:
|
|
100
|
+
return file.read()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_proc_mounts(read_file: Callable[[str], bytes] = read_file_bytes) -> list[MountEntry]:
|
|
104
|
+
"""Read this process's mount table on Linux; an unreadable table is empty, never an error, so the checks keep one group."""
|
|
105
|
+
try:
|
|
106
|
+
data = read_file(PROC_MOUNTS_FILENAME)
|
|
107
|
+
except OSError:
|
|
108
|
+
return []
|
|
109
|
+
return parse_proc_mounts(data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_unwalked_mount(entry: MountEntry) -> bool:
|
|
113
|
+
"""Tell whether even reading a link on this mount may wait on the network or on a program: network, FUSE and automount.
|
|
114
|
+
|
|
115
|
+
Following the links of a recent's path reads nothing on such a mount; only the question itself is asked there.
|
|
116
|
+
"""
|
|
117
|
+
if entry.fs_type in NETWORK_FS_TYPE_SET or entry.fs_type in FUSE_FS_TYPE_SET or entry.fs_type in AUTOMOUNT_FS_TYPE_SET:
|
|
118
|
+
return True
|
|
119
|
+
return entry.fs_type.startswith(FUSE_FS_TYPE_PREFIX)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def smb_server_name(source: str) -> str:
|
|
123
|
+
"""Return the host of a //[user@]host/share source, or an empty text when the source does not have that form."""
|
|
124
|
+
if not source.startswith(NETWORK_SHARE_SOURCE_PREFIX):
|
|
125
|
+
return ""
|
|
126
|
+
user_and_host, _separator, _share = source.removeprefix(NETWORK_SHARE_SOURCE_PREFIX).partition(NETWORK_SHARE_PATH_SEPARATOR)
|
|
127
|
+
_user, _separator, host = user_and_host.rpartition(USER_SEPARATOR)
|
|
128
|
+
return host
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def host_path_server_name(source: str) -> str:
|
|
132
|
+
"""Return the host of a [user@]host:path source, the host being bracketed for an IPv6 address; empty when there is none.
|
|
133
|
+
|
|
134
|
+
The user is only looked for before the host's colon, since the path after it may hold an at sign of its own.
|
|
135
|
+
"""
|
|
136
|
+
host_end = source.find(HOST_PATH_SEPARATOR)
|
|
137
|
+
user_end = source.rfind(USER_SEPARATOR, 0, host_end if host_end != -1 else len(source))
|
|
138
|
+
host_and_path = source[user_end + len(USER_SEPARATOR):] if user_end != -1 else source
|
|
139
|
+
if host_and_path.startswith(BRACKETED_HOST_START):
|
|
140
|
+
host, separator, _path = host_and_path.removeprefix(BRACKETED_HOST_START).partition(BRACKETED_HOST_END)
|
|
141
|
+
return host if separator else ""
|
|
142
|
+
host, separator, _path = host_and_path.partition(HOST_PATH_SEPARATOR)
|
|
143
|
+
return host if separator else ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def server_name(entry: MountEntry) -> str | None:
|
|
147
|
+
"""Return the network server a mount waits on, without user and compared without case; None when its source names none.
|
|
148
|
+
|
|
149
|
+
Only the host is kept, never the user or the path, so neither ends up in a group key or in a log line.
|
|
150
|
+
"""
|
|
151
|
+
if entry.fs_type in SMB_FS_TYPE_SET:
|
|
152
|
+
host = smb_server_name(entry.source)
|
|
153
|
+
elif entry.fs_type in NFS_FS_TYPE_SET or entry.fs_type in SSHFS_FS_TYPE_SET:
|
|
154
|
+
host = host_path_server_name(entry.source)
|
|
155
|
+
else:
|
|
156
|
+
return None
|
|
157
|
+
return host.casefold() or None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def case_sensitive_path_key(path: str) -> str:
|
|
161
|
+
"""Compare paths as written, as Linux does: ``/mnt/NAS`` and ``/mnt/nas`` are two folders."""
|
|
162
|
+
return path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def case_insensitive_path_key(path: str) -> str:
|
|
166
|
+
"""Compare paths without case and without Unicode form, as the disks of macOS do by default.
|
|
167
|
+
|
|
168
|
+
``/volumes/nas/dépôt`` is ``/Volumes/NAS/DÉPÔT`` whether its accents are composed or decomposed. The text is
|
|
169
|
+
decomposed again after the case fold, since folding a letter can give back a composed one.
|
|
170
|
+
"""
|
|
171
|
+
return unicodedata.normalize(DECOMPOSED_UNICODE_FORM, unicodedata.normalize(DECOMPOSED_UNICODE_FORM, path).casefold())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def platform_path_compare_key(platform: str) -> Callable[[str], str]:
|
|
175
|
+
"""Return how this platform compares a recent's path with the mount points: without case on macOS, as written elsewhere."""
|
|
176
|
+
if platform == MACOS_PLATFORM:
|
|
177
|
+
return case_insensitive_path_key
|
|
178
|
+
return case_sensitive_path_key
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Chosen once at import: the platform never changes while GitPane runs.
|
|
182
|
+
PLATFORM_PATH_COMPARE_KEY = platform_path_compare_key(sys.platform)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def without_double_slash_root(path: str) -> str:
|
|
186
|
+
"""Write a path that starts with exactly two slashes with one, as the system reads it, so it compares with the mount points."""
|
|
187
|
+
if path.startswith(DOUBLE_SLASH_ROOT) and not path.startswith(DOUBLE_SLASH_ROOT + posixpath.sep):
|
|
188
|
+
return path[len(posixpath.sep):]
|
|
189
|
+
return path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def comparable_path(path: str, path_compare_key: Callable[[str], str]) -> str:
|
|
193
|
+
"""Return the form of a path or a mount point that is compared: one leading slash, and without case where the platform ignores it."""
|
|
194
|
+
return path_compare_key(without_double_slash_root(path))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def path_parts(path: str) -> list[str]:
|
|
198
|
+
"""Return the names of a path's folders, in order, without the empty ones that slashes leave."""
|
|
199
|
+
parts: list[str] = []
|
|
200
|
+
for part in path.split(posixpath.sep):
|
|
201
|
+
if part:
|
|
202
|
+
parts.append(part)
|
|
203
|
+
return parts
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Mount points on Linux and macOS are POSIX paths: they are compared with posixpath so these functions and their tests
|
|
207
|
+
# behave the same on Windows, where this module never runs for real.
|
|
208
|
+
def mount_contains(mount_dir: str, path: str, path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> bool:
|
|
209
|
+
"""Tell whether the path is the mount point itself or below it: ``/mnt/nas2`` is not inside ``/mnt/nas``.
|
|
210
|
+
|
|
211
|
+
Both are brought to the same form first, so a path written ``//mnt/nas``, or on macOS ``/volumes/nas``, is on the mount.
|
|
212
|
+
"""
|
|
213
|
+
compared_mount_dir = comparable_path(mount_dir, path_compare_key)
|
|
214
|
+
compared_path = comparable_path(path, path_compare_key)
|
|
215
|
+
if compared_path == compared_mount_dir:
|
|
216
|
+
return True
|
|
217
|
+
return compared_path.startswith(compared_mount_dir.rstrip(posixpath.sep) + posixpath.sep)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def entry_preference_key(entry: MountEntry) -> tuple[bool, str, str]:
|
|
221
|
+
"""Order two entries mounted at the same point by their content alone, never by their place in the table.
|
|
222
|
+
|
|
223
|
+
A mount that is not walked comes first, since that is the one a question may wait on; then the type and the source.
|
|
224
|
+
"""
|
|
225
|
+
return (not is_unwalked_mount(entry), entry.fs_type, entry.source)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def deepest_mount_entry(path: str, entries: list[MountEntry], path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> MountEntry | None:
|
|
229
|
+
"""Return the mount that holds the path, the one with the longest mount point; None when no mount holds it.
|
|
230
|
+
|
|
231
|
+
The path is absolute and normalized. Of two entries at the same point, such as an automount point and the network
|
|
232
|
+
share it mounted, the one not walked is preferred, then the smaller type and source: the order of the system's table
|
|
233
|
+
is not guaranteed, so it never decides. Either choice keeps the folder in a group of its own mount.
|
|
234
|
+
"""
|
|
235
|
+
best_entry: MountEntry | None = None
|
|
236
|
+
for entry in entries:
|
|
237
|
+
if not mount_contains(entry.mount_dir, path, path_compare_key):
|
|
238
|
+
continue
|
|
239
|
+
if best_entry is None or len(entry.mount_dir) > len(best_entry.mount_dir):
|
|
240
|
+
best_entry = entry
|
|
241
|
+
continue
|
|
242
|
+
if len(entry.mount_dir) == len(best_entry.mount_dir) and entry_preference_key(entry) < entry_preference_key(best_entry):
|
|
243
|
+
best_entry = entry
|
|
244
|
+
return best_entry
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def mount_group_key(path: str, entries: list[MountEntry], path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> str | None:
|
|
248
|
+
"""Return what the folder's answer waits on: its network server, its disk, or its mount point; None when no mount holds it.
|
|
249
|
+
|
|
250
|
+
Every mount of one server is one group whatever its protocol, since a server that is off blocks them all alike.
|
|
251
|
+
"""
|
|
252
|
+
entry = deepest_mount_entry(path, entries, path_compare_key)
|
|
253
|
+
if entry is None:
|
|
254
|
+
return None
|
|
255
|
+
return entry_group_key(entry)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def entry_group_key(entry: MountEntry) -> str:
|
|
259
|
+
"""Return the group of the folders of one mount: its network server, its disk, or its mount point."""
|
|
260
|
+
server = server_name(entry)
|
|
261
|
+
if server is not None:
|
|
262
|
+
return SERVER_GROUP_PREFIX + server
|
|
263
|
+
if entry.source.startswith(DEVICE_SOURCE_PREFIX):
|
|
264
|
+
return DEVICE_GROUP_PREFIX + entry.source
|
|
265
|
+
return MOUNT_GROUP_PREFIX + entry.mount_dir
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Linux itself follows at most this many links while resolving one path; past it, the question is asked as the path is.
|
|
269
|
+
MAX_FOLLOWED_LINK_COUNT = 40
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class FolderWalk:
|
|
274
|
+
"""Where following the links of a recent's path led.
|
|
275
|
+
|
|
276
|
+
``walked_dir`` is the path with the links read so far replaced by their targets; ``group`` is the group whose thread
|
|
277
|
+
goes on with the folder: its own group to ask the question, another one to hand it over.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
walked_dir: str
|
|
281
|
+
group: str
|
|
282
|
+
followed_link_count: int
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def path_parts_below(path: str, mount_dir: str) -> list[str]:
|
|
286
|
+
"""Return the names of the path's folders below a mount point that holds it, in order; empty for the mount point itself.
|
|
287
|
+
|
|
288
|
+
Counted in folders, not in characters: the path may write the mount point in another case, and folding the case
|
|
289
|
+
can change a name's length (``straße`` and ``STRASSE``).
|
|
290
|
+
"""
|
|
291
|
+
return path_parts(path)[len(path_parts(mount_dir)):]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def follow_folder_links(walked_dir: str, home_group: str, followed_link_count: int, entries: list[MountEntry], read_link: Callable[[str], str],
|
|
295
|
+
path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> FolderWalk:
|
|
296
|
+
"""Follow the links of a recent's path, one folder at a time, as long as it stays on the mounts of its own group.
|
|
297
|
+
|
|
298
|
+
It runs on the thread of the group the path is on: reading a link only waits on the disk that holds it, so a slow disk
|
|
299
|
+
only delays its own folders. The path is compared with the mount table before every read; as soon as it enters a
|
|
300
|
+
mount of another group, the walk stops and names that group, which goes on with the folder: a link on the system
|
|
301
|
+
disk that points to a dead share never holds the system disk's thread. Nothing is ever read on a network, FUSE or
|
|
302
|
+
automount mount: its thread only asks the question. The walk starts at the deepest mount point of the path, since
|
|
303
|
+
the system lists mount points as real paths.
|
|
304
|
+
|
|
305
|
+
The question and its answer stay about the recent as saved, which the caller keeps: the walk only decides which
|
|
306
|
+
thread asks it. So the walk stops and the question is asked in the current group whenever the path cannot be read
|
|
307
|
+
further: a folder that does not exist, a refused access, a name the system rejects, or more links than the system
|
|
308
|
+
itself would follow, the count going on across groups. The question then tells what is there. A relative target is
|
|
309
|
+
joined to the link's folder and normalized: the folders above it are real paths already, so ``..`` is exact.
|
|
310
|
+
|
|
311
|
+
The path is compared with the mount points as the platform compares names, the same way the folders were sorted
|
|
312
|
+
into their groups: a path written in another case on macOS, or with two leading slashes, is still on its mount.
|
|
313
|
+
"""
|
|
314
|
+
# Folders already found not to be links: a path that comes back after a link is not read twice.
|
|
315
|
+
plain_dir_set: set[str] = set()
|
|
316
|
+
while True:
|
|
317
|
+
entry = deepest_mount_entry(walked_dir, entries, path_compare_key)
|
|
318
|
+
if entry is None:
|
|
319
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
320
|
+
group = entry_group_key(entry)
|
|
321
|
+
if group != home_group:
|
|
322
|
+
return FolderWalk(walked_dir, group, followed_link_count)
|
|
323
|
+
if is_unwalked_mount(entry):
|
|
324
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
325
|
+
# Every folder read below is a start of the walked path, so the deepest mount of the whole path holds them all:
|
|
326
|
+
# checking it once, above, is checking each of them before its read.
|
|
327
|
+
next_dir: str | None = None
|
|
328
|
+
candidate_dir = entry.mount_dir
|
|
329
|
+
parts = path_parts_below(walked_dir, entry.mount_dir)
|
|
330
|
+
for part_index, part in enumerate(parts):
|
|
331
|
+
candidate_dir = posixpath.join(candidate_dir, part)
|
|
332
|
+
if candidate_dir in plain_dir_set:
|
|
333
|
+
continue
|
|
334
|
+
if followed_link_count >= MAX_FOLLOWED_LINK_COUNT:
|
|
335
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
336
|
+
try:
|
|
337
|
+
target = read_link(candidate_dir)
|
|
338
|
+
except OSError as error:
|
|
339
|
+
if error.errno != errno.EINVAL:
|
|
340
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
341
|
+
plain_dir_set.add(candidate_dir)
|
|
342
|
+
continue
|
|
343
|
+
except ValueError:
|
|
344
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
345
|
+
next_dir = without_double_slash_root(posixpath.normpath(posixpath.join(posixpath.dirname(candidate_dir), target, *parts[part_index + 1:])))
|
|
346
|
+
followed_link_count += 1
|
|
347
|
+
break
|
|
348
|
+
if next_dir is None:
|
|
349
|
+
return FolderWalk(walked_dir, home_group, followed_link_count)
|
|
350
|
+
walked_dir = next_dir
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# The mount command of macOS, by absolute path: it is never looked for in the PATH, where another program could take its name.
|
|
354
|
+
MOUNT_COMMAND_FILENAME = "/sbin/mount"
|
|
355
|
+
# macOS writes each mount as "<source> on <mount point> (<type>, <option>, ...)", without escaping spaces. The mount
|
|
356
|
+
# point is absolute, so the source ends at the first " on /", which keeps a source such as "Music on NAS" whole; the
|
|
357
|
+
# mount point ends at the last " (".
|
|
358
|
+
MACOS_MOUNT_ON_SEPARATOR = " on /"
|
|
359
|
+
MACOS_MOUNT_OPTION_START = " ("
|
|
360
|
+
MACOS_MOUNT_OPTION_END = ")"
|
|
361
|
+
MACOS_MOUNT_OPTION_SEPARATOR = ","
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def parse_macos_mount_output(text: str) -> list[MountEntry]:
|
|
365
|
+
"""Read the output of the mount command of macOS; a line that does not have the mount form is skipped.
|
|
366
|
+
|
|
367
|
+
A line without an absolute mount point is skipped too, rather than read as a mount that would hold no recent.
|
|
368
|
+
"""
|
|
369
|
+
entries: list[MountEntry] = []
|
|
370
|
+
for line in text.splitlines():
|
|
371
|
+
source, on_separator, point_and_options = line.partition(MACOS_MOUNT_ON_SEPARATOR)
|
|
372
|
+
if not on_separator:
|
|
373
|
+
continue
|
|
374
|
+
# The separator took the mount point's leading slash.
|
|
375
|
+
point_and_options = posixpath.sep + point_and_options
|
|
376
|
+
mount_dir, option_separator, options = point_and_options.rpartition(MACOS_MOUNT_OPTION_START)
|
|
377
|
+
if not option_separator or not options.endswith(MACOS_MOUNT_OPTION_END):
|
|
378
|
+
continue
|
|
379
|
+
fs_type, _separator, _other_options = options.removesuffix(MACOS_MOUNT_OPTION_END).partition(MACOS_MOUNT_OPTION_SEPARATOR)
|
|
380
|
+
fs_type = fs_type.strip()
|
|
381
|
+
if not source or not mount_dir or not fs_type:
|
|
382
|
+
continue
|
|
383
|
+
entries.append(MountEntry(source, mount_dir, fs_type))
|
|
384
|
+
return entries
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def read_macos_mount_table(open_process: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> list[MountEntry]:
|
|
388
|
+
"""Read the mount table of macOS from the mount command; any failure gives an empty table, so the checks keep one group.
|
|
389
|
+
|
|
390
|
+
Only called on the sorting thread of the recent folder checks: the command asks every mount point and may wait.
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
process = open_process([MOUNT_COMMAND_FILENAME], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
394
|
+
except (OSError, ValueError):
|
|
395
|
+
return []
|
|
396
|
+
try:
|
|
397
|
+
stdout, _stderr = process.communicate(timeout=MOUNT_TABLE_TIMEOUT_SECONDS)
|
|
398
|
+
except subprocess.TimeoutExpired:
|
|
399
|
+
# Workaround: subprocess.run waits for the child after killing it, and a child stuck in the kernel on a dead share
|
|
400
|
+
# may never end, which would hold the sorting thread for good. The child is killed and left for subprocess to
|
|
401
|
+
# reap later, without waiting for it here.
|
|
402
|
+
process.kill()
|
|
403
|
+
return []
|
|
404
|
+
if process.returncode != 0:
|
|
405
|
+
return []
|
|
406
|
+
return parse_macos_mount_output(os.fsdecode(stdout))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def platform_mount_table_reader(platform: str) -> Callable[[], list[MountEntry]] | None:
|
|
410
|
+
"""Return the function that reads the mount table on this platform, or None where the folders are grouped otherwise.
|
|
411
|
+
|
|
412
|
+
Windows groups by drive letter and network server, and keeps doing so; an unknown system keeps a single group.
|
|
413
|
+
"""
|
|
414
|
+
if platform == LINUX_PLATFORM:
|
|
415
|
+
return read_proc_mounts
|
|
416
|
+
if platform == MACOS_PLATFORM:
|
|
417
|
+
return read_macos_mount_table
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# Chosen once at import: the platform never changes while GitPane runs.
|
|
422
|
+
PLATFORM_MOUNT_TABLE_READER = platform_mount_table_reader(sys.platform)
|