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,110 @@
|
|
|
1
|
+
"""Describe the steps of a Git command sequence: commands, read batches, file tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from gitpane.config import GIT_READ_TIMEOUT_MS, WORKTREE_OUTPUT_LIMIT_BYTES, ProcessPriority
|
|
9
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
10
|
+
from gitpane.process.process_runner import READ_PROFILE
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from gitpane.process.process_runner import ProcessResult
|
|
16
|
+
from gitpane.tool.editor import EditorExecutionContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class GitCommand:
|
|
21
|
+
"""Describe a bounded command without exposing asynchronous plumbing to callers."""
|
|
22
|
+
|
|
23
|
+
arguments: list[str]
|
|
24
|
+
stdin: bytes | None = None
|
|
25
|
+
environment_by_name: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
accepted_codes: tuple[int, ...] = (0,)
|
|
27
|
+
stdout_limit_bytes: int | None = WORKTREE_OUTPUT_LIMIT_BYTES
|
|
28
|
+
timeout_ms: int = GIT_READ_TIMEOUT_MS
|
|
29
|
+
idle_timeout_ms: int = GIT_READ_TIMEOUT_MS
|
|
30
|
+
consume: Callable[[bytes], None] | None = None
|
|
31
|
+
# None means a read: see command_profile.
|
|
32
|
+
profile: str | None = None
|
|
33
|
+
before_start: Callable[[], None] | None = None
|
|
34
|
+
editor_context: EditorExecutionContext | None = None
|
|
35
|
+
suppress_editor: bool = False
|
|
36
|
+
stdin_filename: str | None = None
|
|
37
|
+
# See ProcessRequest.opens_files_deleted_at_end.
|
|
38
|
+
opens_files_deleted_at_end: bool = False
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
"""Reject competing input sources before the sequence reaches its runner."""
|
|
42
|
+
if self.stdin is not None and self.stdin_filename is not None:
|
|
43
|
+
raise ValueError("stdin and stdin_filename are mutually exclusive")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitCommandError(RuntimeError):
|
|
47
|
+
"""Preserve a prepared command failure for both the view and optional logging."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, result: ProcessResult) -> None:
|
|
50
|
+
"""Filter command details once at the process boundary, naming the process so a console that showed its end does not repeat it."""
|
|
51
|
+
request = result.request
|
|
52
|
+
self.process_status = result.status
|
|
53
|
+
self.return_code = result.return_code
|
|
54
|
+
self.diagnostic = Diagnostic("Read or prepare changes", request.working_dir, request.program_filename, request.arguments, result.cause, result.stderr, result.return_code,
|
|
55
|
+
process_id=request.request_id).prepare()
|
|
56
|
+
super().__init__(self.diagnostic.cause)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ReadBatch:
|
|
61
|
+
"""Read independent Git or filesystem facts concurrently, retaining their original result positions."""
|
|
62
|
+
|
|
63
|
+
commands: tuple[GitCommand | FileTask, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class FileTask:
|
|
68
|
+
"""Move potentially large filesystem reads off the Qt event loop."""
|
|
69
|
+
|
|
70
|
+
callback: Callable[[], object]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# A command that declares nothing is a read and does not need the supervised launcher, which costs an interpreter and
|
|
74
|
+
# a job per process. Every command that writes, or that can run a hook, a filter or the editor, declares LOCAL_WRITE_PROFILE
|
|
75
|
+
# itself, and the tests refuse any undeclared command that is not a read.
|
|
76
|
+
DEFAULT_COMMAND_PROFILE = READ_PROFILE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def command_profile(command: GitCommand) -> str:
|
|
80
|
+
"""Decide the launch profile of one command: the one it declares, otherwise the read profile.
|
|
81
|
+
|
|
82
|
+
This is the only place where that decision is made, so a change of rule cannot forget one of the paths a command
|
|
83
|
+
can take to the process runner.
|
|
84
|
+
"""
|
|
85
|
+
if command.profile is not None:
|
|
86
|
+
return command.profile
|
|
87
|
+
return DEFAULT_COMMAND_PROFILE
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resumed_priority(priority: ProcessPriority) -> ProcessPriority:
|
|
91
|
+
"""Answer the level a sequence resumes at once one of its steps has ended.
|
|
92
|
+
|
|
93
|
+
A user action that has started is from then on the rest of an operation; the rest of an operation and background
|
|
94
|
+
work keep their own level, so background work never overtakes anything by lasting longer.
|
|
95
|
+
"""
|
|
96
|
+
if priority == ProcessPriority.USER:
|
|
97
|
+
return ProcessPriority.CONTINUATION
|
|
98
|
+
return priority
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def observation_priority(observers: list[tuple[Callable[[], bool], Callable, ProcessPriority]]) -> ProcessPriority:
|
|
102
|
+
"""Answer the queue level of a read shared by these callers: the most urgent level among those still current.
|
|
103
|
+
|
|
104
|
+
A caller whose window was replaced meanwhile will not be answered, so its level does not hurry the read.
|
|
105
|
+
"""
|
|
106
|
+
priority = ProcessPriority.BACKGROUND
|
|
107
|
+
for current, _callback, observer_priority in observers:
|
|
108
|
+
if observer_priority < priority and current():
|
|
109
|
+
priority = observer_priority
|
|
110
|
+
return priority
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Describe what a file menu of a repository window points at."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.history.repository_value import FilePanel, RevisionEntry
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from gitpane.history.repository_value import TreeEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileActionTargetKind(Enum):
|
|
17
|
+
"""Distinguish a single historical entry from an entire directory."""
|
|
18
|
+
|
|
19
|
+
FILE = "file"
|
|
20
|
+
FOLDER = "folder"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FileActionSelection:
|
|
25
|
+
"""Capture the clicked entry and panel generation before opening a menu."""
|
|
26
|
+
|
|
27
|
+
entry: RevisionEntry | TreeEntry | None
|
|
28
|
+
commit_oid: str
|
|
29
|
+
panel_kind: FilePanel
|
|
30
|
+
generation: int
|
|
31
|
+
kind: FileActionTargetKind = FileActionTargetKind.FILE
|
|
32
|
+
folder_path: bytes | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
"""Reject ambiguous selections before any caller can treat a directory as a blob."""
|
|
36
|
+
if self.kind == FileActionTargetKind.FOLDER:
|
|
37
|
+
if self.entry is not None or not self.folder_path:
|
|
38
|
+
raise ValueError("A folder selection needs its raw path and no file entry.")
|
|
39
|
+
elif self.entry is None or self.folder_path is not None:
|
|
40
|
+
raise ValueError("A file selection needs its entry and no folder path.")
|
|
41
|
+
|
|
42
|
+
def basename(self) -> str:
|
|
43
|
+
"""Return the literal selected side's basename without display decorations."""
|
|
44
|
+
_directory, _separator, basename = self.path().rpartition(b"/")
|
|
45
|
+
return os.fsdecode(basename)
|
|
46
|
+
|
|
47
|
+
def path(self) -> bytes:
|
|
48
|
+
"""Return the raw folder or file path, using the former spelling for a deletion."""
|
|
49
|
+
if self.kind == FileActionTargetKind.FOLDER:
|
|
50
|
+
return self.folder_path
|
|
51
|
+
return self.entry.new_path or self.entry.old_path if isinstance(self.entry, RevisionEntry) else self.entry.path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class FileRestoreTarget:
|
|
56
|
+
"""Identify a restoration source separately from the commit that owns the visible selection."""
|
|
57
|
+
|
|
58
|
+
commit_oid: str
|
|
59
|
+
label: str
|
|
60
|
+
message: str
|
|
61
|
+
unavailable: str = ""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Name the actions the file menus offer, each with the label it shows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileAction(Enum):
|
|
10
|
+
"""An action of the menu on a file of Prepare changes or of the repository window, with the label of its entry.
|
|
11
|
+
|
|
12
|
+
The value is the name of the action; it is never written to a file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
STAGE = ("stage", "Stage selected")
|
|
16
|
+
"""Prepare changes only: stage the selected files."""
|
|
17
|
+
RESET = ("reset", "Reset")
|
|
18
|
+
"""Prepare changes only: reset the selected side of the files."""
|
|
19
|
+
DELETE = ("delete", "Delete file")
|
|
20
|
+
"""Prepare changes only: move the files to the Trash."""
|
|
21
|
+
COPY_PATHS = ("copy_paths", "Copy path(s)")
|
|
22
|
+
"""Prepare changes only: the title of the submenu that holds the two copies of the path."""
|
|
23
|
+
COPY_BASENAME = ("copy_basename", "Copy basename")
|
|
24
|
+
COPY_ABSOLUTE = ("copy_absolute", "Copy full abs filename")
|
|
25
|
+
FOLDER = ("folder", "Show in folder")
|
|
26
|
+
TEXT_EDITOR = ("text_editor", "Show in text editor")
|
|
27
|
+
HISTORY = ("history", "File history")
|
|
28
|
+
"""Prepare changes only: the history of the working copy file."""
|
|
29
|
+
STOP_TRACKING = ("stop_tracking", "Stop tracking this file")
|
|
30
|
+
"""Prepare changes only: stage the removal of the files from the index, keeping them on disk."""
|
|
31
|
+
RESET_TO = ("reset_to", "Reset to")
|
|
32
|
+
"""Repository window only: the title of the submenu that restores the file from a parent commit."""
|
|
33
|
+
DISPLAY_HISTORY = ("display_history", "Display file history")
|
|
34
|
+
"""Repository window only: the history of the file of the selected commit."""
|
|
35
|
+
COMPARE = ("compare", "Compare with external tool")
|
|
36
|
+
"""Repository window only: the comparison of the file in the external diff tool, in the file menu and the tool menu."""
|
|
37
|
+
SAVE_AS = ("save_as", "Save selected as")
|
|
38
|
+
"""Repository window only: save the file of the selected commit to a chosen place."""
|
|
39
|
+
REMOVE_FROM_HISTORY = ("remove_from_history", "Remove from history")
|
|
40
|
+
"""Repository window only: review removing the selected path from local history."""
|
|
41
|
+
|
|
42
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
43
|
+
"""Keep the name of the action as the value, and its menu label beside it."""
|
|
44
|
+
member = object.__new__(cls)
|
|
45
|
+
member._value_ = value
|
|
46
|
+
member.label = label
|
|
47
|
+
return member
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The actions that only open the working copy or its folder. Prepare changes offers them in both lists, Staged
|
|
51
|
+
# included, because they never change the index; the menu and the session both read this one definition.
|
|
52
|
+
READ_ONLY_FILE_ACTION_SET = frozenset((FileAction.FOLDER, FileAction.TEXT_EDITOR))
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Restore an explicitly chosen historical file without touching the live index or HEAD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QTimer
|
|
12
|
+
|
|
13
|
+
from gitpane.history.file_history import WINDOW_CAPABILITY_BY_MODE, WindowCapability
|
|
14
|
+
from gitpane.history.history_parse import parse_commit, parse_tree_entries
|
|
15
|
+
from gitpane.history.repository_value import FileStatus, RevisionEntry, validate_oid
|
|
16
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
17
|
+
from gitpane.operation.repository_operation import OperationAction
|
|
18
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
19
|
+
from gitpane.worktree.mutation import JOURNAL_SCHEMA_VERSION, JournalKind, JournalState, MutationOutcome, recovery_files_cause, write_record
|
|
20
|
+
from gitpane.worktree.worktree_file import (
|
|
21
|
+
FILE_OBJECT_IDENTITY_FIELDS,
|
|
22
|
+
PRIVATE_DIRECTORY_MODE,
|
|
23
|
+
PRIVATE_FILE_MODE,
|
|
24
|
+
FileImage,
|
|
25
|
+
capture_image,
|
|
26
|
+
clean_created_parents,
|
|
27
|
+
create_missing_parents,
|
|
28
|
+
materialize_blob,
|
|
29
|
+
prepare_publication_image,
|
|
30
|
+
private_file_dir,
|
|
31
|
+
publish_image_mode,
|
|
32
|
+
stat_identity,
|
|
33
|
+
validate_destination,
|
|
34
|
+
validate_image,
|
|
35
|
+
write_image,
|
|
36
|
+
)
|
|
37
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, read_index, verify_local_state
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Callable, Generator
|
|
41
|
+
|
|
42
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
43
|
+
from gitpane.worktree.file_action_target import FileActionSelection
|
|
44
|
+
from gitpane.worktree.mutation import MutationService
|
|
45
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The name of the action, also the title of every window and diagnostic that reports on it.
|
|
49
|
+
RESTORE_SELECTED_VERSION_TITLE = "Restore selected version"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class HistoricalRestoreRequest:
|
|
54
|
+
"""Own one exact historical source and the local state accepted by its confirmation."""
|
|
55
|
+
|
|
56
|
+
operation_id: str
|
|
57
|
+
key: WorktreeKey
|
|
58
|
+
commit_oid: str
|
|
59
|
+
path: bytes
|
|
60
|
+
blob_oid: str
|
|
61
|
+
mode: str
|
|
62
|
+
selection: FileActionSelection
|
|
63
|
+
before: FileImage
|
|
64
|
+
after: FileImage
|
|
65
|
+
snapshot: WorktreeSnapshot
|
|
66
|
+
attributes: bytes
|
|
67
|
+
message: str
|
|
68
|
+
window_mode: str
|
|
69
|
+
validate: Callable[[], bool]
|
|
70
|
+
operation_dir: str
|
|
71
|
+
private_dir: str
|
|
72
|
+
candidate_filename: str
|
|
73
|
+
record_by_name: dict[str, object]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HistoricalRestoreService(QObject):
|
|
77
|
+
"""Retain preparations and writes independently of the lifetime of their initiating widgets."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, mutation: MutationService, parent: QObject | None = None) -> None:
|
|
80
|
+
"""Share worktree reservations, recovery and rereads with other local operations."""
|
|
81
|
+
super().__init__(parent)
|
|
82
|
+
self.mutation = mutation
|
|
83
|
+
self.prepared_by_id: dict[str, HistoricalRestoreRequest] = {}
|
|
84
|
+
self.outcome_by_id: dict[str, MutationOutcome] = {}
|
|
85
|
+
|
|
86
|
+
def prepare(self, context: RepositoryContext, git_filename: str, selection: FileActionSelection | None, message: str,
|
|
87
|
+
window_mode: str, validate: Callable[[], bool], callback: Callable[[HistoricalRestoreRequest | None, Exception | None], None], source_oid: str | None = None) -> str:
|
|
88
|
+
"""Capture the viewed commit or a verified direct parent's file before replacing local content.
|
|
89
|
+
|
|
90
|
+
A refused reservation gives the real reason, worked out right away so it describes the state that refused it:
|
|
91
|
+
another operation is named only when a write really holds the repository.
|
|
92
|
+
"""
|
|
93
|
+
operation_id = uuid.uuid4().hex
|
|
94
|
+
key = WorktreeKey.from_context(context)
|
|
95
|
+
try:
|
|
96
|
+
self._authorize(context, window_mode, validate)
|
|
97
|
+
if selection is None:
|
|
98
|
+
raise ValueError("Select a historical file first")
|
|
99
|
+
if not self.mutation.acquire_context(context, git_filename, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
100
|
+
raise ValueError(self.mutation.acquire_context_refusal_cause(context, git_filename, operation_id, action=OperationAction.WORKTREE_EDIT))
|
|
101
|
+
except ValueError as error:
|
|
102
|
+
QTimer.singleShot(0, lambda error=error: callback(None, error))
|
|
103
|
+
return operation_id
|
|
104
|
+
self.mutation.live_reset_id_set.add(operation_id)
|
|
105
|
+
|
|
106
|
+
def complete(request: HistoricalRestoreRequest | None, error: Exception | None) -> None:
|
|
107
|
+
"""Release the worktree while the owned candidate awaits confirmation."""
|
|
108
|
+
self.mutation.release(key, operation_id)
|
|
109
|
+
if error is None:
|
|
110
|
+
self.prepared_by_id[operation_id] = request
|
|
111
|
+
else:
|
|
112
|
+
self.mutation.live_reset_id_set.discard(operation_id)
|
|
113
|
+
self.mutation.reread_requested.emit(key)
|
|
114
|
+
callback(request, error)
|
|
115
|
+
|
|
116
|
+
sequence = self._prepare(operation_id, context, git_filename, selection, message, window_mode, validate, source_oid)
|
|
117
|
+
self.mutation.worktree_service.run(context, git_filename, sequence, complete, operation_id=operation_id)
|
|
118
|
+
return operation_id
|
|
119
|
+
|
|
120
|
+
def cancel(self, request: HistoricalRestoreRequest) -> None:
|
|
121
|
+
"""Invalidate declined or stale confirmation without touching the working file."""
|
|
122
|
+
if self.prepared_by_id.pop(request.operation_id, None) is not request:
|
|
123
|
+
return
|
|
124
|
+
self.mutation.live_reset_id_set.discard(request.operation_id)
|
|
125
|
+
request.record_by_name["state"] = JournalState.ABANDONED.value
|
|
126
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
127
|
+
self._clean(request)
|
|
128
|
+
|
|
129
|
+
def restore(self, request: HistoricalRestoreRequest) -> str:
|
|
130
|
+
"""Reacquire publication ownership and refuse any changed capability or capture.
|
|
131
|
+
|
|
132
|
+
A refused reservation writes nothing and gives the real reason, followed by the folder of its recovery files.
|
|
133
|
+
"""
|
|
134
|
+
if self.prepared_by_id.pop(request.operation_id, None) is not request:
|
|
135
|
+
raise ValueError("This restoration confirmation is no longer current")
|
|
136
|
+
snapshot = request.snapshot
|
|
137
|
+
|
|
138
|
+
def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
|
|
139
|
+
"""Publish retained terminal results only after all asynchronous work ends."""
|
|
140
|
+
self.mutation.release(request.key, request.operation_id)
|
|
141
|
+
self.mutation.live_reset_id_set.discard(request.operation_id)
|
|
142
|
+
if error is not None:
|
|
143
|
+
outcome = self._failure(request, error, bool(request.record_by_name.get("working_file_published")))
|
|
144
|
+
self.outcome_by_id[request.operation_id] = outcome
|
|
145
|
+
self.mutation._publish_outcome(outcome)
|
|
146
|
+
|
|
147
|
+
if not self.mutation.acquire(snapshot, request.operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
148
|
+
self.mutation.live_reset_id_set.discard(request.operation_id)
|
|
149
|
+
cause = self.mutation.acquire_refusal_cause(snapshot, request.operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
150
|
+
outcome = self._failure(request, ValueError(cause), False)
|
|
151
|
+
self.outcome_by_id[request.operation_id] = outcome
|
|
152
|
+
QTimer.singleShot(0, lambda: self.mutation._publish_outcome(outcome))
|
|
153
|
+
return request.operation_id
|
|
154
|
+
self.mutation.worktree_service.run(snapshot.context, snapshot.git_filename, self._publish(request), complete, operation_id=request.operation_id)
|
|
155
|
+
return request.operation_id
|
|
156
|
+
|
|
157
|
+
def _authorize(self, context: RepositoryContext, window_mode: str, validate: Callable[[], bool]) -> None:
|
|
158
|
+
"""Enforce management permission again at the write service boundary."""
|
|
159
|
+
if WindowCapability.RESTORE not in WINDOW_CAPABILITY_BY_MODE.get(window_mode, ()) or context.is_bare or context.worktree_dir is None:
|
|
160
|
+
raise ValueError("Historical restoration requires a repository window with a working tree")
|
|
161
|
+
if not validate():
|
|
162
|
+
raise ValueError("The selected historical file is no longer current")
|
|
163
|
+
|
|
164
|
+
def _prepare(self, operation_id: str, context: RepositoryContext, git_filename: str, selection: FileActionSelection,
|
|
165
|
+
message: str, window_mode: str, validate: Callable[[], bool], source_oid: str | None = None) -> Generator:
|
|
166
|
+
"""Resolve the explicit source tree while retaining the visible selection as the destination."""
|
|
167
|
+
selected_oid = validate_oid(selection.commit_oid)
|
|
168
|
+
commit_oid = validate_oid(source_oid) if source_oid is not None else selected_oid
|
|
169
|
+
if commit_oid != selected_oid:
|
|
170
|
+
raw_commit = yield GitCommand(["cat-file", "commit", selected_oid])
|
|
171
|
+
selected_commit = parse_commit(selected_oid, raw_commit, selected_oid)
|
|
172
|
+
if commit_oid not in selected_commit.parent_oids:
|
|
173
|
+
raise ValueError("The restoration source is not this commit or one of its direct parents")
|
|
174
|
+
path = selection.path()
|
|
175
|
+
validate_destination(context, path, allow_missing_parents=True)
|
|
176
|
+
source_path = path
|
|
177
|
+
if commit_oid != selected_oid and isinstance(selection.entry, RevisionEntry) and selection.entry.parent_oid == commit_oid and selection.entry.status == FileStatus.RENAMED:
|
|
178
|
+
source_path = selection.entry.old_path or path
|
|
179
|
+
output = yield GitCommand(["--literal-pathspecs", "ls-tree", "-z", commit_oid, "--", os.fsdecode(source_path)])
|
|
180
|
+
entries = parse_tree_entries(output)
|
|
181
|
+
if len(entries) != 1 or next(iter(entries)).path != source_path:
|
|
182
|
+
raise ValueError("This file is absent from the chosen source commit")
|
|
183
|
+
entry = next(iter(entries))
|
|
184
|
+
if entry.mode == "160000":
|
|
185
|
+
raise ValueError("A submodule points to a commit, not a file blob")
|
|
186
|
+
snapshot = yield from self.mutation.worktree_service.capture(context, git_filename)
|
|
187
|
+
recovery = self.mutation.recover(context, snapshot.index_filename, operation_id=operation_id)
|
|
188
|
+
if recovery:
|
|
189
|
+
raise ValueError(recovery)
|
|
190
|
+
attributes = yield GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path + b"\0")
|
|
191
|
+
before = yield FileTask(lambda: capture_image(context, path, allow_missing_parents=True))
|
|
192
|
+
root_dir = os.path.join(context.git_dir, "gitpane-operation")
|
|
193
|
+
os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
|
194
|
+
operation_dir = os.path.join(root_dir, operation_id)
|
|
195
|
+
os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
|
|
196
|
+
record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.FILE.value, "operation_id": operation_id,
|
|
197
|
+
"index_filename": snapshot.index_filename, "path_hex": path.hex(), "before": before.digest(), "after": None, "state": JournalState.PREPARING.value}
|
|
198
|
+
write_record(operation_dir, record_by_name)
|
|
199
|
+
try:
|
|
200
|
+
private_dir = private_file_dir(context, path, operation_dir, allow_missing_parents=True)
|
|
201
|
+
content_filename = os.path.join(private_dir, "blob-content")
|
|
202
|
+
candidate_filename = os.path.join(private_dir, "candidate")
|
|
203
|
+
backup_filename = os.path.join(private_dir, "before")
|
|
204
|
+
symlink_output = yield GitCommand(["config", "--bool", "--get", "core.symlinks"], accepted_codes=(0, 1))
|
|
205
|
+
after = yield from materialize_blob(entry.oid, path, entry.mode, content_filename, symlink_output.strip() != b"false")
|
|
206
|
+
if stat.S_ISREG(after.mode) and stat.S_ISREG(before.mode):
|
|
207
|
+
executable_mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
208
|
+
permission_mode = stat.S_IMODE(before.mode) & ~executable_mask
|
|
209
|
+
if entry.mode == "100755":
|
|
210
|
+
permission_mode |= executable_mask
|
|
211
|
+
after = replace(after, mode=stat.S_IFREG | permission_mode)
|
|
212
|
+
if before.exists:
|
|
213
|
+
yield FileTask(lambda: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, before.read())))
|
|
214
|
+
after = yield FileTask(lambda: prepare_publication_image(candidate_filename, after, before))
|
|
215
|
+
owned_identity_by_basename = {}
|
|
216
|
+
for filename in (content_filename, candidate_filename, backup_filename):
|
|
217
|
+
if os.path.lexists(filename):
|
|
218
|
+
owned_identity_by_basename[os.path.basename(filename)] = stat_identity(os.lstat(filename))
|
|
219
|
+
record_by_name.update({"after": after.digest(), "state": JournalState.PREPARED.value, "private_dir": private_dir,
|
|
220
|
+
"private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS],
|
|
221
|
+
"owned_identity_by_basename": owned_identity_by_basename})
|
|
222
|
+
request = HistoricalRestoreRequest(operation_id, snapshot.key, commit_oid, path, entry.oid, entry.mode, selection, before, after,
|
|
223
|
+
snapshot, attributes, message, window_mode, validate, operation_dir, private_dir, candidate_filename, record_by_name)
|
|
224
|
+
yield from self._check_preparation(request)
|
|
225
|
+
write_record(operation_dir, record_by_name)
|
|
226
|
+
return request
|
|
227
|
+
except (OSError, ValueError, RuntimeError):
|
|
228
|
+
record_by_name["state"] = JournalState.ABANDONED.value
|
|
229
|
+
write_record(operation_dir, record_by_name)
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def _check_preparation(self, request: HistoricalRestoreRequest) -> Generator:
|
|
233
|
+
"""Check without a Git process that the confirmation still matches the capture: authority, index bytes, target file and private storage.
|
|
234
|
+
|
|
235
|
+
These checks only prepare the confirmation. Publication has Git verify the capture again under the index lock.
|
|
236
|
+
"""
|
|
237
|
+
snapshot = request.snapshot
|
|
238
|
+
self._authorize(snapshot.context, request.window_mode, request.validate)
|
|
239
|
+
yield FileTask(lambda: verify_local_state(snapshot, ()))
|
|
240
|
+
yield FileTask(lambda: validate_image(snapshot.context, request.path, request.before))
|
|
241
|
+
yield FileTask(lambda: self._validate_storage(request))
|
|
242
|
+
self._authorize(snapshot.context, request.window_mode, request.validate)
|
|
243
|
+
|
|
244
|
+
def _validate(self, request: HistoricalRestoreRequest) -> Generator:
|
|
245
|
+
"""Have Git verify the capture, then compare the target's own conversion attributes and its exact local file capture.
|
|
246
|
+
|
|
247
|
+
Called under the owned index lock just before publication. The capture is verified without paths on purpose: the
|
|
248
|
+
historical target can be clean or absent from the read, so its content is proved by its own file capture and its
|
|
249
|
+
attributes by the explicit check below.
|
|
250
|
+
"""
|
|
251
|
+
snapshot = request.snapshot
|
|
252
|
+
self._authorize(snapshot.context, request.window_mode, request.validate)
|
|
253
|
+
yield from self.mutation.worktree_service.verify(snapshot, ())
|
|
254
|
+
attributes = yield GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=request.path + b"\0")
|
|
255
|
+
if attributes != request.attributes:
|
|
256
|
+
raise ValueError("File conversion attributes changed after selection")
|
|
257
|
+
yield FileTask(lambda: validate_image(snapshot.context, request.path, request.before))
|
|
258
|
+
self._authorize(snapshot.context, request.window_mode, request.validate)
|
|
259
|
+
|
|
260
|
+
def _publish(self, request: HistoricalRestoreRequest) -> Generator:
|
|
261
|
+
"""Replace only the working file under an owned index lock, then prove index and HEAD stayed unchanged."""
|
|
262
|
+
snapshot = request.snapshot
|
|
263
|
+
lock_filename = snapshot.index_filename + ".lock"
|
|
264
|
+
lock_identity = None
|
|
265
|
+
published = False
|
|
266
|
+
created_dirs: list[tuple[str, tuple[int, int]]] = []
|
|
267
|
+
request.record_by_name["created_dirs"] = created_dirs
|
|
268
|
+
try:
|
|
269
|
+
descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
270
|
+
try:
|
|
271
|
+
lock_identity = stat_identity(os.fstat(descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
|
|
272
|
+
finally:
|
|
273
|
+
os.close(descriptor)
|
|
274
|
+
yield from self._validate(request)
|
|
275
|
+
self._validate_storage(request)
|
|
276
|
+
if request.before.digest() == request.after.digest():
|
|
277
|
+
status = "unchanged"
|
|
278
|
+
else:
|
|
279
|
+
request.record_by_name["state"] = JournalState.PUBLISHING.value
|
|
280
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
281
|
+
validate_image(snapshot.context, request.path, request.before)
|
|
282
|
+
expected = create_missing_parents(snapshot.context, request.path, request.before, created_dirs, lambda: write_record(request.operation_dir, request.record_by_name))
|
|
283
|
+
filename, _parents = validate_destination(snapshot.context, request.path)
|
|
284
|
+
validate_image(snapshot.context, request.path, expected)
|
|
285
|
+
self._authorize(snapshot.context, request.window_mode, request.validate)
|
|
286
|
+
if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
|
|
287
|
+
raise ValueError("The restoration index lock changed")
|
|
288
|
+
publish_image_mode(request.candidate_filename, request.after)
|
|
289
|
+
request.record_by_name["owned_identity_by_basename"][os.path.basename(request.candidate_filename)] = stat_identity(os.lstat(request.candidate_filename))
|
|
290
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
291
|
+
validate_image(snapshot.context, request.path, expected)
|
|
292
|
+
self.mutation.validate_write(snapshot.key)
|
|
293
|
+
os.replace(request.candidate_filename, filename)
|
|
294
|
+
published = True
|
|
295
|
+
request.record_by_name["working_file_published"] = True
|
|
296
|
+
request.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
|
|
297
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
298
|
+
status = "success"
|
|
299
|
+
final_head = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
300
|
+
if read_index(snapshot.index_filename) != snapshot.index_content or (final_head.strip().decode("ascii") or None) != snapshot.head_oid:
|
|
301
|
+
raise ValueError("HEAD or index changed during restoration; recovery files retained")
|
|
302
|
+
actual = yield FileTask(lambda: capture_image(snapshot.context, request.path))
|
|
303
|
+
if actual.digest() != request.after.digest():
|
|
304
|
+
raise ValueError("The working file changed during restoration; recovery files retained")
|
|
305
|
+
outcome = MutationOutcome(request.operation_id, request.key, status, (request.path,), ())
|
|
306
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
307
|
+
outcome = self._failure(request, error, published)
|
|
308
|
+
finally:
|
|
309
|
+
if not published:
|
|
310
|
+
clean_created_parents(created_dirs)
|
|
311
|
+
try:
|
|
312
|
+
if lock_identity is not None and os.path.lexists(lock_filename):
|
|
313
|
+
if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
|
|
314
|
+
raise ValueError("The restoration index lock changed; cleanup refused")
|
|
315
|
+
os.unlink(lock_filename)
|
|
316
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
317
|
+
outcome = self._failure(request, error, published)
|
|
318
|
+
if outcome.diagnostic is None:
|
|
319
|
+
try:
|
|
320
|
+
# Recovery content stays owned until the index lock has been released successfully.
|
|
321
|
+
request.record_by_name["state"] = JournalState.COMPLETE.value
|
|
322
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
323
|
+
self._clean(request)
|
|
324
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
325
|
+
outcome = self._failure(request, error, published)
|
|
326
|
+
return outcome
|
|
327
|
+
|
|
328
|
+
def _validate_storage(self, request: HistoricalRestoreRequest) -> None:
|
|
329
|
+
"""Refuse private directory or candidate substitutions before exposing any prepared content."""
|
|
330
|
+
record = request.record_by_name
|
|
331
|
+
if os.path.islink(request.private_dir) or os.path.isjunction(request.private_dir) or stat_identity(os.lstat(request.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != record["private_identity"]:
|
|
332
|
+
raise ValueError("Private restoration storage changed")
|
|
333
|
+
for basename, identity in record["owned_identity_by_basename"].items():
|
|
334
|
+
if stat_identity(os.lstat(os.path.join(request.private_dir, basename))) != identity:
|
|
335
|
+
raise ValueError("Private restoration content changed")
|
|
336
|
+
|
|
337
|
+
def _clean(self, request: HistoricalRestoreRequest) -> None:
|
|
338
|
+
"""Remove only still-owned private artifacts after success or cancellation."""
|
|
339
|
+
record = request.record_by_name
|
|
340
|
+
if os.path.islink(request.private_dir) or os.path.isjunction(request.private_dir) or stat_identity(os.lstat(request.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != record["private_identity"]:
|
|
341
|
+
raise ValueError("Private restoration storage changed; cleanup refused")
|
|
342
|
+
for basename, identity in request.record_by_name["owned_identity_by_basename"].items():
|
|
343
|
+
filename = os.path.join(request.private_dir, basename)
|
|
344
|
+
if os.path.lexists(filename):
|
|
345
|
+
if stat_identity(os.lstat(filename)) != identity:
|
|
346
|
+
raise ValueError("Private restoration content changed; cleanup refused")
|
|
347
|
+
os.unlink(filename)
|
|
348
|
+
if request.private_dir != request.operation_dir:
|
|
349
|
+
os.rmdir(request.private_dir)
|
|
350
|
+
|
|
351
|
+
def _failure(self, request: HistoricalRestoreRequest, error: Exception, published: bool) -> MutationOutcome:
|
|
352
|
+
"""Retain backups on any uncertain result without undoing a later external edit."""
|
|
353
|
+
request.record_by_name["state"] = (JournalState.FILE_PUBLISHED if published else JournalState.ABANDONED).value
|
|
354
|
+
try:
|
|
355
|
+
write_record(request.operation_dir, request.record_by_name)
|
|
356
|
+
except OSError:
|
|
357
|
+
pass
|
|
358
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(RESTORE_SELECTED_VERSION_TITLE, request.snapshot.context.repository_dir,
|
|
359
|
+
request.snapshot.git_filename, [], str(error)).prepare()
|
|
360
|
+
diagnostic = replace(diagnostic, cause=recovery_files_cause(diagnostic.cause, request.operation_dir))
|
|
361
|
+
return MutationOutcome(request.operation_id, request.key, "unknown" if published else "refused", (request.path,) if published else (), (request.path,), diagnostic, request.operation_dir)
|