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,885 @@
|
|
|
1
|
+
"""Resolve the conflicts of a merge, a rebase or a replay from a repository window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QObject, Qt
|
|
10
|
+
from PySide6.QtWidgets import QMessageBox
|
|
11
|
+
from shiboken6 import isValid
|
|
12
|
+
|
|
13
|
+
from gitpane.controller.refresh_controller import RefreshScope
|
|
14
|
+
from gitpane.controller.stash_controller import StashDropAfterResolution
|
|
15
|
+
from gitpane.history.file_history import WindowCapability
|
|
16
|
+
from gitpane.history.repository_value import FileStatus
|
|
17
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
18
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
19
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent
|
|
20
|
+
from gitpane.preference.preference_store import CONFLICT_PROMPT_PREFERENCE_KEY
|
|
21
|
+
from gitpane.reference.reference_edit import MergeAbortIntent
|
|
22
|
+
from gitpane.reference.stash import StashObservation
|
|
23
|
+
from gitpane.sequencer.conflict import NO_CONFLICT_SELECTED_TEXT, ConflictResolutionIntent, capture_conflict_result, conflict_path_label, has_deleted_side, snapshot_has_conflicts
|
|
24
|
+
from gitpane.sequencer.merge_state import ABORT_ACTION, MERGE_ABORT_REFRESH_REASON, MergeBannerState, merge_action_reason
|
|
25
|
+
from gitpane.sequencer.mergetool import MergetoolRequest, MergetoolStatus
|
|
26
|
+
from gitpane.sequencer.replay_state import BUSY_ACTION_REASON
|
|
27
|
+
from gitpane.tool.mergetool_recipe import MERGETOOL_COMMAND_PREFERENCE_KEY, MERGETOOL_FILENAME_PREFERENCE_KEY, MergetoolRecipe
|
|
28
|
+
from gitpane.ui.sequencer.conflict_dialog import ConflictConfirmation, ConflictDialog, ConflictPrompt, DeletedFileDialog, MergeConfirmation
|
|
29
|
+
from gitpane.ui.sequencer.merge_recovery_dialog import MergeRecoveryDialog
|
|
30
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
31
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommandError
|
|
32
|
+
from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE, WorktreeKey
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Generator
|
|
36
|
+
|
|
37
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
38
|
+
from gitpane.controller.rebase_controller import RebaseController
|
|
39
|
+
from gitpane.controller.reference_form_controller import ReferenceFormController
|
|
40
|
+
from gitpane.controller.refresh_controller import RefreshController
|
|
41
|
+
from gitpane.controller.replay_controller import ReplayController
|
|
42
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
43
|
+
from gitpane.controller.setting_controller import SettingController
|
|
44
|
+
from gitpane.controller.staging_session_registry import StagingSessionRegistry
|
|
45
|
+
from gitpane.controller.stash_controller import StashController
|
|
46
|
+
from gitpane.controller.window_activation import WindowActivationFilter
|
|
47
|
+
from gitpane.controller.worktree_state_controller import WorktreeStateController
|
|
48
|
+
from gitpane.history.repository import GitRepository
|
|
49
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
50
|
+
from gitpane.operation.repository_operation import OperationOutcome, RepositoryOperationService
|
|
51
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
52
|
+
from gitpane.reference.reference_edit import ReferenceService
|
|
53
|
+
from gitpane.reference.stash import StashService
|
|
54
|
+
from gitpane.sequencer.conflict import ConflictSelection, ConflictService, ConflictState
|
|
55
|
+
from gitpane.sequencer.mergetool import MergetoolResult, MergetoolSeries, MergetoolService
|
|
56
|
+
from gitpane.sequencer.rebase import RebaseService
|
|
57
|
+
from gitpane.sequencer.rebase_state import RebaseSnapshot
|
|
58
|
+
from gitpane.worktree.mutation import MutationOutcome, MutationService
|
|
59
|
+
from gitpane.worktree.staging import StagingSession
|
|
60
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
61
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
62
|
+
|
|
63
|
+
# The name of the merge banner's Abort in its console and in its question.
|
|
64
|
+
MERGE_ABORT_LABEL = "Abort merge"
|
|
65
|
+
# Asked only when the working tree no longer matches the tree the merge left, or when that could not be read.
|
|
66
|
+
MERGE_ABORT_QUESTION = ("Abort the merge in progress?\n\nChanges made since the merge stopped, such as resolved conflicts, will be lost. "
|
|
67
|
+
"Submodule content is not changed.")
|
|
68
|
+
MERGE_ABORT_CANCEL_LABEL = "Cancel"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class MergetoolPublication:
|
|
73
|
+
"""What a merge tool series must still match for its terminal reads to become the displayed worktree state.
|
|
74
|
+
|
|
75
|
+
The worktree, the Git executable and the configuration generation are the ones the series started with, and the
|
|
76
|
+
displayed read is still the last one this series published: any other read published meanwhile wins.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
key: WorktreeKey
|
|
80
|
+
git_filename: str
|
|
81
|
+
staging_generation: int
|
|
82
|
+
configuration_generation: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ConflictController(QObject):
|
|
86
|
+
"""The conflicts of every repository window: the conflict list with Mark solved and Delete file, the merge tool series, the retained merge results, the stash a Stash pop kept, and the Abort and Continue of a merge."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, window_activation_controller: WindowActivationFilter,
|
|
89
|
+
refresh_controller: RefreshController, worktree_state_controller: WorktreeStateController, setting_controller: SettingController, reference_form_controller: ReferenceFormController,
|
|
90
|
+
replay_controller: ReplayController, rebase_controller: RebaseController, stash_controller: StashController, preference: PreferenceStore, repository: GitRepository, worktree_service: WorktreeService,
|
|
91
|
+
repository_operation: RepositoryOperationService, rebase: RebaseService, reference: ReferenceService, stash: StashService, mutation: MutationService, conflict: ConflictService, mergetool: MergetoolService,
|
|
92
|
+
diagnostic_log: DiagnosticLog | None, parent: QObject | None = None) -> None:
|
|
93
|
+
"""Receive the services and the lower controllers conflicts are resolved with; the wiring builds it after the stash controller, and the repository windows reach it through the wiring."""
|
|
94
|
+
super().__init__(parent)
|
|
95
|
+
self.session_registry_controller = session_registry_controller
|
|
96
|
+
self.operation_policy_controller = operation_policy_controller
|
|
97
|
+
self.staging_session_registry_controller = staging_session_registry_controller
|
|
98
|
+
self.window_activation_controller = window_activation_controller
|
|
99
|
+
self.refresh_controller = refresh_controller
|
|
100
|
+
self.worktree_state_controller = worktree_state_controller
|
|
101
|
+
self.setting_controller = setting_controller
|
|
102
|
+
self.reference_form_controller = reference_form_controller
|
|
103
|
+
self.replay_controller = replay_controller
|
|
104
|
+
self.rebase_controller = rebase_controller
|
|
105
|
+
self.stash_controller = stash_controller
|
|
106
|
+
self.preference = preference
|
|
107
|
+
self.repository = repository
|
|
108
|
+
self.worktree_service = worktree_service
|
|
109
|
+
self.repository_operation = repository_operation
|
|
110
|
+
self.rebase = rebase
|
|
111
|
+
self.reference = reference
|
|
112
|
+
self.stash = stash
|
|
113
|
+
self.mutation = mutation
|
|
114
|
+
self.conflict = conflict
|
|
115
|
+
self.mergetool = mergetool
|
|
116
|
+
self.diagnostic_log = diagnostic_log
|
|
117
|
+
self.merge_abort_intent_by_id: dict[str, MergeAbortIntent] = {}
|
|
118
|
+
# The operation ID of the latest Abort click of each window: an answer about the work at risk for an older click is dropped.
|
|
119
|
+
self.merge_abort_pending_by_session: dict[str, str] = {}
|
|
120
|
+
self.merge_abort_confirmation_by_session: dict[str, QMessageBox] = {}
|
|
121
|
+
self.merge_recovery_dialog_by_session: dict[str, MergeRecoveryDialog] = {}
|
|
122
|
+
self.merge_confirmation_by_id: dict[str, MergeConfirmation | DeletedFileDialog] = {}
|
|
123
|
+
self.mergetool_publication_by_series: dict[str, MergetoolPublication] = {}
|
|
124
|
+
self.conflict_dialog_by_session: dict[str, ConflictDialog] = {}
|
|
125
|
+
self.conflict_confirmation_by_session: dict[str, ConflictConfirmation] = {}
|
|
126
|
+
self.conflict_resolution_by_id: dict[str, ConflictResolutionIntent] = {}
|
|
127
|
+
self.conflict_followup_by_id: dict[str, tuple[ConflictResolutionIntent, ...]] = {}
|
|
128
|
+
self.conflict_prompt_by_session: dict[str, ConflictPrompt] = {}
|
|
129
|
+
self.conflict_retention_by_key: dict[WorktreeKey, StashObservation] = {}
|
|
130
|
+
self.conflict_retention_read_set: set[WorktreeKey] = set()
|
|
131
|
+
|
|
132
|
+
def open_conflict_dialog(self, session_id: str, selected_state: ConflictState | None = None) -> ConflictDialog | None:
|
|
133
|
+
"""Open only current conflicts belonging to a management-capable repository window."""
|
|
134
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
135
|
+
return None
|
|
136
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
137
|
+
key = WorktreeKey.from_context(window.context)
|
|
138
|
+
state = self.conflict.state_by_key.get(key)
|
|
139
|
+
if window.context.is_bare or state is None or not state.fresh or not state.entries or (selected_state is not None and selected_state != state):
|
|
140
|
+
window.statusBar().showMessage("Refresh the repository before opening its current conflicts")
|
|
141
|
+
return None
|
|
142
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
143
|
+
if dialog is None or not isValid(dialog):
|
|
144
|
+
dialog = ConflictDialog(state, window)
|
|
145
|
+
dialog.installEventFilter(self.window_activation_controller)
|
|
146
|
+
dialog.refresh_requested.connect(lambda: self._refresh_conflict_dialog(session_id))
|
|
147
|
+
dialog.mark_solved_requested.connect(lambda selection: self.mark_conflict_solved(session_id, selection))
|
|
148
|
+
dialog.delete_requested.connect(lambda selection: self.mark_conflict_solved(session_id, selection, delete_file=True))
|
|
149
|
+
dialog.open_tool_requested.connect(lambda selection: self.open_mergetool(session_id, selection))
|
|
150
|
+
dialog.settings_requested.connect(lambda: self.setting_controller.show_setting(session_id, section="mergetool"))
|
|
151
|
+
dialog.stop_series_requested.connect(lambda: self._stop_mergetool_series(session_id))
|
|
152
|
+
dialog.finished.connect(lambda: self._stop_mergetool_series(session_id))
|
|
153
|
+
self.conflict_dialog_by_session[session_id] = dialog
|
|
154
|
+
dialog.show_state(state)
|
|
155
|
+
dialog.show_rebase_state(self.rebase.state_by_key.get(key))
|
|
156
|
+
self.replay_controller.replay_actions_changed()
|
|
157
|
+
dialog.show()
|
|
158
|
+
dialog.raise_()
|
|
159
|
+
if not self._conflict_state_is_recent(key, state):
|
|
160
|
+
self._refresh_conflict_dialog(session_id)
|
|
161
|
+
return dialog
|
|
162
|
+
|
|
163
|
+
def _conflict_state_is_recent(self, key: WorktreeKey, state: ConflictState) -> bool:
|
|
164
|
+
"""Tell whether a conflict list can be shown as it was last read, with no read of its own.
|
|
165
|
+
|
|
166
|
+
It can when it comes from the complete worktree read currently shown, made with the current Git, and no read is
|
|
167
|
+
running or requested. Showing it writes nothing: every action on it checks the repository again before writing,
|
|
168
|
+
and Rescan stays the explicit way to see a resolution made outside the application.
|
|
169
|
+
"""
|
|
170
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(key)
|
|
171
|
+
if staging is None or staging.refresh_active or staging.refresh_queued or staging.explicit_refresh:
|
|
172
|
+
return False
|
|
173
|
+
snapshot = staging.snapshot
|
|
174
|
+
return (snapshot is not None and snapshot.fresh and snapshot.key == key and snapshot.generation == state.generation
|
|
175
|
+
and snapshot.git_filename == staging.git_filename == self.repository.git_filename)
|
|
176
|
+
|
|
177
|
+
def _refresh_conflict_dialog(self, session_id: str) -> None:
|
|
178
|
+
"""Refresh the shared worktree without granting limited windows mutation access."""
|
|
179
|
+
if self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
180
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
181
|
+
if dialog is not None and isValid(dialog):
|
|
182
|
+
dialog.begin_rescan()
|
|
183
|
+
self.staging_session_registry_controller.staging_session(self.session_registry_controller.session_by_id[session_id].repository_context).refresh(explicit=True)
|
|
184
|
+
|
|
185
|
+
def _conflict_action_snapshot(self, session_id: str, selection: ConflictSelection) -> WorktreeSnapshot | None:
|
|
186
|
+
"""Reject expired selections with visible feedback instead of silently losing the click."""
|
|
187
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
188
|
+
if dialog is None or not isValid(dialog) or not dialog.isVisible():
|
|
189
|
+
return None
|
|
190
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
191
|
+
dialog.error_label.setText("This window can no longer resolve conflicts. Reopen the repository.")
|
|
192
|
+
return None
|
|
193
|
+
if dialog.busy:
|
|
194
|
+
dialog.error_label.setText("A conflict resolution is already running. Wait for it to finish.")
|
|
195
|
+
return None
|
|
196
|
+
if not selection.paths:
|
|
197
|
+
dialog.error_label.setText(NO_CONFLICT_SELECTED_TEXT)
|
|
198
|
+
return None
|
|
199
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(selection.key)
|
|
200
|
+
snapshot = staging.snapshot if staging is not None else None
|
|
201
|
+
if (dialog.selection() != selection or not dialog.state.fresh or snapshot is None or not snapshot.fresh
|
|
202
|
+
or snapshot.generation != selection.generation):
|
|
203
|
+
dialog.error_label.setText("The conflict list changed or is refreshing. Wait for the scan to finish, then select the file and try again.")
|
|
204
|
+
self._refresh_conflict_dialog(session_id)
|
|
205
|
+
return None
|
|
206
|
+
return snapshot
|
|
207
|
+
|
|
208
|
+
def open_merge_recovery(self, session_id: str) -> MergeRecoveryDialog | None:
|
|
209
|
+
"""Open retained results even when current conflicts are absent or unreadable."""
|
|
210
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
211
|
+
return None
|
|
212
|
+
dialog = self.merge_recovery_dialog_by_session.get(session_id)
|
|
213
|
+
if dialog is None or not isValid(dialog):
|
|
214
|
+
dialog = MergeRecoveryDialog(self.session_registry_controller.window_by_session[session_id])
|
|
215
|
+
dialog.installEventFilter(self.window_activation_controller)
|
|
216
|
+
dialog.refresh_requested.connect(lambda: self._refresh_merge_recovery(session_id))
|
|
217
|
+
self.merge_recovery_dialog_by_session[session_id] = dialog
|
|
218
|
+
dialog.show()
|
|
219
|
+
dialog.raise_()
|
|
220
|
+
self._refresh_merge_recovery(session_id)
|
|
221
|
+
return dialog
|
|
222
|
+
|
|
223
|
+
def _refresh_merge_recovery(self, session_id: str) -> None:
|
|
224
|
+
"""Discover retained output independently of the vanished session that produced it."""
|
|
225
|
+
dialog = self.merge_recovery_dialog_by_session.get(session_id)
|
|
226
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
227
|
+
if dialog is None or not isValid(dialog) or session is None:
|
|
228
|
+
return
|
|
229
|
+
context = session.repository_context
|
|
230
|
+
key = WorktreeKey.from_context(context)
|
|
231
|
+
store = self.mergetool.temporary_store.recovery_store
|
|
232
|
+
dialog.read_generation += 1
|
|
233
|
+
generation = dialog.read_generation
|
|
234
|
+
dialog.refresh_button.setEnabled(False)
|
|
235
|
+
dialog.recovery_text.setPlainText("Reading retained merge results...")
|
|
236
|
+
|
|
237
|
+
def sequence() -> Generator:
|
|
238
|
+
"""Keep recovery directory and content reads off the Qt thread."""
|
|
239
|
+
return (yield FileTask(lambda: store.discover(context)))
|
|
240
|
+
|
|
241
|
+
def complete(results: object, error: Exception | None) -> None:
|
|
242
|
+
"""Show only locations discovered for this still-open worktree."""
|
|
243
|
+
current = self.session_registry_controller.session_by_id.get(session_id)
|
|
244
|
+
if not isValid(dialog) or self.merge_recovery_dialog_by_session.get(session_id) is not dialog or current is None or WorktreeKey.from_context(current.repository_context) != key:
|
|
245
|
+
return
|
|
246
|
+
if generation != dialog.read_generation:
|
|
247
|
+
return
|
|
248
|
+
if error is not None:
|
|
249
|
+
dialog.show_results((), "Could not read retained merge results. Inspect the recovery directory: " + store.root_dir(context))
|
|
250
|
+
return
|
|
251
|
+
dialog.show_results(results, recovery_dir=store.root_dir(context))
|
|
252
|
+
|
|
253
|
+
self.worktree_service.run(context, self.repository.git_filename, sequence(), complete)
|
|
254
|
+
|
|
255
|
+
def open_mergetool(self, session_id: str, selection: ConflictSelection) -> None:
|
|
256
|
+
"""Capture all selected conflicts once under the original session authority."""
|
|
257
|
+
snapshot = self._conflict_action_snapshot(session_id, selection)
|
|
258
|
+
if snapshot is None:
|
|
259
|
+
return
|
|
260
|
+
dialog = self.conflict_dialog_by_session[session_id]
|
|
261
|
+
path = next(iter(selection.paths))
|
|
262
|
+
entry = next((entry for entry in dialog.state.entries if entry.path == path), None)
|
|
263
|
+
if entry is None:
|
|
264
|
+
return
|
|
265
|
+
self.preference.initialize_mergetool()
|
|
266
|
+
owner = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, session_id=session_id,
|
|
267
|
+
generation=self.session_registry_controller.generation_by_session[session_id], operation="Resolve conflict with external tool", action=OperationAction.CONFLICT_TOOL)
|
|
268
|
+
recipe = MergetoolRecipe(str(self.preference.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY]), str(self.preference.value_by_key[MERGETOOL_COMMAND_PREFERENCE_KEY]))
|
|
269
|
+
request = MergetoolRequest(snapshot, path, entry.sides, owner, recipe)
|
|
270
|
+
dialog.merge_series_id = owner.operation_id
|
|
271
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(selection.key)
|
|
272
|
+
if staging is not None:
|
|
273
|
+
self.mergetool_publication_by_series[owner.operation_id] = MergetoolPublication(snapshot.key, snapshot.git_filename, staging.generation,
|
|
274
|
+
staging.configuration_generation)
|
|
275
|
+
dialog.set_busy(True)
|
|
276
|
+
dialog.error_label.setText("Preparing conflict resolution: " + conflict_path_label(path))
|
|
277
|
+
dialog.settings_button.hide()
|
|
278
|
+
def is_current() -> bool:
|
|
279
|
+
"""Bind every file in this series to the captured session, Git and executable recipe."""
|
|
280
|
+
return (self.operation_policy_controller.operation_allowed(owner) and self.repository.git_filename == snapshot.git_filename
|
|
281
|
+
and self.preference.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY] == recipe.program_filename
|
|
282
|
+
and self.preference.value_by_key[MERGETOOL_COMMAND_PREFERENCE_KEY] == recipe.command)
|
|
283
|
+
|
|
284
|
+
self.mergetool.merge_series(request, selection.paths, is_current)
|
|
285
|
+
|
|
286
|
+
def _stop_mergetool_series(self, session_id: str) -> None:
|
|
287
|
+
"""Stop future editors while the current one remains owned by its service."""
|
|
288
|
+
for series_id, series in tuple(self.mergetool.series_by_id.items()):
|
|
289
|
+
if series.request.operation.session_id == session_id:
|
|
290
|
+
self.mergetool.stop_series(series_id)
|
|
291
|
+
|
|
292
|
+
def mergetool_series_changed(self, series: MergetoolSeries) -> None:
|
|
293
|
+
"""Keep mutation controls disabled throughout capture, process and publication waits."""
|
|
294
|
+
self.rebase_controller.rebase_actions_changed()
|
|
295
|
+
self.replay_controller.replay_actions_changed()
|
|
296
|
+
self.worktree_state_controller.merge_banner_actions_changed()
|
|
297
|
+
if not self.operation_policy_controller.operation_allowed(series.request.operation):
|
|
298
|
+
return
|
|
299
|
+
dialog = self.conflict_dialog_by_session.get(series.request.operation.session_id)
|
|
300
|
+
if dialog is None or not isValid(dialog) or dialog.merge_series_id != series.request.operation.operation_id:
|
|
301
|
+
return
|
|
302
|
+
dialog.set_busy(True)
|
|
303
|
+
dialog.stop_series_button.setVisible(len(series.paths) > 1)
|
|
304
|
+
dialog.stop_series_button.setEnabled(not series.stop_requested)
|
|
305
|
+
if series.stop_requested:
|
|
306
|
+
dialog.error_label.setText("Series stopped. Waiting for the current KDiff3 process.")
|
|
307
|
+
elif series.current_id:
|
|
308
|
+
current_position = series.position - 1
|
|
309
|
+
dialog.error_label.setText("Resolving: " + conflict_path_label(series.paths[current_position]))
|
|
310
|
+
else:
|
|
311
|
+
dialog.error_label.setText("Reading current merge conflicts...")
|
|
312
|
+
|
|
313
|
+
def mergetool_series_finished(self, series: MergetoolSeries) -> None:
|
|
314
|
+
"""Expose failed rereads and preserve the list until fresh Git proves it empty."""
|
|
315
|
+
self.mergetool_publication_by_series.pop(series.request.operation.operation_id, None)
|
|
316
|
+
self.stash_controller.continue_stash_drops_after_resolution()
|
|
317
|
+
self.rebase_controller.rebase_actions_changed()
|
|
318
|
+
self.replay_controller.replay_actions_changed()
|
|
319
|
+
self.worktree_state_controller.merge_banner_actions_changed()
|
|
320
|
+
dialog = self.conflict_dialog_by_session.get(series.request.operation.session_id)
|
|
321
|
+
if dialog is None or not isValid(dialog) or dialog.merge_series_id != series.request.operation.operation_id:
|
|
322
|
+
return
|
|
323
|
+
dialog.merge_series_id = ""
|
|
324
|
+
dialog.set_busy(False)
|
|
325
|
+
dialog.stop_series_button.hide()
|
|
326
|
+
if not self.operation_policy_controller.operation_allowed(series.request.operation):
|
|
327
|
+
if dialog.state.key == series.request.snapshot.key:
|
|
328
|
+
dialog.error_label.setText(series.diagnostic or "Series stopped. The original repository session is no longer current.")
|
|
329
|
+
return
|
|
330
|
+
if series.diagnostic:
|
|
331
|
+
# A series stops at the first result that offers Settings, and that result's diagnostic is the series' own.
|
|
332
|
+
settings_available = bool(series.results) and series.results[-1].settings_available
|
|
333
|
+
dialog.show_mergetool_result(series.diagnostic, settings_available=settings_available)
|
|
334
|
+
if series.snapshot is not None and not series.diagnostic:
|
|
335
|
+
# The last terminal read has already been published, adopted or observed; only a read the series made
|
|
336
|
+
# after it, to skip a file resolved elsewhere, still has to become the list.
|
|
337
|
+
state = self.conflict.state_by_key.get(series.snapshot.key)
|
|
338
|
+
last_result_snapshot = series.results[-1].snapshot if series.results else None
|
|
339
|
+
if state is None or series.snapshot is not last_result_snapshot:
|
|
340
|
+
state = self.conflict.observe(series.snapshot.key, series.snapshot)
|
|
341
|
+
dialog.show_state(state)
|
|
342
|
+
if state.fresh and not state.entries:
|
|
343
|
+
self.replay_controller.request_replay_auto_continue(series.request.operation.session_id, series.snapshot.key)
|
|
344
|
+
dialog.close()
|
|
345
|
+
|
|
346
|
+
def confirm_mergetool(self, request: MergetoolRequest, reason: str) -> None:
|
|
347
|
+
"""Present an explicit answer only in the surviving original repository session."""
|
|
348
|
+
request_id = request.operation.operation_id
|
|
349
|
+
window = self.session_registry_controller.window_by_session.get(request.operation.session_id)
|
|
350
|
+
if window is None or not isValid(window) or not self.operation_policy_controller.operation_allowed(request.operation):
|
|
351
|
+
self.mergetool.confirm(request_id, False)
|
|
352
|
+
return
|
|
353
|
+
if reason == "deleted-side":
|
|
354
|
+
confirmation = DeletedFileDialog(request.path, window, keep_available=request.snapshot.fingerprint_by_path.get(request.path) != "absent")
|
|
355
|
+
confirmation.finished.connect(lambda result: self.mergetool.confirm_deletion(request_id, confirmation.delete_result if result else None))
|
|
356
|
+
dialog = self.conflict_dialog_by_session.get(request.operation.session_id)
|
|
357
|
+
if dialog is not None and isValid(dialog):
|
|
358
|
+
dialog.error_label.setText("Choose whether to keep or delete: " + conflict_path_label(request.path))
|
|
359
|
+
else:
|
|
360
|
+
confirmation = MergeConfirmation(request.path, reason, window)
|
|
361
|
+
confirmation.finished.connect(lambda result: self.mergetool.confirm(request_id, bool(result)))
|
|
362
|
+
confirmation.installEventFilter(self.window_activation_controller)
|
|
363
|
+
self.merge_confirmation_by_id[request_id] = confirmation
|
|
364
|
+
confirmation.finished.connect(lambda: self.merge_confirmation_by_id.pop(request_id, None))
|
|
365
|
+
open_window_modal(confirmation)
|
|
366
|
+
|
|
367
|
+
def close_merge_confirmation(self, request_id: str) -> None:
|
|
368
|
+
"""Dismiss obsolete questions when shutdown or invalidation completes their owner."""
|
|
369
|
+
confirmation = self.merge_confirmation_by_id.pop(request_id, None)
|
|
370
|
+
if confirmation is not None and isValid(confirmation):
|
|
371
|
+
confirmation.reject()
|
|
372
|
+
|
|
373
|
+
def mergetool_finished(self, result: MergetoolResult) -> None:
|
|
374
|
+
"""Show the terminal conflict read immediately, as the staging state when nothing newer wins, else read again."""
|
|
375
|
+
request = result.request
|
|
376
|
+
if result.recovery_filename:
|
|
377
|
+
for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
378
|
+
if WorktreeKey.from_context(session.repository_context) == request.snapshot.key:
|
|
379
|
+
self._refresh_merge_recovery(session_id)
|
|
380
|
+
if self.diagnostic_log is not None:
|
|
381
|
+
message = f"Result: {result.status.value}. " + result.diagnostic
|
|
382
|
+
if result.recovery_filename:
|
|
383
|
+
message += " Saved result kept at: " + result.recovery_filename
|
|
384
|
+
self.diagnostic_log.write(Diagnostic("External merge", request.snapshot.context.repository_dir, request.recipe.program_filename, [], message, return_code=result.return_code).prepare())
|
|
385
|
+
series_id = self.mergetool.series_id_by_request.get(request.operation.operation_id, request.operation.operation_id)
|
|
386
|
+
allowed = self.operation_policy_controller.operation_allowed(request.operation)
|
|
387
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(request.snapshot.key)
|
|
388
|
+
if allowed and result.status == MergetoolStatus.SAVED:
|
|
389
|
+
# Asked before the tool's terminal read is published: that publication is the read that decides.
|
|
390
|
+
self._request_stash_drop_after_resolution(request.operation)
|
|
391
|
+
adopted = allowed and staging is not None and not self.session_registry_controller.shutdown_requested and self._adopt_mergetool_snapshot(series_id, result, staging)
|
|
392
|
+
dialog = self.conflict_dialog_by_session.get(request.operation.session_id)
|
|
393
|
+
if dialog is not None and isValid(dialog) and dialog.merge_series_id == series_id:
|
|
394
|
+
message = result.diagnostic
|
|
395
|
+
if result.recovery_filename:
|
|
396
|
+
message += " Saved result retained at: " + result.recovery_filename
|
|
397
|
+
if not allowed:
|
|
398
|
+
dialog.set_busy(False)
|
|
399
|
+
dialog.stop_series_button.hide()
|
|
400
|
+
if dialog.state.key == request.snapshot.key:
|
|
401
|
+
dialog.error_label.setText(message)
|
|
402
|
+
return
|
|
403
|
+
busy = any(series.request.operation.session_id == request.operation.session_id for series in self.mergetool.series_by_id.values())
|
|
404
|
+
dialog.set_busy(busy)
|
|
405
|
+
dialog.show_mergetool_result(message, settings_available=result.settings_available)
|
|
406
|
+
if result.snapshot is not None and not adopted:
|
|
407
|
+
state = self.conflict.observe(request.snapshot.key, result.snapshot)
|
|
408
|
+
dialog.show_state(state)
|
|
409
|
+
if not allowed:
|
|
410
|
+
return
|
|
411
|
+
if staging is not None and not self.session_registry_controller.shutdown_requested:
|
|
412
|
+
if not adopted:
|
|
413
|
+
staging.refresh(explicit=True)
|
|
414
|
+
self.rebase_controller.observe_rebase(request.operation.session_id)
|
|
415
|
+
|
|
416
|
+
def _adopt_mergetool_snapshot(self, series_id: str, result: MergetoolResult, staging: StagingSession) -> bool:
|
|
417
|
+
"""Publish the merge tool's terminal read as the staging state when nothing newer has to win; answer whether it did.
|
|
418
|
+
|
|
419
|
+
That read is a complete read of the worktree taken after the tool's last write. It becomes the displayed state
|
|
420
|
+
only while the series still owns its publication, for the same worktree, Git and configuration, and while no
|
|
421
|
+
other read has been published, is running or was asked for. It is shown under a new read number of the staging
|
|
422
|
+
session, so selections and prepared actions of the previous state no longer apply.
|
|
423
|
+
"""
|
|
424
|
+
publication = self.mergetool_publication_by_series.get(series_id)
|
|
425
|
+
snapshot = result.snapshot
|
|
426
|
+
if publication is None or snapshot is None or not snapshot.fresh:
|
|
427
|
+
return False
|
|
428
|
+
if (snapshot.key != publication.key or staging.key != publication.key or snapshot.git_filename != publication.git_filename
|
|
429
|
+
or staging.git_filename != publication.git_filename or self.repository.git_filename != publication.git_filename
|
|
430
|
+
or staging.configuration_generation != publication.configuration_generation):
|
|
431
|
+
return False
|
|
432
|
+
if staging.generation != publication.staging_generation or staging.refresh_active or staging.refresh_queued or staging.explicit_refresh or staging.busy:
|
|
433
|
+
return False
|
|
434
|
+
staging.generation += 1
|
|
435
|
+
shown, _reusable = staging._publish_operation_snapshot(replace(snapshot, generation=staging.generation))
|
|
436
|
+
staging._show()
|
|
437
|
+
if shown:
|
|
438
|
+
staging._reconcile_selection()
|
|
439
|
+
self.mergetool_publication_by_series[series_id] = replace(publication, staging_generation=staging.generation)
|
|
440
|
+
staging.changed.emit()
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
def mark_conflict_solved(self, session_id: str, selection: ConflictSelection, *, delete_file: bool = False) -> None:
|
|
444
|
+
"""Recapture the selected working results and stage their exact bytes as they were at the click.
|
|
445
|
+
|
|
446
|
+
A file without conflict markers is staged without a question, since the click is the user's choice; a file that
|
|
447
|
+
still holds markers gets a warning naming their lines, and a deletion keeps its confirmation.
|
|
448
|
+
"""
|
|
449
|
+
snapshot = self._conflict_action_snapshot(session_id, selection)
|
|
450
|
+
if snapshot is None:
|
|
451
|
+
return
|
|
452
|
+
dialog = self.conflict_dialog_by_session[session_id]
|
|
453
|
+
owner = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, session_id=session_id,
|
|
454
|
+
generation=self.session_registry_controller.generation_by_session[session_id], operation="Resolve conflict", action=OperationAction.CONFLICT_STAGE)
|
|
455
|
+
dialog.set_busy(True)
|
|
456
|
+
dialog.error_label.clear()
|
|
457
|
+
|
|
458
|
+
def capture() -> Generator:
|
|
459
|
+
"""Accept the bytes present at the click, then have Git verify everything else against the list shown.
|
|
460
|
+
|
|
461
|
+
An edit made before the click is the result the user chose, so its bytes are accepted locally. The index,
|
|
462
|
+
HEAD and conversion settings are never taken from now: Git compares them with the read the list was built
|
|
463
|
+
from, and the accepted images are proven unchanged again before the intents are built from them.
|
|
464
|
+
"""
|
|
465
|
+
accepted, image_by_path = yield FileTask(lambda: capture_conflict_result(snapshot, selection.paths))
|
|
466
|
+
yield from self.worktree_service.verify(accepted, selection.paths)
|
|
467
|
+
intents = []
|
|
468
|
+
for path in selection.paths:
|
|
469
|
+
entry = next(entry for entry in accepted.entries if entry.path == path and entry.status == FileStatus.UNMERGED)
|
|
470
|
+
role_set = set()
|
|
471
|
+
for side in entry.conflict_sides:
|
|
472
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
|
|
473
|
+
if delete_file and not has_deleted_side(entry.conflict_sides):
|
|
474
|
+
raise ValueError("Delete file requires a conflict with a deleted side")
|
|
475
|
+
image = image_by_path[path]
|
|
476
|
+
delete = delete_file or not image.exists and not {"LOCAL", "REMOTE"}.issubset(role_set)
|
|
477
|
+
intent_owner = replace(owner, operation_id=uuid.uuid4().hex)
|
|
478
|
+
# The markers are looked for in the bytes accepted here, so the warning describes exactly what would be staged.
|
|
479
|
+
intents.append((yield FileTask(lambda path=path, image=image, intent_owner=intent_owner, delete=delete:
|
|
480
|
+
ConflictResolutionIntent.accept(accepted, path, image, intent_owner, delete=delete).with_marker_lines())))
|
|
481
|
+
return tuple(intents)
|
|
482
|
+
|
|
483
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
484
|
+
"""Only a surviving originating dialog can confirm the captured result."""
|
|
485
|
+
if not isValid(dialog):
|
|
486
|
+
return
|
|
487
|
+
if error is not None or not self.operation_policy_controller.operation_allowed(owner) or not dialog.isVisible():
|
|
488
|
+
dialog.set_busy(False)
|
|
489
|
+
if error is not None:
|
|
490
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Resolve conflict", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
491
|
+
dialog.error_label.setText("Could not prepare conflict resolution: " + diagnostic.cause)
|
|
492
|
+
self._refresh_conflict_dialog(session_id)
|
|
493
|
+
return
|
|
494
|
+
self._confirm_manual_result(session_id, value)
|
|
495
|
+
|
|
496
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, capture(), complete)
|
|
497
|
+
|
|
498
|
+
def _confirm_manual_result(self, session_id: str, intents: tuple[ConflictResolutionIntent, ...]) -> None:
|
|
499
|
+
"""Stage each original selected result in turn, asking first only for a file that still holds conflict markers or for a deletion.
|
|
500
|
+
|
|
501
|
+
Clicking Mark conflict as solved or Keep modified file already is the user's explicit choice, so a file without
|
|
502
|
+
markers is staged straight away. Markers left in the file get a warning, and a deletion either removes a working
|
|
503
|
+
file or stages something other than what Keep modified file says, so both keep their question. Each result is
|
|
504
|
+
the one captured at the click: nothing is ever retargeted from the live selection.
|
|
505
|
+
"""
|
|
506
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
507
|
+
if dialog is None or not isValid(dialog):
|
|
508
|
+
return
|
|
509
|
+
intent, *remaining_intents = intents
|
|
510
|
+
owner = intent.operation
|
|
511
|
+
if not dialog.isVisible() or not self.operation_policy_controller.operation_allowed(owner):
|
|
512
|
+
dialog.set_busy(False)
|
|
513
|
+
return
|
|
514
|
+
if not intent.marker_lines and not intent.delete:
|
|
515
|
+
self._submit_manual_result(intent, tuple(remaining_intents))
|
|
516
|
+
return
|
|
517
|
+
confirmation = ConflictConfirmation(intent.path, dialog, delete=intent.delete, remove_existing=intent.delete and intent.image.exists, marker_lines=intent.marker_lines)
|
|
518
|
+
confirmation.installEventFilter(self.window_activation_controller)
|
|
519
|
+
self.conflict_confirmation_by_session[session_id] = confirmation
|
|
520
|
+
|
|
521
|
+
def answer(result: int) -> None:
|
|
522
|
+
"""Submit only explicit agreement; stale results are rechecked by the mutation."""
|
|
523
|
+
self.conflict_confirmation_by_session.pop(session_id, None)
|
|
524
|
+
if not result or not dialog.isVisible() or not self.operation_policy_controller.operation_allowed(owner):
|
|
525
|
+
dialog.set_busy(False)
|
|
526
|
+
return
|
|
527
|
+
self._submit_manual_result(intent, tuple(remaining_intents))
|
|
528
|
+
|
|
529
|
+
confirmation.finished.connect(answer)
|
|
530
|
+
open_window_modal(confirmation)
|
|
531
|
+
|
|
532
|
+
def _submit_manual_result(self, intent: ConflictResolutionIntent, remaining_intents: tuple[ConflictResolutionIntent, ...]) -> None:
|
|
533
|
+
"""Hand one accepted result to the mutation and remember the selected files still waiting behind it.
|
|
534
|
+
|
|
535
|
+
The mutation checks again that the accepted bytes, the index and HEAD are unchanged before staging anything, so
|
|
536
|
+
a result submitted without a question is refused exactly like a confirmed one when another program changed it.
|
|
537
|
+
"""
|
|
538
|
+
operation_id = intent.operation.operation_id
|
|
539
|
+
self.conflict_resolution_by_id[operation_id] = intent
|
|
540
|
+
self.conflict_followup_by_id[operation_id] = remaining_intents
|
|
541
|
+
self.mutation.resolve_conflict(intent)
|
|
542
|
+
|
|
543
|
+
def conflict_resolution_finished(self, outcome: MutationOutcome) -> None:
|
|
544
|
+
"""Keep refusal diagnostics visible until a deliberate rescan or new selection."""
|
|
545
|
+
intent = self.conflict_resolution_by_id.pop(outcome.operation_id, None)
|
|
546
|
+
remaining_intents = self.conflict_followup_by_id.pop(outcome.operation_id, ())
|
|
547
|
+
if intent is None:
|
|
548
|
+
return
|
|
549
|
+
dialog = self.conflict_dialog_by_session.get(intent.operation.session_id)
|
|
550
|
+
if dialog is not None and isValid(dialog):
|
|
551
|
+
dialog.set_busy(bool(remaining_intents) and outcome.status == OperationStatus.SUCCESS.value)
|
|
552
|
+
if outcome.diagnostic is not None:
|
|
553
|
+
dialog.error_label.setText(outcome.diagnostic.cause)
|
|
554
|
+
if remaining_intents and outcome.status == OperationStatus.SUCCESS.value:
|
|
555
|
+
self._read_manual_followup(intent, remaining_intents)
|
|
556
|
+
if not remaining_intents and outcome.status == OperationStatus.SUCCESS.value:
|
|
557
|
+
self._request_stash_drop_after_resolution(intent.operation)
|
|
558
|
+
refusal = self._replay_auto_continue_refusal(intent.operation)
|
|
559
|
+
if not refusal:
|
|
560
|
+
self.replay_controller.request_replay_auto_continue(intent.operation.session_id, intent.operation.key)
|
|
561
|
+
elif self.diagnostic_log is not None:
|
|
562
|
+
self.diagnostic_log.write(Diagnostic("Automatic replay continue", intent.operation.context.repository_dir, self.repository.git_filename, [],
|
|
563
|
+
"Conflict resolution finished in GitPane: automatic Continue not requested (" + refusal + ").").prepare())
|
|
564
|
+
|
|
565
|
+
def _replay_auto_continue_refusal(self, operation: OperationIntent) -> str:
|
|
566
|
+
"""Tell why a finished resolution may not ask for the automatic Continue, or return an empty text when it may.
|
|
567
|
+
|
|
568
|
+
Only the requesting window has to be alive, allowed to write and still on the same worktree. A graph refresh
|
|
569
|
+
in between (F5, another operation) changes the history generation but not the paused operation, and the
|
|
570
|
+
request rereads the native state, its owner and its mode itself before continuing anything.
|
|
571
|
+
"""
|
|
572
|
+
if operation.session_id is None:
|
|
573
|
+
return "no repository window requested this resolution"
|
|
574
|
+
session = self.session_registry_controller.session_by_id.get(operation.session_id)
|
|
575
|
+
if session is None or not self.operation_policy_controller.allows(operation.session_id, WindowCapability.MANAGE):
|
|
576
|
+
return "the repository window was closed or can no longer write"
|
|
577
|
+
if session.repository_context != operation.context:
|
|
578
|
+
return "the repository window now shows another worktree"
|
|
579
|
+
return ""
|
|
580
|
+
|
|
581
|
+
def _read_manual_followup(self, completed: ConflictResolutionIntent, remaining_intents: tuple[ConflictResolutionIntent, ...]) -> None:
|
|
582
|
+
"""Rebase only confirmed index changes while retaining every pending result's identity."""
|
|
583
|
+
snapshot = completed.snapshot
|
|
584
|
+
session_id = completed.operation.session_id
|
|
585
|
+
|
|
586
|
+
def capture() -> Generator:
|
|
587
|
+
"""Require unchanged neighbours and pending bytes before the next separate confirmation."""
|
|
588
|
+
from gitpane.worktree.worktree_file import validate_image
|
|
589
|
+
|
|
590
|
+
current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
591
|
+
yield from self.worktree_service.verify_conversion(snapshot)
|
|
592
|
+
if current.head_oid != snapshot.head_oid:
|
|
593
|
+
raise ValueError("Repository changed during conflict resolution; rescan merge conflicts")
|
|
594
|
+
unchanged_records = []
|
|
595
|
+
for output in (snapshot.index_entries, current.index_entries):
|
|
596
|
+
records = []
|
|
597
|
+
for record in output.split(b"\0"):
|
|
598
|
+
_metadata, _separator, path = record.partition(b"\t")
|
|
599
|
+
if record and path != completed.path:
|
|
600
|
+
records.append(record)
|
|
601
|
+
unchanged_records.append(sorted(records))
|
|
602
|
+
before_records, after_records = unchanged_records
|
|
603
|
+
if before_records != after_records:
|
|
604
|
+
raise ValueError("Other index entries changed; rescan merge conflicts")
|
|
605
|
+
intents = []
|
|
606
|
+
for pending in remaining_intents:
|
|
607
|
+
yield FileTask(lambda pending=pending: validate_image(current.context, pending.path, pending.image))
|
|
608
|
+
intents.append(replace(pending, snapshot=current))
|
|
609
|
+
return tuple(intents)
|
|
610
|
+
|
|
611
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
612
|
+
"""A lost reread stops the selection without undoing already staged results."""
|
|
613
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
614
|
+
if dialog is None or not isValid(dialog):
|
|
615
|
+
return
|
|
616
|
+
if error is not None:
|
|
617
|
+
dialog.set_busy(False)
|
|
618
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Resolve conflict", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
619
|
+
dialog.error_label.setText(diagnostic.cause)
|
|
620
|
+
return
|
|
621
|
+
self._confirm_manual_result(session_id, value)
|
|
622
|
+
|
|
623
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, capture(), complete)
|
|
624
|
+
|
|
625
|
+
def offer_conflict_resolution(self, session_id: str) -> None:
|
|
626
|
+
"""Offer a current conflict list or open it directly when the proposal is suppressed."""
|
|
627
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
628
|
+
return
|
|
629
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
630
|
+
state = self.conflict.state_by_key.get(WorktreeKey.from_context(session.repository_context))
|
|
631
|
+
if state is None or not state.fresh or not state.entries:
|
|
632
|
+
return
|
|
633
|
+
if self.preference.read(CONFLICT_PROMPT_PREFERENCE_KEY):
|
|
634
|
+
self.open_conflict_dialog(session_id, state)
|
|
635
|
+
return
|
|
636
|
+
existing = self.conflict_prompt_by_session.get(session_id)
|
|
637
|
+
if existing is not None and isValid(existing) and existing.isVisible():
|
|
638
|
+
return
|
|
639
|
+
generation = session.generation
|
|
640
|
+
prompt = ConflictPrompt(self.preference, self.session_registry_controller.window_by_session[session_id])
|
|
641
|
+
prompt.installEventFilter(self.window_activation_controller)
|
|
642
|
+
self.conflict_prompt_by_session[session_id] = prompt
|
|
643
|
+
|
|
644
|
+
def resolve() -> None:
|
|
645
|
+
"""Discard an answer whose originating session has since been refreshed or closed."""
|
|
646
|
+
if not session.is_closed and session.generation == generation:
|
|
647
|
+
self.open_conflict_dialog(session_id, state)
|
|
648
|
+
|
|
649
|
+
prompt.resolve_requested.connect(resolve)
|
|
650
|
+
prompt.show()
|
|
651
|
+
|
|
652
|
+
def track_conflict_retention(self, key: WorktreeKey, observation: StashObservation) -> None:
|
|
653
|
+
"""Keep watching the stash a pop left behind only while that can still tell the user something.
|
|
654
|
+
|
|
655
|
+
A stash known to be gone (retained False) or a pop that left no conflict ends the watch. An unknown terminal
|
|
656
|
+
read (no conflict list) keeps it until a fresh read says otherwise. The pop's own result is never changed.
|
|
657
|
+
A drop still waiting for an earlier pop's resolution is forgotten: only the stash this pop kept may be dropped.
|
|
658
|
+
"""
|
|
659
|
+
self.stash_controller.stash_drop_after_resolution_by_key.pop(key, None)
|
|
660
|
+
if observation.retained is False or observation.unmerged_paths == ():
|
|
661
|
+
self._stop_conflict_retention(key)
|
|
662
|
+
return
|
|
663
|
+
self.conflict_retention_by_key[key] = observation
|
|
664
|
+
|
|
665
|
+
def _stop_conflict_retention(self, key: WorktreeKey) -> None:
|
|
666
|
+
"""End the watch of a worktree's retained stash and clear it from the conflict lists still open on that worktree."""
|
|
667
|
+
if self.conflict_retention_by_key.pop(key, None) is None:
|
|
668
|
+
return
|
|
669
|
+
for dialog in self.conflict_dialog_by_session.values():
|
|
670
|
+
if isValid(dialog) and dialog.state.key == key:
|
|
671
|
+
dialog.show_retention(None)
|
|
672
|
+
|
|
673
|
+
def read_conflict_retention(self, key: WorktreeKey) -> None:
|
|
674
|
+
"""Recheck a previously observed stash independently of historic operation output, and only while conflicts remain."""
|
|
675
|
+
previous = self.conflict_retention_by_key.get(key)
|
|
676
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(key)
|
|
677
|
+
if previous is None or staging is None:
|
|
678
|
+
return
|
|
679
|
+
snapshot = staging.snapshot
|
|
680
|
+
if snapshot is None or not snapshot.fresh:
|
|
681
|
+
return
|
|
682
|
+
# Checked before the read in progress, so a resolution ends the watch even while an older read is still out.
|
|
683
|
+
if previous.retained is False or not snapshot_has_conflicts(snapshot):
|
|
684
|
+
self._stop_conflict_retention(key)
|
|
685
|
+
return
|
|
686
|
+
if key in self.conflict_retention_read_set:
|
|
687
|
+
return
|
|
688
|
+
self.conflict_retention_read_set.add(key)
|
|
689
|
+
|
|
690
|
+
def complete(observation: StashObservation | None, error: Exception | None) -> None:
|
|
691
|
+
"""Publish only for the watch, session and worktree read this answer was asked for."""
|
|
692
|
+
self.conflict_retention_read_set.discard(key)
|
|
693
|
+
if (self.staging_session_registry_controller.staging_by_key.get(key) is not staging or staging.snapshot is not snapshot or staging.context != snapshot.context
|
|
694
|
+
or self.conflict_retention_by_key.get(key) is not previous):
|
|
695
|
+
return
|
|
696
|
+
if not snapshot_has_conflicts(snapshot):
|
|
697
|
+
self._stop_conflict_retention(key)
|
|
698
|
+
return
|
|
699
|
+
current = replace(previous, retained=None)
|
|
700
|
+
if error is None and observation is not None:
|
|
701
|
+
current = observation.retention_of(StashObservation(previous.retained_oid, ()))
|
|
702
|
+
if current.retained is False:
|
|
703
|
+
self._stop_conflict_retention(key)
|
|
704
|
+
return
|
|
705
|
+
self.conflict_retention_by_key[key] = current
|
|
706
|
+
for dialog in self.conflict_dialog_by_session.values():
|
|
707
|
+
if isValid(dialog) and dialog.state.key == key and dialog.state.fresh:
|
|
708
|
+
dialog.show_retention(current)
|
|
709
|
+
|
|
710
|
+
self.worktree_service.run(staging.context, staging.git_filename, self.stash.observe(staging.context), complete)
|
|
711
|
+
|
|
712
|
+
def _request_stash_drop_after_resolution(self, operation: OperationIntent) -> None:
|
|
713
|
+
"""Ask to drop the stash a Stash pop kept, after a conflict of that pop was resolved in GitPane.
|
|
714
|
+
|
|
715
|
+
Only the two places where GitPane itself resolves a conflict call this: Mark solved (or Delete file) in the
|
|
716
|
+
conflict list, and a result saved in the merge tool GitPane started. A resolution made in a terminal, a reset
|
|
717
|
+
or an abandoned merge never asks, so the stash then stays. Nothing is asked when no kept stash is watched.
|
|
718
|
+
"""
|
|
719
|
+
key, session_id = operation.key, operation.session_id
|
|
720
|
+
retention = self.conflict_retention_by_key.get(key)
|
|
721
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(key)
|
|
722
|
+
if session_id is None or retention is None or retention.retained is False or retention.retained_oid is None or staging is None:
|
|
723
|
+
return
|
|
724
|
+
self.stash_controller.stash_drop_after_resolution_by_key[key] = StashDropAfterResolution(session_id, retention.retained_oid, staging.generation)
|
|
725
|
+
self.stash_controller.continue_stash_drop_after_resolution(key)
|
|
726
|
+
|
|
727
|
+
def abort_merge(self, session_id: str) -> None:
|
|
728
|
+
"""Abort the merge the banner shows with Git's own merge --abort, asking first only when work would be lost.
|
|
729
|
+
|
|
730
|
+
The merge and HEAD are taken from the state the window shows, so the Abort applies to that merge only. One Git
|
|
731
|
+
read tells whether anything changed since the merge stopped; with nothing to lose the Abort starts at once,
|
|
732
|
+
otherwise a question opens with Cancel as the default and Escape answer. A new click replaces an open question.
|
|
733
|
+
The question opens once that read answers, so it is found in ``merge_abort_confirmation_by_session``. A click
|
|
734
|
+
that cannot act always says why in the status bar: busy, a merge that could not be read, or no merge, snapshot
|
|
735
|
+
or HEAD read yet, which a refresh brings.
|
|
736
|
+
"""
|
|
737
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
738
|
+
return
|
|
739
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
740
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
741
|
+
context = session.repository_context
|
|
742
|
+
if window is None or context.is_bare:
|
|
743
|
+
return
|
|
744
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(WorktreeKey.from_context(context))
|
|
745
|
+
merge_state = staging.merge_banner_state if staging is not None else MergeBannerState()
|
|
746
|
+
snapshot = staging.snapshot if staging is not None else None
|
|
747
|
+
busy = self.mergetool.owns_repository(context.common_git_dir) or self.repository_operation.owns_worktree(WorktreeKey.from_context(context))
|
|
748
|
+
reason = merge_action_reason(merge_state, ABORT_ACTION, conflict_count=0, busy=busy)
|
|
749
|
+
if not reason and (not merge_state.in_progress or merge_state.merge is None or snapshot is None or snapshot.head_oid is None):
|
|
750
|
+
reason = MERGE_ABORT_REFRESH_REASON
|
|
751
|
+
if reason:
|
|
752
|
+
window.statusBar().showMessage(reason)
|
|
753
|
+
return
|
|
754
|
+
previous = self.merge_abort_confirmation_by_session.pop(session_id, None)
|
|
755
|
+
if previous is not None and isValid(previous):
|
|
756
|
+
previous.close()
|
|
757
|
+
previous.deleteLater()
|
|
758
|
+
merge_head_oids = merge_state.merge.parent_oids
|
|
759
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, context, self.repository.git_filename, session_id=session_id, generation=self.session_registry_controller.generation_by_session[session_id],
|
|
760
|
+
targets=(*merge_head_oids, snapshot.head_oid), operation=MERGE_ABORT_LABEL, action=OperationAction.MERGE_ABORT)
|
|
761
|
+
intent = MergeAbortIntent(operation, merge_head_oids, snapshot.head_oid)
|
|
762
|
+
self.merge_abort_pending_by_session[session_id] = operation.operation_id
|
|
763
|
+
|
|
764
|
+
def answered(at_risk: object, error: Exception | None) -> None:
|
|
765
|
+
"""Abort at once when nothing would be lost; otherwise ask first."""
|
|
766
|
+
if self.merge_abort_pending_by_session.get(session_id) != operation.operation_id:
|
|
767
|
+
return
|
|
768
|
+
del self.merge_abort_pending_by_session[session_id]
|
|
769
|
+
if self.session_registry_controller.session_by_id.get(session_id) is not session or session.is_closed or not self.operation_policy_controller.operation_allowed(operation):
|
|
770
|
+
return
|
|
771
|
+
if error is None and not at_risk:
|
|
772
|
+
self._submit_merge_abort(intent)
|
|
773
|
+
return
|
|
774
|
+
self._ask_merge_abort(intent)
|
|
775
|
+
|
|
776
|
+
self.reference.merge_work_at_risk(context, operation.git_filename, answered)
|
|
777
|
+
|
|
778
|
+
def _ask_merge_abort(self, intent: MergeAbortIntent) -> None:
|
|
779
|
+
"""Ask before an Abort that would lose work, and abort only on an explicit Abort merge."""
|
|
780
|
+
session_id = intent.operation.session_id
|
|
781
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
782
|
+
if window is None:
|
|
783
|
+
return
|
|
784
|
+
confirmation = QMessageBox(QMessageBox.Icon.Question, MERGE_ABORT_LABEL, MERGE_ABORT_QUESTION, QMessageBox.StandardButton.NoButton, window)
|
|
785
|
+
prepare_message_box(confirmation, MERGE_ABORT_LABEL)
|
|
786
|
+
confirmation.setTextFormat(Qt.TextFormat.PlainText)
|
|
787
|
+
abort_button = confirmation.addButton(MERGE_ABORT_LABEL, QMessageBox.ButtonRole.DestructiveRole)
|
|
788
|
+
cancel_button = confirmation.addButton(MERGE_ABORT_CANCEL_LABEL, QMessageBox.ButtonRole.RejectRole)
|
|
789
|
+
confirmation.setDefaultButton(cancel_button)
|
|
790
|
+
confirmation.setEscapeButton(cancel_button)
|
|
791
|
+
self.merge_abort_confirmation_by_session[session_id] = confirmation
|
|
792
|
+
|
|
793
|
+
def answer() -> None:
|
|
794
|
+
"""Abort only on an explicit Abort merge; any other ending leaves the merge in place."""
|
|
795
|
+
if self.merge_abort_confirmation_by_session.get(session_id) is confirmation:
|
|
796
|
+
del self.merge_abort_confirmation_by_session[session_id]
|
|
797
|
+
accepted = confirmation.clickedButton() is abort_button
|
|
798
|
+
confirmation.deleteLater()
|
|
799
|
+
if accepted:
|
|
800
|
+
self._submit_merge_abort(intent)
|
|
801
|
+
|
|
802
|
+
confirmation.finished.connect(answer)
|
|
803
|
+
open_window_modal(confirmation)
|
|
804
|
+
|
|
805
|
+
def _submit_merge_abort(self, intent: MergeAbortIntent) -> None:
|
|
806
|
+
"""Open the console, then hand the Abort to the reference service while its window, repository and Git are unchanged.
|
|
807
|
+
|
|
808
|
+
A merge tool series may have begun during the read of the work at risk or while the question was open: it does
|
|
809
|
+
not hold the repository between two files, so it is looked at here and again just before Git starts, as the
|
|
810
|
+
rebase's Abort and Continue do, and the Abort then waits with the reason in the status bar.
|
|
811
|
+
"""
|
|
812
|
+
operation = intent.operation
|
|
813
|
+
session_id = operation.session_id
|
|
814
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
815
|
+
if session is None or session.is_closed or not self.operation_policy_controller.operation_allowed(operation) or self.repository.git_filename != operation.git_filename:
|
|
816
|
+
return
|
|
817
|
+
if self.mergetool.owns_repository(operation.common_git_dir):
|
|
818
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
819
|
+
if window is not None:
|
|
820
|
+
window.statusBar().showMessage(BUSY_ACTION_REASON)
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
def is_current() -> bool:
|
|
824
|
+
"""Keep the window's authority, repository and Git current, and no merge tool at work, until Git starts."""
|
|
825
|
+
return (self.session_registry_controller.session_by_id.get(session_id) is session and not session.is_closed and self.operation_policy_controller.operation_allowed(operation)
|
|
826
|
+
and self.repository.git_filename == operation.git_filename and not self.mergetool.owns_repository(operation.common_git_dir))
|
|
827
|
+
|
|
828
|
+
self.merge_abort_intent_by_id[operation.operation_id] = intent
|
|
829
|
+
self.reference_form_controller.open_reference_output(intent, None, modal=True)
|
|
830
|
+
self.reference.abort_merge(intent, is_current=is_current)
|
|
831
|
+
|
|
832
|
+
def merge_abort_finished(self, outcome: OperationOutcome) -> None:
|
|
833
|
+
"""Publish the Abort's result in its console, log a failure, close the worktree's Prepare changes after a successful Abort and refresh the windows of the repository."""
|
|
834
|
+
intent = self.merge_abort_intent_by_id.pop(outcome.operation_id, None)
|
|
835
|
+
if intent is None:
|
|
836
|
+
return
|
|
837
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
838
|
+
# A refusal explains itself in GitPane's words (the merge changed or ended), which the user needs to read.
|
|
839
|
+
refusal_text = outcome.diagnostic.cause if outcome.status == OperationStatus.REFUSED and outcome.diagnostic is not None else ""
|
|
840
|
+
self.reference_form_controller.finish_reference_output(outcome, failure_text=refusal_text)
|
|
841
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
842
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
843
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
844
|
+
# Nothing is left to commit once the merge is abandoned; the console keeps the result, and a message the
|
|
845
|
+
# user typed stays in the session and comes back when Prepare changes is reopened, as the rebase window
|
|
846
|
+
# closes after its Abort.
|
|
847
|
+
staging = self.staging_session_registry_controller.staging_by_key.get(intent.operation.key)
|
|
848
|
+
if staging is not None and staging.window is not None:
|
|
849
|
+
staging.window.close()
|
|
850
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
|
|
851
|
+
|
|
852
|
+
def show_rebase_state_in_conflict_dialog(self, session_id: str, state: RebaseSnapshot) -> None:
|
|
853
|
+
"""Show in the conflict window of one repository window the rebase state that window has just shown."""
|
|
854
|
+
conflict_dialog = self.conflict_dialog_by_session.get(session_id)
|
|
855
|
+
if conflict_dialog is not None and isValid(conflict_dialog):
|
|
856
|
+
conflict_dialog.show_rebase_state(state)
|
|
857
|
+
|
|
858
|
+
def show_conflict_write_availability(self, session_id: str, locked: bool) -> None:
|
|
859
|
+
"""Lock or unlock the writes of the conflict window of one repository window, as the replay availability just computed it."""
|
|
860
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
861
|
+
if dialog is not None and isValid(dialog):
|
|
862
|
+
# Only a possible replay locks the conflict list up front. An ordinary conflict keeps its buttons, so a second window
|
|
863
|
+
# that tries to write while a merge series runs gets the explicit refusal instead of silently disabled controls.
|
|
864
|
+
dialog.show_write_availability("Wait for the current operation or refresh the native state." if locked else "")
|
|
865
|
+
|
|
866
|
+
def show_window_conflict_state(self, session_id: str, key: WorktreeKey, conflict_state: ConflictState) -> None:
|
|
867
|
+
"""Bring the conflict window and the conflict prompts of one repository window to the conflict state it has just shown.
|
|
868
|
+
|
|
869
|
+
A conflict window shows the new state, ends its rescan once no read is running or waiting, and closes once no
|
|
870
|
+
conflict is left; a conflict resolution or a replay that waited for this read is offered once it is fresh.
|
|
871
|
+
"""
|
|
872
|
+
staging = self.staging_session_registry_controller.staging_by_key[key]
|
|
873
|
+
dialog = self.conflict_dialog_by_session.get(session_id)
|
|
874
|
+
if dialog is not None and isValid(dialog):
|
|
875
|
+
dialog.show_state(conflict_state)
|
|
876
|
+
if not staging.refresh_active and not staging.refresh_queued and not staging.explicit_refresh:
|
|
877
|
+
dialog.finish_rescan()
|
|
878
|
+
if conflict_state.fresh and not conflict_state.entries and not dialog.busy and not staging.refresh_active and not staging.refresh_queued:
|
|
879
|
+
dialog.close()
|
|
880
|
+
if session_id in self.session_registry_controller.conflict_pending_session_set and conflict_state.fresh and not staging.refresh_active and not staging.refresh_queued:
|
|
881
|
+
self.session_registry_controller.conflict_pending_session_set.discard(session_id)
|
|
882
|
+
self.offer_conflict_resolution(session_id)
|
|
883
|
+
if session_id in self.session_registry_controller.replay_pending_resolve_set and conflict_state.fresh and not staging.refresh_active and not staging.refresh_queued:
|
|
884
|
+
self.session_registry_controller.replay_pending_resolve_set.discard(session_id)
|
|
885
|
+
self.open_conflict_dialog(session_id, conflict_state)
|