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,739 @@
|
|
|
1
|
+
"""Coordinate explicitly confirmed removal of historical paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from threading import Event
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
13
|
+
from shiboken6 import isValid
|
|
14
|
+
|
|
15
|
+
from gitpane.config import LOCAL_PREFIX, ConsoleOperation
|
|
16
|
+
from gitpane.controller.refresh_controller import RefreshScope
|
|
17
|
+
from gitpane.history.file_history import WindowCapability
|
|
18
|
+
from gitpane.history_rewrite.prepare import discard_preparation, prepare_rewrite
|
|
19
|
+
from gitpane.history_rewrite.publish import RewriteProgress, RewritePublicationOutcome, compensate_rewrite, publish_rewrite, rewrite_operation
|
|
20
|
+
from gitpane.history_rewrite.recovery import RecoveryDiscovery, RewritePublicationState, coherent_recovery_state, context_identity, discover_recovery, observe_recovery, read_recovery_record, recover_rewrite
|
|
21
|
+
from gitpane.history_rewrite.value import RewriteCapture, RewriteTarget, RewriteTargetKind
|
|
22
|
+
from gitpane.operation.console_line import REPOSITORY_HEADER_LABEL, ConsoleHeader, operation_end_text
|
|
23
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
24
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
25
|
+
from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
|
|
26
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, WriteScope
|
|
27
|
+
from gitpane.system.path_identity import canonical_path
|
|
28
|
+
from gitpane.ui.console.operation_dialog import OperationDialog
|
|
29
|
+
from gitpane.ui.history_rewrite.preview import HistoryRewritePreview, PreviewState
|
|
30
|
+
from gitpane.ui.history_rewrite.question import HistoryRewriteQuestion
|
|
31
|
+
from gitpane.ui.history_rewrite.recovery_dialog import HistoryRewriteRecoveryDialog, RecoveryText, RestoreBranchesQuestion, RestoreText, can_restore
|
|
32
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal
|
|
33
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
34
|
+
from gitpane.worktree.file_action_target import FileActionTargetKind
|
|
35
|
+
from gitpane.worktree.file_menu_action import FileAction
|
|
36
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey
|
|
37
|
+
|
|
38
|
+
PREVIEW_STORAGE_SUFFIX = "-previews"
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Generator
|
|
42
|
+
|
|
43
|
+
from gitpane.controller.console_registry import ConsoleRegistry
|
|
44
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
45
|
+
from gitpane.controller.reference_read_controller import ReferenceReadController
|
|
46
|
+
from gitpane.controller.refresh_controller import RefreshController
|
|
47
|
+
from gitpane.controller.session_registry import RepositorySession, SessionRegistry
|
|
48
|
+
from gitpane.history.repository import GitRepository
|
|
49
|
+
from gitpane.history_rewrite.recovery import RecoveryObservation, RewriteRecoveryRecord
|
|
50
|
+
from gitpane.history_rewrite.value import RewritePreparation
|
|
51
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
52
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
53
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
54
|
+
from gitpane.worktree.file_action_target import FileActionSelection
|
|
55
|
+
from gitpane.worktree.staging import StagingSession
|
|
56
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HistoryRewriteText(Enum):
|
|
60
|
+
"""Describe unavailable historical actions without inventing repository facts."""
|
|
61
|
+
|
|
62
|
+
UNAVAILABLE = "This window can no longer remove history. Reopen the repository and select the path again."
|
|
63
|
+
STALE_SELECTION = "This file selection changed. Select the path again before removing its history."
|
|
64
|
+
WORKTREE_REQUIRED = "Open a working repository before removing a path from history."
|
|
65
|
+
READING = "Wait for the repository references to finish loading."
|
|
66
|
+
RESTORED = "Previous branches were restored. The original index and working files were retained."
|
|
67
|
+
RECOVERY = "The repository update needs recovery. Keep the recovery data at {directory}."
|
|
68
|
+
REFRESH_FAILED = "The repository update finished, but its views could not be fully refreshed. Refresh the repository to inspect its current state."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class RewriteRequest:
|
|
73
|
+
"""Bind one private calculation to its requesting session and explicit consent."""
|
|
74
|
+
|
|
75
|
+
session: RepositorySession
|
|
76
|
+
operation: OperationIntent
|
|
77
|
+
target: RewriteTarget
|
|
78
|
+
branch: str | None
|
|
79
|
+
cancelled: Event = field(default_factory=Event)
|
|
80
|
+
running: bool = False
|
|
81
|
+
preparation: RewritePreparation | None = None
|
|
82
|
+
submitted: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RewriteRefresh:
|
|
87
|
+
"""Keep a reconciled result pending until its current repository readers have settled."""
|
|
88
|
+
|
|
89
|
+
outcome: OperationOutcome
|
|
90
|
+
generation_by_session: dict[str, int]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class RecoveryRequest:
|
|
95
|
+
"""Bind visible recovery facts to the exact session that may approve their restoration."""
|
|
96
|
+
|
|
97
|
+
session: RepositorySession
|
|
98
|
+
operation: OperationIntent
|
|
99
|
+
dialog: HistoryRewriteRecoveryDialog
|
|
100
|
+
record: RewriteRecoveryRecord | None = None
|
|
101
|
+
observation: RecoveryObservation | None = None
|
|
102
|
+
question: RestoreBranchesQuestion | None = None
|
|
103
|
+
running: bool = False
|
|
104
|
+
submitted: bool = False
|
|
105
|
+
resolving: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HistoryRewriteController(QObject):
|
|
109
|
+
"""Coordinate the question, private preview and confirmed publication lifecycle."""
|
|
110
|
+
|
|
111
|
+
recovery_requested = Signal(object)
|
|
112
|
+
|
|
113
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy,
|
|
114
|
+
reference_read_controller: ReferenceReadController, console_registry_controller: ConsoleRegistry, refresh_controller: RefreshController,
|
|
115
|
+
repository: GitRepository, worktree_service: WorktreeService, repository_operation: RepositoryOperationService,
|
|
116
|
+
storage_root_dir: str, preference: PreferenceStore, diagnostic_log: DiagnosticLog | None = None, parent: QObject | None = None) -> None:
|
|
117
|
+
"""Receive existing authorities and services without owning a second Git runner."""
|
|
118
|
+
super().__init__(parent)
|
|
119
|
+
self.session_registry_controller = session_registry_controller
|
|
120
|
+
self.operation_policy_controller = operation_policy_controller
|
|
121
|
+
self.reference_read_controller = reference_read_controller
|
|
122
|
+
self.console_registry_controller = console_registry_controller
|
|
123
|
+
self.refresh_controller = refresh_controller
|
|
124
|
+
self.repository = repository
|
|
125
|
+
self.worktree_service = worktree_service
|
|
126
|
+
self.repository_operation = repository_operation
|
|
127
|
+
self.storage_root_dir = storage_root_dir
|
|
128
|
+
self.preference = preference
|
|
129
|
+
self.diagnostic_log = diagnostic_log
|
|
130
|
+
self.request_by_session: dict[str, RewriteRequest] = {}
|
|
131
|
+
self.question_by_session: dict[str, HistoryRewriteQuestion] = {}
|
|
132
|
+
self.preview_by_session: dict[str, HistoryRewritePreview] = {}
|
|
133
|
+
self.publication_by_id: dict[str, RewriteRequest] = {}
|
|
134
|
+
self.outcome_by_id: dict[str, OperationOutcome] = {}
|
|
135
|
+
self.finishing_id_set: set[str] = set()
|
|
136
|
+
self.refresh_by_id: dict[str, RewriteRefresh] = {}
|
|
137
|
+
self.recovery_by_session: dict[str, RecoveryRequest] = {}
|
|
138
|
+
self.recovery_publication_by_id: dict[str, RecoveryRequest] = {}
|
|
139
|
+
self.recovery_discovery_session_set: set[str] = set()
|
|
140
|
+
self.pending_recoveries_by_session: dict[str, list[RecoveryDiscovery]] = {}
|
|
141
|
+
|
|
142
|
+
def present_recovery(self, record: RewriteRecoveryRecord) -> None:
|
|
143
|
+
"""Rediscover interrupted work in its live owning session after the normal refresh starts."""
|
|
144
|
+
for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
145
|
+
if not session.is_closed and context_identity(session.repository_context) == record.context_identity:
|
|
146
|
+
QTimer.singleShot(0, self, lambda identity=session_id: self.check_pending_recovery(identity))
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
def check_pending_recovery(self, session_id: str) -> None:
|
|
150
|
+
"""Find durable incomplete records at repository access, including unreadable journals."""
|
|
151
|
+
registry = self.session_registry_controller
|
|
152
|
+
session = registry.session_by_id.get(session_id)
|
|
153
|
+
window = registry.window_by_session.get(session_id)
|
|
154
|
+
if (session is None or session.is_closed or session.repository_context.is_bare or window is None or not isValid(window)
|
|
155
|
+
or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
|
|
156
|
+
return
|
|
157
|
+
if session_id in self.recovery_by_session:
|
|
158
|
+
return
|
|
159
|
+
if session_id in self.recovery_discovery_session_set:
|
|
160
|
+
return
|
|
161
|
+
self.recovery_discovery_session_set.add(session_id)
|
|
162
|
+
context = session.repository_context
|
|
163
|
+
git_filename = self.repository.git_filename
|
|
164
|
+
|
|
165
|
+
def sequence() -> Generator:
|
|
166
|
+
"""Read private journal directories on the shared file worker."""
|
|
167
|
+
return (yield FileTask(lambda: discover_recovery(self.storage_root_dir, context)))
|
|
168
|
+
|
|
169
|
+
def complete(discoveries: tuple[RecoveryDiscovery, ...] | None, error: Exception | None) -> None:
|
|
170
|
+
"""Ignore a discovery completed after navigation or closure of its original session."""
|
|
171
|
+
self.recovery_discovery_session_set.discard(session_id)
|
|
172
|
+
if (registry.session_by_id.get(session_id) is not session or session.is_closed or session.repository_context != context
|
|
173
|
+
or self.repository.git_filename != git_filename or not isValid(window)):
|
|
174
|
+
return
|
|
175
|
+
if error is not None:
|
|
176
|
+
discoveries = (RecoveryDiscovery(self.storage_root_dir, None, str(error)),)
|
|
177
|
+
self.pending_recoveries_by_session[session_id] = list(discoveries or ())
|
|
178
|
+
self._show_next_recovery(session_id)
|
|
179
|
+
|
|
180
|
+
self.worktree_service.run(context, git_filename, sequence(), complete)
|
|
181
|
+
|
|
182
|
+
def _show_next_recovery(self, session_id: str) -> None:
|
|
183
|
+
"""Present one durable record at a time without deleting dismissed evidence."""
|
|
184
|
+
discoveries = self.pending_recoveries_by_session.get(session_id)
|
|
185
|
+
registry = self.session_registry_controller
|
|
186
|
+
session = registry.session_by_id.get(session_id)
|
|
187
|
+
window = registry.window_by_session.get(session_id)
|
|
188
|
+
if not discoveries or session is None or session.is_closed or window is None or not isValid(window) or session_id in self.recovery_by_session:
|
|
189
|
+
return
|
|
190
|
+
discovery = discoveries.pop(0)
|
|
191
|
+
dialog = HistoryRewriteRecoveryDialog(session.repository_context.repository_dir, discovery, window)
|
|
192
|
+
request = RecoveryRequest(session, self._operation(session_id), dialog, discovery.record)
|
|
193
|
+
self.recovery_by_session[session_id] = request
|
|
194
|
+
dialog.refresh_requested.connect(lambda: self.refresh_recovery_status(session_id))
|
|
195
|
+
dialog.restore_requested.connect(lambda: self.request_restore(session_id))
|
|
196
|
+
|
|
197
|
+
def closed(_result: int) -> None:
|
|
198
|
+
"""Dismiss only the window; an accepted operation continues under its reservation."""
|
|
199
|
+
if self.recovery_by_session.get(session_id) is request:
|
|
200
|
+
self.recovery_by_session.pop(session_id, None)
|
|
201
|
+
if request.question is not None and isValid(request.question):
|
|
202
|
+
request.question.reject()
|
|
203
|
+
dialog.deleteLater()
|
|
204
|
+
QTimer.singleShot(0, self, lambda: self._show_next_recovery(session_id))
|
|
205
|
+
|
|
206
|
+
dialog.finished.connect(closed)
|
|
207
|
+
dialog.show_modal()
|
|
208
|
+
self.refresh_recovery_status(session_id)
|
|
209
|
+
|
|
210
|
+
def _recovery_current(self, session_id: str, request: RecoveryRequest) -> bool:
|
|
211
|
+
"""Require the original live session, repository, executable and form authority."""
|
|
212
|
+
return (self.recovery_by_session.get(session_id) is request and isValid(request.dialog)
|
|
213
|
+
and self.session_registry_controller.session_by_id.get(session_id) is request.session and not request.session.is_closed
|
|
214
|
+
and request.session.repository_context == request.operation.context and self.repository.git_filename == request.operation.git_filename
|
|
215
|
+
and self.operation_policy_controller.operation_allowed(request.operation))
|
|
216
|
+
|
|
217
|
+
def refresh_recovery_status(self, session_id: str) -> None:
|
|
218
|
+
"""Observe interrupted work, resolving only already consistent public state under reservation."""
|
|
219
|
+
request = self.recovery_by_session.get(session_id)
|
|
220
|
+
if request is None or request.running or request.submitted or not isValid(request.dialog):
|
|
221
|
+
return
|
|
222
|
+
if request.question is not None and isValid(request.question):
|
|
223
|
+
request.question.reject()
|
|
224
|
+
request.question = None
|
|
225
|
+
registry = self.session_registry_controller
|
|
226
|
+
if (registry.session_by_id.get(session_id) is not request.session or request.session.is_closed
|
|
227
|
+
or request.session.repository_context != request.operation.context or self.repository.git_filename != request.operation.git_filename):
|
|
228
|
+
request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
|
|
229
|
+
return
|
|
230
|
+
request.operation = replace(self._operation(session_id), operation=RestoreText.RESTORE.value,
|
|
231
|
+
scope=WriteScope.capture(request.operation.context, (LOCAL_PREFIX + "*",)))
|
|
232
|
+
request.observation = None
|
|
233
|
+
request.running = True
|
|
234
|
+
request.dialog.set_loading()
|
|
235
|
+
operation = request.operation
|
|
236
|
+
|
|
237
|
+
def sequence() -> Generator:
|
|
238
|
+
"""Never substitute an in-memory journal when the durable record became unreadable."""
|
|
239
|
+
record = yield FileTask(lambda: read_recovery_record(request.dialog.operation_dir, operation.context))
|
|
240
|
+
if record.git_filename != operation.git_filename:
|
|
241
|
+
raise ValueError(RecoveryText.EXECUTABLE_CHANGED.value)
|
|
242
|
+
observation = yield from observe_recovery(record, operation.context)
|
|
243
|
+
return record, observation
|
|
244
|
+
|
|
245
|
+
def complete(value: tuple | None, error: Exception | None) -> None:
|
|
246
|
+
"""Install only observations still attached to the original live authority."""
|
|
247
|
+
request.running = False
|
|
248
|
+
if self.recovery_by_session.get(session_id) is not request or not isValid(request.dialog):
|
|
249
|
+
return
|
|
250
|
+
if not self._recovery_current(session_id, request):
|
|
251
|
+
request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
|
|
252
|
+
return
|
|
253
|
+
if error is not None or value is None:
|
|
254
|
+
request.record = None
|
|
255
|
+
request.dialog.set_observation(None, None, str(error) if error is not None else RecoveryText.MISSING.value)
|
|
256
|
+
return
|
|
257
|
+
request.record, request.observation = value
|
|
258
|
+
request.dialog.set_observation(request.record, request.observation)
|
|
259
|
+
if (request.record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED)
|
|
260
|
+
and coherent_recovery_state(request.record, request.observation) is not None):
|
|
261
|
+
self._resolve_recovery(session_id, request)
|
|
262
|
+
|
|
263
|
+
self.worktree_service.run(operation.context, operation.git_filename, sequence(), complete)
|
|
264
|
+
|
|
265
|
+
def _resolve_recovery(self, session_id: str, request: RecoveryRequest) -> None:
|
|
266
|
+
"""Submit one cleanup of coherent state without authorizing any public branch restoration."""
|
|
267
|
+
record, observation = request.record, request.observation
|
|
268
|
+
operation = request.operation
|
|
269
|
+
request.submitted = True
|
|
270
|
+
request.resolving = True
|
|
271
|
+
request.dialog.set_loading()
|
|
272
|
+
self.recovery_publication_by_id[operation.operation_id] = request
|
|
273
|
+
|
|
274
|
+
def sequence() -> Generator:
|
|
275
|
+
"""Bind cleanup to the displayed journal, public state and still-live session."""
|
|
276
|
+
current = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
|
|
277
|
+
if current != record or not self._recovery_current(session_id, request):
|
|
278
|
+
raise ValueError(RecoveryText.UNAVAILABLE.value)
|
|
279
|
+
return (yield from recover_rewrite(current, operation, self.repository_operation, restore=False, expected_observation=observation))
|
|
280
|
+
|
|
281
|
+
self.repository_operation.submit(operation, sequence())
|
|
282
|
+
|
|
283
|
+
def request_restore(self, session_id: str) -> RestoreBranchesQuestion | None:
|
|
284
|
+
"""Open a separate confirmation for precisely the currently displayed observation."""
|
|
285
|
+
request = self.recovery_by_session.get(session_id)
|
|
286
|
+
if request is None or request.running or request.submitted or not can_restore(request.record, request.observation):
|
|
287
|
+
return None
|
|
288
|
+
if not self._recovery_current(session_id, request):
|
|
289
|
+
request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
|
|
290
|
+
return None
|
|
291
|
+
if request.question is not None and isValid(request.question) and request.question.isVisible():
|
|
292
|
+
return request.question
|
|
293
|
+
question = RestoreBranchesQuestion(request.operation.context.repository_dir, request.record, request.observation, request.dialog)
|
|
294
|
+
request.question = question
|
|
295
|
+
question.restore_requested.connect(lambda: self.confirm_restore(session_id, question))
|
|
296
|
+
question.show_modal()
|
|
297
|
+
return question
|
|
298
|
+
|
|
299
|
+
def confirm_restore(self, session_id: str, question: RestoreBranchesQuestion) -> str | None:
|
|
300
|
+
"""Consume explicit consent once, then revalidate its exact record and observed state."""
|
|
301
|
+
request = self.recovery_by_session.get(session_id)
|
|
302
|
+
if (request is None or request.running or request.submitted or request.question is not question or not isValid(question)
|
|
303
|
+
or question.result() != question.DialogCode.Accepted or not can_restore(request.record, request.observation)):
|
|
304
|
+
return None
|
|
305
|
+
request.question = None
|
|
306
|
+
if not self._recovery_current(session_id, request):
|
|
307
|
+
request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
|
|
308
|
+
return None
|
|
309
|
+
record, observation = request.record, request.observation
|
|
310
|
+
operation = replace(request.operation, operation_id=uuid.uuid4().hex)
|
|
311
|
+
request.operation = operation
|
|
312
|
+
request.submitted = True
|
|
313
|
+
request.dialog.set_loading()
|
|
314
|
+
self.recovery_publication_by_id[operation.operation_id] = request
|
|
315
|
+
self._open_output(request)
|
|
316
|
+
|
|
317
|
+
def sequence() -> Generator:
|
|
318
|
+
"""Refuse altered journals before the domain performs its fresh collective comparison."""
|
|
319
|
+
fresh_record = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
|
|
320
|
+
if fresh_record != record or not self._recovery_current(session_id, request):
|
|
321
|
+
raise ValueError(RecoveryText.UNAVAILABLE.value)
|
|
322
|
+
return (yield from compensate_rewrite(fresh_record, observation, operation, self.repository_operation))
|
|
323
|
+
|
|
324
|
+
self.repository_operation.submit(operation, sequence())
|
|
325
|
+
return operation.operation_id
|
|
326
|
+
|
|
327
|
+
def _recovery_finished(self, outcome: OperationOutcome, request: RecoveryRequest) -> None:
|
|
328
|
+
"""Display verified restoration or preserve the failed attempt as an inspectable diagnostic."""
|
|
329
|
+
request.submitted = False
|
|
330
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
331
|
+
session_id = request.operation.session_id
|
|
332
|
+
if request.resolving:
|
|
333
|
+
request.resolving = False
|
|
334
|
+
if not self._recovery_current(session_id, request):
|
|
335
|
+
return
|
|
336
|
+
if outcome.diagnostic is not None:
|
|
337
|
+
request.dialog.set_error(outcome.diagnostic.cause)
|
|
338
|
+
return
|
|
339
|
+
# No public refs or index were changed, so a diagnostic reread is sufficient.
|
|
340
|
+
self.refresh_recovery_status(session_id)
|
|
341
|
+
return
|
|
342
|
+
# The domain reports the original rewrite as failed after compensation; this separately requested restoration succeeded.
|
|
343
|
+
restored = isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None and outcome.record.state == RewritePublicationState.RESTORED
|
|
344
|
+
self._refresh_result(request.operation, replace(outcome, status=OperationStatus.SUCCESS) if restored else outcome)
|
|
345
|
+
if self.recovery_by_session.get(session_id) is request and isValid(request.dialog):
|
|
346
|
+
if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None:
|
|
347
|
+
request.record = outcome.record
|
|
348
|
+
if outcome.diagnostic is not None:
|
|
349
|
+
request.dialog.set_error(RecoveryText.UNAVAILABLE.value + "\n" + outcome.diagnostic.cause)
|
|
350
|
+
else:
|
|
351
|
+
self.refresh_recovery_status(session_id)
|
|
352
|
+
|
|
353
|
+
def _operation(self, session_id: str) -> OperationIntent:
|
|
354
|
+
"""Capture current form authority through the common operation policy."""
|
|
355
|
+
registry = self.session_registry_controller
|
|
356
|
+
session = registry.session_by_id[session_id]
|
|
357
|
+
return OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
|
|
358
|
+
session_id=session_id, generation=registry.generation_by_session[session_id], action=OperationAction.REF_WRITE,
|
|
359
|
+
operation=FileAction.REMOVE_FROM_HISTORY.label, from_form=True)
|
|
360
|
+
|
|
361
|
+
def remove_history_reason(self, session_id: str, selection: FileActionSelection) -> str:
|
|
362
|
+
"""Check menu authority without launching Git or treating display data as write proof."""
|
|
363
|
+
registry = self.session_registry_controller
|
|
364
|
+
window = registry.window_by_session.get(session_id)
|
|
365
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or window is None or not isValid(window):
|
|
366
|
+
return HistoryRewriteText.UNAVAILABLE.value
|
|
367
|
+
if not window.file_action_is_current(selection):
|
|
368
|
+
return HistoryRewriteText.STALE_SELECTION.value
|
|
369
|
+
if window.context.is_bare:
|
|
370
|
+
return HistoryRewriteText.WORKTREE_REQUIRED.value
|
|
371
|
+
if self.reference_read_controller.reference_snapshot_by_session.get(session_id) is None:
|
|
372
|
+
return HistoryRewriteText.READING.value
|
|
373
|
+
operation = self._operation(session_id)
|
|
374
|
+
return self.operation_policy_controller.operation_refusal(operation) or self.repository_operation.availability_diagnostic(operation)
|
|
375
|
+
|
|
376
|
+
def request(self, session_id: str, selection: FileActionSelection) -> HistoryRewriteQuestion | None:
|
|
377
|
+
"""Open one initial question for the exact still-current historical file or folder."""
|
|
378
|
+
reason = self.remove_history_reason(session_id, selection)
|
|
379
|
+
if reason:
|
|
380
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
381
|
+
if window is not None and isValid(window):
|
|
382
|
+
window.statusBar().showMessage(reason)
|
|
383
|
+
return None
|
|
384
|
+
if session_id in self.request_by_session:
|
|
385
|
+
return self.question_by_session.get(session_id)
|
|
386
|
+
registry = self.session_registry_controller
|
|
387
|
+
snapshot = self.reference_read_controller.reference_snapshot_by_session[session_id]
|
|
388
|
+
kind = RewriteTargetKind.FOLDER if selection.kind == FileActionTargetKind.FOLDER else RewriteTargetKind.FILE
|
|
389
|
+
target = RewriteTarget(selection.path(), kind)
|
|
390
|
+
request = RewriteRequest(registry.session_by_id[session_id], self._operation(session_id), target, snapshot.head_ref)
|
|
391
|
+
self.request_by_session[session_id] = request
|
|
392
|
+
question = HistoryRewriteQuestion(target, request.branch, registry.window_by_session[session_id])
|
|
393
|
+
self.question_by_session[session_id] = question
|
|
394
|
+
question.build_requested.connect(lambda _target: self.build_preview(session_id, request))
|
|
395
|
+
question.abort_requested.connect(lambda: self.abort_preview(session_id, request))
|
|
396
|
+
request.session.closed.connect(lambda: self.abort_preview(session_id))
|
|
397
|
+
question.show_modal()
|
|
398
|
+
return question
|
|
399
|
+
|
|
400
|
+
def _current(self, session_id: str, request: RewriteRequest) -> bool:
|
|
401
|
+
"""Reject results after cancellation, replacement, loss of authority or a new generation."""
|
|
402
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
403
|
+
return (self.request_by_session.get(session_id) is request and not request.cancelled.is_set()
|
|
404
|
+
and window is not None and isValid(window) and not window.is_closed
|
|
405
|
+
and self.session_registry_controller.session_by_id.get(session_id) is request.session
|
|
406
|
+
and not request.session.is_closed and request.session.repository_context == request.operation.context
|
|
407
|
+
and self.repository.git_filename == request.operation.git_filename
|
|
408
|
+
and self.operation_policy_controller.operation_allowed(request.operation))
|
|
409
|
+
|
|
410
|
+
def build_preview(self, session_id: str, request: RewriteRequest) -> None:
|
|
411
|
+
"""Start one private calculation after explicit Build preview consent."""
|
|
412
|
+
if request.running or request.preparation is not None or request.cancelled.is_set():
|
|
413
|
+
return
|
|
414
|
+
registry = self.session_registry_controller
|
|
415
|
+
preview = self.preview_by_session.get(session_id)
|
|
416
|
+
if preview is None or not isValid(preview) or preview.state == PreviewState.CLOSED:
|
|
417
|
+
family = self.refresh_controller.submodule_snapshot_by_session.get(session_id)
|
|
418
|
+
parent_dir = ""
|
|
419
|
+
if family is not None:
|
|
420
|
+
for entry in family.entries:
|
|
421
|
+
if entry.repository_dir == request.operation.context.repository_dir:
|
|
422
|
+
parent_dir = entry.parent_dir or ""
|
|
423
|
+
break
|
|
424
|
+
preview = HistoryRewritePreview(request.target, request.branch or "", request.operation.context.repository_dir,
|
|
425
|
+
registry.window_by_session[session_id], active_submodule=bool(parent_dir), parent_repository_dir=parent_dir)
|
|
426
|
+
self.preview_by_session[session_id] = preview
|
|
427
|
+
preview.abort_requested.connect(lambda: self.abort_preview(session_id))
|
|
428
|
+
preview.recompute_requested.connect(lambda: self.recompute(session_id))
|
|
429
|
+
preview.confirm_requested.connect(lambda preparation: self.confirm(session_id, preparation))
|
|
430
|
+
preview.show_modal()
|
|
431
|
+
if not self._current(session_id, request):
|
|
432
|
+
preview.mark_stale()
|
|
433
|
+
return
|
|
434
|
+
request.running = True
|
|
435
|
+
# _current has just checked that the operation's Git is the repository's chosen Git. Its version is the line the Git check at start or in
|
|
436
|
+
# Settings read for that Git; it stays empty while that check has not answered or after it failed, and the preparation then reads it itself
|
|
437
|
+
# before the export.
|
|
438
|
+
capture = RewriteCapture(request.operation.context, request.operation.git_filename, request.branch, generation=request.operation.generation,
|
|
439
|
+
git_version=self.repository.git_version)
|
|
440
|
+
|
|
441
|
+
def prepare() -> Generator:
|
|
442
|
+
"""Read the active repository's parent identity before showing its mandatory warning."""
|
|
443
|
+
parent_output = yield GitCommand(["rev-parse", "--show-superproject-working-tree"])
|
|
444
|
+
if isValid(preview) and self._current(session_id, request):
|
|
445
|
+
preview.parent_repository_dir = os.fsdecode(parent_output.removesuffix(b"\n"))
|
|
446
|
+
preview.active_submodule = bool(preview.parent_repository_dir)
|
|
447
|
+
# Unpublished scratch directories must never be mistaken for durable interrupted publications.
|
|
448
|
+
preview_storage_dir = self.storage_root_dir + PREVIEW_STORAGE_SUFFIX
|
|
449
|
+
return (yield from prepare_rewrite(capture, request.target, preview_storage_dir, cancelled=request.cancelled.is_set))
|
|
450
|
+
|
|
451
|
+
def complete(preparation: RewritePreparation | None, error: Exception | None) -> None:
|
|
452
|
+
"""Publish only into the live original preview after every private process has drained."""
|
|
453
|
+
request.running = False
|
|
454
|
+
request.preparation = preparation
|
|
455
|
+
if request.cancelled.is_set() or not isValid(preview) or request.session.is_closed:
|
|
456
|
+
self._discard(session_id, request, close=True)
|
|
457
|
+
return
|
|
458
|
+
if not self._current(session_id, request):
|
|
459
|
+
preview.mark_stale()
|
|
460
|
+
preview.recompute_button.setEnabled(True)
|
|
461
|
+
return
|
|
462
|
+
if error is not None:
|
|
463
|
+
preview.set_error(str(error))
|
|
464
|
+
return
|
|
465
|
+
if preparation is None:
|
|
466
|
+
preview.set_error(HistoryRewriteText.READING.value)
|
|
467
|
+
return
|
|
468
|
+
preview.set_preparation(preparation)
|
|
469
|
+
|
|
470
|
+
self.worktree_service.run(capture.context, capture.git_filename, prepare(), complete, operation_id=request.operation.operation_id)
|
|
471
|
+
|
|
472
|
+
def abort_preview(self, session_id: str, request: RewriteRequest | None = None) -> None:
|
|
473
|
+
"""Cancel only this private calculation, then close after its processes and cleanup finish."""
|
|
474
|
+
request = request or self.request_by_session.get(session_id)
|
|
475
|
+
if request is None or self.request_by_session.get(session_id) is not request or request.submitted:
|
|
476
|
+
return
|
|
477
|
+
request.cancelled.set()
|
|
478
|
+
if request.running:
|
|
479
|
+
runner = self.worktree_service.runner
|
|
480
|
+
requests = list(runner.pending_requests)
|
|
481
|
+
for active in tuple(runner.active_by_id.values()):
|
|
482
|
+
requests.append(active.request)
|
|
483
|
+
for process in requests:
|
|
484
|
+
if process.operation_id == request.operation.operation_id:
|
|
485
|
+
runner.cancel(process.request_id)
|
|
486
|
+
return
|
|
487
|
+
self._discard(session_id, request, close=True)
|
|
488
|
+
|
|
489
|
+
def _discard(self, session_id: str, request: RewriteRequest, *, close: bool) -> None:
|
|
490
|
+
"""Remove only owned private storage outside the GUI thread before releasing the form."""
|
|
491
|
+
def sequence() -> Generator:
|
|
492
|
+
"""Wait for ownership-checked private cleanup on the shared file worker."""
|
|
493
|
+
if request.preparation is not None:
|
|
494
|
+
yield FileTask(lambda: discard_preparation(request.preparation))
|
|
495
|
+
|
|
496
|
+
def complete(_value: object, error: Exception | None) -> None:
|
|
497
|
+
"""Close the original form without interfering with a newer request."""
|
|
498
|
+
request.running = False
|
|
499
|
+
if self.request_by_session.get(session_id) is not request:
|
|
500
|
+
return
|
|
501
|
+
preview = self.preview_by_session.get(session_id)
|
|
502
|
+
if error is not None:
|
|
503
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
504
|
+
if window is not None and isValid(window):
|
|
505
|
+
window.statusBar().showMessage(str(error))
|
|
506
|
+
self.request_by_session.pop(session_id, None)
|
|
507
|
+
question = self.question_by_session.pop(session_id, None)
|
|
508
|
+
if question is not None and isValid(question):
|
|
509
|
+
question.close()
|
|
510
|
+
question.deleteLater()
|
|
511
|
+
if close and preview is not None and isValid(preview):
|
|
512
|
+
preview.finish_abort()
|
|
513
|
+
|
|
514
|
+
request.running = True
|
|
515
|
+
self.worktree_service.run(request.operation.context, request.operation.git_filename, sequence(), complete)
|
|
516
|
+
|
|
517
|
+
def recompute(self, session_id: str) -> None:
|
|
518
|
+
"""Create fresh form authority after draining the previous private proposal."""
|
|
519
|
+
previous = self.request_by_session.get(session_id)
|
|
520
|
+
if previous is None or previous.running or previous.submitted or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
521
|
+
return
|
|
522
|
+
previous.cancelled.set()
|
|
523
|
+
|
|
524
|
+
def sequence() -> Generator:
|
|
525
|
+
"""Discard the superseded token before calculating another proposal."""
|
|
526
|
+
if previous.preparation is not None:
|
|
527
|
+
yield FileTask(lambda: discard_preparation(previous.preparation))
|
|
528
|
+
|
|
529
|
+
def complete(_value: object, error: Exception | None) -> None:
|
|
530
|
+
"""Start anew only while the same repository session still owns the form."""
|
|
531
|
+
previous.running = False
|
|
532
|
+
if self.request_by_session.get(session_id) is not previous or previous.session.is_closed:
|
|
533
|
+
return
|
|
534
|
+
preview = self.preview_by_session.get(session_id)
|
|
535
|
+
if preview is None or not isValid(preview) or preview.state != PreviewState.PREPARING:
|
|
536
|
+
self.abort_preview(session_id)
|
|
537
|
+
return
|
|
538
|
+
if error is not None:
|
|
539
|
+
preview.set_error(str(error))
|
|
540
|
+
return
|
|
541
|
+
if (self.session_registry_controller.session_by_id.get(session_id) is not previous.session
|
|
542
|
+
or previous.session.repository_context != previous.operation.context):
|
|
543
|
+
preview.mark_stale(HistoryRewriteText.UNAVAILABLE.value)
|
|
544
|
+
return
|
|
545
|
+
snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
546
|
+
request = RewriteRequest(previous.session, self._operation(session_id), previous.target, snapshot.head_ref if snapshot is not None else previous.branch)
|
|
547
|
+
self.request_by_session[session_id] = request
|
|
548
|
+
self.build_preview(session_id, request)
|
|
549
|
+
|
|
550
|
+
previous.running = True
|
|
551
|
+
self.worktree_service.run(previous.operation.context, previous.operation.git_filename, sequence(), complete)
|
|
552
|
+
|
|
553
|
+
def confirm(self, session_id: str, preparation: RewritePreparation) -> str | None:
|
|
554
|
+
"""Consume exactly one fresh proposal and let the shared service reserve its publication."""
|
|
555
|
+
request = self.request_by_session.get(session_id)
|
|
556
|
+
preview = self.preview_by_session.get(session_id)
|
|
557
|
+
if (request is None or request.submitted or request.running or request.preparation is not preparation
|
|
558
|
+
or preview is None or not isValid(preview) or preview.state != PreviewState.EXECUTING):
|
|
559
|
+
return None
|
|
560
|
+
if not self._current(session_id, request):
|
|
561
|
+
preview.mark_stale(self.operation_policy_controller.operation_refusal(request.operation))
|
|
562
|
+
return None
|
|
563
|
+
request.submitted = True
|
|
564
|
+
operation = rewrite_operation(preparation, uuid.uuid4().hex, session_id=session_id)
|
|
565
|
+
request.operation = operation
|
|
566
|
+
self.publication_by_id[operation.operation_id] = request
|
|
567
|
+
self._open_output(request)
|
|
568
|
+
self.repository_operation.submit(operation, publish_rewrite(preparation, operation, self.repository_operation, self.storage_root_dir,
|
|
569
|
+
progress=lambda phase: self._show_progress(operation.operation_id, phase)))
|
|
570
|
+
return operation.operation_id
|
|
571
|
+
|
|
572
|
+
def _open_output(self, request: RewriteRequest | RecoveryRequest) -> None:
|
|
573
|
+
"""Keep the common console registered so repository closure waits for reconciliation."""
|
|
574
|
+
operation = request.operation
|
|
575
|
+
trace = OperationTrace(operation.operation_id, self.repository.runner, self.diagnostic_log, native_commands_only=True,
|
|
576
|
+
header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
|
|
577
|
+
window = self.session_registry_controller.window_by_session[operation.session_id]
|
|
578
|
+
output = OperationDialog(trace, operation.operation, window, preference=self.preference, console_operation=ConsoleOperation.HISTORY_REWRITE)
|
|
579
|
+
self.console_registry_controller.reference_output_by_id[operation.operation_id] = output
|
|
580
|
+
|
|
581
|
+
def closed(_result: int) -> None:
|
|
582
|
+
"""Release only this console's subscription once it can actually close."""
|
|
583
|
+
self.console_registry_controller.reference_output_by_id.pop(operation.operation_id, None)
|
|
584
|
+
trace.close()
|
|
585
|
+
|
|
586
|
+
output.finished.connect(closed)
|
|
587
|
+
output.abort_requested.connect(lambda: self.finish_safely(operation.operation_id))
|
|
588
|
+
open_window_modal(output, keeps_window_buttons=True)
|
|
589
|
+
|
|
590
|
+
def _show_progress(self, operation_id: str, phase: RewriteProgress) -> None:
|
|
591
|
+
"""Report actual service steps without inventing a completion percentage."""
|
|
592
|
+
output = self.console_registry_controller.reference_output_by_id.get(operation_id)
|
|
593
|
+
if output is None or not isValid(output) or not output.running:
|
|
594
|
+
return
|
|
595
|
+
output.trace.append_event(operation_id, "status", phase.value)
|
|
596
|
+
output.status_label.setText((RewriteProgress.FINISHING if operation_id in self.finishing_id_set else phase).value)
|
|
597
|
+
|
|
598
|
+
def finish_safely(self, operation_id: str) -> None:
|
|
599
|
+
"""Keep an accepted publication alive until refs and index have been reconciled."""
|
|
600
|
+
if operation_id not in self.publication_by_id and operation_id not in self.recovery_publication_by_id:
|
|
601
|
+
return
|
|
602
|
+
self.finishing_id_set.add(operation_id)
|
|
603
|
+
self._show_progress(operation_id, RewriteProgress.FINISHING)
|
|
604
|
+
|
|
605
|
+
def operation_finished(self, outcome: OperationOutcome) -> None:
|
|
606
|
+
"""Present only the service's reconciled outcome, preserving incomplete recovery evidence."""
|
|
607
|
+
recovery = self.recovery_publication_by_id.pop(outcome.operation_id, None)
|
|
608
|
+
if recovery is not None:
|
|
609
|
+
self._recovery_finished(outcome, recovery)
|
|
610
|
+
return
|
|
611
|
+
request = self.publication_by_id.pop(outcome.operation_id, None)
|
|
612
|
+
if request is None:
|
|
613
|
+
return
|
|
614
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
615
|
+
session_id = request.operation.session_id
|
|
616
|
+
preview = self.preview_by_session.get(session_id)
|
|
617
|
+
if outcome.status == OperationStatus.REFUSED:
|
|
618
|
+
request.submitted = False
|
|
619
|
+
if preview is not None and isValid(preview):
|
|
620
|
+
preview.mark_stale(outcome.diagnostic.cause if outcome.diagnostic is not None else "")
|
|
621
|
+
else:
|
|
622
|
+
if preview is not None and isValid(preview):
|
|
623
|
+
preview.finish_publication()
|
|
624
|
+
self._discard(session_id, request, close=False)
|
|
625
|
+
if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None and outcome.record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED):
|
|
626
|
+
self.recovery_requested.emit(outcome.record)
|
|
627
|
+
if outcome.status == OperationStatus.REFUSED:
|
|
628
|
+
self._finish_output(outcome)
|
|
629
|
+
return
|
|
630
|
+
replacement_by_oid = request.preparation.replacement_by_oid if outcome.status == OperationStatus.SUCCESS else None
|
|
631
|
+
self._refresh_result(request.operation, outcome, replacement_by_oid)
|
|
632
|
+
|
|
633
|
+
def _refresh_result(self, operation: OperationIntent, outcome: OperationOutcome, replacement_by_oid: dict[str, str | None] | None = None) -> None:
|
|
634
|
+
"""Keep publication and restoration consoles pending until the shared readers settle."""
|
|
635
|
+
generation_by_session = {}
|
|
636
|
+
for identity, session in self.session_registry_controller.session_by_id.items():
|
|
637
|
+
if (not session.is_closed and canonical_path(session.repository_context.common_git_dir) == operation.common_git_dir
|
|
638
|
+
and self.operation_policy_controller.allows(identity, WindowCapability.MANAGE)):
|
|
639
|
+
generation_by_session[identity] = session.generation + 1
|
|
640
|
+
self.refresh_by_id[outcome.operation_id] = RewriteRefresh(outcome, generation_by_session)
|
|
641
|
+
self._show_progress(outcome.operation_id, RewriteProgress.REFRESHING)
|
|
642
|
+
self.refresh_controller.refresh_after_reference_operation(operation, outcome, RefreshScope.FULL, replacement_by_oid=replacement_by_oid)
|
|
643
|
+
self.readers_changed()
|
|
644
|
+
|
|
645
|
+
def observe_refresh(self, session_id: str) -> None:
|
|
646
|
+
"""Revoke a visible proposal immediately when its repository starts a new read generation."""
|
|
647
|
+
recovery = self.recovery_by_session.get(session_id)
|
|
648
|
+
if recovery is not None and not recovery.submitted and isValid(recovery.dialog) and not self._recovery_current(session_id, recovery):
|
|
649
|
+
recovery.observation = None
|
|
650
|
+
recovery.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
|
|
651
|
+
if recovery.question is not None and isValid(recovery.question):
|
|
652
|
+
recovery.question.reject()
|
|
653
|
+
recovery.question = None
|
|
654
|
+
request = self.request_by_session.get(session_id)
|
|
655
|
+
preview = self.preview_by_session.get(session_id)
|
|
656
|
+
if request is None or request.submitted or preview is None or not isValid(preview):
|
|
657
|
+
return
|
|
658
|
+
if not self._current(session_id, request):
|
|
659
|
+
preview.mark_stale()
|
|
660
|
+
preview.recompute_button.setEnabled(not request.running)
|
|
661
|
+
|
|
662
|
+
def observe_worktree(self, staging: StagingSession) -> None:
|
|
663
|
+
"""Invalidate old index proposals when an existing worktree reader observes a change."""
|
|
664
|
+
snapshot = staging.snapshot
|
|
665
|
+
if snapshot is not None and snapshot.fresh:
|
|
666
|
+
for session_id, request in tuple(self.request_by_session.items()):
|
|
667
|
+
preparation = request.preparation
|
|
668
|
+
if request.submitted or preparation is None or snapshot.key != request.operation.key:
|
|
669
|
+
continue
|
|
670
|
+
capture = preparation.capture
|
|
671
|
+
if (snapshot.index_content != capture.index_identity or snapshot.head_oid != capture.local_ref_by_name.get(capture.head_ref)
|
|
672
|
+
or snapshot.probe is not None and snapshot.probe.branch_head != capture.head_ref):
|
|
673
|
+
preview = self.preview_by_session.get(session_id)
|
|
674
|
+
if preview is not None and isValid(preview):
|
|
675
|
+
preview.mark_stale()
|
|
676
|
+
self.readers_changed()
|
|
677
|
+
|
|
678
|
+
def readers_changed(self) -> None:
|
|
679
|
+
"""Check pending results after the current signal has finished updating reader state."""
|
|
680
|
+
if self.refresh_by_id:
|
|
681
|
+
QTimer.singleShot(0, self, self._finish_refresh)
|
|
682
|
+
|
|
683
|
+
def _finish_refresh(self) -> None:
|
|
684
|
+
"""End consoles only after refreshed graphs, worktree reads and their processes have drained."""
|
|
685
|
+
if (self.repository.runner.active_by_id or self.repository.runner.pending_requests or self.worktree_service.file_task_by_id
|
|
686
|
+
or self.worktree_service.continuation_by_id):
|
|
687
|
+
return
|
|
688
|
+
registry = self.session_registry_controller
|
|
689
|
+
for operation_id, pending in tuple(self.refresh_by_id.items()):
|
|
690
|
+
waiting = False
|
|
691
|
+
failed = False
|
|
692
|
+
for session_id, generation in pending.generation_by_session.items():
|
|
693
|
+
session = registry.session_by_id.get(session_id)
|
|
694
|
+
if session is None or session.is_closed:
|
|
695
|
+
continue
|
|
696
|
+
if session.generation < generation or session.refresh_active or session.refresh_queued is not None:
|
|
697
|
+
waiting = True
|
|
698
|
+
break
|
|
699
|
+
staging = self.refresh_controller.staging_session_registry_controller.staging_by_key.get(WorktreeKey.from_context(session.repository_context))
|
|
700
|
+
if staging is not None and staging.refresh_active:
|
|
701
|
+
waiting = True
|
|
702
|
+
break
|
|
703
|
+
if (session.snapshot is None or not session.snapshot.complete or session.snapshot.error
|
|
704
|
+
or staging is not None and (staging.snapshot is None or not staging.snapshot.fresh)):
|
|
705
|
+
failed = True
|
|
706
|
+
if waiting:
|
|
707
|
+
continue
|
|
708
|
+
self.refresh_by_id.pop(operation_id, None)
|
|
709
|
+
outcome = pending.outcome
|
|
710
|
+
if failed and outcome.status == OperationStatus.SUCCESS:
|
|
711
|
+
diagnostic = Diagnostic(FileAction.REMOVE_FROM_HISTORY.label, "", self.repository.git_filename, [], HistoryRewriteText.REFRESH_FAILED.value).prepare()
|
|
712
|
+
outcome = replace(outcome, status=OperationStatus.UNKNOWN, diagnostic=diagnostic)
|
|
713
|
+
self._finish_output(outcome)
|
|
714
|
+
|
|
715
|
+
def _finish_output(self, outcome: OperationOutcome) -> None:
|
|
716
|
+
"""Append the terminal result after the trace has consumed the final native output."""
|
|
717
|
+
self.finishing_id_set.discard(outcome.operation_id)
|
|
718
|
+
output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
|
|
719
|
+
if output is None or not isValid(output):
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
def publish() -> None:
|
|
723
|
+
"""End a still-live console with the exact observed status and diagnostic."""
|
|
724
|
+
if not isValid(output):
|
|
725
|
+
return
|
|
726
|
+
if outcome.diagnostic is not None:
|
|
727
|
+
output.trace.append_diagnostic(outcome.operation_id, "status", outcome.diagnostic, outcome.status)
|
|
728
|
+
if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None:
|
|
729
|
+
if outcome.record.state == RewritePublicationState.RESTORED:
|
|
730
|
+
output.trace.append_event(outcome.operation_id, "status", HistoryRewriteText.RESTORED.value)
|
|
731
|
+
elif outcome.record.state != RewritePublicationState.COMPLETE:
|
|
732
|
+
output.trace.append_event(outcome.operation_id, "status", HistoryRewriteText.RECOVERY.value.format(directory=outcome.record.operation_dir))
|
|
733
|
+
output.trace.append_end_line(outcome.operation_id, "status", outcome.status.value,
|
|
734
|
+
operation_end_text(output.windowTitle(), outcome.status), outcome.status,
|
|
735
|
+
failure_reported=outcome.diagnostic is not None)
|
|
736
|
+
output.show_result(outcome.status, failure_reported=outcome.diagnostic is not None,
|
|
737
|
+
failure_text=outcome.diagnostic.cause if outcome.diagnostic is not None else "")
|
|
738
|
+
|
|
739
|
+
publish_after_trace(publish, self)
|