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,302 @@
|
|
|
1
|
+
"""Move confirmed working files through the OS trash service without permanent removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QFile, QObject, QTimer, Signal
|
|
12
|
+
|
|
13
|
+
from gitpane.config import TRASH_TIMEOUT_MS
|
|
14
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
15
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
16
|
+
from gitpane.operation.repository_operation import OperationAction
|
|
17
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
18
|
+
from gitpane.worktree.worktree_file import FileImage, capture_image, validate_destination, validate_image
|
|
19
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, conflict_side_by_path, verify_local_state
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable, Generator
|
|
23
|
+
|
|
24
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
25
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
26
|
+
from gitpane.worktree.mutation import MutationService
|
|
27
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
28
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def move_file_to_trash(target_filename: str) -> None:
|
|
32
|
+
"""Use one QFile instance so a failed OS move retains its actual error cause."""
|
|
33
|
+
target = QFile(target_filename)
|
|
34
|
+
if not target.moveToTrash():
|
|
35
|
+
raise OSError(target.errorString())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def trash_filename(context: RepositoryContext, path: bytes) -> str:
|
|
39
|
+
"""Require a lossless OS and QString path before handing a name to Qt."""
|
|
40
|
+
try:
|
|
41
|
+
decoded = os.fsdecode(path)
|
|
42
|
+
decoded.encode("utf-8", "strict")
|
|
43
|
+
if os.fsencode(decoded) != path or "\ufffd" in decoded:
|
|
44
|
+
raise ValueError("The file name cannot be represented exactly by the trash service")
|
|
45
|
+
except UnicodeError as error:
|
|
46
|
+
raise ValueError("The file name cannot be represented exactly by the trash service") from error
|
|
47
|
+
filename, _parents = validate_destination(context, path)
|
|
48
|
+
try:
|
|
49
|
+
filename.encode("utf-8", "strict")
|
|
50
|
+
except UnicodeError as error:
|
|
51
|
+
raise ValueError("The absolute file name cannot be represented by the trash service") from error
|
|
52
|
+
return filename
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tracked_trash_path_set(index_entries: bytes) -> set[bytes]:
|
|
56
|
+
"""Name every path the index records, each stage of a conflict included, from `ls-files --stage -z` records.
|
|
57
|
+
|
|
58
|
+
The records go through the same validation as the conflict reader first, so a malformed listing is refused instead
|
|
59
|
+
of turning into a wrong tracked or untracked label in the confirmation.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
conflict_side_by_path(index_entries)
|
|
63
|
+
except ValueError as error:
|
|
64
|
+
raise ValueError("Git returned malformed index entries; refresh and select the files again") from error
|
|
65
|
+
tracked_path_set: set[bytes] = set()
|
|
66
|
+
for record in index_entries.split(b"\0"):
|
|
67
|
+
if not record:
|
|
68
|
+
continue
|
|
69
|
+
_metadata, _separator, path = record.partition(b"\t")
|
|
70
|
+
tracked_path_set.add(path)
|
|
71
|
+
return tracked_path_set
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TrashTargetState(Enum):
|
|
75
|
+
"""Where one confirmed file stands in a move to the Trash; the value is the word the per-file details show."""
|
|
76
|
+
|
|
77
|
+
NOT_STARTED = "not_started"
|
|
78
|
+
"""The file was not handed to the system, because the move stopped before it."""
|
|
79
|
+
PENDING = "pending"
|
|
80
|
+
"""The system is moving the file and has not answered yet."""
|
|
81
|
+
MOVED = "moved"
|
|
82
|
+
"""The system moved the file to the Trash."""
|
|
83
|
+
FAILED = "failed"
|
|
84
|
+
"""The system refused the move, and the file is still in place."""
|
|
85
|
+
UNKNOWN = "unknown"
|
|
86
|
+
"""The system reported an error but the file is gone, so whether it reached the Trash is not known."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class TrashTarget:
|
|
91
|
+
"""Retain the confirmed identity alongside each observed OS result."""
|
|
92
|
+
|
|
93
|
+
path: bytes
|
|
94
|
+
filename: str
|
|
95
|
+
image: FileImage
|
|
96
|
+
tracked: bool
|
|
97
|
+
status: TrashTargetState = TrashTargetState.NOT_STARTED
|
|
98
|
+
cause: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class TrashRequest:
|
|
103
|
+
"""Freeze the selected paths and their file images before asking for confirmation."""
|
|
104
|
+
|
|
105
|
+
operation_id: str
|
|
106
|
+
snapshot: WorktreeSnapshot
|
|
107
|
+
targets: tuple[TrashTarget, ...]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class TrashOutcome:
|
|
112
|
+
"""Keep per-file results and the real completion state beyond the view lifetime."""
|
|
113
|
+
|
|
114
|
+
operation_id: str
|
|
115
|
+
key: WorktreeKey
|
|
116
|
+
status: OperationStatus
|
|
117
|
+
targets: tuple[TrashTarget, ...]
|
|
118
|
+
finished: bool
|
|
119
|
+
snapshot: WorktreeSnapshot | None = None
|
|
120
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TrashService(QObject):
|
|
124
|
+
"""Share mutation reservations and the existing file worker with a replaceable OS service."""
|
|
125
|
+
|
|
126
|
+
finished = Signal(object)
|
|
127
|
+
waiting = Signal(object)
|
|
128
|
+
|
|
129
|
+
def __init__(self, worktree_service: WorktreeService, mutation: MutationService, move_file_to_trash: Callable[[str], None] = move_file_to_trash, parent: QObject | None = None) -> None:
|
|
130
|
+
"""Inject the trash primitive and keep all file work under application ownership."""
|
|
131
|
+
super().__init__(parent)
|
|
132
|
+
self.worktree_service = worktree_service
|
|
133
|
+
self.mutation = mutation
|
|
134
|
+
self.move_file_to_trash = move_file_to_trash
|
|
135
|
+
self.prepared_by_id: dict[str, TrashRequest] = {}
|
|
136
|
+
self.active_by_id: dict[str, TrashRequest] = {}
|
|
137
|
+
self.outcome_by_id: dict[str, TrashOutcome] = {}
|
|
138
|
+
|
|
139
|
+
def prepare(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], callback: Callable[[TrashRequest | None, Exception | None], None]) -> str:
|
|
140
|
+
"""Capture validated file identities while holding a shared preparation reservation.
|
|
141
|
+
|
|
142
|
+
A refused reservation gives the real reason, worked out right away so it describes the state that refused it:
|
|
143
|
+
another operation is named only when a write really holds the repository.
|
|
144
|
+
"""
|
|
145
|
+
operation_id = uuid.uuid4().hex
|
|
146
|
+
if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
147
|
+
cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
148
|
+
QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
|
|
149
|
+
return operation_id
|
|
150
|
+
|
|
151
|
+
def complete(request: TrashRequest | None, error: Exception | None) -> None:
|
|
152
|
+
"""Release calculation ownership while the user reviews the captured paths."""
|
|
153
|
+
self.mutation.release(snapshot.key, operation_id)
|
|
154
|
+
if error is None:
|
|
155
|
+
self.prepared_by_id[operation_id] = request
|
|
156
|
+
callback(request, error)
|
|
157
|
+
|
|
158
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare(operation_id, snapshot, entries), complete, operation_id=operation_id)
|
|
159
|
+
return operation_id
|
|
160
|
+
|
|
161
|
+
def _prepare(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> Generator:
|
|
162
|
+
"""Check the selection against the index bytes and files of the displayed read, then capture each file to move.
|
|
163
|
+
|
|
164
|
+
This only prepares the confirmation: the index bytes and selected files are compared without a process before
|
|
165
|
+
and after, the tracked paths come from the index records of the read, and Git itself checks HEAD, configuration
|
|
166
|
+
and attributes again before the first move.
|
|
167
|
+
"""
|
|
168
|
+
if not snapshot.fresh or not entries or any(entry not in snapshot.entries for entry in entries):
|
|
169
|
+
raise ValueError("The selected changes are stale; Refresh and select them again")
|
|
170
|
+
paths = tuple(dict.fromkeys(entry.path for entry in entries))
|
|
171
|
+
yield FileTask(lambda: verify_local_state(snapshot, paths))
|
|
172
|
+
index_entries = snapshot.index_entries
|
|
173
|
+
if index_entries is None:
|
|
174
|
+
# A read that kept no index records lists the index once. The local checks before and after tie that
|
|
175
|
+
# listing to the index bytes of the read.
|
|
176
|
+
index_entries = yield GitCommand(["ls-files", "--stage", "-z"])
|
|
177
|
+
tracked_path_set = tracked_trash_path_set(index_entries)
|
|
178
|
+
targets: list[TrashTarget] = []
|
|
179
|
+
entry_by_path: dict[bytes, LocalEntry] = {}
|
|
180
|
+
for entry in entries:
|
|
181
|
+
entry_by_path[entry.path] = entry
|
|
182
|
+
for path in sorted(entry_by_path):
|
|
183
|
+
entry = entry_by_path[path]
|
|
184
|
+
if entry.old_mode == "160000" or entry.new_mode == "160000":
|
|
185
|
+
raise ValueError("A submodule directory cannot be moved to Trash as a file")
|
|
186
|
+
filename = yield FileTask(lambda path=entry.path: trash_filename(snapshot.context, path))
|
|
187
|
+
image = yield FileTask(lambda path=entry.path: capture_image(snapshot.context, path))
|
|
188
|
+
if not image.exists:
|
|
189
|
+
raise ValueError("The selected file is absent; Refresh and select the changes again")
|
|
190
|
+
targets.append(TrashTarget(entry.path, filename, image, entry.path in tracked_path_set))
|
|
191
|
+
yield FileTask(lambda: verify_local_state(snapshot, paths))
|
|
192
|
+
return TrashRequest(operation_id, snapshot, tuple(targets))
|
|
193
|
+
|
|
194
|
+
def cancel(self, request: TrashRequest) -> None:
|
|
195
|
+
"""Forget a declined confirmation without touching any working path."""
|
|
196
|
+
self.prepared_by_id.pop(request.operation_id, None)
|
|
197
|
+
|
|
198
|
+
def submit(self, request: TrashRequest) -> str:
|
|
199
|
+
"""Accept each captured intention at most once and reserve until the actual result.
|
|
200
|
+
|
|
201
|
+
A refused reservation moves nothing and gives the real reason, worked out right away so it describes the state
|
|
202
|
+
that refused it.
|
|
203
|
+
"""
|
|
204
|
+
operation_id = request.operation_id
|
|
205
|
+
if operation_id in self.active_by_id or operation_id in self.outcome_by_id:
|
|
206
|
+
return operation_id
|
|
207
|
+
if self.prepared_by_id.pop(operation_id, None) is not request:
|
|
208
|
+
raise ValueError("This trash confirmation is no longer current")
|
|
209
|
+
snapshot = request.snapshot
|
|
210
|
+
if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
211
|
+
cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
212
|
+
outcome = self._outcome(request, OperationStatus.REFUSED, request.targets, None, cause)
|
|
213
|
+
self.outcome_by_id[operation_id] = outcome
|
|
214
|
+
QTimer.singleShot(0, lambda: self.finished.emit(outcome))
|
|
215
|
+
return operation_id
|
|
216
|
+
self.active_by_id[operation_id] = request
|
|
217
|
+
|
|
218
|
+
def complete(outcome: TrashOutcome | None, error: Exception | None) -> None:
|
|
219
|
+
"""Release ownership only once both OS work and the final read have returned."""
|
|
220
|
+
if error is not None:
|
|
221
|
+
outcome = self._outcome(request, OperationStatus.UNKNOWN, request.targets, None, str(error))
|
|
222
|
+
self.active_by_id.pop(operation_id, None)
|
|
223
|
+
self.outcome_by_id[operation_id] = outcome
|
|
224
|
+
self.mutation.release(snapshot.key, operation_id)
|
|
225
|
+
self.finished.emit(outcome)
|
|
226
|
+
self.mutation.reread_requested.emit(snapshot.key)
|
|
227
|
+
|
|
228
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._execute(request), complete, operation_id=operation_id)
|
|
229
|
+
return operation_id
|
|
230
|
+
|
|
231
|
+
def _execute(self, request: TrashRequest) -> Generator:
|
|
232
|
+
"""Have Git verify the confirmed state, move in a worker, and report the complete state read afterwards.
|
|
233
|
+
|
|
234
|
+
The verification needs no complete read: it compares HEAD, index, configuration and attributes with fresh Git
|
|
235
|
+
reads, and each file is checked again immediately before its own move.
|
|
236
|
+
"""
|
|
237
|
+
snapshot = request.snapshot
|
|
238
|
+
targets = request.targets
|
|
239
|
+
cause = ""
|
|
240
|
+
status = OperationStatus.REFUSED
|
|
241
|
+
try:
|
|
242
|
+
yield from self.worktree_service.verify(snapshot, tuple(target.path for target in targets))
|
|
243
|
+
for target in targets:
|
|
244
|
+
yield FileTask(lambda target=target: validate_image(snapshot.context, target.path, target.image))
|
|
245
|
+
results: list[TrashTarget] = []
|
|
246
|
+
status = OperationStatus.SUCCESS
|
|
247
|
+
for target in targets:
|
|
248
|
+
waiting_targets = tuple(results) + (replace(target, status=TrashTargetState.PENDING),) + targets[len(results) + 1:]
|
|
249
|
+
timer = QTimer(self)
|
|
250
|
+
timer.setSingleShot(True)
|
|
251
|
+
timer.timeout.connect(lambda waiting_targets=waiting_targets: self._waiting(request, waiting_targets))
|
|
252
|
+
timer.start(TRASH_TIMEOUT_MS)
|
|
253
|
+
try:
|
|
254
|
+
result = yield FileTask(lambda target=target: self._move(snapshot.context, target))
|
|
255
|
+
finally:
|
|
256
|
+
timer.stop()
|
|
257
|
+
timer.deleteLater()
|
|
258
|
+
results.append(result)
|
|
259
|
+
if result.status != TrashTargetState.MOVED:
|
|
260
|
+
cause = result.cause
|
|
261
|
+
status = OperationStatus.PARTIAL if any(previous.status == TrashTargetState.MOVED for previous in results) else OperationStatus.FAILED
|
|
262
|
+
results.extend(targets[len(results):])
|
|
263
|
+
break
|
|
264
|
+
targets = tuple(results)
|
|
265
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
266
|
+
cause = str(error)
|
|
267
|
+
after = None
|
|
268
|
+
try:
|
|
269
|
+
after = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename)
|
|
270
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
271
|
+
cause += " The result could not be refreshed: " + str(error)
|
|
272
|
+
status = OperationStatus.UNKNOWN
|
|
273
|
+
return self._outcome(request, status, targets, after, cause)
|
|
274
|
+
|
|
275
|
+
def _move(self, context: RepositoryContext, target: TrashTarget) -> TrashTarget:
|
|
276
|
+
"""Validate immediately before the native call without following a leaf symlink."""
|
|
277
|
+
started = False
|
|
278
|
+
try:
|
|
279
|
+
self.mutation.validate_write(WorktreeKey.from_context(context))
|
|
280
|
+
validate_image(context, target.path, target.image)
|
|
281
|
+
started = True
|
|
282
|
+
self.move_file_to_trash(target.filename)
|
|
283
|
+
return replace(target, status=TrashTargetState.MOVED)
|
|
284
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
285
|
+
# A failed native response cannot prove that a now-absent path was never moved.
|
|
286
|
+
state = TrashTargetState.UNKNOWN if started and not os.path.lexists(target.filename) else TrashTargetState.FAILED
|
|
287
|
+
return replace(target, status=state, cause=str(error))
|
|
288
|
+
|
|
289
|
+
def _waiting(self, request: TrashRequest, targets: tuple[TrashTarget, ...]) -> None:
|
|
290
|
+
"""Report a presentation deadline while leaving the OS call and reservation active."""
|
|
291
|
+
if request.operation_id not in self.active_by_id:
|
|
292
|
+
return
|
|
293
|
+
outcome = self._outcome(request, OperationStatus.PENDING, targets, None, "Waiting for the OS Trash operation to finish. Its result is still unknown; do not repeat the operation.")
|
|
294
|
+
self.waiting.emit(replace(outcome, finished=False))
|
|
295
|
+
|
|
296
|
+
def _outcome(self, request: TrashRequest, status: OperationStatus, targets: tuple[TrashTarget, ...], snapshot: WorktreeSnapshot | None, cause: str) -> TrashOutcome:
|
|
297
|
+
"""Prepare a readable per-path result without claiming that a batch can be undone."""
|
|
298
|
+
diagnostic = None
|
|
299
|
+
if cause:
|
|
300
|
+
details = "\n".join(target.path.decode("utf-8", "replace") + ": " + target.status.value + (" — " + target.cause if target.cause else "") for target in targets)
|
|
301
|
+
diagnostic = Diagnostic("Move to Trash", request.snapshot.context.repository_dir, request.snapshot.git_filename, [], cause + "\n" + details).prepare()
|
|
302
|
+
return TrashOutcome(request.operation_id, request.snapshot.key, status, targets, True, snapshot, diagnostic)
|