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,856 @@
|
|
|
1
|
+
"""Resolve a series of conflicts through owned external tools or explicit keep or delete choices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
|
18
|
+
|
|
19
|
+
from gitpane.config import MERGETOOL_START_TIMEOUT_MS, MERGETOOL_TERMINATE_TIMEOUT_MS, TOOL_RESULT_POLL_MS, WINDOWS_EXCEPTION_STATUS_START
|
|
20
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
21
|
+
from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OperationOutcome
|
|
22
|
+
from gitpane.process.process_group import NativeProcessGroup
|
|
23
|
+
from gitpane.sequencer.conflict import NO_CONFLICT_SELECTED_TEXT, ConflictResolutionIntent, conflicts_from_snapshot, has_deleted_side
|
|
24
|
+
from gitpane.sequencer.merge_recovery import RECOVERY_OUTPUT_BASENAME, MergeRecoveryStore
|
|
25
|
+
from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
|
|
26
|
+
from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
|
|
27
|
+
from gitpane.tool.mergetool_recipe import MERGETOOL_DEFAULT_COMMAND, parse_mergetool_command
|
|
28
|
+
from gitpane.worktree.command_sequence import FileTask
|
|
29
|
+
from gitpane.worktree.worktree_file import FILE_OBJECT_IDENTITY_FIELDS, FILE_STABLE_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, FileImage, stat_identity
|
|
30
|
+
from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable, Generator
|
|
34
|
+
|
|
35
|
+
from gitpane.operation.repository_operation import OperationIntent
|
|
36
|
+
from gitpane.sequencer.conflict import ConflictService
|
|
37
|
+
from gitpane.tool.mergetool_recipe import MergetoolRecipe
|
|
38
|
+
from gitpane.worktree.mutation import MutationService
|
|
39
|
+
from gitpane.worktree.worktree_snapshot import ConflictSide, WorktreeSnapshot
|
|
40
|
+
|
|
41
|
+
AWAITING_MERGE_STATES = ("awaiting-base", "awaiting-result", "awaiting-deletion")
|
|
42
|
+
# The only run states _poll has to look at over time: a starting tool has a startup deadline, and a pending confirmation becomes
|
|
43
|
+
# stale when its authority is lost without any repository operation change. No worker event announces either, so only these keep the timer running.
|
|
44
|
+
MERGETOOL_WATCHED_STATE_SET = frozenset(("starting", *AWAITING_MERGE_STATES))
|
|
45
|
+
# Said when a merge series is asked while another series already resolves conflicts in the same shared repository.
|
|
46
|
+
MERGE_SERIES_RUNNING_TEXT = "The merge tool is already resolving conflicts in this repository. Wait for it to finish."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class MergetoolRequest:
|
|
51
|
+
"""Retain the exact session, index stages, executable recipe and selected path."""
|
|
52
|
+
|
|
53
|
+
snapshot: WorktreeSnapshot
|
|
54
|
+
path: bytes
|
|
55
|
+
sides: tuple[ConflictSide, ...]
|
|
56
|
+
operation: OperationIntent
|
|
57
|
+
recipe: MergetoolRecipe
|
|
58
|
+
allow_missing_base: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MergetoolStatus(Enum):
|
|
62
|
+
"""How one merge tool run ended for its conflict, written as its value in the --log line of the result."""
|
|
63
|
+
|
|
64
|
+
SAVED = "saved"
|
|
65
|
+
"""The saved file was staged as the resolution of the conflict."""
|
|
66
|
+
UNRESOLVED = "unresolved"
|
|
67
|
+
"""No saved result was recognized, or the tool could not run: the conflict stays as it was."""
|
|
68
|
+
STALE = "stale"
|
|
69
|
+
"""The repository or the conflict changed under the tool, so its result was not staged."""
|
|
70
|
+
UNKNOWN = "unknown"
|
|
71
|
+
"""The final reading of the repository failed, so how the run ended is not known."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class MergetoolResult:
|
|
76
|
+
"""Distinguish saved, unresolved and refused results from native process completion."""
|
|
77
|
+
|
|
78
|
+
request: MergetoolRequest
|
|
79
|
+
status: MergetoolStatus
|
|
80
|
+
diagnostic: str = ""
|
|
81
|
+
recovery_filename: str = ""
|
|
82
|
+
return_code: int | None = None
|
|
83
|
+
snapshot: WorktreeSnapshot | None = None
|
|
84
|
+
settings_available: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class MergetoolRun:
|
|
89
|
+
"""Keep child and private data ownership independent of the originating widget.
|
|
90
|
+
|
|
91
|
+
``activation`` is the startup value of the click that launches this tool, which lets its window come to the front;
|
|
92
|
+
a tool started without a click has none.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
request: MergetoolRequest
|
|
96
|
+
is_current: Callable[[], bool]
|
|
97
|
+
state: str = "preparing"
|
|
98
|
+
before: FileImage | None = None
|
|
99
|
+
output: FileImage | None = None
|
|
100
|
+
resolution: ConflictResolutionIntent | None = None
|
|
101
|
+
process: subprocess.Popen | None = None
|
|
102
|
+
group: NativeProcessGroup | None = None
|
|
103
|
+
worker: threading.Thread | None = None
|
|
104
|
+
stop_requested: threading.Event = field(default_factory=threading.Event)
|
|
105
|
+
return_code: int | None = None
|
|
106
|
+
deadline: float | None = None
|
|
107
|
+
forced: bool = False
|
|
108
|
+
uncertain: bool = False
|
|
109
|
+
invalidated: bool = False
|
|
110
|
+
activation: StartupActivation = NO_STARTUP_ACTIVATION
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class MergetoolSeries:
|
|
115
|
+
"""Retain the original ordered paths while each tool gets a newly verified snapshot.
|
|
116
|
+
|
|
117
|
+
``activation`` holds the startup value of the click that started the series until its first tool takes it: the
|
|
118
|
+
following tools start when the previous one closes, without a click, so they get none. A keep or delete answer is a
|
|
119
|
+
click too: it gives the series a new value for the tool of the next file.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
request: MergetoolRequest
|
|
123
|
+
paths: tuple[bytes, ...]
|
|
124
|
+
is_current: Callable[[], bool]
|
|
125
|
+
position: int = 0
|
|
126
|
+
stop_requested: bool = False
|
|
127
|
+
current_id: str = ""
|
|
128
|
+
results: list[MergetoolResult] = field(default_factory=list)
|
|
129
|
+
skipped_paths: list[bytes] = field(default_factory=list)
|
|
130
|
+
snapshot: WorktreeSnapshot | None = None
|
|
131
|
+
diagnostic: str = ""
|
|
132
|
+
invalidated: bool = False
|
|
133
|
+
activation: StartupActivation = NO_STARTUP_ACTIVATION
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MergetoolUnavailable(ValueError):
|
|
137
|
+
"""Identify executable errors separately from unsupported conflict content."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TemporaryMergeStore:
|
|
141
|
+
"""Keep request-owned directories, immutable inputs and initially absent outputs."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, recovery_store: MergeRecoveryStore | None = None) -> None:
|
|
144
|
+
"""Record filesystem identities independently from request-supplied paths."""
|
|
145
|
+
self.recovery_store = recovery_store if recovery_store is not None else MergeRecoveryStore()
|
|
146
|
+
self.directory_by_request: dict[str, tuple[str, tuple[int, ...]]] = {}
|
|
147
|
+
self.input_by_request: dict[str, dict[str, FileImage]] = {}
|
|
148
|
+
self.parent_by_request: dict[str, tuple[tuple[str, tuple[int, ...]], ...]] = {}
|
|
149
|
+
|
|
150
|
+
def create(self, request: MergetoolRequest) -> None:
|
|
151
|
+
"""Create private storage outside the worktree without precreating MERGED."""
|
|
152
|
+
directory = self.recovery_store.create_directory(request.snapshot.context)
|
|
153
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
154
|
+
request_id = request.operation.operation_id
|
|
155
|
+
self.directory_by_request[request_id] = (directory, stat_identity(os.lstat(directory)))
|
|
156
|
+
self.input_by_request[request_id] = {}
|
|
157
|
+
parents = []
|
|
158
|
+
current_dir = directory
|
|
159
|
+
while True:
|
|
160
|
+
parents.append((current_dir, stat_identity(os.lstat(current_dir))[:FILE_OBJECT_IDENTITY_FIELDS]))
|
|
161
|
+
parent_dir = os.path.dirname(current_dir)
|
|
162
|
+
if parent_dir == current_dir:
|
|
163
|
+
break
|
|
164
|
+
current_dir = parent_dir
|
|
165
|
+
self.parent_by_request[request_id] = tuple(parents)
|
|
166
|
+
|
|
167
|
+
def filename(self, request: MergetoolRequest, role: str) -> str:
|
|
168
|
+
"""Return an inventoried role path with no raw repository path interpolation."""
|
|
169
|
+
if role not in (*CONFLICT_ROLE_BY_STAGE.values(), RECOVERY_OUTPUT_BASENAME):
|
|
170
|
+
raise ValueError("Unknown merge file role")
|
|
171
|
+
directory, _identity = self.directory_by_request[request.operation.operation_id]
|
|
172
|
+
return os.path.join(directory, role)
|
|
173
|
+
|
|
174
|
+
def validate_directory(self, request: MergetoolRequest) -> None:
|
|
175
|
+
"""Refuse replacement or redirection of the owned directory and its ancestors."""
|
|
176
|
+
directory, expected = self.directory_by_request[request.operation.operation_id]
|
|
177
|
+
metadata = os.lstat(directory)
|
|
178
|
+
if stat_identity(metadata)[:FILE_OBJECT_IDENTITY_FIELDS] != expected[:FILE_OBJECT_IDENTITY_FIELDS]:
|
|
179
|
+
raise ValueError("The private merge directory was replaced.")
|
|
180
|
+
current_dir = directory
|
|
181
|
+
for parent_dir, identity in self.parent_by_request[request.operation.operation_id]:
|
|
182
|
+
if stat_identity(os.lstat(parent_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != identity:
|
|
183
|
+
raise ValueError("A private merge ancestor was replaced.")
|
|
184
|
+
while True:
|
|
185
|
+
if os.path.islink(current_dir) or os.path.isjunction(current_dir) or not stat.S_ISDIR(os.lstat(current_dir).st_mode):
|
|
186
|
+
raise ValueError("The private merge path was redirected.")
|
|
187
|
+
parent_dir = os.path.dirname(current_dir)
|
|
188
|
+
if parent_dir == current_dir:
|
|
189
|
+
break
|
|
190
|
+
current_dir = parent_dir
|
|
191
|
+
|
|
192
|
+
def read_file(self, request: MergetoolRequest, filename: str) -> FileImage:
|
|
193
|
+
"""Accept a regular single-link file only while its handle and path identities agree."""
|
|
194
|
+
self.validate_directory(request)
|
|
195
|
+
metadata = os.lstat(filename)
|
|
196
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or os.path.islink(filename) or os.path.isjunction(filename):
|
|
197
|
+
raise ValueError("The private merge result is not an owned regular file.")
|
|
198
|
+
with open(filename, "rb") as stream:
|
|
199
|
+
if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
|
|
200
|
+
raise ValueError("The private merge file changed while reading.")
|
|
201
|
+
content = stream.read()
|
|
202
|
+
if stat_identity(os.lstat(filename)) != stat_identity(metadata):
|
|
203
|
+
raise ValueError("The private merge file changed while reading.")
|
|
204
|
+
return FileImage(True, metadata.st_mode, content, identity=stat_identity(metadata))
|
|
205
|
+
|
|
206
|
+
def freeze_input(self, request: MergetoolRequest, filename: str) -> None:
|
|
207
|
+
"""Remove write permission and retain exact input identities for later verification."""
|
|
208
|
+
os.chmod(filename, stat.S_IRUSR)
|
|
209
|
+
self.input_by_request[request.operation.operation_id][filename] = self.read_file(request, filename)
|
|
210
|
+
|
|
211
|
+
def capture_output(self, request: MergetoolRequest) -> FileImage | None:
|
|
212
|
+
"""Recognize saved empty or identical bytes only after the process has really exited."""
|
|
213
|
+
self.validate_directory(request)
|
|
214
|
+
for filename, image in self.input_by_request[request.operation.operation_id].items():
|
|
215
|
+
if self.read_file(request, filename) != image:
|
|
216
|
+
raise ValueError("An immutable merge input was changed.")
|
|
217
|
+
filename = self.filename(request, RECOVERY_OUTPUT_BASENAME)
|
|
218
|
+
return self.read_file(request, filename) if os.path.lexists(filename) else None
|
|
219
|
+
|
|
220
|
+
def validate_output(self, request: MergetoolRequest, expected: FileImage) -> None:
|
|
221
|
+
"""Require the same saved file identity and content after any confirmation wait."""
|
|
222
|
+
if self.capture_output(request) != expected:
|
|
223
|
+
raise ValueError("The saved merge result changed; it has not been published.")
|
|
224
|
+
|
|
225
|
+
def cleanup(self, request: MergetoolRequest, output: FileImage | None) -> None:
|
|
226
|
+
"""Delete only named files with recorded identities, retaining unexpected content."""
|
|
227
|
+
request_id = request.operation.operation_id
|
|
228
|
+
if request_id not in self.directory_by_request:
|
|
229
|
+
return
|
|
230
|
+
self.validate_directory(request)
|
|
231
|
+
image_by_filename = dict(self.input_by_request[request_id])
|
|
232
|
+
if output is not None:
|
|
233
|
+
image_by_filename[self.filename(request, RECOVERY_OUTPUT_BASENAME)] = output
|
|
234
|
+
for filename, image in image_by_filename.items():
|
|
235
|
+
if self.read_file(request, filename) != image:
|
|
236
|
+
raise ValueError("A private file changed; cleanup was refused.")
|
|
237
|
+
for filename in image_by_filename:
|
|
238
|
+
os.chmod(filename, PRIVATE_FILE_MODE)
|
|
239
|
+
os.unlink(filename)
|
|
240
|
+
directory, _identity = self.directory_by_request[request_id]
|
|
241
|
+
os.rmdir(directory)
|
|
242
|
+
self.directory_by_request.pop(request_id)
|
|
243
|
+
self.input_by_request.pop(request_id)
|
|
244
|
+
self.parent_by_request.pop(request_id)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class MergetoolService(QObject):
|
|
248
|
+
"""Reserve each selected conflict through its tool or file choice and final Git reread."""
|
|
249
|
+
|
|
250
|
+
started = Signal(object)
|
|
251
|
+
finished = Signal(object)
|
|
252
|
+
confirmation_requested = Signal(object, str)
|
|
253
|
+
confirmation_closed = Signal(str)
|
|
254
|
+
idle = Signal()
|
|
255
|
+
series_changed = Signal(object)
|
|
256
|
+
series_finished = Signal(object)
|
|
257
|
+
event_ready = Signal()
|
|
258
|
+
|
|
259
|
+
def __init__(self, conflict: ConflictService, mutation: MutationService, parent: QObject | None = None,
|
|
260
|
+
*, process_factory: Callable[..., subprocess.Popen] = subprocess.Popen, temporary_store: TemporaryMergeStore | None = None,
|
|
261
|
+
activation: Callable[[], StartupActivation] = no_startup_activation, platform: str = sys.platform,
|
|
262
|
+
distribution_family: DistributionFamily = DistributionFamily.UNKNOWN, distribution_release: DistributionRelease = DistributionRelease.OTHER) -> None:
|
|
263
|
+
"""Inject the real transaction and process creator without hiding their ownership.
|
|
264
|
+
|
|
265
|
+
A merge refused for a missing tool says how to install KDiff3 on ``platform``, and under Linux on the distribution
|
|
266
|
+
``distribution_family`` and ``distribution_release`` that the wiring read from the session once; all are received,
|
|
267
|
+
so each system's message can be checked from any of them.
|
|
268
|
+
|
|
269
|
+
The activation source tells the desktop that the user asked for a merge tool (an X11 startup identifier or a
|
|
270
|
+
Wayland activation token), so its window may come to the front. It is read on the window's thread at the click
|
|
271
|
+
that starts a series and at the agreement on a missing base, never for a tool started without a click. Without
|
|
272
|
+
a desktop, as in the tests, there is none.
|
|
273
|
+
"""
|
|
274
|
+
super().__init__(parent)
|
|
275
|
+
self.conflict = conflict
|
|
276
|
+
self.mutation = mutation
|
|
277
|
+
self.activation = activation
|
|
278
|
+
self.platform = platform
|
|
279
|
+
self.distribution_family = distribution_family
|
|
280
|
+
self.distribution_release = distribution_release
|
|
281
|
+
self.worktree_service = mutation.worktree_service
|
|
282
|
+
self.repository_operation = mutation.repository_operation
|
|
283
|
+
self.process_factory = process_factory
|
|
284
|
+
self.temporary_store = temporary_store if temporary_store is not None else TemporaryMergeStore()
|
|
285
|
+
self.active_by_id: dict[str, MergetoolRun] = {}
|
|
286
|
+
self.result_by_id: dict[str, MergetoolResult] = {}
|
|
287
|
+
self.series_by_id: dict[str, MergetoolSeries] = {}
|
|
288
|
+
self.series_id_by_request: dict[str, str] = {}
|
|
289
|
+
self.event_queue: queue.SimpleQueue[tuple[str, str, object]] = queue.SimpleQueue()
|
|
290
|
+
self.closed = False
|
|
291
|
+
self.timer = QTimer(self)
|
|
292
|
+
self.timer.setInterval(TOOL_RESULT_POLL_MS)
|
|
293
|
+
self.timer.timeout.connect(self._poll)
|
|
294
|
+
self.event_ready.connect(self._poll, Qt.ConnectionType.QueuedConnection)
|
|
295
|
+
self.repository_operation.changed.connect(self._revalidate_runs)
|
|
296
|
+
|
|
297
|
+
def owns_repository(self, common_git_dir: str) -> bool:
|
|
298
|
+
"""Include between-file reads so a series cannot race a rebase continuation."""
|
|
299
|
+
return (any(run.request.operation.common_git_dir == common_git_dir for run in self.active_by_id.values())
|
|
300
|
+
or any(series.request.operation.common_git_dir == common_git_dir for series in self.series_by_id.values()))
|
|
301
|
+
|
|
302
|
+
def merge_series(self, request: MergetoolRequest, paths: tuple[bytes, ...], is_current: Callable[[], bool]) -> str:
|
|
303
|
+
"""Capture paths once and refuse another series against the same shared repository.
|
|
304
|
+
|
|
305
|
+
A refused series says why, so the user knows whether to wait, reopen the window or select a file: GitPane is
|
|
306
|
+
closing; no conflicted file is selected; the window is stale, with the reason it lost its authority (never
|
|
307
|
+
another running operation, since waiting for it would not help); or another series already works on the same
|
|
308
|
+
shared repository. Between two files a series holds no reservation, so only this service can name it.
|
|
309
|
+
|
|
310
|
+
Called by the user's click: an accepted series reads the click's startup value here, on the window's thread,
|
|
311
|
+
and keeps it for its first tool.
|
|
312
|
+
"""
|
|
313
|
+
series_id = request.operation.operation_id
|
|
314
|
+
if self.closed:
|
|
315
|
+
self._refuse_series(request, GITPANE_CLOSING_TEXT)
|
|
316
|
+
return series_id
|
|
317
|
+
if not paths:
|
|
318
|
+
self._refuse_series(request, NO_CONFLICT_SELECTED_TEXT)
|
|
319
|
+
return series_id
|
|
320
|
+
if not is_current():
|
|
321
|
+
self._refuse_series(request, self.repository_operation.authority_cause(request.operation))
|
|
322
|
+
return series_id
|
|
323
|
+
for series in self.series_by_id.values():
|
|
324
|
+
if series.request.operation.common_git_dir == request.operation.common_git_dir:
|
|
325
|
+
self._refuse_series(request, MERGE_SERIES_RUNNING_TEXT)
|
|
326
|
+
return series_id
|
|
327
|
+
series = MergetoolSeries(request, tuple(sorted(set(paths))), is_current, activation=self.activation())
|
|
328
|
+
self.series_by_id[series_id] = series
|
|
329
|
+
self._next_series_path(series)
|
|
330
|
+
return series_id
|
|
331
|
+
|
|
332
|
+
def _refuse_series(self, request: MergetoolRequest, cause: str) -> None:
|
|
333
|
+
"""Return a visible refusal with its real cause, without touching another series's reservation or state.
|
|
334
|
+
|
|
335
|
+
The result is published on the next event-loop turn, like any other result, so the caller has returned the
|
|
336
|
+
series identifier before the conflicts window reads the refusal.
|
|
337
|
+
"""
|
|
338
|
+
result = MergetoolResult(request, MergetoolStatus.UNRESOLVED, cause)
|
|
339
|
+
self.result_by_id[request.operation.operation_id] = result
|
|
340
|
+
QTimer.singleShot(0, self, lambda: self.finished.emit(result))
|
|
341
|
+
|
|
342
|
+
def invalidate_session(self, session_id: str | None) -> None:
|
|
343
|
+
"""Permanently revoke old publication rights while preserving children and saved output."""
|
|
344
|
+
for series in tuple(self.series_by_id.values()):
|
|
345
|
+
if series.request.operation.session_id == session_id:
|
|
346
|
+
series.invalidated = True
|
|
347
|
+
self.stop_series(series.request.operation.operation_id)
|
|
348
|
+
for run in tuple(self.active_by_id.values()):
|
|
349
|
+
if run.request.operation.session_id == session_id:
|
|
350
|
+
run.invalidated = True
|
|
351
|
+
if run.state in AWAITING_MERGE_STATES:
|
|
352
|
+
self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)
|
|
353
|
+
|
|
354
|
+
def stop_series(self, series_id: str) -> None:
|
|
355
|
+
"""Prevent future launches without revoking the current process's publication rights."""
|
|
356
|
+
series = self.series_by_id.get(series_id)
|
|
357
|
+
if series is not None:
|
|
358
|
+
series.stop_requested = True
|
|
359
|
+
self.series_changed.emit(series)
|
|
360
|
+
|
|
361
|
+
def _next_series_path(self, series: MergetoolSeries) -> None:
|
|
362
|
+
"""Choose the next path from a complete read of the repository, including paths already resolved externally.
|
|
363
|
+
|
|
364
|
+
The first path reads the repository. Each following path starts from the terminal read the previous file has
|
|
365
|
+
just finished, when that read still covers exactly the original inventory: its conversion settings are then
|
|
366
|
+
compared directly with the original ones. When the inventory changed, for instance a result equal to HEAD
|
|
367
|
+
leaves the list of changed files, the repository is read again and its conversion is checked on the original
|
|
368
|
+
inventory, because settings read on two different sets of paths cannot be compared. Either way the next tool
|
|
369
|
+
only starts after its own preparation has asked Git to verify the state it relies on.
|
|
370
|
+
|
|
371
|
+
The first tool the series starts takes the startup value of the click that started it; the series then forgets
|
|
372
|
+
it, so a tool started when the previous one closes cannot take the focus without a click.
|
|
373
|
+
"""
|
|
374
|
+
request = series.request
|
|
375
|
+
if series.stop_requested or series.invalidated or self.closed or not series.is_current() or series.position >= len(series.paths):
|
|
376
|
+
self._finish_series(series)
|
|
377
|
+
return
|
|
378
|
+
series.current_id = ""
|
|
379
|
+
self.series_changed.emit(series)
|
|
380
|
+
|
|
381
|
+
def captured(snapshot: object, error: Exception | None) -> None:
|
|
382
|
+
"""Advance only from a fresh read and never reinterpret the original path selection."""
|
|
383
|
+
if error is not None or snapshot is None or not snapshot.fresh:
|
|
384
|
+
series.diagnostic = "Could not read current merge conflicts. Rescan before retrying."
|
|
385
|
+
if error is not None:
|
|
386
|
+
series.diagnostic += " " + self._safe_error(error)
|
|
387
|
+
self._finish_series(series)
|
|
388
|
+
return
|
|
389
|
+
series.snapshot = snapshot
|
|
390
|
+
if series.stop_requested or series.invalidated or self.closed or not series.is_current():
|
|
391
|
+
self._finish_series(series)
|
|
392
|
+
return
|
|
393
|
+
if snapshot.head_oid != request.snapshot.head_oid:
|
|
394
|
+
series.diagnostic = "Repository configuration or HEAD changed. Rescan before retrying."
|
|
395
|
+
self._finish_series(series)
|
|
396
|
+
return
|
|
397
|
+
path = series.paths[series.position]
|
|
398
|
+
series.position += 1
|
|
399
|
+
entry = next((entry for entry in conflicts_from_snapshot(snapshot) if entry.path == path), None)
|
|
400
|
+
if entry is None:
|
|
401
|
+
series.skipped_paths.append(path)
|
|
402
|
+
self._next_series_path(series)
|
|
403
|
+
return
|
|
404
|
+
original = next((entry for entry in conflicts_from_snapshot(request.snapshot) if entry.path == path), None)
|
|
405
|
+
if original is None or entry.sides != original.sides:
|
|
406
|
+
series.diagnostic = "The selected conflict versions changed. Rescan before retrying."
|
|
407
|
+
self._finish_series(series)
|
|
408
|
+
return
|
|
409
|
+
owner = replace(request.operation, operation_id=uuid.uuid4().hex)
|
|
410
|
+
current = replace(request, snapshot=snapshot, path=path, sides=entry.sides, operation=owner)
|
|
411
|
+
series.current_id = owner.operation_id
|
|
412
|
+
self.series_id_by_request[owner.operation_id] = request.operation.operation_id
|
|
413
|
+
self.series_changed.emit(series)
|
|
414
|
+
activation = series.activation
|
|
415
|
+
series.activation = NO_STARTUP_ACTIVATION
|
|
416
|
+
self.merge(current, series.is_current, activation=activation)
|
|
417
|
+
|
|
418
|
+
def capture() -> Generator:
|
|
419
|
+
"""Check attributes against the original inventory without changing the new snapshot."""
|
|
420
|
+
snapshot = yield from self.worktree_service.capture(request.snapshot.context, request.snapshot.git_filename, request.snapshot.generation)
|
|
421
|
+
yield from self.worktree_service.verify_conversion(request.snapshot)
|
|
422
|
+
return snapshot
|
|
423
|
+
|
|
424
|
+
if series.results:
|
|
425
|
+
terminal = series.snapshot
|
|
426
|
+
original = request.snapshot
|
|
427
|
+
if (terminal is None or not terminal.fresh or terminal.key != original.key or terminal.context != original.context
|
|
428
|
+
or terminal.git_filename != original.git_filename):
|
|
429
|
+
series.diagnostic = "Could not read current merge conflicts. Rescan before retrying."
|
|
430
|
+
self._finish_series(series)
|
|
431
|
+
return
|
|
432
|
+
if tuple(terminal.fingerprint_by_path) == tuple(original.fingerprint_by_path):
|
|
433
|
+
if terminal.conversion != original.conversion:
|
|
434
|
+
series.diagnostic = "Repository configuration or attributes changed. Rescan before retrying."
|
|
435
|
+
self._finish_series(series)
|
|
436
|
+
return
|
|
437
|
+
captured(terminal, None)
|
|
438
|
+
return
|
|
439
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, capture(), captured)
|
|
440
|
+
|
|
441
|
+
def _finish_series(self, series: MergetoolSeries) -> None:
|
|
442
|
+
"""Publish the series observation without counting external resolutions as our results."""
|
|
443
|
+
self.series_by_id.pop(series.request.operation.operation_id, None)
|
|
444
|
+
self.series_finished.emit(series)
|
|
445
|
+
self._update_poll_timer()
|
|
446
|
+
if not self.active_by_id and not self.series_by_id:
|
|
447
|
+
self.idle.emit()
|
|
448
|
+
|
|
449
|
+
def _series_result(self, result: MergetoolResult) -> None:
|
|
450
|
+
"""Require saved success and terminal Git proof before considering the next editor."""
|
|
451
|
+
series_id = self.series_id_by_request.pop(result.request.operation.operation_id, None)
|
|
452
|
+
series = self.series_by_id.get(series_id)
|
|
453
|
+
if series is None:
|
|
454
|
+
return
|
|
455
|
+
series.results.append(result)
|
|
456
|
+
series.snapshot = result.snapshot
|
|
457
|
+
if (result.status != MergetoolStatus.SAVED or result.snapshot is None or not result.snapshot.fresh
|
|
458
|
+
or any(entry.path == result.request.path for entry in conflicts_from_snapshot(result.snapshot))):
|
|
459
|
+
series.diagnostic = result.diagnostic
|
|
460
|
+
if result.recovery_filename:
|
|
461
|
+
series.diagnostic += " Saved result retained at: " + result.recovery_filename
|
|
462
|
+
self._finish_series(series)
|
|
463
|
+
return
|
|
464
|
+
self._next_series_path(series)
|
|
465
|
+
|
|
466
|
+
def merge(self, request: MergetoolRequest, is_current: Callable[[], bool], *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> str:
|
|
467
|
+
"""Reserve each captured file before choosing a deletion prompt or an external merge.
|
|
468
|
+
|
|
469
|
+
``activation`` is the startup value of the click that asked for this file, handed to its tool; a file started
|
|
470
|
+
without a click gets none.
|
|
471
|
+
|
|
472
|
+
A refused merge says why: GitPane is closing; the window is stale, with the reason it lost its authority (never
|
|
473
|
+
another running operation, since waiting for it would not help); or the repository could not be reserved, with
|
|
474
|
+
the reason the repository operation service finds, which names another operation only when one really holds it.
|
|
475
|
+
"""
|
|
476
|
+
request_id = request.operation.operation_id
|
|
477
|
+
cause = ""
|
|
478
|
+
if self.closed:
|
|
479
|
+
cause = GITPANE_CLOSING_TEXT
|
|
480
|
+
elif not is_current():
|
|
481
|
+
cause = self.repository_operation.authority_cause(request.operation)
|
|
482
|
+
elif not self.repository_operation.reserve(request.operation):
|
|
483
|
+
cause = self.repository_operation.refusal_cause(request.operation)
|
|
484
|
+
if cause:
|
|
485
|
+
result = MergetoolResult(request, MergetoolStatus.UNRESOLVED, cause)
|
|
486
|
+
def refused() -> None:
|
|
487
|
+
"""Report the refusal before ending its owning series."""
|
|
488
|
+
self.finished.emit(result)
|
|
489
|
+
self._series_result(result)
|
|
490
|
+
|
|
491
|
+
QTimer.singleShot(0, self, refused)
|
|
492
|
+
return request_id
|
|
493
|
+
run = MergetoolRun(request, is_current, activation=activation)
|
|
494
|
+
self.active_by_id[request_id] = run
|
|
495
|
+
self._prepare(run)
|
|
496
|
+
self._update_poll_timer()
|
|
497
|
+
return request_id
|
|
498
|
+
|
|
499
|
+
def _prepare(self, run: MergetoolRun) -> None:
|
|
500
|
+
"""Validate missing-side choices before private extraction and native startup."""
|
|
501
|
+
request = run.request
|
|
502
|
+
request_id = request.operation.operation_id
|
|
503
|
+
|
|
504
|
+
def prepare() -> str:
|
|
505
|
+
"""Inspect the conflict first so a deleted side does not require an installed text merge tool."""
|
|
506
|
+
if (request.operation.key != request.snapshot.key or request.operation.context != request.snapshot.context
|
|
507
|
+
or request.operation.git_filename != request.snapshot.git_filename):
|
|
508
|
+
raise ValueError("The merge request does not belong to its repository owner.")
|
|
509
|
+
entry = next((entry for entry in conflicts_from_snapshot(request.snapshot) if entry.path == request.path), None)
|
|
510
|
+
if entry is None or entry.sides != request.sides:
|
|
511
|
+
raise ValueError("The selected conflict versions changed. Rescan before retrying.")
|
|
512
|
+
if has_deleted_side(entry.sides):
|
|
513
|
+
return "deleted-side"
|
|
514
|
+
try:
|
|
515
|
+
request.recipe.validate(platform=self.platform, family=self.distribution_family, release=self.distribution_release)
|
|
516
|
+
except ValueError as error:
|
|
517
|
+
raise MergetoolUnavailable(str(error)) from error
|
|
518
|
+
if entry.kind not in ("Text conflict", "Add/add (no base)"):
|
|
519
|
+
raise ValueError("Use manual conflict resolution for this file type or missing side.")
|
|
520
|
+
missing_base = entry.kind == "Add/add (no base)"
|
|
521
|
+
if missing_base and request.allow_missing_base:
|
|
522
|
+
parse_mergetool_command(request.recipe.command, None, "LOCAL", "REMOTE", RECOVERY_OUTPUT_BASENAME, allow_missing_base=True)
|
|
523
|
+
return "missing-base" if missing_base and not request.allow_missing_base else ""
|
|
524
|
+
|
|
525
|
+
def sequence() -> Generator:
|
|
526
|
+
"""Keep both file and Git work out of synchronous widget callbacks."""
|
|
527
|
+
reason = yield FileTask(prepare)
|
|
528
|
+
if reason == "deleted-side":
|
|
529
|
+
yield from self.worktree_service.verify(request.snapshot, (request.path,))
|
|
530
|
+
resolution = yield FileTask(lambda: ConflictResolutionIntent.capture(request.snapshot, request.path, request.operation, delete=True))
|
|
531
|
+
return reason, resolution
|
|
532
|
+
if reason:
|
|
533
|
+
return reason, None
|
|
534
|
+
return "", (yield from self.conflict.prepare_merge(request, self.temporary_store, self.mutation))
|
|
535
|
+
|
|
536
|
+
def prepared(value: object, error: Exception | None) -> None:
|
|
537
|
+
"""Start the owned child only while the captured session is still valid."""
|
|
538
|
+
if error is not None:
|
|
539
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, self._safe_error(error), settings_available=isinstance(error, MergetoolUnavailable))
|
|
540
|
+
return
|
|
541
|
+
if not self._current(run):
|
|
542
|
+
self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.")
|
|
543
|
+
return
|
|
544
|
+
reason, result = value
|
|
545
|
+
if reason == "deleted-side":
|
|
546
|
+
run.resolution = result
|
|
547
|
+
run.state = "awaiting-deletion"
|
|
548
|
+
self._update_poll_timer()
|
|
549
|
+
self.confirmation_requested.emit(request, reason)
|
|
550
|
+
return
|
|
551
|
+
if reason == "missing-base":
|
|
552
|
+
run.state = "awaiting-base"
|
|
553
|
+
self._update_poll_timer()
|
|
554
|
+
self.confirmation_requested.emit(request, "missing-base")
|
|
555
|
+
return
|
|
556
|
+
run.before = result
|
|
557
|
+
run.state = "starting"
|
|
558
|
+
run.deadline = time.monotonic() + MERGETOOL_START_TIMEOUT_MS / 1000
|
|
559
|
+
run.worker = threading.Thread(target=self._run_process, args=(run,), daemon=True)
|
|
560
|
+
run.worker.start()
|
|
561
|
+
self._update_poll_timer()
|
|
562
|
+
|
|
563
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), prepared, operation_id=request_id)
|
|
564
|
+
|
|
565
|
+
def confirm(self, request_id: str, accepted: bool) -> None:
|
|
566
|
+
"""Require explicit agreement while rechecking saved output and original authority.
|
|
567
|
+
|
|
568
|
+
Called when the user answers the question: an agreement on a missing base starts the tool, so the startup value
|
|
569
|
+
of that answer is read here, on the window's thread, for it.
|
|
570
|
+
"""
|
|
571
|
+
run = self.active_by_id.get(request_id)
|
|
572
|
+
if run is not None and run.state == "awaiting-deletion":
|
|
573
|
+
if not accepted:
|
|
574
|
+
self.confirm_deletion(request_id, None)
|
|
575
|
+
return
|
|
576
|
+
if run is None or run.state not in ("awaiting-base", "awaiting-result"):
|
|
577
|
+
return
|
|
578
|
+
if not accepted or not self._current(run):
|
|
579
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, "The conflict was kept unresolved.", retain=run.output is not None)
|
|
580
|
+
return
|
|
581
|
+
if run.state == "awaiting-base":
|
|
582
|
+
run.request = replace(run.request, allow_missing_base=True)
|
|
583
|
+
run.activation = self.activation()
|
|
584
|
+
run.state = "preparing"
|
|
585
|
+
self._prepare(run)
|
|
586
|
+
return
|
|
587
|
+
self._publish(run)
|
|
588
|
+
|
|
589
|
+
def confirm_deletion(self, request_id: str, delete: bool | None) -> None:
|
|
590
|
+
"""Stage the exact keep/delete choice under the existing owner, or stop the series on cancellation.
|
|
591
|
+
|
|
592
|
+
Called when the user answers the question: when a series has files left, the tool of the next one starts once the
|
|
593
|
+
choice is staged, so the startup value of that answer is read here, on the window's thread, for it.
|
|
594
|
+
"""
|
|
595
|
+
run = self.active_by_id.get(request_id)
|
|
596
|
+
if run is None or run.state != "awaiting-deletion" or run.resolution is None:
|
|
597
|
+
return
|
|
598
|
+
if delete is None or not self._current(run):
|
|
599
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, "The conflict was kept unresolved. The remaining selected files were not processed.")
|
|
600
|
+
return
|
|
601
|
+
if not delete and not run.resolution.image.exists:
|
|
602
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, "The working file is absent and cannot be kept. Rescan before retrying.")
|
|
603
|
+
return
|
|
604
|
+
series = self.series_by_id.get(self.series_id_by_request.get(request_id))
|
|
605
|
+
if series is not None and series.position < len(series.paths):
|
|
606
|
+
series.activation = self.activation()
|
|
607
|
+
run.state = "publishing"
|
|
608
|
+
resolution = replace(run.resolution, delete=delete)
|
|
609
|
+
|
|
610
|
+
def completed(outcome: object, error: Exception | None) -> None:
|
|
611
|
+
"""Continue only after successful publication; retain the transaction's recovery diagnostic on failure."""
|
|
612
|
+
if error is not None:
|
|
613
|
+
self._finish(run, MergetoolStatus.STALE, self._safe_error(error))
|
|
614
|
+
return
|
|
615
|
+
if outcome.status != "success":
|
|
616
|
+
self._finish(run, MergetoolStatus.STALE, outcome.diagnostic.cause if outcome.diagnostic is not None else "Git could not stage the chosen conflict resolution.")
|
|
617
|
+
return
|
|
618
|
+
self._finish(run, MergetoolStatus.SAVED)
|
|
619
|
+
|
|
620
|
+
self.worktree_service.run(resolution.snapshot.context, resolution.snapshot.git_filename, self.mutation.resolve_conflict_sequence(resolution), completed,
|
|
621
|
+
operation_id=request_id)
|
|
622
|
+
|
|
623
|
+
def _current(self, run: MergetoolRun) -> bool:
|
|
624
|
+
"""Recheck both application authority and exact repository ownership before publication."""
|
|
625
|
+
registry = self.repository_operation
|
|
626
|
+
return (not run.invalidated and not self.closed and not registry.closed and run.is_current() and registry.active_by_id.get(run.request.operation.operation_id) is run.request.operation
|
|
627
|
+
and (registry.allows is None or registry.allows(run.request.operation)))
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _safe_error(error: Exception) -> str:
|
|
631
|
+
"""Avoid including arbitrary child output or command arguments in merge diagnostics."""
|
|
632
|
+
from gitpane.worktree.command_sequence import GitCommandError
|
|
633
|
+
|
|
634
|
+
return "Git could not validate or stage the merge result. Rescan before retrying." if isinstance(error, GitCommandError) else str(error)
|
|
635
|
+
|
|
636
|
+
def _run_process(self, run: MergetoolRun) -> None:
|
|
637
|
+
"""Wait for the supervisor and every descendant without blocking the Qt event loop.
|
|
638
|
+
|
|
639
|
+
The supervisor gets GitPane's environment without any inherited startup value, plus the one of the click that
|
|
640
|
+
launched this tool, and hands that environment on to the tool.
|
|
641
|
+
"""
|
|
642
|
+
request = run.request
|
|
643
|
+
request_id = request.operation.operation_id
|
|
644
|
+
try:
|
|
645
|
+
store = self.temporary_store
|
|
646
|
+
store.validate_directory(request)
|
|
647
|
+
role_set = set()
|
|
648
|
+
for side in request.sides:
|
|
649
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
|
|
650
|
+
arguments = parse_mergetool_command(request.recipe.command, store.filename(request, "BASE") if "BASE" in role_set else None,
|
|
651
|
+
store.filename(request, "LOCAL"), store.filename(request, "REMOTE"), store.filename(request, RECOVERY_OUTPUT_BASENAME),
|
|
652
|
+
allow_missing_base=request.allow_missing_base)
|
|
653
|
+
if os.path.lexists(store.filename(request, RECOVERY_OUTPUT_BASENAME)):
|
|
654
|
+
raise ValueError("The private merge output was created before launch.")
|
|
655
|
+
run.group = NativeProcessGroup()
|
|
656
|
+
program_filename, arguments = run.group.prepare(request.recipe.program_filename, arguments)
|
|
657
|
+
directory, _identity = store.directory_by_request[request_id]
|
|
658
|
+
# Tool streams may contain whole file versions; redaction cannot safely classify arbitrary blob text.
|
|
659
|
+
run.process = self.process_factory([program_filename, *arguments], cwd=directory, shell=False, stdin=subprocess.DEVNULL,
|
|
660
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=run.activation.environment(os.environ),
|
|
661
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
662
|
+
self._queue_event((request_id, "started", None))
|
|
663
|
+
self._wait_process(run)
|
|
664
|
+
except (OSError, ValueError) as error:
|
|
665
|
+
if run.process is not None:
|
|
666
|
+
run.uncertain = True
|
|
667
|
+
self._wait_process(run)
|
|
668
|
+
return
|
|
669
|
+
if run.group is not None:
|
|
670
|
+
run.group.close()
|
|
671
|
+
self._queue_event((request_id, "error", type(error).__name__))
|
|
672
|
+
|
|
673
|
+
def _wait_process(self, run: MergetoolRun) -> None:
|
|
674
|
+
"""Keep polling uncertain OS proof instead of abandoning a still-owned native child."""
|
|
675
|
+
request_id = run.request.operation.operation_id
|
|
676
|
+
stop_sent = False
|
|
677
|
+
while True:
|
|
678
|
+
if run.stop_requested.is_set() and not stop_sent:
|
|
679
|
+
try:
|
|
680
|
+
run.group.stop()
|
|
681
|
+
stop_sent = True
|
|
682
|
+
except OSError:
|
|
683
|
+
run.uncertain = True
|
|
684
|
+
try:
|
|
685
|
+
return_code = run.process.poll()
|
|
686
|
+
if return_code is not None and run.group.is_empty():
|
|
687
|
+
run.process.wait(timeout=MERGETOOL_TERMINATE_TIMEOUT_MS / 1000)
|
|
688
|
+
run.return_code = run.group.result_return_code(return_code)
|
|
689
|
+
failed = run.group.launch_failed()
|
|
690
|
+
run.group.close()
|
|
691
|
+
self._queue_event((request_id, "exited", failed))
|
|
692
|
+
return
|
|
693
|
+
except (OSError, ValueError):
|
|
694
|
+
if not run.uncertain:
|
|
695
|
+
self._queue_event((request_id, "unknown", None))
|
|
696
|
+
run.uncertain = True
|
|
697
|
+
time.sleep(TOOL_RESULT_POLL_MS / 1000)
|
|
698
|
+
|
|
699
|
+
def _queue_event(self, event: tuple[str, str, object]) -> None:
|
|
700
|
+
"""Let a worker thread drop one event and wake the Qt thread, the only one that touches the runs."""
|
|
701
|
+
self.event_queue.put(event)
|
|
702
|
+
self.event_ready.emit()
|
|
703
|
+
|
|
704
|
+
def _update_poll_timer(self) -> None:
|
|
705
|
+
"""Run the timer only while a run waits in a state that needs checking over time, never while a tool window is open."""
|
|
706
|
+
watched = any(run.state in MERGETOOL_WATCHED_STATE_SET for run in self.active_by_id.values())
|
|
707
|
+
if watched and not self.timer.isActive():
|
|
708
|
+
self.timer.start()
|
|
709
|
+
elif not watched and self.timer.isActive():
|
|
710
|
+
self.timer.stop()
|
|
711
|
+
|
|
712
|
+
def _revalidate_runs(self) -> None:
|
|
713
|
+
"""Permanently invalidate every run that lost its authority, and end a pending confirmation that did.
|
|
714
|
+
|
|
715
|
+
Nothing is polled while a tool window is open: authority is rechecked each time a repository operation starts or ends,
|
|
716
|
+
when the tool exits and during publication. A change of the merge tool or Git preference invalidates the run for good
|
|
717
|
+
through invalidate_session, even if the preference is restored afterwards.
|
|
718
|
+
"""
|
|
719
|
+
for run in tuple(self.active_by_id.values()):
|
|
720
|
+
if self._current(run):
|
|
721
|
+
continue
|
|
722
|
+
run.invalidated = True
|
|
723
|
+
if run.state in AWAITING_MERGE_STATES:
|
|
724
|
+
self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)
|
|
725
|
+
|
|
726
|
+
def _poll(self) -> None:
|
|
727
|
+
"""Deliver worker events on Qt while retaining timed-out or unknown process ownership.
|
|
728
|
+
|
|
729
|
+
Worker events wake it through event_ready; the timer only adds the checks over time of the watched states.
|
|
730
|
+
"""
|
|
731
|
+
while True:
|
|
732
|
+
try:
|
|
733
|
+
request_id, kind, value = self.event_queue.get_nowait()
|
|
734
|
+
except queue.Empty:
|
|
735
|
+
break
|
|
736
|
+
run = self.active_by_id.get(request_id)
|
|
737
|
+
if run is None:
|
|
738
|
+
continue
|
|
739
|
+
if kind == "started":
|
|
740
|
+
run.state = "running"
|
|
741
|
+
self.started.emit(run.request)
|
|
742
|
+
elif kind == "exited":
|
|
743
|
+
self._process_finished(run, bool(value))
|
|
744
|
+
elif kind == "error":
|
|
745
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, "The external merge tool could not start (" + str(value) + ").", settings_available=True)
|
|
746
|
+
elif kind == "unknown":
|
|
747
|
+
run.state = "unknown"
|
|
748
|
+
for run in tuple(self.active_by_id.values()):
|
|
749
|
+
if run.state == "starting" and run.deadline is not None and time.monotonic() >= run.deadline:
|
|
750
|
+
run.forced = True
|
|
751
|
+
run.stop_requested.set()
|
|
752
|
+
self._revalidate_runs()
|
|
753
|
+
self._update_poll_timer()
|
|
754
|
+
|
|
755
|
+
def _process_finished(self, run: MergetoolRun, launch_failed: bool) -> None:
|
|
756
|
+
"""Inspect a request-owned result only after the OS proves actual process completion."""
|
|
757
|
+
run.state = "inspecting"
|
|
758
|
+
|
|
759
|
+
def sequence() -> Generator:
|
|
760
|
+
"""Read regular saved bytes on a file worker."""
|
|
761
|
+
return (yield FileTask(lambda: self.temporary_store.capture_output(run.request)))
|
|
762
|
+
|
|
763
|
+
def inspected(output: object, error: Exception | None) -> None:
|
|
764
|
+
"""Never equate a successful close without a produced file to a resolution."""
|
|
765
|
+
run.output = output
|
|
766
|
+
if error is not None:
|
|
767
|
+
self._finish(run, MergetoolStatus.STALE, self._safe_error(error), retain=True)
|
|
768
|
+
elif not self._current(run):
|
|
769
|
+
self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=output is not None)
|
|
770
|
+
elif output is None or launch_failed or run.forced or self._crashed(run.return_code):
|
|
771
|
+
self._finish(run, MergetoolStatus.UNRESOLVED, "No successful saved merge result was recognized. Use Mark conflict as solved after checking the working file.", retain=output is not None)
|
|
772
|
+
elif run.return_code != 0 or run.uncertain or run.request.recipe.command != MERGETOOL_DEFAULT_COMMAND or self._has_markers(output):
|
|
773
|
+
run.state = "awaiting-result"
|
|
774
|
+
self._update_poll_timer()
|
|
775
|
+
self.confirmation_requested.emit(run.request, "saved-result")
|
|
776
|
+
else:
|
|
777
|
+
self._publish(run)
|
|
778
|
+
|
|
779
|
+
request = run.request
|
|
780
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), inspected, operation_id=request.operation.operation_id)
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def _crashed(return_code: int | None) -> bool:
|
|
784
|
+
"""Distinguish signalled exits and Windows exception statuses from normal refusal codes."""
|
|
785
|
+
return return_code is None or return_code < 0 or os.name == "nt" and return_code >= WINDOWS_EXCEPTION_STATUS_START
|
|
786
|
+
|
|
787
|
+
@staticmethod
|
|
788
|
+
def _has_markers(output: FileImage) -> bool:
|
|
789
|
+
"""Treat merge-like markers as ambiguity while allowing explicit saved-result approval."""
|
|
790
|
+
content = output.read()
|
|
791
|
+
return b"<<<<<<<" in content and b"=======" in content and b">>>>>>>" in content
|
|
792
|
+
|
|
793
|
+
def _publish(self, run: MergetoolRun) -> None:
|
|
794
|
+
"""Keep metadata reads lightweight while the index transaction supervises its writes."""
|
|
795
|
+
run.state = "publishing"
|
|
796
|
+
request = run.request
|
|
797
|
+
|
|
798
|
+
def completed(outcome: object, error: Exception | None) -> None:
|
|
799
|
+
"""Preserve saved bytes on any refusal, including Git filter or lock failures."""
|
|
800
|
+
if error is not None or outcome.status != "success":
|
|
801
|
+
self._finish(run, MergetoolStatus.STALE, self._safe_error(error) if error else "Git could not stage the saved working result.", retain=True)
|
|
802
|
+
return
|
|
803
|
+
self._finish(run, MergetoolStatus.SAVED)
|
|
804
|
+
|
|
805
|
+
sequence = self.conflict.publish_merge_result(request, run.before, run.output, self.temporary_store, self.mutation, lambda: self._current(run))
|
|
806
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence, completed, operation_id=request.operation.operation_id)
|
|
807
|
+
|
|
808
|
+
def _finish(self, run: MergetoolRun, status: MergetoolStatus, diagnostic: str = "", *, retain: bool = False, settings_available: bool = False) -> None:
|
|
809
|
+
"""Reread terminal Git state and finish file work before releasing the sole owner."""
|
|
810
|
+
run.state = "finishing"
|
|
811
|
+
request, store = run.request, self.temporary_store
|
|
812
|
+
request_id = request.operation.operation_id
|
|
813
|
+
self.confirmation_closed.emit(request_id)
|
|
814
|
+
recovery_filename = store.filename(request, RECOVERY_OUTPUT_BASENAME) if retain and request_id in store.directory_by_request else ""
|
|
815
|
+
recovery_diagnostic = ""
|
|
816
|
+
|
|
817
|
+
def sequence() -> Generator:
|
|
818
|
+
"""Keep cleanup and the final observation within the reservation's lifetime."""
|
|
819
|
+
nonlocal recovery_diagnostic
|
|
820
|
+
if recovery_filename:
|
|
821
|
+
try:
|
|
822
|
+
yield FileTask(lambda: store.validate_directory(request))
|
|
823
|
+
yield FileTask(lambda: store.recovery_store.retain(request.snapshot.context, os.path.dirname(recovery_filename), request.path))
|
|
824
|
+
except (OSError, ValueError) as error:
|
|
825
|
+
recovery_diagnostic = " Recovery information could not be recorded: " + self._safe_error(error)
|
|
826
|
+
if not retain:
|
|
827
|
+
yield FileTask(lambda: store.cleanup(request, run.output))
|
|
828
|
+
return (yield from self.worktree_service.capture(request.snapshot.context, request.snapshot.git_filename, request.snapshot.generation))
|
|
829
|
+
|
|
830
|
+
def complete(snapshot: object, error: Exception | None) -> None:
|
|
831
|
+
"""Retain the result before notifying views or allowing another repository operation."""
|
|
832
|
+
final_status = MergetoolStatus.UNKNOWN if error is not None else status
|
|
833
|
+
final_diagnostic = diagnostic or (self._safe_error(error) if error else "")
|
|
834
|
+
final_diagnostic += recovery_diagnostic
|
|
835
|
+
result = MergetoolResult(request, final_status, final_diagnostic, recovery_filename, run.return_code, snapshot, settings_available)
|
|
836
|
+
self.result_by_id[request_id] = result
|
|
837
|
+
self.active_by_id.pop(request_id, None)
|
|
838
|
+
self.repository_operation.reconcile(OperationOutcome(request_id, OperationStatus.SUCCESS if final_status == MergetoolStatus.SAVED else OperationStatus.REFUSED))
|
|
839
|
+
self.finished.emit(result)
|
|
840
|
+
self._series_result(result)
|
|
841
|
+
self._update_poll_timer()
|
|
842
|
+
if not self.active_by_id and not self.series_by_id:
|
|
843
|
+
self.idle.emit()
|
|
844
|
+
|
|
845
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), complete, operation_id=request_id)
|
|
846
|
+
|
|
847
|
+
def close(self) -> None:
|
|
848
|
+
"""Reject new launches and stop supervised descendants while retaining their resources."""
|
|
849
|
+
self.closed = True
|
|
850
|
+
for series_id in tuple(self.series_by_id):
|
|
851
|
+
self.stop_series(series_id)
|
|
852
|
+
for run in tuple(self.active_by_id.values()):
|
|
853
|
+
run.forced = True
|
|
854
|
+
run.stop_requested.set()
|
|
855
|
+
if run.state in AWAITING_MERGE_STATES:
|
|
856
|
+
self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)
|