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
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Select commit verification candidates without reading otherwise clean file contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import os
|
|
7
|
+
from ctypes import wintypes
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.worktree.worktree_file import stat_identity, validate_destination
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
14
|
+
|
|
15
|
+
FILE_READ_ATTRIBUTES = 0x0080
|
|
16
|
+
FILE_SHARE_READ_WRITE_DELETE = 0x0007
|
|
17
|
+
OPEN_EXISTING = 3
|
|
18
|
+
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
|
19
|
+
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
|
20
|
+
FILE_BASIC_INFO_CLASS = 0
|
|
21
|
+
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FileBasicInformation(ctypes.Structure):
|
|
25
|
+
"""Match the Windows structure that distinguishes change time from creation time."""
|
|
26
|
+
|
|
27
|
+
_fields_ = [("creation_time", ctypes.c_longlong), ("last_access_time", ctypes.c_longlong), ("last_write_time", ctypes.c_longlong),
|
|
28
|
+
("change_time", ctypes.c_longlong), ("file_attributes", wintypes.DWORD)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if os.name == "nt":
|
|
32
|
+
_kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
33
|
+
_kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
34
|
+
_kernel.CreateFileW.restype = wintypes.HANDLE
|
|
35
|
+
_kernel.GetFileInformationByHandleEx.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
|
36
|
+
_kernel.GetFileInformationByHandleEx.restype = wintypes.BOOL
|
|
37
|
+
_kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
38
|
+
_kernel.CloseHandle.restype = wintypes.BOOL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _windows_change_time(filename: str) -> int:
|
|
42
|
+
"""Read NTFS change time because Windows stat ctime can still mean creation time."""
|
|
43
|
+
handle = _kernel.CreateFileW(filename, FILE_READ_ATTRIBUTES, FILE_SHARE_READ_WRITE_DELETE, None, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, None)
|
|
44
|
+
if handle == INVALID_HANDLE_VALUE:
|
|
45
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
46
|
+
try:
|
|
47
|
+
information = _FileBasicInformation()
|
|
48
|
+
if not _kernel.GetFileInformationByHandleEx(handle, FILE_BASIC_INFO_CLASS, ctypes.byref(information), ctypes.sizeof(information)):
|
|
49
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
50
|
+
return information.change_time
|
|
51
|
+
finally:
|
|
52
|
+
_kernel.CloseHandle(handle)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_path_metadata(filename: str) -> tuple[int, ...] | None:
|
|
56
|
+
"""Read the identity and change time of one absolute path without opening its content.
|
|
57
|
+
|
|
58
|
+
Answers None only when the path does not exist; an access error or a path that changes while it is read is
|
|
59
|
+
propagated, never replaced by the modification time alone. Git can miss a same-size write whose modification time
|
|
60
|
+
was put back; the Windows ChangeTime survives that restoration, and POSIX stat already supplies the change ctime.
|
|
61
|
+
Opening a Windows handle for these attributes reads no byte of the file.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
identity = stat_identity(os.lstat(filename))
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
return None
|
|
67
|
+
change_time = _windows_change_time(filename) if os.name == "nt" else os.lstat(filename).st_ctime_ns
|
|
68
|
+
if identity != stat_identity(os.lstat(filename)):
|
|
69
|
+
raise ValueError("A file changed while its metadata was read")
|
|
70
|
+
return (*identity, change_time)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_commit_metadata(context: RepositoryContext, paths: tuple[bytes, ...]) -> dict[bytes, tuple[int, ...] | None]:
|
|
74
|
+
"""Observe tracked-file identities and change times; propagate inaccessible or unstable reads."""
|
|
75
|
+
metadata_by_path: dict[bytes, tuple[int, ...] | None] = {}
|
|
76
|
+
for path in paths:
|
|
77
|
+
if path in metadata_by_path:
|
|
78
|
+
continue
|
|
79
|
+
filename, parents = validate_destination(context, path, allow_missing_parents=True)
|
|
80
|
+
metadata = read_path_metadata(filename)
|
|
81
|
+
if metadata is None:
|
|
82
|
+
metadata_by_path[path] = None
|
|
83
|
+
continue
|
|
84
|
+
_filename, after_parents = validate_destination(context, path, allow_missing_parents=True)
|
|
85
|
+
if parents != after_parents:
|
|
86
|
+
raise ValueError("Working directory changed during commit metadata verification")
|
|
87
|
+
metadata_by_path[path] = metadata
|
|
88
|
+
return metadata_by_path
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Keep a successful local commit separate from its later network delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QObject, Signal
|
|
10
|
+
|
|
11
|
+
from gitpane.account.authentication import transfer_account
|
|
12
|
+
from gitpane.config import LOCAL_PREFIX
|
|
13
|
+
from gitpane.history.file_history import WindowCapability
|
|
14
|
+
from gitpane.operation.console_line import NOT_STARTED_STATUS_SET
|
|
15
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
16
|
+
from gitpane.operation.repository_operation import OperationIntent
|
|
17
|
+
from gitpane.process.process_runner import READ_PROFILE
|
|
18
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
19
|
+
from gitpane.transport.branch_exchange import TransportRequest
|
|
20
|
+
from gitpane.transport.refusal_notice import failure_headline, failure_summary
|
|
21
|
+
from gitpane.transport.remote_config import config_signature, default_push_destination_name, fetch_remote_name
|
|
22
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable, Generator
|
|
26
|
+
|
|
27
|
+
from gitpane.commit.commit_write import CommitOutcome, CommitRequest
|
|
28
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
29
|
+
from gitpane.transport.branch_exchange import PushTarget, TransportOutcome, TransportRun, TransportService
|
|
30
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
31
|
+
|
|
32
|
+
COMMIT_PUSH_CHOOSE = "Commit created. Choose a push destination."
|
|
33
|
+
COMMIT_PUSH_FAILED = "Commit created, but push failed. The local commit is kept. Review the details and retry the push."
|
|
34
|
+
NO_PROPOSED_REMOTE_TEXT = "Push proposes no remote for this branch."
|
|
35
|
+
NO_PROPOSED_DESTINATION_TEXT = "Push proposes no remote branch for this branch."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class PushDestination:
|
|
40
|
+
"""Retain the remote, branch and exact endpoint the toolbar Push window proposes."""
|
|
41
|
+
|
|
42
|
+
remote_name: str
|
|
43
|
+
destination_ref: str
|
|
44
|
+
target: PushTarget
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CommitPushIntent:
|
|
49
|
+
"""Bind the sole commit request to its original branch, config and window authority.
|
|
50
|
+
|
|
51
|
+
``reason`` explains why the push did not happen by itself; ``push_failed`` tells that it comes from a push that ran
|
|
52
|
+
and failed, so the Push window shows it as a failure rather than as a routing note. ``reason_headline`` is the
|
|
53
|
+
sentence naming the address and account a refused push used; it is kept apart from ``reason`` because the Push
|
|
54
|
+
window filters the reason but not this sentence, which holds no secret.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
operation: OperationIntent
|
|
58
|
+
commit_request: CommitRequest
|
|
59
|
+
source_ref: str = ""
|
|
60
|
+
snapshot: RemoteSnapshot | None = None
|
|
61
|
+
destination: PushDestination | None = None
|
|
62
|
+
created_oid: str = ""
|
|
63
|
+
reason: str = ""
|
|
64
|
+
push_failed: bool = False
|
|
65
|
+
reason_headline: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CommitPushService(QObject):
|
|
69
|
+
"""Own the chain beyond staging closure and never replay a successful commit."""
|
|
70
|
+
|
|
71
|
+
choose_requested = Signal(object, str)
|
|
72
|
+
push_started = Signal(object)
|
|
73
|
+
# The commit exists and has a complete destination: the controller decides on the tracking reference from the
|
|
74
|
+
# capture taken before the commit, asking the user when nothing decides it already, then calls push_after_decision.
|
|
75
|
+
tracking_decision_requested = Signal(object)
|
|
76
|
+
|
|
77
|
+
def __init__(self, transport: TransportService, *, current_operation: Callable[[OperationIntent], OperationIntent | None], parent: QObject | None = None) -> None:
|
|
78
|
+
"""Inject transport and the live management authority for each independent repository."""
|
|
79
|
+
super().__init__(parent)
|
|
80
|
+
self.transport = transport
|
|
81
|
+
self.current_operation = current_operation
|
|
82
|
+
self.intent_by_id: dict[str, CommitPushIntent] = {}
|
|
83
|
+
self.intent_by_push_id: dict[str, CommitPushIntent] = {}
|
|
84
|
+
self.finished_id_set: set[str] = set()
|
|
85
|
+
transport.finished.connect(self._push_finished)
|
|
86
|
+
|
|
87
|
+
def resolve_destination(self, snapshot: RemoteSnapshot, source_ref: str) -> PushDestination:
|
|
88
|
+
"""Pick the remote, branch and address the toolbar Push window proposes for the current branch.
|
|
89
|
+
|
|
90
|
+
Commit & push sends exactly where Push would send. Git's own push defaults (push.default, pushRemote,
|
|
91
|
+
remote.pushDefault, a configured push mapping) are not followed, as Push does not follow them. An upstream is set
|
|
92
|
+
only as Push sets it: after the tracking question answered Yes, or a kept Always add, and once the Push is
|
|
93
|
+
verified. Without a complete proposal (no remote, several addresses, no remote branch), the created commit
|
|
94
|
+
waits for a choice in the Push window.
|
|
95
|
+
"""
|
|
96
|
+
if not source_ref.startswith(LOCAL_PREFIX) or source_ref not in snapshot.tracking_by_ref:
|
|
97
|
+
raise ValueError("A current local branch is required for a default push.")
|
|
98
|
+
remote_name = fetch_remote_name(snapshot, source_ref)
|
|
99
|
+
entry = snapshot.entry_by_name.get(remote_name)
|
|
100
|
+
if entry is None:
|
|
101
|
+
raise ValueError(NO_PROPOSED_REMOTE_TEXT)
|
|
102
|
+
if len(entry.push_urls) != 1:
|
|
103
|
+
raise ValueError("The default Push remote needs one unambiguous endpoint.")
|
|
104
|
+
endpoint, = entry.push_urls
|
|
105
|
+
destination_name = default_push_destination_name(snapshot, source_ref, remote_name)
|
|
106
|
+
if not destination_name:
|
|
107
|
+
raise ValueError(NO_PROPOSED_DESTINATION_TEXT)
|
|
108
|
+
return PushDestination(remote_name, LOCAL_PREFIX + destination_name, self.transport.capture_push_target(snapshot, remote_name, endpoint))
|
|
109
|
+
|
|
110
|
+
def capture(self, operation: OperationIntent, request: CommitRequest, complete: Callable[[CommitPushIntent | None, Exception | None], None]) -> None:
|
|
111
|
+
"""Read routing before the sole commit submission without contacting any server."""
|
|
112
|
+
if (operation.capability != WindowCapability.MANAGE or self.current_operation(operation) is None or request.operation_id in self.intent_by_id
|
|
113
|
+
or request.snapshot.context != operation.context or request.snapshot.git_filename != operation.git_filename or request.key != operation.key):
|
|
114
|
+
complete(None, ValueError("Commit & push is no longer authorized."))
|
|
115
|
+
return
|
|
116
|
+
intent = CommitPushIntent(operation, request)
|
|
117
|
+
self.intent_by_id[request.operation_id] = intent
|
|
118
|
+
captured_ref: str | None = None
|
|
119
|
+
|
|
120
|
+
def read() -> Generator:
|
|
121
|
+
"""Capture HEAD and its destination in one routing capture under the original index authority.
|
|
122
|
+
|
|
123
|
+
The capture observes HEAD between its own reference signatures. When it fails, HEAD is read alone, only while
|
|
124
|
+
the operation is still current, so a routing error still allows the commit with a manual push choice.
|
|
125
|
+
"""
|
|
126
|
+
nonlocal captured_ref
|
|
127
|
+
try:
|
|
128
|
+
snapshot = yield from self.transport.remote.capture(operation.context, operation.git_filename, operation.generation)
|
|
129
|
+
except (ValueError, OSError, RuntimeError):
|
|
130
|
+
if self.current_operation(operation) is None:
|
|
131
|
+
raise
|
|
132
|
+
captured_ref = yield from self._read_current_head(operation.context)
|
|
133
|
+
raise
|
|
134
|
+
if snapshot.head_ref is None:
|
|
135
|
+
raise ValueError("The current branch could not be captured.")
|
|
136
|
+
source_ref = snapshot.head_ref
|
|
137
|
+
captured_ref = source_ref
|
|
138
|
+
reason = ""
|
|
139
|
+
destination = None
|
|
140
|
+
try:
|
|
141
|
+
destination = self.resolve_destination(snapshot, source_ref)
|
|
142
|
+
except (ValueError, OSError, RuntimeError) as error:
|
|
143
|
+
reason = str(error)
|
|
144
|
+
return replace(intent, commit_request=replace(request, expected_head_ref=source_ref), source_ref=source_ref, snapshot=snapshot, destination=destination, reason=reason)
|
|
145
|
+
|
|
146
|
+
def captured(value: CommitPushIntent | None, error: Exception | None) -> None:
|
|
147
|
+
"""Preserve a recoverable routing failure while rejecting revoked commit authority."""
|
|
148
|
+
if self.current_operation(operation) is None:
|
|
149
|
+
complete(None, ValueError("Commit & push is no longer authorized."))
|
|
150
|
+
return
|
|
151
|
+
if captured_ref is None:
|
|
152
|
+
complete(None, error or ValueError("The current branch could not be captured."))
|
|
153
|
+
return
|
|
154
|
+
value = value or replace(intent, commit_request=replace(request, expected_head_ref=captured_ref), source_ref=captured_ref, reason=str(error))
|
|
155
|
+
self.intent_by_id[request.operation_id] = value
|
|
156
|
+
complete(value, None)
|
|
157
|
+
|
|
158
|
+
self.transport.remote.worktree_service.run(operation.context, operation.git_filename, read(), captured)
|
|
159
|
+
|
|
160
|
+
def commit_finished(self, outcome: CommitOutcome) -> None:
|
|
161
|
+
"""Accept only the successful outcome's observed OID, once, after staging closes."""
|
|
162
|
+
intent = self.intent_by_id.get(outcome.operation_id)
|
|
163
|
+
if intent is None or outcome.operation_id in self.finished_id_set:
|
|
164
|
+
return
|
|
165
|
+
if outcome.key != intent.commit_request.key or (outcome.after is not None and outcome.after.snapshot.context != intent.operation.context):
|
|
166
|
+
return
|
|
167
|
+
self.finished_id_set.add(outcome.operation_id)
|
|
168
|
+
if outcome.status != OperationStatus.SUCCESS or outcome.after is None or not outcome.after.snapshot.head_oid:
|
|
169
|
+
return
|
|
170
|
+
intent = replace(intent, created_oid=outcome.after.snapshot.head_oid)
|
|
171
|
+
self.intent_by_id[outcome.operation_id] = intent
|
|
172
|
+
if intent.destination is None or intent.snapshot is None:
|
|
173
|
+
self.choose_requested.emit(intent, COMMIT_PUSH_CHOOSE)
|
|
174
|
+
return
|
|
175
|
+
self.tracking_decision_requested.emit(intent)
|
|
176
|
+
|
|
177
|
+
def push_after_decision(self, intent: CommitPushIntent, add_tracking: bool, not_sent: Callable[[], None]) -> None:
|
|
178
|
+
"""Read the configuration, HEAD and destination again once the tracking reference is decided, then push the created commit.
|
|
179
|
+
|
|
180
|
+
The decision can wait on the user as long as they like, so this read comes after it and right before the Push:
|
|
181
|
+
a remote address, account or branch changed meanwhile sends the same created commit to the Push window instead.
|
|
182
|
+
``add_tracking`` links the branch to the remote branch once the Push is verified. ``not_sent`` runs when the
|
|
183
|
+
window's management authority or the captured commit is gone, before or after the read, so nothing is pushed.
|
|
184
|
+
"""
|
|
185
|
+
operation = self.current_operation(intent.operation)
|
|
186
|
+
if operation is None:
|
|
187
|
+
not_sent()
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
def read() -> Generator:
|
|
191
|
+
"""Reject changed config or branch identity while retaining the created commit."""
|
|
192
|
+
if config_signature(intent.snapshot.context, intent.snapshot.values) != intent.snapshot.signature:
|
|
193
|
+
raise ValueError("Push configuration changed after the commit.")
|
|
194
|
+
snapshot = yield from self.transport.remote.capture(operation.context, operation.git_filename, operation.generation)
|
|
195
|
+
branch = snapshot.tracking_by_ref.get(intent.source_ref)
|
|
196
|
+
# The branch name comes from HEAD itself: another branch can hold the same commit.
|
|
197
|
+
if (snapshot.head_ref is None or snapshot.head_ref != intent.source_ref or snapshot.head_oid != intent.created_oid
|
|
198
|
+
or branch is None or branch.oid != intent.created_oid):
|
|
199
|
+
raise ValueError("HEAD changed after the commit.")
|
|
200
|
+
if snapshot.values != intent.snapshot.values or self.resolve_destination(snapshot, intent.source_ref) != intent.destination:
|
|
201
|
+
raise ValueError("The default push destination changed after the commit.")
|
|
202
|
+
return snapshot
|
|
203
|
+
|
|
204
|
+
def ready(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
|
|
205
|
+
"""Route a stale or failed destination to the same immutable commit's Push form, else push to the destination just read."""
|
|
206
|
+
if error is not None:
|
|
207
|
+
self.choose_requested.emit(replace(intent, reason=str(error)), COMMIT_PUSH_CHOOSE)
|
|
208
|
+
return
|
|
209
|
+
destination = intent.destination
|
|
210
|
+
if self.retry_push(intent, snapshot, destination.remote_name, destination.target.endpoint, destination.destination_ref, destination.target,
|
|
211
|
+
add_tracking=add_tracking) is None:
|
|
212
|
+
not_sent()
|
|
213
|
+
|
|
214
|
+
self.transport.remote.worktree_service.run(operation.context, operation.git_filename, read(), ready)
|
|
215
|
+
|
|
216
|
+
def _read_current_head(self, context: RepositoryContext) -> Generator:
|
|
217
|
+
"""Read the branch HEAD is on, alone, after a failed routing capture, and only if no reference moved meanwhile.
|
|
218
|
+
|
|
219
|
+
Answers the full branch name, or an empty string for a detached HEAD. A failed or cut answer, or references
|
|
220
|
+
that moved during the read, refuse the commit rather than guessing which branch it would land on.
|
|
221
|
+
"""
|
|
222
|
+
before = yield FileTask(lambda: reference_signature(context))
|
|
223
|
+
output = yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1), profile=READ_PROFILE)
|
|
224
|
+
after = yield FileTask(lambda: reference_signature(context))
|
|
225
|
+
if before != after:
|
|
226
|
+
raise ValueError("The current branch changed while capturing Commit & push.")
|
|
227
|
+
if output and not output.endswith(b"\n"):
|
|
228
|
+
raise ValueError("Git returned an incomplete current branch.")
|
|
229
|
+
return output.decode("utf-8", "surrogateescape").strip()
|
|
230
|
+
|
|
231
|
+
def retry_push(self, intent: CommitPushIntent, snapshot: RemoteSnapshot, remote_name: str, endpoint: str, destination_ref: str, target: PushTarget,
|
|
232
|
+
force: bool = False, *, add_tracking: bool = False) -> TransportRun | None:
|
|
233
|
+
"""Submit only the already-created commit under freshly selected destination authority, the way the toolbar Push sends.
|
|
234
|
+
|
|
235
|
+
``add_tracking`` links the branch to the remote branch once the Push is verified, as the tracking answer asked.
|
|
236
|
+
"""
|
|
237
|
+
operation = self.current_operation(intent.operation)
|
|
238
|
+
captured = self.intent_by_id.get(intent.commit_request.operation_id)
|
|
239
|
+
if (not intent.created_oid or operation is None or operation.capability != WindowCapability.MANAGE or captured is None
|
|
240
|
+
or captured.created_oid != intent.created_oid or captured.commit_request != intent.commit_request or captured.operation != intent.operation or captured.source_ref != intent.source_ref):
|
|
241
|
+
return None
|
|
242
|
+
operation = replace(operation, operation_id=uuid.uuid4().hex, operation="Push", targets=(intent.created_oid, destination_ref, endpoint))
|
|
243
|
+
snapshot = replace(snapshot, generation=operation.generation)
|
|
244
|
+
self.intent_by_push_id[operation.operation_id] = intent
|
|
245
|
+
run = self.transport.push(TransportRequest(operation, mode="push", snapshot=snapshot, remote_name=remote_name, source_ref=intent.source_ref,
|
|
246
|
+
source_oid=intent.created_oid, destination_ref=destination_ref, endpoint=endpoint, push_target=target, force=force,
|
|
247
|
+
immutable_commit=True, add_tracking=add_tracking))
|
|
248
|
+
self.push_started.emit(run)
|
|
249
|
+
return run
|
|
250
|
+
|
|
251
|
+
def _push_finished(self, outcome: TransportOutcome) -> None:
|
|
252
|
+
"""Offer a push-only recovery after an unsuccessful push without touching the local commit.
|
|
253
|
+
|
|
254
|
+
The note under the created commit is red only when Git really contacted the remote and the push failed there,
|
|
255
|
+
a refused authentication on the first contact included. A push refused before any contact, for example by a
|
|
256
|
+
destination name Git rejects, only asks to choose a destination.
|
|
257
|
+
|
|
258
|
+
The reason shown is the same short summary as the Push console's: a sentence naming the address and the account
|
|
259
|
+
used when the server refused it, then Git's own lines. An account already known to have lost its stored secret
|
|
260
|
+
is said to need a reconnect there too; one found out later is only marked in the Push console and in Settings.
|
|
261
|
+
"""
|
|
262
|
+
intent = self.intent_by_push_id.get(outcome.operation_id)
|
|
263
|
+
if intent is not None and outcome.status != OperationStatus.SUCCESS:
|
|
264
|
+
run = self.transport.run_by_id[outcome.operation_id]
|
|
265
|
+
# A recorded step whose process never started, refused by its launch check for instance, contacted no server;
|
|
266
|
+
# checks refused before any process record no step at all.
|
|
267
|
+
attempted = False
|
|
268
|
+
for step in run.steps:
|
|
269
|
+
if step.process_status not in NOT_STARTED_STATUS_SET:
|
|
270
|
+
attempted = True
|
|
271
|
+
break
|
|
272
|
+
reason, headline = intent.reason, ""
|
|
273
|
+
if outcome.diagnostic:
|
|
274
|
+
reason = failure_summary(outcome.diagnostic)
|
|
275
|
+
headline = failure_headline(outcome.diagnostic, transfer_account(outcome.selection, outcome.endpoint, self.transport.authentication.missing_secret_namespace_set))
|
|
276
|
+
self.choose_requested.emit(replace(intent, reason=reason, reason_headline=headline, push_failed=attempted), COMMIT_PUSH_FAILED if attempted else COMMIT_PUSH_CHOOSE)
|