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
gitpane/tool/editor.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Serve the editor requests Git sends through the bridge until each one is answered."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import shlex
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
20
|
+
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
|
21
|
+
|
|
22
|
+
import gitpane.tool.editor_bridge
|
|
23
|
+
from gitpane.account.ssh_askpass import peer_process_id
|
|
24
|
+
from gitpane.config import (
|
|
25
|
+
EDITOR_ANSWERED_CLOSE_TIMEOUT_MS,
|
|
26
|
+
EDITOR_CONNECT_TIMEOUT_MS,
|
|
27
|
+
EDITOR_HEARTBEAT_INTERVAL_MS,
|
|
28
|
+
EDITOR_IPC_LIMIT_BYTES,
|
|
29
|
+
EDITOR_LIVENESS_TIMEOUT_MS,
|
|
30
|
+
EDITOR_PENDING_LIMIT,
|
|
31
|
+
EDITOR_REQUEST_LIMIT,
|
|
32
|
+
EDITOR_TOKEN_BYTES,
|
|
33
|
+
MILLISECONDS_PER_SECOND,
|
|
34
|
+
MODULE_OPTION,
|
|
35
|
+
application_module_interpreter_options,
|
|
36
|
+
)
|
|
37
|
+
from gitpane.sequencer.rebase_edit_file import edit_file_open_flags
|
|
38
|
+
from gitpane.system.path_identity import canonical_path, link_resolved_path
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
|
|
43
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def editor_bridge_module_name() -> str:
|
|
47
|
+
"""Answer the module name Git receives after -m to start the editor bridge.
|
|
48
|
+
|
|
49
|
+
It is read from the imported module, so a move that rewrites the import cannot leave a stale name in the command.
|
|
50
|
+
"""
|
|
51
|
+
return gitpane.tool.editor_bridge.__name__
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EditorRequestKind(Enum):
|
|
55
|
+
"""Which file Git asks to edit: the rebase instruction list or a commit message, as the bridge names them."""
|
|
56
|
+
|
|
57
|
+
SEQUENCE = "sequence"
|
|
58
|
+
MESSAGE = "message"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def window_title(self) -> str:
|
|
62
|
+
"""Answer the title of the window that shows this kind of edit."""
|
|
63
|
+
return "Rebase instructions" if self == EditorRequestKind.SEQUENCE else "Commit message"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def progress_text(self) -> str:
|
|
67
|
+
"""Answer the progress line shown while Git waits for this kind of edit."""
|
|
68
|
+
return "Editing rebase instructions…" if self == EditorRequestKind.SEQUENCE else "Editing commit message…"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EditorFailure(Enum):
|
|
72
|
+
"""Why the editor service could not take an edit request of Git; each value is the cause written in the output of the rebase."""
|
|
73
|
+
|
|
74
|
+
REQUEST_REFUSED = "The GitPane editor could not take this edit request."
|
|
75
|
+
CLOSED = "The GitPane rebase editor is closed."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EditorAnswer(Enum):
|
|
79
|
+
"""What the user chose for one edit Git waits for."""
|
|
80
|
+
|
|
81
|
+
CONTINUE = 0
|
|
82
|
+
ABORT = 1
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def return_code(self) -> int:
|
|
86
|
+
"""Answer the exit status the bridge reports to Git for this choice."""
|
|
87
|
+
return self.value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class EditorRequest:
|
|
92
|
+
"""Identify one file waiter without exposing private bridge credentials."""
|
|
93
|
+
|
|
94
|
+
operation_id: str
|
|
95
|
+
request_id: str
|
|
96
|
+
kind: EditorRequestKind
|
|
97
|
+
file_filename: str
|
|
98
|
+
file_identity: tuple[int, int] | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EditorResult:
|
|
103
|
+
"""Report how one edit ended: the exit status Git receives, the user's choice when a GitPane window answered, and a fixed cause on failure."""
|
|
104
|
+
|
|
105
|
+
request: EditorRequest
|
|
106
|
+
return_code: int
|
|
107
|
+
cause: str = ""
|
|
108
|
+
answer: EditorAnswer | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PendingEdit:
|
|
112
|
+
"""One edit Git waits for until a GitPane window answers it, answered at most once."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, request: EditorRequest, finish: Callable[[PendingEdit, EditorAnswer], None]) -> None:
|
|
115
|
+
"""Keep the request and the service's way to end it; the service builds it when the bridge asks for an edit."""
|
|
116
|
+
self.request = request
|
|
117
|
+
self.answered = False
|
|
118
|
+
self.finish = finish
|
|
119
|
+
|
|
120
|
+
def answer(self, answer: EditorAnswer) -> None:
|
|
121
|
+
"""Send the user's choice to Git once; a later answer, or one given after the bridge was lost, changes nothing."""
|
|
122
|
+
if self.answered:
|
|
123
|
+
return
|
|
124
|
+
self.answered = True
|
|
125
|
+
self.finish(self, answer)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class EditorService(QObject):
|
|
129
|
+
"""Hold every edit Git waits for until a GitPane window answers it, while Qt keeps serving other windows.
|
|
130
|
+
|
|
131
|
+
It serves the requests of Git only and never starts a program: Show in text editor opens ordinary files elsewhere.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
waiting = Signal(object)
|
|
135
|
+
finished = Signal(object)
|
|
136
|
+
idle = Signal()
|
|
137
|
+
edit_requested = Signal(object)
|
|
138
|
+
edit_cancelled = Signal(object)
|
|
139
|
+
|
|
140
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
141
|
+
"""Keep each request and its direct waiter under this service's ownership."""
|
|
142
|
+
super().__init__(parent)
|
|
143
|
+
# One entry per edit Git waits for, until a GitPane window answers it or its bridge is lost.
|
|
144
|
+
self.active_by_id: dict[str, PendingEdit] = {}
|
|
145
|
+
self.context_by_id: dict[str, EditorExecutionContext] = {}
|
|
146
|
+
self.closed = False
|
|
147
|
+
|
|
148
|
+
def close(self) -> bool:
|
|
149
|
+
"""Expire idle channels only after every owned file waiter has exited normally."""
|
|
150
|
+
if self.active_by_id:
|
|
151
|
+
return False
|
|
152
|
+
self.closed = True
|
|
153
|
+
for context in tuple(self.findChildren(EditorExecutionContext)):
|
|
154
|
+
context.close()
|
|
155
|
+
return not self.findChildren(EditorExecutionContext)
|
|
156
|
+
|
|
157
|
+
def create_context(self, operation_id: str, git_dir: str, worktree_key: WorktreeKey | None = None) -> EditorExecutionContext:
|
|
158
|
+
"""Capture one canonical Git directory for the lifetime of an operation; every edit of it waits for a GitPane window's answer."""
|
|
159
|
+
if self.closed:
|
|
160
|
+
raise ValueError("The editor service is closed.")
|
|
161
|
+
if not operation_id or operation_id in self.context_by_id:
|
|
162
|
+
raise ValueError("An editor operation must have a unique identity.")
|
|
163
|
+
if worktree_key is not None and worktree_key.git_dir != canonical_path(git_dir):
|
|
164
|
+
raise ValueError("The editor belongs to another worktree.")
|
|
165
|
+
context = EditorExecutionContext(self, operation_id, git_dir, worktree_key)
|
|
166
|
+
self.context_by_id[operation_id] = context
|
|
167
|
+
return context
|
|
168
|
+
|
|
169
|
+
def request_edit(self, request: EditorRequest, complete: Callable[[EditorResult], None]) -> PendingEdit:
|
|
170
|
+
"""Hold one edit Git waits for until a GitPane window answers it, and announce it to whoever shows it.
|
|
171
|
+
|
|
172
|
+
The request stays in ``active_by_id`` until its answer, so closing GitPane or its repository window waits for it.
|
|
173
|
+
"""
|
|
174
|
+
if self.closed:
|
|
175
|
+
raise ValueError("The editor service is closed.")
|
|
176
|
+
if request.request_id in self.active_by_id:
|
|
177
|
+
raise ValueError("This editor request is already active.")
|
|
178
|
+
|
|
179
|
+
def finish(pending: PendingEdit, answer: EditorAnswer) -> None:
|
|
180
|
+
"""Publish the one answer of this request while it is still the active one."""
|
|
181
|
+
if self.active_by_id.get(request.request_id) is not pending:
|
|
182
|
+
return
|
|
183
|
+
del self.active_by_id[request.request_id]
|
|
184
|
+
result = EditorResult(request, answer.return_code, answer=answer)
|
|
185
|
+
complete(result)
|
|
186
|
+
self.finished.emit(result)
|
|
187
|
+
if not self.active_by_id:
|
|
188
|
+
self.idle.emit()
|
|
189
|
+
|
|
190
|
+
pending = PendingEdit(request, finish)
|
|
191
|
+
self.active_by_id[request.request_id] = pending
|
|
192
|
+
self.waiting.emit(request)
|
|
193
|
+
self.edit_requested.emit(pending)
|
|
194
|
+
return pending
|
|
195
|
+
|
|
196
|
+
def cancel(self, request_id: str) -> None:
|
|
197
|
+
"""Forget a request whose bridge was lost; it takes no answer any more, since the connection that would carry it to Git is already gone."""
|
|
198
|
+
active = self.active_by_id.pop(request_id, None)
|
|
199
|
+
if active is None:
|
|
200
|
+
return
|
|
201
|
+
active.answered = True
|
|
202
|
+
self.edit_cancelled.emit(active.request)
|
|
203
|
+
if not self.active_by_id:
|
|
204
|
+
self.idle.emit()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class EditorExecutionContext(QObject):
|
|
208
|
+
"""Authenticate exact Git descendants and files before handing a live editing request to a GitPane window.
|
|
209
|
+
|
|
210
|
+
``abort_requested`` turns true when the user answers Abort, so the rebase that owns this context knows its command
|
|
211
|
+
failed because the whole rebase must be cancelled, not because of Git or a lost bridge.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
wait_changed = Signal(bool)
|
|
215
|
+
failed = Signal(str)
|
|
216
|
+
|
|
217
|
+
def __init__(self, service: EditorService, operation_id: str, git_dir: str, worktree_key: WorktreeKey | None = None) -> None:
|
|
218
|
+
"""Own one private channel for one operation without placing a token in argv; the service builds it in create_context."""
|
|
219
|
+
super().__init__(service)
|
|
220
|
+
self.service = service
|
|
221
|
+
self.operation_id = operation_id
|
|
222
|
+
self.git_dir = os.path.realpath(git_dir)
|
|
223
|
+
self.worktree_key = worktree_key
|
|
224
|
+
self.closed = False
|
|
225
|
+
self.abort_requested = False
|
|
226
|
+
self.cleanup_complete = False
|
|
227
|
+
self.process_request_id = ""
|
|
228
|
+
self.token = secrets.token_hex(EDITOR_TOKEN_BYTES)
|
|
229
|
+
self.authorize_peer: Callable[[int], bool] = lambda _peer: False
|
|
230
|
+
self.request_id_set: set[str] = set()
|
|
231
|
+
self.buffer_by_socket: dict[QLocalSocket, bytearray] = {}
|
|
232
|
+
self.deadline_by_socket: dict[QLocalSocket, float] = {}
|
|
233
|
+
# The connections that received their answer and wait for the bridge to close them.
|
|
234
|
+
self.answered_socket_set: set[QLocalSocket] = set()
|
|
235
|
+
self.pending_socket: QLocalSocket | None = None
|
|
236
|
+
self.pending_request: EditorRequest | None = None
|
|
237
|
+
self.directory = tempfile.TemporaryDirectory(prefix="gitpane-editor-")
|
|
238
|
+
self.server = QLocalServer(self)
|
|
239
|
+
self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
|
|
240
|
+
self.server.setMaxPendingConnections(EDITOR_PENDING_LIMIT)
|
|
241
|
+
pipe_name = "gitpane-editor-" + uuid.uuid4().hex if os.name == "nt" else os.path.join(self.directory.name, "socket")
|
|
242
|
+
if not self.server.listen(pipe_name):
|
|
243
|
+
self.directory.cleanup()
|
|
244
|
+
raise OSError("The private editor channel could not be opened.")
|
|
245
|
+
self.server.newConnection.connect(self._accept)
|
|
246
|
+
self.timer = QTimer(self)
|
|
247
|
+
self.timer.setInterval(EDITOR_HEARTBEAT_INTERVAL_MS)
|
|
248
|
+
self.timer.timeout.connect(self._check_liveness)
|
|
249
|
+
self.timer.start()
|
|
250
|
+
|
|
251
|
+
def bind_peer(self, operation_id: str, authorize_peer: Callable[[int], bool]) -> None:
|
|
252
|
+
"""Bind native peer validation to the command that owns this operation."""
|
|
253
|
+
if self.closed or operation_id != self.operation_id:
|
|
254
|
+
raise ValueError("The editor context belongs to another operation.")
|
|
255
|
+
self.authorize_peer = authorize_peer
|
|
256
|
+
|
|
257
|
+
def environment(self) -> dict[str, str]:
|
|
258
|
+
"""Generate Git shell commands containing only the installed bridge, started without the Python variables the interpreter reads itself or the current folder, and its kind."""
|
|
259
|
+
if self.closed:
|
|
260
|
+
raise ValueError("The editor context has expired.")
|
|
261
|
+
command = shlex.join([sys.executable, *application_module_interpreter_options(bool(sys.flags.no_user_site)), MODULE_OPTION, editor_bridge_module_name()]) + " "
|
|
262
|
+
return {"GIT_EDITOR": command + "message", "GIT_SEQUENCE_EDITOR": command + "sequence", "GITPANE_EDITOR_PIPE": self.server.fullServerName(),
|
|
263
|
+
"GITPANE_EDITOR_TOKEN": self.token, "GITPANE_EDITOR_OPERATION": self.operation_id, "GITPANE_EDITOR_OWNER_PID": str(os.getpid())}
|
|
264
|
+
|
|
265
|
+
def owns(self, operation_id: str) -> bool:
|
|
266
|
+
"""Check channel ownership without accepting environment variables as authority."""
|
|
267
|
+
return not self.closed and self.server.isListening() and operation_id == self.operation_id and self.service.context_by_id.get(operation_id) is self
|
|
268
|
+
|
|
269
|
+
def close(self) -> str:
|
|
270
|
+
"""Expire authority immediately and retry denied cleanup without blocking command completion."""
|
|
271
|
+
if self.cleanup_complete:
|
|
272
|
+
return ""
|
|
273
|
+
if not self.closed:
|
|
274
|
+
self.closed = True
|
|
275
|
+
self.timer.stop()
|
|
276
|
+
for connection in tuple(self.buffer_by_socket):
|
|
277
|
+
connection.abort()
|
|
278
|
+
self.server.close()
|
|
279
|
+
self.token = ""
|
|
280
|
+
self.service.context_by_id.pop(self.operation_id, None)
|
|
281
|
+
try:
|
|
282
|
+
self.directory.cleanup()
|
|
283
|
+
except OSError:
|
|
284
|
+
# A transient file lock must not keep Git waiting. The service retains this
|
|
285
|
+
# expired context as a Qt child until its timer can remove the private directory.
|
|
286
|
+
self.timer.start()
|
|
287
|
+
return "Command finished, but private editor files could not be removed; cleanup will be retried."
|
|
288
|
+
self.cleanup_complete = True
|
|
289
|
+
self.timer.stop()
|
|
290
|
+
self.setParent(None)
|
|
291
|
+
self.deleteLater()
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
def _accept(self) -> None:
|
|
295
|
+
"""Reject native peers outside the current supervised Git group before reading JSON."""
|
|
296
|
+
while self.server.hasPendingConnections():
|
|
297
|
+
connection = self.server.nextPendingConnection()
|
|
298
|
+
connection.setReadBufferSize(EDITOR_IPC_LIMIT_BYTES + 1)
|
|
299
|
+
try:
|
|
300
|
+
if not self.owns(self.operation_id) or len(self.buffer_by_socket) >= EDITOR_PENDING_LIMIT or not self.authorize_peer(peer_process_id(connection)):
|
|
301
|
+
raise ValueError("Unrecognized editor peer")
|
|
302
|
+
except (OSError, ValueError):
|
|
303
|
+
connection.abort()
|
|
304
|
+
connection.deleteLater()
|
|
305
|
+
continue
|
|
306
|
+
self.buffer_by_socket[connection] = bytearray()
|
|
307
|
+
self.deadline_by_socket[connection] = time.monotonic() + EDITOR_CONNECT_TIMEOUT_MS / 1000
|
|
308
|
+
connection.readyRead.connect(lambda current=connection: self._read(current))
|
|
309
|
+
connection.disconnected.connect(lambda current=connection: self._disconnect(current))
|
|
310
|
+
self._read(connection)
|
|
311
|
+
|
|
312
|
+
def _read(self, connection: QLocalSocket) -> None:
|
|
313
|
+
"""Validate framed credentials, nonce and exact regular file before handing the edit to a GitPane window."""
|
|
314
|
+
buffer = self.buffer_by_socket.get(connection)
|
|
315
|
+
if buffer is None:
|
|
316
|
+
return
|
|
317
|
+
buffer.extend(bytes(connection.readAll()))
|
|
318
|
+
if len(buffer) > EDITOR_IPC_LIMIT_BYTES:
|
|
319
|
+
connection.abort()
|
|
320
|
+
return
|
|
321
|
+
while b"\n" in buffer:
|
|
322
|
+
line, _, remainder = buffer.partition(b"\n")
|
|
323
|
+
buffer[:] = remainder
|
|
324
|
+
try:
|
|
325
|
+
data_by_key = json.loads(line)
|
|
326
|
+
if connection in self.answered_socket_set:
|
|
327
|
+
# The bridge can write one more liveness message before it reads the answer; anything else on an
|
|
328
|
+
# answered connection is refused.
|
|
329
|
+
if data_by_key != {"alive": True}:
|
|
330
|
+
raise ValueError("Unexpected line after the editor answer")
|
|
331
|
+
continue
|
|
332
|
+
if connection is self.pending_socket and data_by_key == {"alive": True}:
|
|
333
|
+
if not self.authorize_peer(peer_process_id(connection)):
|
|
334
|
+
raise ValueError("The editor peer expired")
|
|
335
|
+
self.deadline_by_socket[connection] = time.monotonic() + EDITOR_LIVENESS_TIMEOUT_MS / 1000
|
|
336
|
+
connection.write(b'{"alive":true}\n')
|
|
337
|
+
continue
|
|
338
|
+
request = self._validate(data_by_key)
|
|
339
|
+
self.request_id_set.add(request.request_id)
|
|
340
|
+
self.pending_socket = connection
|
|
341
|
+
self.pending_request = request
|
|
342
|
+
self.deadline_by_socket[connection] = time.monotonic() + EDITOR_LIVENESS_TIMEOUT_MS / 1000
|
|
343
|
+
self.wait_changed.emit(True)
|
|
344
|
+
connection.write(b'{"alive":true}\n')
|
|
345
|
+
if self.service.closed:
|
|
346
|
+
self._finish(connection, EditorResult(request, 1, EditorFailure.CLOSED.value))
|
|
347
|
+
continue
|
|
348
|
+
try:
|
|
349
|
+
self.service.request_edit(request, lambda result, current=connection: self._finish(current, result))
|
|
350
|
+
except ValueError:
|
|
351
|
+
self._finish(connection, EditorResult(request, 1, EditorFailure.REQUEST_REFUSED.value))
|
|
352
|
+
except (OSError, ValueError, TypeError, AttributeError, UnicodeError):
|
|
353
|
+
connection.abort()
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
def _validate(self, data_by_key: dict[str, object]) -> EditorRequest:
|
|
357
|
+
"""Limit authorized filenames to canonical native sequence and message locations."""
|
|
358
|
+
token = data_by_key.get("token")
|
|
359
|
+
request_id = data_by_key.get("request")
|
|
360
|
+
if (not self.owns(self.operation_id) or not isinstance(token, str) or not hmac.compare_digest(token, self.token)
|
|
361
|
+
or data_by_key.get("operation") != self.operation_id or not isinstance(request_id, str) or len(request_id) != len(uuid.uuid4().hex)
|
|
362
|
+
or request_id in self.request_id_set or len(self.request_id_set) >= EDITOR_REQUEST_LIMIT or self.pending_socket is not None):
|
|
363
|
+
raise ValueError("Invalid editor authorization")
|
|
364
|
+
filename = data_by_key.get("file")
|
|
365
|
+
if not isinstance(filename, str) or not os.path.isabs(filename) or "\0" in filename:
|
|
366
|
+
raise ValueError("Invalid editor filename")
|
|
367
|
+
try:
|
|
368
|
+
kind = EditorRequestKind(data_by_key.get("kind"))
|
|
369
|
+
except ValueError:
|
|
370
|
+
raise ValueError("Invalid editor kind") from None
|
|
371
|
+
if kind == EditorRequestKind.SEQUENCE:
|
|
372
|
+
allowed_filenames = (os.path.join(self.git_dir, "rebase-merge", "git-rebase-todo"),)
|
|
373
|
+
else:
|
|
374
|
+
allowed_filenames = (os.path.join(self.git_dir, "COMMIT_EDITMSG"), os.path.join(self.git_dir, "SQUASH_MSG"), os.path.join(self.git_dir, "MERGE_MSG"),
|
|
375
|
+
os.path.join(self.git_dir, "rebase-merge", "message"), os.path.join(self.git_dir, "rebase-merge", "message-squash"))
|
|
376
|
+
# A link is found by resolving it without the disk spelling, so a name typed with another letter case is not
|
|
377
|
+
# taken for a link; the allowed files are then compared by identity, both sides spelled the same way.
|
|
378
|
+
canonical_filename = canonical_path(filename)
|
|
379
|
+
if link_resolved_path(filename) != os.path.normcase(os.path.abspath(filename)) or not any(canonical_filename == canonical_path(allowed) for allowed in allowed_filenames):
|
|
380
|
+
raise ValueError("This file is outside the current editor request")
|
|
381
|
+
identity = os.lstat(filename)
|
|
382
|
+
if not stat.S_ISREG(identity.st_mode) or identity.st_nlink != 1:
|
|
383
|
+
raise ValueError("The editor requires a private regular Git file")
|
|
384
|
+
descriptor = os.open(filename, edit_file_open_flags(os.name))
|
|
385
|
+
try:
|
|
386
|
+
opened = os.fstat(descriptor)
|
|
387
|
+
if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
|
|
388
|
+
raise ValueError("The editor requires a private regular Git file")
|
|
389
|
+
current = os.lstat(filename)
|
|
390
|
+
if (not stat.S_ISREG(current.st_mode) or current.st_nlink != 1
|
|
391
|
+
or (identity.st_dev, identity.st_ino) != (opened.st_dev, opened.st_ino) or (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino)):
|
|
392
|
+
raise ValueError("The editor file changed before launch")
|
|
393
|
+
finally:
|
|
394
|
+
os.close(descriptor)
|
|
395
|
+
return EditorRequest(self.operation_id, request_id, kind, os.path.realpath(filename), (identity.st_dev, identity.st_ino))
|
|
396
|
+
|
|
397
|
+
def _finish(self, connection: QLocalSocket, result: EditorResult) -> None:
|
|
398
|
+
"""Release only the matching live request, remember an Abort answer and return the exit code to Git.
|
|
399
|
+
|
|
400
|
+
The connection stays open: the bridge closes it once it has read the answer, and GitPane only cuts it when that
|
|
401
|
+
does not happen within its delay. Closing it right after the answer made a liveness message the bridge wrote
|
|
402
|
+
meanwhile fail, and Qt then dropped the bridge's socket before it read the answer. The connection is registered as
|
|
403
|
+
answered before the answer is written, so that the cleanup Qt runs during the flush, when the bridge has already
|
|
404
|
+
closed its side, finds it and forgets it.
|
|
405
|
+
"""
|
|
406
|
+
if connection is not self.pending_socket or result.request is not self.pending_request:
|
|
407
|
+
return
|
|
408
|
+
self.pending_socket = None
|
|
409
|
+
self.pending_request = None
|
|
410
|
+
if result.answer == EditorAnswer.ABORT:
|
|
411
|
+
self.abort_requested = True
|
|
412
|
+
if result.cause:
|
|
413
|
+
self.failed.emit(result.cause)
|
|
414
|
+
self.wait_changed.emit(False)
|
|
415
|
+
# Registered before the write: when the bridge has already closed its side, Qt reports the disconnection during
|
|
416
|
+
# the flush, and the cleanup that runs then must find the connection to forget it.
|
|
417
|
+
self.answered_socket_set.add(connection)
|
|
418
|
+
self.deadline_by_socket[connection] = time.monotonic() + EDITOR_ANSWERED_CLOSE_TIMEOUT_MS / MILLISECONDS_PER_SECOND
|
|
419
|
+
connection.write(json.dumps({"code": result.return_code}).encode() + b"\n")
|
|
420
|
+
connection.flush()
|
|
421
|
+
|
|
422
|
+
def _disconnect(self, connection: QLocalSocket) -> None:
|
|
423
|
+
"""End a lost authenticated wait without keeping Git deadlines disabled, and forget a connection the bridge closed after its answer."""
|
|
424
|
+
self.buffer_by_socket.pop(connection, None)
|
|
425
|
+
self.deadline_by_socket.pop(connection, None)
|
|
426
|
+
self.answered_socket_set.discard(connection)
|
|
427
|
+
if connection is self.pending_socket:
|
|
428
|
+
request = self.pending_request
|
|
429
|
+
self.pending_socket = None
|
|
430
|
+
self.pending_request = None
|
|
431
|
+
self.wait_changed.emit(False)
|
|
432
|
+
self.failed.emit("The private editor connection was lost. Git execution deadlines have resumed.")
|
|
433
|
+
if request is not None:
|
|
434
|
+
self.service.cancel(request.request_id)
|
|
435
|
+
connection.deleteLater()
|
|
436
|
+
|
|
437
|
+
def _check_liveness(self) -> None:
|
|
438
|
+
"""Expire silent peers while allowing an authenticated human edit to take any duration."""
|
|
439
|
+
if self.closed:
|
|
440
|
+
self.close()
|
|
441
|
+
return
|
|
442
|
+
now = time.monotonic()
|
|
443
|
+
for connection, deadline in tuple(self.deadline_by_socket.items()):
|
|
444
|
+
if now >= deadline:
|
|
445
|
+
connection.abort()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Wait for one authenticated editor request using only the installed application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QCoreApplication
|
|
14
|
+
from PySide6.QtNetwork import QLocalSocket
|
|
15
|
+
|
|
16
|
+
from gitpane.account.ssh_askpass import peer_process_id
|
|
17
|
+
from gitpane.config import (
|
|
18
|
+
EDITOR_CONNECT_TIMEOUT_MS,
|
|
19
|
+
EDITOR_HEARTBEAT_INTERVAL_MS,
|
|
20
|
+
EDITOR_IPC_LIMIT_BYTES,
|
|
21
|
+
EDITOR_LIVENESS_TIMEOUT_MS,
|
|
22
|
+
LINUX_PLATFORM,
|
|
23
|
+
MACOS_PLATFORM,
|
|
24
|
+
MILLISECONDS_PER_SECOND,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import MutableMapping
|
|
29
|
+
|
|
30
|
+
EDITOR_ENVIRONMENT_NAME_SET = frozenset({"GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GITPANE_EDITOR_PIPE", "GITPANE_EDITOR_TOKEN", "GITPANE_EDITOR_OPERATION", "GITPANE_EDITOR_OWNER_PID"})
|
|
31
|
+
# The variables that name the character encoding, in the order the C library reads them: the first one set wins.
|
|
32
|
+
LOCALE_VARIABLE_NAMES = ("LC_ALL", "LC_CTYPE", "LANG")
|
|
33
|
+
UTF8_CODESET_SET = frozenset(("utf-8", "utf8"))
|
|
34
|
+
# Present on every glibc the Linux targets ship and on macOS, and needs no generated locale.
|
|
35
|
+
UTF8_LOCALE_NAME = "C.UTF-8"
|
|
36
|
+
# The systems where Qt requires a UTF-8 locale and warns about any other one; compared as prefixes of sys.platform.
|
|
37
|
+
UTF8_LOCALE_PLATFORM_PREFIXES = (LINUX_PLATFORM, MACOS_PLATFORM)
|
|
38
|
+
# Every line the bridge writes starts with this, so a reader of Git's output knows which program gave up.
|
|
39
|
+
BRIDGE_REASON_PREFIX = "GitPane editor bridge: "
|
|
40
|
+
# The shortest wait for GitPane's next line: waitForReadyRead(0) returns at once, so a wait that reached zero while the
|
|
41
|
+
# next liveness message is due would turn the loop back into a busy loop.
|
|
42
|
+
BRIDGE_MINIMUM_WAIT_MS = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BridgeExitReason(Enum):
|
|
46
|
+
"""Why the bridge gave up on an edit, as the fixed English text it writes on its error output for Git to copy.
|
|
47
|
+
|
|
48
|
+
The texts never carry the token, the socket name, the edited file or its content: Git copies them into the rebase
|
|
49
|
+
output that GitPane shows and that --log can keep.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
ARGUMENTS = "unexpected arguments."
|
|
53
|
+
CONNECT = "could not connect to GitPane."
|
|
54
|
+
OWNER = "the program answering is not the GitPane that started Git."
|
|
55
|
+
REQUEST_TOO_LARGE = "the request is too large."
|
|
56
|
+
SEND = "could not send the request."
|
|
57
|
+
ANSWER_TOO_LARGE = "GitPane's answer is too large."
|
|
58
|
+
INVALID_ANSWER = "GitPane's answer is not valid."
|
|
59
|
+
NO_ANSWER = "no answer from GitPane for {timeout_ms} ms."
|
|
60
|
+
DISCONNECTED = "GitPane closed the connection."
|
|
61
|
+
ABORTED = "the edit was aborted in GitPane."
|
|
62
|
+
ERROR = "unexpected error ({error_type})."
|
|
63
|
+
|
|
64
|
+
def text(self, detail: str = "") -> str:
|
|
65
|
+
"""Answer the whole line written for this reason; ``detail`` is only ever the class name of an unexpected exception."""
|
|
66
|
+
return BRIDGE_REASON_PREFIX + self.value.format(timeout_ms=EDITOR_LIVENESS_TIMEOUT_MS, error_type=detail)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_exit_reason(reason: BridgeExitReason, detail: str = "") -> None:
|
|
70
|
+
"""Write why the bridge gives up on its error output, which Git copies into the output of the command it runs."""
|
|
71
|
+
sys.stderr.write(reason.text(detail) + "\n")
|
|
72
|
+
sys.stderr.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def use_utf8_locale(platform: str, environment: MutableMapping[str, str]) -> None:
|
|
76
|
+
"""Give the bridge a UTF-8 locale on Linux and macOS before Qt starts, leaving Windows and a UTF-8 locale alone.
|
|
77
|
+
|
|
78
|
+
Workaround: GitPane runs Git with LC_ALL=C so it can read Git's output, and the bridge Git starts as its editor
|
|
79
|
+
inherits that locale. Qt requires a UTF-8 locale on Linux and macOS and otherwise writes a warning on its error
|
|
80
|
+
output, which Git copies into the rebase output; C.UTF-8 exists on both. Only the environment of the bridge
|
|
81
|
+
process changes; Git keeps LC_ALL=C.
|
|
82
|
+
"""
|
|
83
|
+
if not platform.startswith(UTF8_LOCALE_PLATFORM_PREFIXES):
|
|
84
|
+
return
|
|
85
|
+
locale_name = ""
|
|
86
|
+
for name in LOCALE_VARIABLE_NAMES:
|
|
87
|
+
locale_name = environment.get(name, "")
|
|
88
|
+
if locale_name:
|
|
89
|
+
break
|
|
90
|
+
codeset = locale_name.partition(".")[2].partition("@")[0].lower()
|
|
91
|
+
if codeset in UTF8_CODESET_SET:
|
|
92
|
+
return
|
|
93
|
+
environment["LC_ALL"] = UTF8_LOCALE_NAME
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def submit_request(connection: QLocalSocket, kind: str, filename: str) -> BridgeExitReason | None:
|
|
97
|
+
"""Connect to the GitPane that started Git, check that it is that process, and send it the edit request.
|
|
98
|
+
|
|
99
|
+
Answers why the request could not be sent, or None once it is sent.
|
|
100
|
+
"""
|
|
101
|
+
connection.connectToServer(os.environ["GITPANE_EDITOR_PIPE"])
|
|
102
|
+
if not connection.waitForConnected(EDITOR_CONNECT_TIMEOUT_MS):
|
|
103
|
+
return BridgeExitReason.CONNECT
|
|
104
|
+
if peer_process_id(connection, server=True) != int(os.environ["GITPANE_EDITOR_OWNER_PID"]):
|
|
105
|
+
return BridgeExitReason.OWNER
|
|
106
|
+
request_by_key = {"token": os.environ["GITPANE_EDITOR_TOKEN"], "operation": os.environ["GITPANE_EDITOR_OPERATION"],
|
|
107
|
+
"request": uuid.uuid4().hex, "kind": kind, "file": os.path.abspath(filename)}
|
|
108
|
+
content = json.dumps(request_by_key).encode("utf-8") + b"\n"
|
|
109
|
+
if len(content) > EDITOR_IPC_LIMIT_BYTES:
|
|
110
|
+
return BridgeExitReason.REQUEST_TOO_LARGE
|
|
111
|
+
connection.write(content)
|
|
112
|
+
if not connection.waitForBytesWritten(EDITOR_CONNECT_TIMEOUT_MS):
|
|
113
|
+
return BridgeExitReason.SEND
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_answer(line: bytes) -> dict[str, object] | None:
|
|
118
|
+
"""Decode one line GitPane wrote, or None when it is not a JSON object."""
|
|
119
|
+
try:
|
|
120
|
+
answer_by_key = json.loads(line)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
if not isinstance(answer_by_key, dict):
|
|
124
|
+
return None
|
|
125
|
+
return answer_by_key
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def wait_for_answer(connection: QLocalSocket) -> BridgeExitReason | None:
|
|
129
|
+
"""Send one liveness message per interval until GitPane answers the edit.
|
|
130
|
+
|
|
131
|
+
Answers why the edit failed, or None when GitPane answered with code 0. The wait for the next line lasts until the
|
|
132
|
+
next liveness message is due, so GitPane's immediate reply to each message does not make the bridge send another
|
|
133
|
+
one at once: the exchange stays at one message per interval however long the user takes.
|
|
134
|
+
"""
|
|
135
|
+
buffer = bytearray()
|
|
136
|
+
heartbeat_interval_seconds = EDITOR_HEARTBEAT_INTERVAL_MS / MILLISECONDS_PER_SECOND
|
|
137
|
+
next_send = time.monotonic()
|
|
138
|
+
last_response = next_send
|
|
139
|
+
while connection.state() == QLocalSocket.LocalSocketState.ConnectedState:
|
|
140
|
+
wait_ms = max(BRIDGE_MINIMUM_WAIT_MS, int((next_send - time.monotonic()) * MILLISECONDS_PER_SECOND))
|
|
141
|
+
connection.waitForReadyRead(wait_ms)
|
|
142
|
+
buffer.extend(bytes(connection.readAll()))
|
|
143
|
+
if len(buffer) > EDITOR_IPC_LIMIT_BYTES:
|
|
144
|
+
return BridgeExitReason.ANSWER_TOO_LARGE
|
|
145
|
+
while b"\n" in buffer:
|
|
146
|
+
line, _, remainder = buffer.partition(b"\n")
|
|
147
|
+
buffer = bytearray(remainder)
|
|
148
|
+
answer_by_key = read_answer(bytes(line))
|
|
149
|
+
if answer_by_key == {"alive": True}:
|
|
150
|
+
last_response = time.monotonic()
|
|
151
|
+
continue
|
|
152
|
+
if answer_by_key is None or type(answer_by_key.get("code")) is not int:
|
|
153
|
+
return BridgeExitReason.INVALID_ANSWER
|
|
154
|
+
return None if answer_by_key["code"] == 0 else BridgeExitReason.ABORTED
|
|
155
|
+
now = time.monotonic()
|
|
156
|
+
if (now - last_response) * MILLISECONDS_PER_SECOND > EDITOR_LIVENESS_TIMEOUT_MS:
|
|
157
|
+
return BridgeExitReason.NO_ANSWER
|
|
158
|
+
if now >= next_send:
|
|
159
|
+
connection.write(b'{"alive":true}\n')
|
|
160
|
+
connection.flush()
|
|
161
|
+
next_send = now + heartbeat_interval_seconds
|
|
162
|
+
return BridgeExitReason.DISCONNECTED
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> int:
|
|
166
|
+
"""Hand one file Git wants edited to the GitPane that started Git and return its answer as the exit status.
|
|
167
|
+
|
|
168
|
+
Every way of giving up writes its fixed reason on the error output, which Git copies into the output of the
|
|
169
|
+
command, so a failed rebase says which step of the exchange failed.
|
|
170
|
+
"""
|
|
171
|
+
use_utf8_locale(sys.platform, os.environ)
|
|
172
|
+
application = QCoreApplication.instance() or QCoreApplication(sys.argv)
|
|
173
|
+
kind_index, file_index, argument_count = 1, 2, 3
|
|
174
|
+
if len(sys.argv) != argument_count or sys.argv[kind_index] not in {"sequence", "message"}:
|
|
175
|
+
write_exit_reason(BridgeExitReason.ARGUMENTS)
|
|
176
|
+
return 1
|
|
177
|
+
connection = QLocalSocket()
|
|
178
|
+
connection.setReadBufferSize(EDITOR_IPC_LIMIT_BYTES + 1)
|
|
179
|
+
try:
|
|
180
|
+
reason = submit_request(connection, sys.argv[kind_index], sys.argv[file_index]) or wait_for_answer(connection)
|
|
181
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError) as error:
|
|
182
|
+
write_exit_reason(BridgeExitReason.ERROR, type(error).__name__)
|
|
183
|
+
return 1
|
|
184
|
+
finally:
|
|
185
|
+
connection.abort()
|
|
186
|
+
del application
|
|
187
|
+
if reason is not None:
|
|
188
|
+
write_exit_reason(reason)
|
|
189
|
+
return 1
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
raise SystemExit(main())
|