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,805 @@
|
|
|
1
|
+
"""Cherry-pick or revert commits from a repository window, through their pauses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
12
|
+
from PySide6.QtWidgets import QMessageBox
|
|
13
|
+
from shiboken6 import isValid
|
|
14
|
+
|
|
15
|
+
from gitpane.config import ProcessPriority
|
|
16
|
+
from gitpane.controller.refresh_controller import ReplayRefreshPublication
|
|
17
|
+
from gitpane.history.file_history import WindowCapability
|
|
18
|
+
from gitpane.operation.console_line import COMPLETED_STATUS_SET
|
|
19
|
+
from gitpane.operation.console_tone import ConsoleTone
|
|
20
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
21
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
22
|
+
from gitpane.operation.repository_operation import REPLAY_PAUSE_OBSERVATION, OperationAction, OperationIntent, OperationOutcome
|
|
23
|
+
from gitpane.sequencer.replay import ReplayIntent
|
|
24
|
+
from gitpane.sequencer.replay_state import COMMIT_OPERATION_LABEL, REPLAY_REFRESH_INTERVAL_MS, ReplayAction, ReplayCommitMode, ReplayKind, replay_action_diagnostic, replay_auto_continue_ready, replay_effect_message, replay_may_be_present
|
|
25
|
+
from gitpane.system.path_identity import canonical_path
|
|
26
|
+
from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
|
|
27
|
+
from gitpane.ui.repository.repository_window import LEGACY_BACKUP_NOTICE_KIND
|
|
28
|
+
from gitpane.ui.sequencer.replay_conflict_prompt import ReplayConflictPrompt
|
|
29
|
+
from gitpane.ui.sequencer.replay_dialog import ReplayDialog
|
|
30
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
31
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from gitpane.controller.console_registry import ConsoleRegistry
|
|
35
|
+
from gitpane.controller.history_read_controller import HistoryReadController
|
|
36
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
37
|
+
from gitpane.controller.reference_form_controller import ReferenceFormController
|
|
38
|
+
from gitpane.controller.reference_read_controller import ReferenceReadController
|
|
39
|
+
from gitpane.controller.refresh_controller import FullRefreshRead, RefreshController
|
|
40
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
41
|
+
from gitpane.controller.staging_session_registry import StagingSessionRegistry
|
|
42
|
+
from gitpane.controller.worktree_state_controller import WorktreeStateController
|
|
43
|
+
from gitpane.history.repository import GitRepository
|
|
44
|
+
from gitpane.history.repository_value import RepositoryContext, RequestIdentity
|
|
45
|
+
from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
|
|
46
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
47
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
48
|
+
from gitpane.sequencer.mergetool import MergetoolService
|
|
49
|
+
from gitpane.sequencer.replay import ReplayService, ReplaySource
|
|
50
|
+
from gitpane.sequencer.replay_state import ReplaySnapshot
|
|
51
|
+
from gitpane.worktree.worktree_snapshot import PassiveObservationSource
|
|
52
|
+
|
|
53
|
+
REPLAY_SUBMISSION_REFUSED_TEXT = "This window can no longer start the operation. Refresh the repository and try again."
|
|
54
|
+
# Cherry-picking the checked-out commit onto itself has nothing to apply; the graph menu greys the action out on it too.
|
|
55
|
+
REPLAY_CURRENT_COMMIT_REFUSED_TEXT = "This is the current commit: there is nothing to cherry-pick. Choose another revision."
|
|
56
|
+
REPLAY_AUTO_CONTINUE_TEXT = "All conflicts are marked as solved. Continuing automatically."
|
|
57
|
+
# Written in the console once Git's own Abort has ended a Cherry-pick or Revert: Git brought the checkout back, GitPane checked nothing more.
|
|
58
|
+
REPLAY_ABORT_FINISHED_TEXT = "Abort finished. Git restored the checkout the operation started from."
|
|
59
|
+
# Followed by the folder holding the backups and a final period.
|
|
60
|
+
LEGACY_REPLAY_BACKUP_NOTICE_PREFIX = "An earlier GitPane version kept backups of an interrupted cherry-pick or revert in "
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ReplayResumeObservation:
|
|
65
|
+
"""The complete replay read that qualified an automatic Continue, with where it came from.
|
|
66
|
+
|
|
67
|
+
It was asked by the automatic request itself, through Git, for this session under this request identity and Git
|
|
68
|
+
executable. It replaces only the reread the Continue would otherwise make before submitting; the reservation
|
|
69
|
+
still captures the pause twice and checks it before Git runs.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
session_id: str
|
|
73
|
+
identity: RequestIdentity
|
|
74
|
+
git_filename: str
|
|
75
|
+
state: ReplaySnapshot
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ReplayAutoContinueRequest:
|
|
80
|
+
"""Remember that a conflict resolution made in GitPane asked to continue its paused Cherry-pick or Revert.
|
|
81
|
+
|
|
82
|
+
``known_generation`` is the replay read generation when the request was made: only a later read can show the
|
|
83
|
+
staged resolution, so an older state still listing the conflict must neither launch nor cancel the request.
|
|
84
|
+
``observation`` is the read the request asked for, once it answered with a newer state; the Continue uses it once.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
session_id: str
|
|
88
|
+
known_generation: int
|
|
89
|
+
observation: ReplayResumeObservation | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# A successful launch or Continue has just created a commit (or changed the checkout); its console only reports
|
|
93
|
+
# success once the refreshed graph shows that result.
|
|
94
|
+
REPLAY_GRAPH_WAITING_ACTION_SET = frozenset((OperationAction.REPLAY_START, OperationAction.REPLAY_CONTINUE))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class PendingReplayRefresh:
|
|
99
|
+
"""A successful Cherry-pick, Revert or Continue whose console waits for a graph that shows its result.
|
|
100
|
+
|
|
101
|
+
``required_generation`` is the first history generation read after the result: a graph read that started
|
|
102
|
+
earlier, before the commit existed, must not report it.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
outcome: OperationOutcome
|
|
106
|
+
required_generation: int
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ReplayController(QObject):
|
|
110
|
+
"""The Cherry-pick and Revert of every repository window: form, launch, console waiting for the refreshed graph, pause banner with Continue and Abort, automatic Continue, and the backups an earlier version left."""
|
|
111
|
+
|
|
112
|
+
# Tells whether the conflict window of a repository window must refuse writes while a replay may be present and
|
|
113
|
+
# another write or the merge tool holds its worktree: the session id and True to lock, False to unlock.
|
|
114
|
+
conflict_write_availability_changed = Signal(str, bool)
|
|
115
|
+
|
|
116
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, console_registry_controller: ConsoleRegistry,
|
|
117
|
+
reference_read_controller: ReferenceReadController, refresh_controller: RefreshController, worktree_state_controller: WorktreeStateController, history_read_controller: HistoryReadController,
|
|
118
|
+
reference_form_controller: ReferenceFormController, repository: GitRepository, repository_operation: RepositoryOperationService, replay: ReplayService, mergetool: MergetoolService, diagnostic_log: DiagnosticLog | None,
|
|
119
|
+
parent: QObject | None = None) -> None:
|
|
120
|
+
"""Receive the services and the lower controllers a replay works with; the wiring builds it after the reference form controller, and the rebase, the conflict and the repository windows reach it through the wiring."""
|
|
121
|
+
super().__init__(parent)
|
|
122
|
+
self.session_registry_controller = session_registry_controller
|
|
123
|
+
self.operation_policy_controller = operation_policy_controller
|
|
124
|
+
self.staging_session_registry_controller = staging_session_registry_controller
|
|
125
|
+
self.console_registry_controller = console_registry_controller
|
|
126
|
+
self.reference_read_controller = reference_read_controller
|
|
127
|
+
self.refresh_controller = refresh_controller
|
|
128
|
+
self.worktree_state_controller = worktree_state_controller
|
|
129
|
+
self.history_read_controller = history_read_controller
|
|
130
|
+
self.reference_form_controller = reference_form_controller
|
|
131
|
+
self.repository = repository
|
|
132
|
+
self.repository_operation = repository_operation
|
|
133
|
+
self.replay = replay
|
|
134
|
+
self.mergetool = mergetool
|
|
135
|
+
self.diagnostic_log = diagnostic_log
|
|
136
|
+
self.replay_dialog_by_session: dict[str, ReplayDialog] = {}
|
|
137
|
+
self.replay_prompt_by_session: dict[str, ReplayConflictPrompt | QMessageBox] = {}
|
|
138
|
+
self.replay_intent_by_id: dict[str, ReplayIntent] = {}
|
|
139
|
+
# Every successful launch of a session still waiting for the refreshed graph, in launch order: a second launch
|
|
140
|
+
# can be accepted before the first one's graph is published, and each console must still get its result.
|
|
141
|
+
self.replay_refresh_by_session: dict[str, list[PendingReplayRefresh]] = {}
|
|
142
|
+
self.replay_auto_continue_by_key: dict[WorktreeKey, ReplayAutoContinueRequest] = {}
|
|
143
|
+
# Worktrees whose Continue or Abort is rereading the pause before it reserves the repository, with the requesting
|
|
144
|
+
# session. Until the reservation exists nothing else marks the worktree busy, so a second Continue could start.
|
|
145
|
+
self.replay_resume_session_by_key: dict[WorktreeKey, str] = {}
|
|
146
|
+
self.replay_observed_at_by_key: dict[WorktreeKey, float] = {}
|
|
147
|
+
# Repositories, by canonical common Git folder, whose anchor references left by earlier versions were already
|
|
148
|
+
# looked at during this run of the program.
|
|
149
|
+
self.legacy_replay_checked_common_dir_set: set[str] = set()
|
|
150
|
+
# What the retirement answered for each canonical common Git folder during this run: the backup folders an
|
|
151
|
+
# earlier version kept there. Every working tree of that repository opened later is told from this answer.
|
|
152
|
+
self.legacy_replay_kept_dirs_by_common_dir: dict[str, tuple[str, ...]] = {}
|
|
153
|
+
# Working trees already told about those backups during this run, so that each is told once.
|
|
154
|
+
self.legacy_replay_notified_worktree_set: set[WorktreeKey] = set()
|
|
155
|
+
|
|
156
|
+
def open_replay(self, session_id: str, source_oid: str, kind: ReplayKind, snapshot: ReferenceSnapshot | None = None) -> ReplayDialog | None:
|
|
157
|
+
"""Open read-only source metadata while capturing the current destination checkout."""
|
|
158
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
159
|
+
return None
|
|
160
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
161
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
162
|
+
snapshot = snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
163
|
+
if window.context.is_bare or snapshot is None:
|
|
164
|
+
return None
|
|
165
|
+
existing = self.replay_dialog_by_session.get(session_id)
|
|
166
|
+
if existing is not None and isValid(existing) and not existing.closed:
|
|
167
|
+
existing.raise_()
|
|
168
|
+
return existing
|
|
169
|
+
dialog = ReplayDialog(session_id, source_oid, kind, window)
|
|
170
|
+
self.replay_dialog_by_session[session_id] = dialog
|
|
171
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
172
|
+
session_id=session_id, operation="Cherry pick" if kind == ReplayKind.CHERRY_PICK else "Revert", action=OperationAction.REPLAY_START, from_form=True)
|
|
173
|
+
intent = ReplayIntent(operation, kind, source_oid, snapshot.head_oid, snapshot.head_ref)
|
|
174
|
+
dialog.replay_requested.connect(lambda captured: self.submit_replay(session_id, captured))
|
|
175
|
+
dialog.revision_requested.connect(lambda: self.open_replay_revision(session_id))
|
|
176
|
+
open_window_modal(dialog)
|
|
177
|
+
|
|
178
|
+
def current() -> bool:
|
|
179
|
+
"""Discard preparation after session replacement or closing the source form."""
|
|
180
|
+
return (isValid(dialog) and not dialog.closed and not session.is_closed and session.repository_context == operation.context
|
|
181
|
+
and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and self.repository.git_filename == operation.git_filename)
|
|
182
|
+
|
|
183
|
+
def prepared(captured: ReplayIntent | None, error: Exception | None) -> None:
|
|
184
|
+
"""Read complete metadata only after destination and source validation."""
|
|
185
|
+
if not current():
|
|
186
|
+
return
|
|
187
|
+
if error is not None:
|
|
188
|
+
dialog.show_error(str(error))
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
def metadata_complete(source: ReplaySource | None, error: Exception | None) -> None:
|
|
192
|
+
"""Publish real commit details without allowing stale reads to enable a closed form."""
|
|
193
|
+
if not current():
|
|
194
|
+
return
|
|
195
|
+
if error is not None:
|
|
196
|
+
dialog.show_error(str(error))
|
|
197
|
+
return
|
|
198
|
+
dialog.populate(captured, source.fields, source.parents)
|
|
199
|
+
dialog.summary.show_references(snapshot)
|
|
200
|
+
|
|
201
|
+
# The preparation has just resolved and checked this very commit: only its metadata remains to be read.
|
|
202
|
+
self.replay.read_source(operation.context, operation.git_filename, captured.source_oid, callback=metadata_complete, resolved=True)
|
|
203
|
+
|
|
204
|
+
self.replay.prepare(intent, is_current=current, callback=prepared)
|
|
205
|
+
return dialog
|
|
206
|
+
|
|
207
|
+
def open_replay_revision(self, session_id: str) -> None:
|
|
208
|
+
"""Replace a prepared source only after the latest local revision read succeeds."""
|
|
209
|
+
dialog = self.replay_dialog_by_session.get(session_id)
|
|
210
|
+
if (dialog is None or dialog.closed or dialog.busy or dialog.completed or dialog.kind != ReplayKind.CHERRY_PICK
|
|
211
|
+
or dialog.intent is None or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
|
|
212
|
+
return
|
|
213
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
214
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
215
|
+
if dialog.revision_dialog is not None and not dialog.revision_dialog.closed:
|
|
216
|
+
dialog.revision_dialog.raise_()
|
|
217
|
+
return
|
|
218
|
+
selector = RevisionChoiceDialog(window.history_model.commits, dialog)
|
|
219
|
+
dialog.revision_dialog = selector
|
|
220
|
+
dialog.source_generation += 1
|
|
221
|
+
source_generation = dialog.source_generation
|
|
222
|
+
captured = dialog.intent
|
|
223
|
+
|
|
224
|
+
def read_revision(revision: str) -> None:
|
|
225
|
+
"""Capture the edit generation so older callbacks cannot overwrite later choices."""
|
|
226
|
+
generation = selector.generation
|
|
227
|
+
|
|
228
|
+
def complete(source: ReplaySource | None, error: Exception | None) -> None:
|
|
229
|
+
"""Publish the new frame and empty parent choice only in the original session."""
|
|
230
|
+
if (not isValid(dialog) or not isValid(selector) or dialog.closed or dialog.busy or dialog.completed or selector.closed
|
|
231
|
+
or selector.generation != generation or dialog.source_generation != source_generation or session.is_closed
|
|
232
|
+
or session.repository_context != captured.operation.context or self.repository.git_filename != captured.operation.git_filename
|
|
233
|
+
or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
|
|
234
|
+
return
|
|
235
|
+
if error is not None or source is None:
|
|
236
|
+
selector.show_error()
|
|
237
|
+
return
|
|
238
|
+
if source.oid == captured.original_head_oid:
|
|
239
|
+
selector.show_error(REPLAY_CURRENT_COMMIT_REFUSED_TEXT)
|
|
240
|
+
return
|
|
241
|
+
dialog.source_generation += 1
|
|
242
|
+
dialog.populate(replace(captured, source_oid=source.oid), source.fields, source.parents)
|
|
243
|
+
snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
244
|
+
if snapshot is not None:
|
|
245
|
+
dialog.summary.show_references(snapshot)
|
|
246
|
+
selector.accept()
|
|
247
|
+
|
|
248
|
+
self.replay.read_source(captured.operation.context, captured.operation.git_filename, revision, callback=complete)
|
|
249
|
+
|
|
250
|
+
selector.revision_requested.connect(read_revision)
|
|
251
|
+
open_window_modal(selector)
|
|
252
|
+
selector.revision_edit.setFocus()
|
|
253
|
+
|
|
254
|
+
def submit_replay(self, session_id: str, intent: ReplayIntent) -> str | None:
|
|
255
|
+
"""Open a non-modal console, launch the captured native operation, then close the form.
|
|
256
|
+
|
|
257
|
+
The form closes as soon as the launch is accepted: a form or console left window-modal on the repository
|
|
258
|
+
window would block the conflict prompt and the banner once Git pauses. Results,
|
|
259
|
+
errors and refusals from then on are shown in the console.
|
|
260
|
+
"""
|
|
261
|
+
dialog = self.replay_dialog_by_session.get(session_id)
|
|
262
|
+
if dialog is None or not isValid(dialog):
|
|
263
|
+
return None
|
|
264
|
+
if (not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or dialog.closed or dialog.intent != intent
|
|
265
|
+
or intent.operation.session_id != session_id or intent.operation.operation_id in self.replay_intent_by_id):
|
|
266
|
+
dialog.show_submission_refused(REPLAY_SUBMISSION_REFUSED_TEXT)
|
|
267
|
+
return None
|
|
268
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
269
|
+
|
|
270
|
+
def current() -> bool:
|
|
271
|
+
"""Retain requester and worktree identity through the last native launch check."""
|
|
272
|
+
return (not session.is_closed and session.repository_context == intent.operation.context
|
|
273
|
+
and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and self.repository.git_filename == intent.operation.git_filename)
|
|
274
|
+
|
|
275
|
+
self.replay_intent_by_id[intent.operation.operation_id] = intent
|
|
276
|
+
self.reference_form_controller.open_reference_output(intent, dialog, modal=False)
|
|
277
|
+
try:
|
|
278
|
+
operation_id = self.replay.start(intent, is_current=current)
|
|
279
|
+
except ValueError as error:
|
|
280
|
+
# The service refused before any Git command ran (it is closed or unavailable): release the request and end
|
|
281
|
+
# the console just opened with the cause, so neither the form nor the console stays stuck as running.
|
|
282
|
+
self.replay_intent_by_id.pop(intent.operation.operation_id, None)
|
|
283
|
+
diagnostic = Diagnostic(intent.operation.operation, intent.operation.context.repository_dir, intent.operation.git_filename, [], str(error)).prepare()
|
|
284
|
+
if self.diagnostic_log is not None:
|
|
285
|
+
self.diagnostic_log.write(diagnostic)
|
|
286
|
+
self.reference_form_controller.finish_reference_output(OperationOutcome(intent.operation.operation_id, OperationStatus.REFUSED, diagnostic=diagnostic))
|
|
287
|
+
dialog.close_after_submission()
|
|
288
|
+
return None
|
|
289
|
+
dialog.close_after_submission()
|
|
290
|
+
return operation_id
|
|
291
|
+
|
|
292
|
+
def replay_finished(self, outcome: OperationOutcome) -> None:
|
|
293
|
+
"""Refresh live repository views, publish the console result and offer resolution for a fresh conflict."""
|
|
294
|
+
intent = self.replay_intent_by_id.pop(outcome.operation_id, None)
|
|
295
|
+
if intent is None:
|
|
296
|
+
return
|
|
297
|
+
self.replay.outcome_by_id[outcome.operation_id] = outcome
|
|
298
|
+
if outcome.status == OperationStatus.ABORTED:
|
|
299
|
+
trace = self.reference_form_controller.reference_trace_by_id.get(outcome.operation_id)
|
|
300
|
+
if trace is not None:
|
|
301
|
+
# The Abort was verified before this line is written: like the result line after it, it tells a success.
|
|
302
|
+
trace.append_event(outcome.operation_id, "status", REPLAY_ABORT_FINISHED_TEXT, ConsoleTone.SUCCESS)
|
|
303
|
+
session = self.session_registry_controller.session_by_id.get(intent.operation.session_id)
|
|
304
|
+
conflict_state = None
|
|
305
|
+
waits_for_graph = False
|
|
306
|
+
if session is not None and not session.is_closed and session.repository_context == intent.operation.context:
|
|
307
|
+
state = self.replay.state_by_key.get(intent.operation.key)
|
|
308
|
+
if outcome.status == OperationStatus.PAUSED and state is not None and state.fresh and state.unmerged_paths and intent.operation.action == OperationAction.REPLAY_START:
|
|
309
|
+
conflict_state = state
|
|
310
|
+
if outcome.status == OperationStatus.SUCCESS and intent.operation.action in REPLAY_GRAPH_WAITING_ACTION_SET:
|
|
311
|
+
self.history_read_controller.history_top_session_set.add(intent.operation.session_id)
|
|
312
|
+
# The next history read, queued behind any read already running, is the first one that can show the commit.
|
|
313
|
+
required_generation = session.generation + 1
|
|
314
|
+
self.replay_refresh_by_session.setdefault(intent.operation.session_id, []).append(PendingReplayRefresh(outcome, required_generation))
|
|
315
|
+
waits_for_graph = True
|
|
316
|
+
shared_by_key: dict[WorktreeKey, FullRefreshRead] = {}
|
|
317
|
+
for session_id, current_session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
318
|
+
if not current_session.is_closed and canonical_path(current_session.repository_context.common_git_dir) == intent.operation.common_git_dir:
|
|
319
|
+
self.refresh_controller.refresh(session_id, full_refresh_read=self.refresh_controller.full_refresh_read_for(current_session, shared_by_key))
|
|
320
|
+
shared = shared_by_key.get(intent.operation.key)
|
|
321
|
+
if shared is not None:
|
|
322
|
+
# The operation has just published the state of its own worktree; the refresh shows it instead of reading
|
|
323
|
+
# it again. Other worktrees of the repository read their own state.
|
|
324
|
+
shared.replay_publication = self._replay_finish_publication(intent.operation.key)
|
|
325
|
+
self.refresh_controller.observe_full_refresh_pauses(shared_by_key)
|
|
326
|
+
if not waits_for_graph:
|
|
327
|
+
# A replay refused before Git ran says why in GitPane's own words (files a submodule would lose, staged
|
|
328
|
+
# changes, a path outside the working tree), which the user needs in plain sight to fix and try again.
|
|
329
|
+
refusal_text = outcome.diagnostic.cause if outcome.status == OperationStatus.REFUSED and outcome.diagnostic is not None else ""
|
|
330
|
+
self.reference_form_controller.finish_reference_output(outcome, failure_text=refusal_text)
|
|
331
|
+
if outcome.status == OperationStatus.PAUSED:
|
|
332
|
+
self.console_registry_controller.paused_console_ids_by_key.setdefault(intent.operation.key, []).append(outcome.operation_id)
|
|
333
|
+
elif outcome.status in COMPLETED_STATUS_SET:
|
|
334
|
+
self.console_registry_controller.settle_paused_consoles(intent.operation.key)
|
|
335
|
+
if conflict_state is not None:
|
|
336
|
+
self._offer_replay_resolution(intent.operation.session_id, conflict_state, outcome.operation_id)
|
|
337
|
+
|
|
338
|
+
def _replay_finish_publication(self, key: WorktreeKey) -> ReplayRefreshPublication | None:
|
|
339
|
+
"""Answer the state the replay operation that just ended published for this worktree, while nothing newer replaced it.
|
|
340
|
+
|
|
341
|
+
Only a fresh state without a message of its own is offered. A published message (an Abort's result, a refusal,
|
|
342
|
+
a result that could not be verified) stays on the banner until the next read replaces the state, so that read
|
|
343
|
+
still runs and the banner reads exactly as before.
|
|
344
|
+
"""
|
|
345
|
+
state = self.replay.state_by_key.get(key)
|
|
346
|
+
if (state is None or not state.fresh or state.diagnostic or state.generation != self.replay.published_generation_by_key.get(key)
|
|
347
|
+
or state.generation != self.replay.generation_by_key.get(key)):
|
|
348
|
+
return None
|
|
349
|
+
return ReplayRefreshPublication(state, self.repository.git_filename)
|
|
350
|
+
|
|
351
|
+
def _replay_publication_current(self, key: WorktreeKey, publication: ReplayRefreshPublication) -> bool:
|
|
352
|
+
"""Tell whether a finish publication may still be shown: the latest state of this worktree, same Git, and no read started since."""
|
|
353
|
+
state = publication.state
|
|
354
|
+
return (state.key == key and publication.git_filename == self.repository.git_filename and self.replay.state_by_key.get(key) is state
|
|
355
|
+
and self.replay.generation_by_key.get(key) == state.generation and key not in self.replay.observers_by_key)
|
|
356
|
+
|
|
357
|
+
def finish_replay_refresh(self, session_id: str, diagnostic: PreparedDiagnostic | None = None, *, published_generation: int | None = None) -> None:
|
|
358
|
+
"""Finish the launches this published graph can show, retaining a failed refresh for inspection.
|
|
359
|
+
|
|
360
|
+
A failed refresh (``diagnostic``) drops any queued refresh, so it finishes every waiting launch as partial.
|
|
361
|
+
A published graph only finishes the launches whose result it was read after; later ones wait for the queued refresh.
|
|
362
|
+
"""
|
|
363
|
+
remaining_pendings = []
|
|
364
|
+
finished_pendings = []
|
|
365
|
+
for pending in self.replay_refresh_by_session.pop(session_id, []):
|
|
366
|
+
if diagnostic is None and published_generation is not None and pending.required_generation > published_generation:
|
|
367
|
+
remaining_pendings.append(pending)
|
|
368
|
+
else:
|
|
369
|
+
finished_pendings.append(pending)
|
|
370
|
+
if remaining_pendings:
|
|
371
|
+
self.replay_refresh_by_session[session_id] = remaining_pendings
|
|
372
|
+
for pending in finished_pendings:
|
|
373
|
+
self._finish_replay_launch(session_id, pending.outcome, diagnostic)
|
|
374
|
+
|
|
375
|
+
def _finish_replay_launch(self, session_id: str, outcome: OperationOutcome, diagnostic: PreparedDiagnostic | None) -> None:
|
|
376
|
+
"""Publish one launch result now that its commit is visible, with any "no commit was created" message."""
|
|
377
|
+
if diagnostic is not None:
|
|
378
|
+
outcome = replace(outcome, status=OperationStatus.PARTIAL, diagnostic=diagnostic)
|
|
379
|
+
self.reference_form_controller.finish_reference_output(outcome)
|
|
380
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
381
|
+
for step in outcome.steps:
|
|
382
|
+
message = replay_effect_message(step.effect)
|
|
383
|
+
if message:
|
|
384
|
+
prompt = QMessageBox(QMessageBox.Icon.Information, COMMIT_OPERATION_LABEL, message, QMessageBox.StandardButton.Ok, self.session_registry_controller.window_by_session[session_id])
|
|
385
|
+
prepare_message_box(prompt, COMMIT_OPERATION_LABEL)
|
|
386
|
+
prompt.finished.connect(lambda _result, prompt=prompt: self._release_replay_message(session_id, prompt))
|
|
387
|
+
self._replace_replay_prompt(session_id, prompt)
|
|
388
|
+
open_window_modal(prompt)
|
|
389
|
+
trace = self.reference_form_controller.reference_trace_by_id.get(outcome.operation_id)
|
|
390
|
+
if trace is not None:
|
|
391
|
+
trace.append_event(outcome.operation_id, "status", message)
|
|
392
|
+
|
|
393
|
+
def observe_replay(self, session_id: str, *, automatic: bool = False, publication: ReplayRefreshPublication | None = None,
|
|
394
|
+
auto_continue_request: ReplayAutoContinueRequest | None = None) -> None:
|
|
395
|
+
"""Publish a passive native replay banner only to the original current views.
|
|
396
|
+
|
|
397
|
+
``publication`` is the state an operation just published when it ended: while it is still the service's latest
|
|
398
|
+
state it is shown as the answer, and no read starts. ``auto_continue_request`` is the automatic Continue request
|
|
399
|
+
this read is asked for: the complete read that answers it is kept with the request, for that Continue to use.
|
|
400
|
+
"""
|
|
401
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
402
|
+
return
|
|
403
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
404
|
+
context = session.repository_context
|
|
405
|
+
if context.is_bare:
|
|
406
|
+
return
|
|
407
|
+
key = WorktreeKey.from_context(context)
|
|
408
|
+
identity = self.session_registry_controller.identity_by_session[session_id]
|
|
409
|
+
git_filename = self.repository.git_filename
|
|
410
|
+
# Checked before the delay below on purpose: returning here stamps no moment, so the first observation asked
|
|
411
|
+
# for once the worktree is free is not held back by a timestamp it never wrote.
|
|
412
|
+
if automatic and self.repository_operation.owns_worktree(key):
|
|
413
|
+
return
|
|
414
|
+
now = time.monotonic()
|
|
415
|
+
if automatic and (now - self.replay_observed_at_by_key.get(key, 0)) * 1000 < REPLAY_REFRESH_INTERVAL_MS:
|
|
416
|
+
return
|
|
417
|
+
self.replay_observed_at_by_key[key] = now
|
|
418
|
+
self.worktree_state_controller.mark_pause_observed(key, REPLAY_PAUSE_OBSERVATION)
|
|
419
|
+
recipients = []
|
|
420
|
+
for target_id, target_session in self.session_registry_controller.session_by_id.items():
|
|
421
|
+
if not target_session.is_closed and WindowCapability.MANAGE in target_session.capabilities and WorktreeKey.from_context(target_session.repository_context) == key:
|
|
422
|
+
recipients.append((target_id, target_session, self.session_registry_controller.identity_by_session.get(target_id)))
|
|
423
|
+
|
|
424
|
+
def current() -> bool:
|
|
425
|
+
"""Require session identity as well as canonical worktree identity."""
|
|
426
|
+
return (self.session_registry_controller.session_by_id.get(session_id) is session and self.session_registry_controller.identity_by_session.get(session_id) == identity
|
|
427
|
+
and not session.is_closed and WorktreeKey.from_context(session.repository_context) == key)
|
|
428
|
+
|
|
429
|
+
def complete(state: ReplaySnapshot) -> None:
|
|
430
|
+
"""Refresh all captured current views without opening any auxiliary window."""
|
|
431
|
+
self.repository_operation.register_context(key, context)
|
|
432
|
+
for target_id, target_session, target_identity in recipients:
|
|
433
|
+
window = self.session_registry_controller.window_by_session.get(target_id)
|
|
434
|
+
if (self.session_registry_controller.session_by_id.get(target_id) is target_session and self.session_registry_controller.identity_by_session.get(target_id) == target_identity
|
|
435
|
+
and not target_session.is_closed and window is not None and isValid(window) and not window.is_closed):
|
|
436
|
+
self._show_replay_state(target_id, state)
|
|
437
|
+
if qualifying_request is not None:
|
|
438
|
+
self._qualify_replay_auto_continue(key, qualifying_request, ReplayResumeObservation(session_id, identity, git_filename, state))
|
|
439
|
+
self._continue_requested_replay(key)
|
|
440
|
+
if current():
|
|
441
|
+
self._retire_legacy_replay_anchors(session_id, state)
|
|
442
|
+
|
|
443
|
+
if publication is not None and self._replay_publication_current(key, publication):
|
|
444
|
+
# Shown for display only: a publication never qualifies an automatic Continue.
|
|
445
|
+
qualifying_request = None
|
|
446
|
+
complete(publication.state)
|
|
447
|
+
return
|
|
448
|
+
source = self.passive_observation_source(key) if automatic else None
|
|
449
|
+
# Only a complete read through Git, asked by the request itself, may qualify it.
|
|
450
|
+
qualifying_request = auto_continue_request if source is None else None
|
|
451
|
+
# An automatic look waits behind every request somebody is waiting for; a click reads as a user action.
|
|
452
|
+
priority = ProcessPriority.BACKGROUND if automatic else ProcessPriority.USER
|
|
453
|
+
if source is None:
|
|
454
|
+
self.replay.observe(context, git_filename, is_current=current, callback=complete, priority=priority)
|
|
455
|
+
return
|
|
456
|
+
self.replay.observe_passive(context, git_filename, source, is_current=current, callback=complete, priority=priority)
|
|
457
|
+
|
|
458
|
+
def _retire_legacy_replay_anchors(self, session_id: str, state: ReplaySnapshot) -> None:
|
|
459
|
+
"""Retire, once per repository and run, the anchor references earlier versions left, and tell each working tree once per run about kept backups.
|
|
460
|
+
|
|
461
|
+
The retirement waits for a fresh read showing no Cherry-pick or Revert, and a repository another write holds
|
|
462
|
+
waits for a later read. When an earlier version stopped in the middle of a restoration, its backups stay and
|
|
463
|
+
every working tree of that repository opened during the run gets a notice naming their folder, including one
|
|
464
|
+
opened after the retirement already ran for another working tree.
|
|
465
|
+
"""
|
|
466
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
467
|
+
if session is None or session.is_closed:
|
|
468
|
+
return
|
|
469
|
+
context = session.repository_context
|
|
470
|
+
if context.is_bare:
|
|
471
|
+
return
|
|
472
|
+
common_git_dir = canonical_path(context.common_git_dir)
|
|
473
|
+
if common_git_dir in self.legacy_replay_checked_common_dir_set:
|
|
474
|
+
# The notice does not depend on the replay state; before the answer arrives, the retirement's callback tells this window.
|
|
475
|
+
self._announce_legacy_replay_backups(common_git_dir)
|
|
476
|
+
return
|
|
477
|
+
if not state.fresh or state.kind != ReplayKind.ABSENT or state.unmerged_paths or self.replay_writer_busy(context):
|
|
478
|
+
return
|
|
479
|
+
self.legacy_replay_checked_common_dir_set.add(common_git_dir)
|
|
480
|
+
identity = self.session_registry_controller.identity_by_session.get(session_id)
|
|
481
|
+
|
|
482
|
+
def current() -> bool:
|
|
483
|
+
"""Require the same open session as when the retirement was asked."""
|
|
484
|
+
return self.session_registry_controller.session_by_id.get(session_id) is session and self.session_registry_controller.identity_by_session.get(session_id) == identity and not session.is_closed
|
|
485
|
+
|
|
486
|
+
def retired(kept_dirs: tuple[str, ...]) -> None:
|
|
487
|
+
"""Keep the answer for the whole run, even when the asking window closed meanwhile, and tell every open working tree of this repository."""
|
|
488
|
+
self.legacy_replay_kept_dirs_by_common_dir[common_git_dir] = kept_dirs
|
|
489
|
+
self._announce_legacy_replay_backups(common_git_dir)
|
|
490
|
+
|
|
491
|
+
self.replay.retire_legacy_anchors(context, self.repository.git_filename, is_current=current, callback=retired, session_id=session_id,
|
|
492
|
+
generation=self.session_registry_controller.generation_by_session.get(session_id, 0))
|
|
493
|
+
|
|
494
|
+
def _announce_legacy_replay_backups(self, common_git_dir: str) -> None:
|
|
495
|
+
"""Name, once per working tree and run, the folder of the backups an earlier version kept for this repository.
|
|
496
|
+
|
|
497
|
+
Only windows that manage their working tree are told. The notice stays on the history status line of the
|
|
498
|
+
window, so that the lines a loading history writes next do not hide it.
|
|
499
|
+
"""
|
|
500
|
+
kept_dirs = self.legacy_replay_kept_dirs_by_common_dir.get(common_git_dir, ())
|
|
501
|
+
if not kept_dirs:
|
|
502
|
+
return
|
|
503
|
+
notice = LEGACY_REPLAY_BACKUP_NOTICE_PREFIX + os.path.commonpath(kept_dirs) + "."
|
|
504
|
+
for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
505
|
+
if session.is_closed or WindowCapability.MANAGE not in session.capabilities:
|
|
506
|
+
continue
|
|
507
|
+
if canonical_path(session.repository_context.common_git_dir) != common_git_dir:
|
|
508
|
+
continue
|
|
509
|
+
key = WorktreeKey.from_context(session.repository_context)
|
|
510
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
511
|
+
if key in self.legacy_replay_notified_worktree_set or window is None or not isValid(window):
|
|
512
|
+
continue
|
|
513
|
+
self.legacy_replay_notified_worktree_set.add(key)
|
|
514
|
+
window.show_status_notice(key, LEGACY_BACKUP_NOTICE_KIND, notice)
|
|
515
|
+
|
|
516
|
+
def _show_replay_state(self, session_id: str, state: ReplaySnapshot, *, busy: bool | None = None) -> None:
|
|
517
|
+
"""Publish one observed pause to the repository banner with common writer availability."""
|
|
518
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
519
|
+
if window is None or not isValid(window):
|
|
520
|
+
return
|
|
521
|
+
if busy is None:
|
|
522
|
+
busy = self.replay_writer_busy(window.context)
|
|
523
|
+
busy = busy or WorktreeKey.from_context(window.context) in self.replay_resume_session_by_key
|
|
524
|
+
abort_available = callable(getattr(self.replay, "abort", None))
|
|
525
|
+
window.show_replay_state(state, busy=busy, abort_available=abort_available)
|
|
526
|
+
|
|
527
|
+
def replay_actions_changed(self) -> None:
|
|
528
|
+
"""Reconcile action buttons whenever shared writing or tool ownership changes."""
|
|
529
|
+
busy_by_key = {}
|
|
530
|
+
for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
531
|
+
if session.is_closed:
|
|
532
|
+
continue
|
|
533
|
+
key = WorktreeKey.from_context(session.repository_context)
|
|
534
|
+
state = self.replay.state_by_key.get(key)
|
|
535
|
+
if state is not None:
|
|
536
|
+
if key not in busy_by_key:
|
|
537
|
+
busy_by_key[key] = self.replay_writer_busy(session.repository_context)
|
|
538
|
+
self._show_replay_state(session_id, state, busy=busy_by_key[key])
|
|
539
|
+
locked = busy_by_key[key] and replay_may_be_present(state)
|
|
540
|
+
self.conflict_write_availability_changed.emit(session_id, locked)
|
|
541
|
+
for key in tuple(self.replay_auto_continue_by_key):
|
|
542
|
+
self._continue_requested_replay(key, busy=busy_by_key.get(key))
|
|
543
|
+
|
|
544
|
+
def replay_writer_busy(self, context: RepositoryContext) -> bool:
|
|
545
|
+
"""Tell whether another write or the merge tool currently holds this worktree, which blocks every replay action."""
|
|
546
|
+
operation = OperationIntent.capture("replay-availability", context, self.repository.git_filename, action=OperationAction.CONFLICT_STAGE)
|
|
547
|
+
return bool(self.repository_operation.availability_diagnostic(operation)) or self.mergetool.owns_repository(operation.common_git_dir)
|
|
548
|
+
|
|
549
|
+
def _qualify_replay_auto_continue(self, key: WorktreeKey, request: ReplayAutoContinueRequest, observation: ReplayResumeObservation) -> None:
|
|
550
|
+
"""Keep the read an automatic request asked for with that request, when the request is still armed and the read is newer than it."""
|
|
551
|
+
if self.replay_auto_continue_by_key.get(key) is not request or observation.state.key != key or observation.state.generation <= request.known_generation:
|
|
552
|
+
return
|
|
553
|
+
self.replay_auto_continue_by_key[key] = replace(request, observation=observation)
|
|
554
|
+
|
|
555
|
+
def _replay_resume_observation_holds(self, observation: ReplayResumeObservation, session_id: str, state: ReplaySnapshot) -> bool:
|
|
556
|
+
"""Tell whether a qualifying read still describes this very pause for this session, so a Continue may use it instead of reading again.
|
|
557
|
+
|
|
558
|
+
It must be this state object, for this session under the same request identity and Git executable, and still the
|
|
559
|
+
service's latest state of its worktree with no read started since; a window closing or leaving refuses it.
|
|
560
|
+
"""
|
|
561
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
562
|
+
key = state.key
|
|
563
|
+
return (observation.session_id == session_id and observation.state is state and session is not None and not session.is_closed
|
|
564
|
+
and WorktreeKey.from_context(session.repository_context) == key and self.session_registry_controller.identity_by_session.get(session_id) == observation.identity
|
|
565
|
+
and observation.git_filename == self.repository.git_filename and self.replay.state_by_key.get(key) is state
|
|
566
|
+
and self.replay.generation_by_key.get(key) == state.generation and key not in self.replay.observers_by_key
|
|
567
|
+
and not self.replay_session_leaving(session_id))
|
|
568
|
+
|
|
569
|
+
def _take_auto_continue_observation(self, session_id: str, state: ReplaySnapshot) -> ReplayResumeObservation | None:
|
|
570
|
+
"""Take the armed automatic request of this worktree, so it starts one Continue at most, and answer its read while that still holds."""
|
|
571
|
+
request = self.replay_auto_continue_by_key.pop(state.key, None)
|
|
572
|
+
if request is None or request.observation is None or request.session_id != session_id:
|
|
573
|
+
return None
|
|
574
|
+
if not self._replay_resume_observation_holds(request.observation, session_id, state):
|
|
575
|
+
return None
|
|
576
|
+
return request.observation
|
|
577
|
+
|
|
578
|
+
def replay_session_leaving(self, session_id: str) -> bool:
|
|
579
|
+
"""Tell whether the user asked to close, quit or leave this repository window, which must not start a new Continue on its own."""
|
|
580
|
+
return self.session_registry_controller.quit_pending or session_id in self.session_registry_controller.close_session_set or session_id in self.session_registry_controller.navigation_request_by_session
|
|
581
|
+
|
|
582
|
+
def _continue_requested_replay(self, key: WorktreeKey, *, busy: bool | None = None) -> None:
|
|
583
|
+
"""Start the requested automatic Continue once, only when a newer read qualifies and nothing holds the worktree.
|
|
584
|
+
|
|
585
|
+
A read older than the request is ignored. A busy worktree keeps the request for the next read or availability
|
|
586
|
+
change. Any other newer state ends the request, starting Continue only when every conflict is solved. A request
|
|
587
|
+
that starts its Continue stays armed until that Continue takes it, with the read that qualified it.
|
|
588
|
+
"""
|
|
589
|
+
request = self.replay_auto_continue_by_key.get(key)
|
|
590
|
+
if request is None:
|
|
591
|
+
return
|
|
592
|
+
session = self.session_registry_controller.session_by_id.get(request.session_id)
|
|
593
|
+
if session is None or session.is_closed or WorktreeKey.from_context(session.repository_context) != key or self.replay_session_leaving(request.session_id):
|
|
594
|
+
self.replay_auto_continue_by_key.pop(key, None)
|
|
595
|
+
return
|
|
596
|
+
state = self.replay.state_by_key.get(key)
|
|
597
|
+
if state is None or state.generation <= request.known_generation:
|
|
598
|
+
return
|
|
599
|
+
if busy is None:
|
|
600
|
+
busy = self.replay_writer_busy(session.repository_context)
|
|
601
|
+
if busy:
|
|
602
|
+
return
|
|
603
|
+
ready = replay_auto_continue_ready(state)
|
|
604
|
+
if not ready:
|
|
605
|
+
self.replay_auto_continue_by_key.pop(key, None)
|
|
606
|
+
if self.diagnostic_log is not None:
|
|
607
|
+
decision = "starting Continue" if ready else "Continue left to the user (" + (state.diagnostic or "conflicts remain or the commit mode is unknown") + ")"
|
|
608
|
+
self.diagnostic_log.write(Diagnostic("Automatic replay continue", session.repository_context.repository_dir, self.repository.git_filename, [],
|
|
609
|
+
"Conflict resolution finished in GitPane: " + decision + ".").prepare())
|
|
610
|
+
if ready:
|
|
611
|
+
self.continue_replay(request.session_id, state, automatic=True)
|
|
612
|
+
# The Continue takes the request before it submits anything; should it not have, the request still ends here,
|
|
613
|
+
# so that it can never start a second Continue.
|
|
614
|
+
remaining = self.replay_auto_continue_by_key.get(key)
|
|
615
|
+
if remaining is not None and (remaining.session_id, remaining.known_generation) == (request.session_id, request.known_generation):
|
|
616
|
+
del self.replay_auto_continue_by_key[key]
|
|
617
|
+
|
|
618
|
+
def _replace_replay_prompt(self, session_id: str, prompt: ReplayConflictPrompt | QMessageBox) -> None:
|
|
619
|
+
"""Keep one replay question or message per session, closing and deleting the one it replaces."""
|
|
620
|
+
previous = self.replay_prompt_by_session.get(session_id)
|
|
621
|
+
if previous is not None and previous is not prompt and isValid(previous):
|
|
622
|
+
previous.close()
|
|
623
|
+
previous.deleteLater()
|
|
624
|
+
self.replay_prompt_by_session[session_id] = prompt
|
|
625
|
+
|
|
626
|
+
def _release_replay_message(self, session_id: str, prompt: QMessageBox) -> None:
|
|
627
|
+
"""Forget and delete an answered "no commit was created" message instead of keeping it under the window."""
|
|
628
|
+
if self.replay_prompt_by_session.get(session_id) is prompt:
|
|
629
|
+
del self.replay_prompt_by_session[session_id]
|
|
630
|
+
if isValid(prompt):
|
|
631
|
+
prompt.deleteLater()
|
|
632
|
+
|
|
633
|
+
def _offer_replay_resolution(self, session_id: str, state: ReplaySnapshot, operation_id: str) -> None:
|
|
634
|
+
"""Ask once after a locally launched conflict independently of other prompt preferences.
|
|
635
|
+
|
|
636
|
+
The launch console already closed on the pause unless Keep dialog open is checked. Whatever the answer (Yes,
|
|
637
|
+
No, Escape or the title-bar close), that console has done its job and is settled, so unchecking the option later
|
|
638
|
+
still closes it.
|
|
639
|
+
"""
|
|
640
|
+
prompt = ReplayConflictPrompt(self.session_registry_controller.window_by_session[session_id])
|
|
641
|
+
self._replace_replay_prompt(session_id, prompt)
|
|
642
|
+
prompt.resolve_requested.connect(lambda: self.resolve_replay(session_id, state))
|
|
643
|
+
prompt.finished.connect(lambda _result: self.console_registry_controller.settle_answered_console(operation_id))
|
|
644
|
+
|
|
645
|
+
def present() -> None:
|
|
646
|
+
"""Bring the prompt to the front after the console has published the pause."""
|
|
647
|
+
if not isValid(prompt) or self.replay_prompt_by_session.get(session_id) is not prompt:
|
|
648
|
+
return
|
|
649
|
+
prompt.show()
|
|
650
|
+
prompt.raise_()
|
|
651
|
+
prompt.activateWindow()
|
|
652
|
+
|
|
653
|
+
# The console publishes its pause from a zero timer queued just before this one; presenting the prompt
|
|
654
|
+
# afterwards keeps the console from ending up in front of it.
|
|
655
|
+
QTimer.singleShot(0, present)
|
|
656
|
+
|
|
657
|
+
def resolve_replay(self, session_id: str, state: ReplaySnapshot) -> None:
|
|
658
|
+
"""Refresh the existing conflict list before opening it; never launch a tool here."""
|
|
659
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
660
|
+
return
|
|
661
|
+
context = self.session_registry_controller.session_by_id[session_id].repository_context
|
|
662
|
+
if state.key != WorktreeKey.from_context(context):
|
|
663
|
+
return
|
|
664
|
+
self.session_registry_controller.replay_pending_resolve_set.add(session_id)
|
|
665
|
+
self.staging_session_registry_controller.staging_session(context).refresh(explicit=True)
|
|
666
|
+
self.observe_replay(session_id)
|
|
667
|
+
|
|
668
|
+
def continue_replay(self, session_id: str, state: ReplaySnapshot, *, automatic: bool = False) -> None:
|
|
669
|
+
"""Relate a Continue to its original pause and a new write reservation.
|
|
670
|
+
|
|
671
|
+
``automatic`` marks the Continue GitPane starts by itself after the last conflict was solved in its own list;
|
|
672
|
+
it goes through exactly the same checks as the button and says so in its console. It takes the armed request
|
|
673
|
+
of its worktree, and submits the read that qualified it instead of reading the pause again while that read holds.
|
|
674
|
+
"""
|
|
675
|
+
observed = self._take_auto_continue_observation(session_id, state) if automatic else None
|
|
676
|
+
self._submit_replay_resume(session_id, state, abort=False, automatic=automatic, observed=observed)
|
|
677
|
+
|
|
678
|
+
def _submit_replay_resume(self, session_id: str, state: ReplaySnapshot, *, abort: bool, automatic: bool = False,
|
|
679
|
+
observed: ReplayResumeObservation | None = None) -> None:
|
|
680
|
+
"""Reobserve before submitting, keeping changed identities and active tools refused.
|
|
681
|
+
|
|
682
|
+
``observed`` is the read that qualified an automatic Continue of this very pause: it is checked the same way as
|
|
683
|
+
a reread and handed to the service in its place while it still holds; otherwise the pause is read again. The
|
|
684
|
+
service captures the pause twice again inside its reservation either way.
|
|
685
|
+
"""
|
|
686
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
687
|
+
return
|
|
688
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
689
|
+
context = session.repository_context
|
|
690
|
+
git_filename = self.repository.git_filename
|
|
691
|
+
if state.key != WorktreeKey.from_context(context):
|
|
692
|
+
return
|
|
693
|
+
action = ReplayAction.ABORT if abort else ReplayAction.CONTINUE
|
|
694
|
+
resume = getattr(self.replay, "abort" if abort else "continue_replay", None)
|
|
695
|
+
diagnostic = replay_action_diagnostic(state, action, busy=self.mergetool.owns_repository(context.common_git_dir), abort_available=callable(resume))
|
|
696
|
+
if diagnostic or not callable(resume):
|
|
697
|
+
self.session_registry_controller.window_by_session[session_id].statusBar().showMessage(diagnostic or "This recovery action is unavailable.")
|
|
698
|
+
return
|
|
699
|
+
key = state.key
|
|
700
|
+
if key in self.replay_resume_session_by_key:
|
|
701
|
+
self.session_registry_controller.window_by_session[session_id].statusBar().showMessage("A Continue or Abort of this commit operation is already starting.")
|
|
702
|
+
return
|
|
703
|
+
self.replay_resume_session_by_key[key] = session_id
|
|
704
|
+
self.replay_actions_changed()
|
|
705
|
+
|
|
706
|
+
def current() -> bool:
|
|
707
|
+
"""Hold session and tool ownership until the service's final launch check."""
|
|
708
|
+
return (self.session_registry_controller.session_by_id.get(session_id) is session and not session.is_closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)
|
|
709
|
+
and session.repository_context == context and self.repository.git_filename == git_filename
|
|
710
|
+
and not self.mergetool.owns_repository(context.common_git_dir))
|
|
711
|
+
|
|
712
|
+
def read_current() -> bool:
|
|
713
|
+
"""Release the starting mark when the reread is dropped, so the banner does not stay disabled."""
|
|
714
|
+
if current():
|
|
715
|
+
return True
|
|
716
|
+
self._release_replay_resume(key, session_id)
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
def complete(observed: ReplaySnapshot) -> None:
|
|
720
|
+
"""Refuse a different pause rather than applying a click to replacement metadata."""
|
|
721
|
+
try:
|
|
722
|
+
submit(observed)
|
|
723
|
+
finally:
|
|
724
|
+
self._release_replay_resume(key, session_id)
|
|
725
|
+
|
|
726
|
+
def submit(observed: ReplaySnapshot) -> None:
|
|
727
|
+
"""Open the console and hand the checked pause to the service, which reserves the repository."""
|
|
728
|
+
if not current():
|
|
729
|
+
return
|
|
730
|
+
if (observed.identity != state.identity or observed.head_oid != state.head_oid or observed.symbolic_ref != state.symbolic_ref
|
|
731
|
+
or observed.launch != state.launch or replay_action_diagnostic(observed, action, abort_available=callable(resume))):
|
|
732
|
+
self._show_replay_state(session_id, observed)
|
|
733
|
+
self.session_registry_controller.window_by_session[session_id].statusBar().showMessage("The commit operation changed. Review its current state before trying again.")
|
|
734
|
+
return
|
|
735
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, context, self.repository.git_filename, session_id=session_id,
|
|
736
|
+
generation=self.session_registry_controller.generation_by_session[session_id], operation="Abort replay" if abort else "Continue replay",
|
|
737
|
+
action=OperationAction.REPLAY_ABORT if abort else OperationAction.REPLAY_CONTINUE)
|
|
738
|
+
# A pause GitPane launched in this run keeps the source and the choices of its form; any other one is named by Git's instruction.
|
|
739
|
+
launch = observed.launch
|
|
740
|
+
source_oid = launch.source_oid if launch is not None else next(iter(observed.instruction_oids))
|
|
741
|
+
add_reference = launch.add_reference if launch is not None else False
|
|
742
|
+
intent = ReplayIntent(operation, observed.kind, source_oid, observed.head_oid, observed.symbolic_ref, create_commit=observed.mode == ReplayCommitMode.COMMIT,
|
|
743
|
+
add_reference=add_reference)
|
|
744
|
+
self.replay_intent_by_id[operation.operation_id] = intent
|
|
745
|
+
self.reference_form_controller.open_reference_output(intent, None, modal=False)
|
|
746
|
+
trace = self.reference_form_controller.reference_trace_by_id.get(operation.operation_id)
|
|
747
|
+
if automatic and trace is not None:
|
|
748
|
+
trace.append_event(operation.operation_id, "status", REPLAY_AUTO_CONTINUE_TEXT)
|
|
749
|
+
resume(operation, observed, is_current=current)
|
|
750
|
+
|
|
751
|
+
if observed is None:
|
|
752
|
+
self.replay.observe(context, self.repository.git_filename, is_current=read_current, callback=complete)
|
|
753
|
+
return
|
|
754
|
+
qualified = observed
|
|
755
|
+
|
|
756
|
+
def hand_over_qualified_read() -> None:
|
|
757
|
+
"""Submit with the qualifying read while it still holds, or read the pause again as for a click."""
|
|
758
|
+
if not read_current():
|
|
759
|
+
return
|
|
760
|
+
if self._replay_resume_observation_holds(qualified, session_id, qualified.state):
|
|
761
|
+
complete(qualified.state)
|
|
762
|
+
return
|
|
763
|
+
self.replay.observe(context, self.repository.git_filename, is_current=read_current, callback=complete)
|
|
764
|
+
|
|
765
|
+
# Handed over on the next turn of the event loop, the way a read answers: until then the starting mark stays set,
|
|
766
|
+
# so a banner click in between is refused as for a Continue already starting.
|
|
767
|
+
QTimer.singleShot(0, self, hand_over_qualified_read)
|
|
768
|
+
|
|
769
|
+
def _release_replay_resume(self, key: WorktreeKey, session_id: str) -> None:
|
|
770
|
+
"""End the starting mark of a Continue or Abort once it reserved the repository or gave up, then refresh the buttons.
|
|
771
|
+
|
|
772
|
+
The buttons are refreshed from a zero timer because this can run while the replay service is still publishing a read.
|
|
773
|
+
"""
|
|
774
|
+
if self.replay_resume_session_by_key.get(key) != session_id:
|
|
775
|
+
return
|
|
776
|
+
del self.replay_resume_session_by_key[key]
|
|
777
|
+
QTimer.singleShot(0, self, self.replay_actions_changed)
|
|
778
|
+
|
|
779
|
+
def passive_observation_source(self, key: WorktreeKey) -> PassiveObservationSource | None:
|
|
780
|
+
"""Answer what the automatic refresh of this worktree has just read, or nothing when the observation must read through Git."""
|
|
781
|
+
# While a Continue or an Abort waits for its decision, that decision is taken on a state read through Git.
|
|
782
|
+
if key in self.replay_auto_continue_by_key or key in self.replay_resume_session_by_key:
|
|
783
|
+
return None
|
|
784
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(key)
|
|
785
|
+
if staging is None:
|
|
786
|
+
return None
|
|
787
|
+
return staging.passive_observation_source()
|
|
788
|
+
|
|
789
|
+
def request_replay_auto_continue(self, session_id: str, key: WorktreeKey) -> None:
|
|
790
|
+
"""Ask to continue a Cherry-pick or Revert once a new read proves that its last conflict is solved.
|
|
791
|
+
|
|
792
|
+
Only a resolution made in GitPane's conflict list or merge tool series calls this; nothing here runs on a passive
|
|
793
|
+
refresh or a reopening. The pause may have been started by GitPane or in a terminal: the newer read decides, and
|
|
794
|
+
only a pause the banner Continue accepts (known commit mode) is continued.
|
|
795
|
+
"""
|
|
796
|
+
state = self.replay.state_by_key.get(key)
|
|
797
|
+
if not replay_may_be_present(state) or session_id not in self.session_registry_controller.session_by_id or self.replay_session_leaving(session_id):
|
|
798
|
+
return
|
|
799
|
+
request = ReplayAutoContinueRequest(session_id, self.replay.generation_by_key.get(key, 0))
|
|
800
|
+
self.replay_auto_continue_by_key[key] = request
|
|
801
|
+
self.observe_replay(session_id, auto_continue_request=request)
|
|
802
|
+
|
|
803
|
+
def abort_replay(self, session_id: str, state: ReplaySnapshot) -> None:
|
|
804
|
+
"""Submit Git's own Abort of the pause the banner shows, after the same checks as Continue."""
|
|
805
|
+
self._submit_replay_resume(session_id, state, abort=True)
|