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,1677 @@
|
|
|
1
|
+
"""Run the forms that create, move, delete or check out a reference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shiboken6 import isValid
|
|
11
|
+
|
|
12
|
+
from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, TAG_PREFIX, ConsoleOperation, SharedText
|
|
13
|
+
from gitpane.controller.refresh_controller import RefreshScope
|
|
14
|
+
from gitpane.history.file_history import WindowCapability
|
|
15
|
+
from gitpane.history.repository_value import ReferenceKind
|
|
16
|
+
from gitpane.history.revision import commit_count_text, commit_difference_in_memory, complete_ancestor_oid_set, is_ancestor_in_memory, resolve_revision_in_memory, revision_text_problem, unmerged_branch_refs_in_memory
|
|
17
|
+
from gitpane.operation.console_line import REPOSITORY_HEADER_LABEL, ConsoleHeader, end_line_with_cause, operation_end_text
|
|
18
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
19
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
20
|
+
from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
|
|
21
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult
|
|
22
|
+
from gitpane.reference.reference_edit import (
|
|
23
|
+
DELETE_LOCAL_BRANCH_OPERATION,
|
|
24
|
+
DELETE_LOCAL_TAG_OPERATION,
|
|
25
|
+
DELETE_REMOTE_BRANCH_OPERATION,
|
|
26
|
+
DELETE_REMOTE_TAG_OPERATION,
|
|
27
|
+
BranchDeletionIntent,
|
|
28
|
+
BranchMode,
|
|
29
|
+
ResetMode,
|
|
30
|
+
deletable_branch_refs,
|
|
31
|
+
merge_base_oid_by_ref,
|
|
32
|
+
push_remote_name,
|
|
33
|
+
remote_branch_destination,
|
|
34
|
+
remote_delete_candidates,
|
|
35
|
+
suggested_branch_name,
|
|
36
|
+
)
|
|
37
|
+
from gitpane.reference.reference_snapshot import short_reference_name
|
|
38
|
+
from gitpane.transport.branch_exchange import TransportRequest
|
|
39
|
+
from gitpane.ui.console.operation_dialog import OperationDialog
|
|
40
|
+
from gitpane.ui.reference.checkout_dialog import CheckoutDialog
|
|
41
|
+
from gitpane.ui.reference.checkout_revision_dialog import CheckoutRevisionDialog
|
|
42
|
+
from gitpane.ui.reference.create_branch_dialog import CreateBranchDialog
|
|
43
|
+
from gitpane.ui.reference.create_tag_dialog import CreateTagDialog
|
|
44
|
+
from gitpane.ui.reference.delete_local_branch_dialog import DeleteLocalBranchDialog
|
|
45
|
+
from gitpane.ui.reference.delete_tag_dialog import NO_PUSH_ADDRESS_CAUSE, NO_REMOTE_CAUSE, SEVERAL_PUSH_ADDRESSES_CAUSE, UNUSABLE_REMOTE_CAUSE, DeleteTagDialog
|
|
46
|
+
from gitpane.ui.reference.form_opening import refuse_form_opening, reveal_form
|
|
47
|
+
from gitpane.ui.reference.reference_dialog import DeleteBranchDialog
|
|
48
|
+
from gitpane.ui.reference.rename_branch_dialog import RenameBranchDialog
|
|
49
|
+
from gitpane.ui.reference.reset_branch_dialog import ResetOtherBranchDialog
|
|
50
|
+
from gitpane.ui.reference.reset_dialog import ResetDialog
|
|
51
|
+
from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
|
|
52
|
+
from gitpane.ui.widget.refresh_advice import refresh_advice_text
|
|
53
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal
|
|
54
|
+
from gitpane.worktree.command_sequence import GitCommandError
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from collections.abc import Callable, Generator
|
|
58
|
+
|
|
59
|
+
from gitpane.controller.console_registry import ConsoleRegistry
|
|
60
|
+
from gitpane.controller.history_read_controller import HistoryReadController
|
|
61
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
62
|
+
from gitpane.controller.reference_read_controller import ReferenceReadController
|
|
63
|
+
from gitpane.controller.refresh_controller import RefreshController
|
|
64
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
65
|
+
from gitpane.controller.staging_controller import StagingController
|
|
66
|
+
from gitpane.controller.transport_controller import TransportController
|
|
67
|
+
from gitpane.history.repository import GitRepository
|
|
68
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
69
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
70
|
+
from gitpane.reference.reference_edit import BranchIntent, CheckoutIntent, MergeAbortIntent, ReferenceDeletionIntent, ReferenceService, ResetIntent, TagIntent
|
|
71
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
72
|
+
from gitpane.sequencer.replay import ReplayIntent
|
|
73
|
+
from gitpane.transport.branch_exchange import TransportService
|
|
74
|
+
from gitpane.transport.remote import RemoteService
|
|
75
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
76
|
+
from gitpane.ui.sequencer.replay_dialog import ReplayDialog
|
|
77
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
78
|
+
|
|
79
|
+
# The reference forms built around the Git Extensions revision field.
|
|
80
|
+
RevisionForm = CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog
|
|
81
|
+
# Text the --log file keeps at the end of a reference operation (Checkout, branches, tags, Cherry pick, Revert); the
|
|
82
|
+
# console shows the operation's name and the word of its status instead.
|
|
83
|
+
REFERENCE_RESULT_LOG_TEMPLATE = "Result: {status}"
|
|
84
|
+
# The branch forms whose result is a branch on a new commit or a new name, shown at the top of the graph once done.
|
|
85
|
+
HISTORY_TOP_BRANCH_MODE_SET = frozenset((BranchMode.CREATE, BranchMode.CREATE_CHECKOUT, BranchMode.CREATE_ORPHAN, BranchMode.RENAME))
|
|
86
|
+
# A Cherry-pick or Revert console shows what the user would run in a terminal: Git's own command, its output and its
|
|
87
|
+
# result. The reads GitPane runs around it for its checks stay in the --log file only.
|
|
88
|
+
NATIVE_ONLY_CONSOLE_ACTION_SET = frozenset((OperationAction.REPLAY_START, OperationAction.REPLAY_CONTINUE, OperationAction.REPLAY_ABORT, OperationAction.MERGE_ABORT))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ReferenceFormController:
|
|
92
|
+
"""The reference forms of every repository window: Reset, Create tag, the branch forms, the deletions of branches and tags, Checkout and Checkout revision, with the consoles of the reference operations they start."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, console_registry_controller: ConsoleRegistry, reference_read_controller: ReferenceReadController,
|
|
95
|
+
refresh_controller: RefreshController, history_read_controller: HistoryReadController, staging_controller: StagingController, transport_controller: TransportController, preference: PreferenceStore, repository: GitRepository,
|
|
96
|
+
worktree_service: WorktreeService, reference: ReferenceService, remote: RemoteService, transport: TransportService, diagnostic_log: DiagnosticLog | None) -> None:
|
|
97
|
+
"""Receive the services and the lower controllers the reference forms work with; the wiring builds it after the transport controller, and the repository windows, the replay and the conflict reach it through the wiring."""
|
|
98
|
+
self.session_registry_controller = session_registry_controller
|
|
99
|
+
self.operation_policy_controller = operation_policy_controller
|
|
100
|
+
self.reference_read_controller = reference_read_controller
|
|
101
|
+
self.refresh_controller = refresh_controller
|
|
102
|
+
self.repository = repository
|
|
103
|
+
self.worktree_service = worktree_service
|
|
104
|
+
self.reference = reference
|
|
105
|
+
self.diagnostic_log = diagnostic_log
|
|
106
|
+
self.console_registry_controller = console_registry_controller
|
|
107
|
+
self.history_read_controller = history_read_controller
|
|
108
|
+
self.transport_controller = transport_controller
|
|
109
|
+
self.preference = preference
|
|
110
|
+
self.remote = remote
|
|
111
|
+
self.transport = transport
|
|
112
|
+
self.staging_controller = staging_controller
|
|
113
|
+
self.reset_dialog_by_session: dict[str, ResetDialog] = {}
|
|
114
|
+
self.reset_intent_by_id: dict[str, ResetIntent] = {}
|
|
115
|
+
self.reference_trace_by_id: dict[str, OperationTrace] = {}
|
|
116
|
+
self.reference_label_by_id: dict[str, str] = {}
|
|
117
|
+
self.reference_form_by_id: dict[str, CheckoutDialog | CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog | DeleteLocalBranchDialog | ReplayDialog | None] = {}
|
|
118
|
+
self.branch_dialog_by_session: dict[str, CreateBranchDialog | RenameBranchDialog | ResetOtherBranchDialog] = {}
|
|
119
|
+
self.branch_intent_by_id: dict[str, BranchIntent] = {}
|
|
120
|
+
# The deletion form of each window: a remote branch's, or Delete tag; one at a time per window.
|
|
121
|
+
self.delete_dialog_by_session: dict[str, DeleteBranchDialog | DeleteTagDialog] = {}
|
|
122
|
+
self.delete_intent_by_id: dict[str, ReferenceDeletionIntent] = {}
|
|
123
|
+
# The Delete branch window of local branches, and the group deletions it submitted, by operation.
|
|
124
|
+
self.local_branch_delete_dialog_by_session: dict[str, DeleteLocalBranchDialog] = {}
|
|
125
|
+
self.branch_deletion_intent_by_id: dict[str, BranchDeletionIntent] = {}
|
|
126
|
+
self.tag_dialog_by_session: dict[str, CreateTagDialog] = {}
|
|
127
|
+
self.tag_intent_by_id: dict[str, TagIntent] = {}
|
|
128
|
+
self.created_tag_by_id: dict[str, tuple[TagIntent, str, str]] = {}
|
|
129
|
+
self.checkout_dialog_by_session: dict[str, CheckoutDialog] = {}
|
|
130
|
+
self.checkout_revision_dialog_by_session: dict[str, CheckoutRevisionDialog] = {}
|
|
131
|
+
self.checkout_intent_by_id: dict[str, CheckoutIntent] = {}
|
|
132
|
+
|
|
133
|
+
def open_reset(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> ResetDialog | None:
|
|
134
|
+
"""Capture current-branch reset authority and local state before user confirmation."""
|
|
135
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
136
|
+
return None
|
|
137
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
138
|
+
if window.context.is_bare:
|
|
139
|
+
return None
|
|
140
|
+
existing = self.reset_dialog_by_session.get(session_id)
|
|
141
|
+
if existing is not None and not existing.closed:
|
|
142
|
+
existing.raise_()
|
|
143
|
+
return existing
|
|
144
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
145
|
+
generation = session.generation
|
|
146
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
147
|
+
dialog = ResetDialog(session_id, target_oid, window)
|
|
148
|
+
self.reset_dialog_by_session[session_id] = dialog
|
|
149
|
+
dialog.reset_requested.connect(lambda intent: self.submit_reset(session_id, intent))
|
|
150
|
+
dialog.finished.connect(lambda: self.reset_dialog_by_session.pop(session_id, None) if self.reset_dialog_by_session.get(session_id) is dialog else None)
|
|
151
|
+
|
|
152
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
153
|
+
|
|
154
|
+
def is_current() -> bool:
|
|
155
|
+
"""Reject late reads after closing or replacing the form or the repository authority."""
|
|
156
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.reset_dialog_by_session, context, git_filename)
|
|
157
|
+
|
|
158
|
+
def metadata_complete(fields: tuple | None, error: Exception | None) -> None:
|
|
159
|
+
"""Publish only the originally requested commit's metadata, and never to a submitted form."""
|
|
160
|
+
if is_current() and not dialog.busy and not dialog.completed:
|
|
161
|
+
dialog.show_metadata(fields, str(error) if error else "")
|
|
162
|
+
|
|
163
|
+
preparing = False
|
|
164
|
+
|
|
165
|
+
def prepare_local() -> None:
|
|
166
|
+
"""Capture file and index authority only when the chosen mode can change them."""
|
|
167
|
+
nonlocal preparing
|
|
168
|
+
snapshot = dialog.snapshot
|
|
169
|
+
if preparing or snapshot is None or not is_current() or dialog.mode == ResetMode.SOFT or dialog.worktree_snapshot is not None:
|
|
170
|
+
return
|
|
171
|
+
preparing = True
|
|
172
|
+
dialog.status_label.setText("Checking index and working files…")
|
|
173
|
+
|
|
174
|
+
def local_complete(result: tuple | None, error: Exception | None) -> None:
|
|
175
|
+
"""Never replace submitted authority when a background read finishes."""
|
|
176
|
+
nonlocal preparing
|
|
177
|
+
preparing = False
|
|
178
|
+
if not is_current() or dialog.busy or dialog.completed:
|
|
179
|
+
return
|
|
180
|
+
if error is not None:
|
|
181
|
+
if dialog.mode != ResetMode.SOFT:
|
|
182
|
+
dialog.show_error(str(error))
|
|
183
|
+
return
|
|
184
|
+
local, signature, paths, target_paths = result
|
|
185
|
+
dialog.populate(snapshot, local, signature, paths, target_paths)
|
|
186
|
+
dialog.status_label.clear()
|
|
187
|
+
|
|
188
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.capture_reset(snapshot, target_oid), local_complete)
|
|
189
|
+
|
|
190
|
+
dialog.preparation_requested.connect(prepare_local)
|
|
191
|
+
|
|
192
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
193
|
+
"""Check symbolic HEAD and read local bytes before enabling the form."""
|
|
194
|
+
if not is_current() or dialog.busy or dialog.completed:
|
|
195
|
+
return
|
|
196
|
+
if error is not None:
|
|
197
|
+
refuse_form_opening(dialog, str(error))
|
|
198
|
+
return
|
|
199
|
+
if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
200
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
|
|
201
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
|
|
202
|
+
return
|
|
203
|
+
if not snapshot.head_ref or not snapshot.head_oid:
|
|
204
|
+
refuse_form_opening(dialog, "Reset requires a current local branch with a commit")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
dialog.populate(snapshot)
|
|
208
|
+
reveal_form(dialog)
|
|
209
|
+
self.reference.metadata(snapshot, target_oid, metadata_complete)
|
|
210
|
+
prepare_local()
|
|
211
|
+
|
|
212
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
213
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_reset(session_id, target_oid, expected_snapshot=capture))
|
|
214
|
+
return dialog
|
|
215
|
+
|
|
216
|
+
def submit_reset(self, session_id: str, intent: ResetIntent) -> str | None:
|
|
217
|
+
"""Submit only the captured reset from its authorized live form."""
|
|
218
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
219
|
+
return None
|
|
220
|
+
dialog = self.reset_dialog_by_session.get(session_id)
|
|
221
|
+
if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id or not dialog.busy:
|
|
222
|
+
return None
|
|
223
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
224
|
+
|
|
225
|
+
def is_current() -> bool:
|
|
226
|
+
"""Recheck live session, executable and context at the actual process launch."""
|
|
227
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
228
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
229
|
+
|
|
230
|
+
self.reset_intent_by_id[intent.operation.operation_id] = intent
|
|
231
|
+
return self.reference.reset(intent, is_current=is_current)
|
|
232
|
+
|
|
233
|
+
def reset_finished(self, outcome: object) -> None:
|
|
234
|
+
"""Show observed reset effects and refresh related windows after real process exit."""
|
|
235
|
+
intent = self.reset_intent_by_id.pop(outcome.operation_id, None)
|
|
236
|
+
if intent is None:
|
|
237
|
+
return
|
|
238
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
239
|
+
dialog = self.reset_dialog_by_session.get(intent.operation.session_id)
|
|
240
|
+
if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
|
|
241
|
+
dialog.show_outcome(outcome)
|
|
242
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
243
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
244
|
+
if outcome.status == OperationStatus.REFUSED:
|
|
245
|
+
return
|
|
246
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
|
|
247
|
+
|
|
248
|
+
def open_tag(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> CreateTagDialog | None:
|
|
249
|
+
"""Open the Create tag window on one commit, drawn from what the repository window already holds.
|
|
250
|
+
|
|
251
|
+
The references come from the window's own capture while their files are unchanged, the push remote from their
|
|
252
|
+
listing and the position from the history graph in memory; the window shows no commit details, so opening it
|
|
253
|
+
on a displayed commit of a fully loaded graph starts no Git process.
|
|
254
|
+
"""
|
|
255
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
256
|
+
return None
|
|
257
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
258
|
+
if window.context.is_bare:
|
|
259
|
+
return None
|
|
260
|
+
existing = self.tag_dialog_by_session.get(session_id)
|
|
261
|
+
if existing is not None and not existing.closed:
|
|
262
|
+
existing.raise_()
|
|
263
|
+
return existing
|
|
264
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
265
|
+
generation = session.generation
|
|
266
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
267
|
+
dialog = CreateTagDialog(session_id, target_oid, window)
|
|
268
|
+
dialog.setAccessibleDescription(f"Create a tag in {window.context.repository_dir}")
|
|
269
|
+
self.tag_dialog_by_session[session_id] = dialog
|
|
270
|
+
dialog.tag_requested.connect(lambda intent: self.submit_tag(session_id, intent))
|
|
271
|
+
dialog.finished.connect(lambda: self.tag_dialog_by_session.pop(session_id, None) if self.tag_dialog_by_session.get(session_id) is dialog else None)
|
|
272
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
273
|
+
|
|
274
|
+
def is_current() -> bool:
|
|
275
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
276
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.tag_dialog_by_session, context, git_filename) and not dialog.busy
|
|
277
|
+
|
|
278
|
+
self._bind_revision_picker(session_id, dialog, is_current)
|
|
279
|
+
|
|
280
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
281
|
+
"""Show the commit and the push remote only while the form and the references it was asked from are current."""
|
|
282
|
+
if not is_current():
|
|
283
|
+
return
|
|
284
|
+
if error is not None:
|
|
285
|
+
refuse_form_opening(dialog, str(error))
|
|
286
|
+
return
|
|
287
|
+
if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
288
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
|
|
289
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
|
|
290
|
+
return
|
|
291
|
+
dialog.populate(snapshot, target_oid, push_remote_name(snapshot))
|
|
292
|
+
reveal_form(dialog)
|
|
293
|
+
|
|
294
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
295
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_tag(session_id, target_oid, expected_snapshot=capture))
|
|
296
|
+
return dialog
|
|
297
|
+
|
|
298
|
+
def submit_tag(self, session_id: str, intent: TagIntent) -> str | None:
|
|
299
|
+
"""Submit only the exact tag intention displayed by the authorized live form."""
|
|
300
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
301
|
+
return None
|
|
302
|
+
dialog = self.tag_dialog_by_session.get(session_id)
|
|
303
|
+
if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
|
|
304
|
+
return None
|
|
305
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
306
|
+
|
|
307
|
+
def is_current() -> bool:
|
|
308
|
+
"""Recheck the form and repository authority when queued Git actually starts."""
|
|
309
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
310
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
311
|
+
|
|
312
|
+
self.tag_intent_by_id[intent.operation.operation_id] = intent
|
|
313
|
+
self.open_reference_output(intent, dialog)
|
|
314
|
+
return self.reference.create_tag(intent, is_current=is_current)
|
|
315
|
+
|
|
316
|
+
def tag_finished(self, outcome: object) -> None:
|
|
317
|
+
"""Publish separate creation and refresh results before refreshing related windows."""
|
|
318
|
+
intent = self.tag_intent_by_id.pop(outcome.operation_id, None)
|
|
319
|
+
if intent is None:
|
|
320
|
+
return
|
|
321
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
322
|
+
dialog = self.tag_dialog_by_session.get(intent.operation.session_id)
|
|
323
|
+
if outcome.status == OperationStatus.SUCCESS and intent.push_after_creation:
|
|
324
|
+
creation = next((step for step in outcome.steps if step.operation == "Create tag"), None)
|
|
325
|
+
object_oid = creation.after.object_oid_by_name.get(intent.full_ref) if creation is not None and creation.after is not None else None
|
|
326
|
+
if object_oid:
|
|
327
|
+
# The remote the form's Push check box named, derived from the very references the request was frozen on.
|
|
328
|
+
self.created_tag_by_id[outcome.operation_id] = (intent, object_oid, push_remote_name(intent.snapshot))
|
|
329
|
+
output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
|
|
330
|
+
if output is not None:
|
|
331
|
+
output.followup_requested.connect(lambda: self.push_created_tag(outcome.operation_id))
|
|
332
|
+
if (dialog is self.reference_form_by_id.get(outcome.operation_id) and dialog is not None
|
|
333
|
+
and isValid(dialog) and not dialog.closed and dialog.intent == intent):
|
|
334
|
+
dialog.show_outcome(outcome)
|
|
335
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
336
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
337
|
+
if outcome.status != OperationStatus.REFUSED:
|
|
338
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
|
|
339
|
+
self.push_created_tag(outcome.operation_id)
|
|
340
|
+
self.finish_reference_output(outcome)
|
|
341
|
+
|
|
342
|
+
def push_created_tag(self, operation_id: str) -> None:
|
|
343
|
+
"""Open the exact created tag's Push form on the remote the Create tag form named, keeping a retry if another Push is busy.
|
|
344
|
+
|
|
345
|
+
The Push check box of Create tag never pushes by itself: this only opens Push, where the user confirms.
|
|
346
|
+
"""
|
|
347
|
+
capture = self.created_tag_by_id.get(operation_id)
|
|
348
|
+
output = self.console_registry_controller.reference_output_by_id.get(operation_id)
|
|
349
|
+
if capture is None or output is None or not isValid(output) or output.dismissed:
|
|
350
|
+
return
|
|
351
|
+
intent, object_oid, remote_name = capture
|
|
352
|
+
session_id = intent.operation.session_id
|
|
353
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
354
|
+
if (not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or session is None
|
|
355
|
+
or intent.snapshot.context != session.repository_context or intent.snapshot.git_filename != self.repository.git_filename):
|
|
356
|
+
output.show_followup("Tag created locally. Reopen the original repository with management access before pushing it.")
|
|
357
|
+
return
|
|
358
|
+
existing = self.transport_controller.push_dialog_by_session.get(session_id)
|
|
359
|
+
if existing is not None and not existing.closed and (existing.busy or existing.commit_push_intent is not None):
|
|
360
|
+
output.show_followup("Tag created locally. Finish and close the existing Push dialog, then choose Push tag.")
|
|
361
|
+
return
|
|
362
|
+
push_dialog = self.transport_controller.open_push(session_id)
|
|
363
|
+
if push_dialog is None or not push_dialog.pin_tag(intent.full_ref, object_oid, remote_name):
|
|
364
|
+
output.show_followup("Tag created locally. Push could not be prepared. Choose Push tag to try again.")
|
|
365
|
+
return
|
|
366
|
+
if existing is push_dialog:
|
|
367
|
+
self.transport_controller.read_push(session_id, push_dialog)
|
|
368
|
+
self.created_tag_by_id.pop(operation_id, None)
|
|
369
|
+
output.show_followup()
|
|
370
|
+
if not output.running:
|
|
371
|
+
output.show_result(OperationStatus.SUCCESS)
|
|
372
|
+
|
|
373
|
+
def open_delete_branch(self, session_id: str, full_ref: str, target_oid: str, *,
|
|
374
|
+
expected_snapshot: ReferenceSnapshot | None = None) -> DeleteBranchDialog | DeleteLocalBranchDialog | DeleteTagDialog | None:
|
|
375
|
+
"""Open the deletion form of the clicked reference, only for a live management session.
|
|
376
|
+
|
|
377
|
+
A local branch opens the Delete branch window of Git Extensions, which deletes local branches only; a tag opens
|
|
378
|
+
the Delete tag window; a remote branch keeps its own form, which deletes it on the server.
|
|
379
|
+
"""
|
|
380
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
381
|
+
return None
|
|
382
|
+
if full_ref.startswith(LOCAL_PREFIX):
|
|
383
|
+
return self._open_local_branch_deletion(session_id, full_ref, target_oid, expected_snapshot)
|
|
384
|
+
if full_ref.startswith(TAG_PREFIX):
|
|
385
|
+
return self._open_tag_deletion(session_id, full_ref, target_oid, expected_snapshot)
|
|
386
|
+
if not full_ref.startswith(REMOTE_PREFIX):
|
|
387
|
+
return None
|
|
388
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
389
|
+
if window.context.is_bare:
|
|
390
|
+
return None
|
|
391
|
+
existing = self.delete_dialog_by_session.get(session_id)
|
|
392
|
+
if existing is not None and not existing.closed:
|
|
393
|
+
existing.raise_()
|
|
394
|
+
return existing
|
|
395
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
396
|
+
generation = session.generation
|
|
397
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
398
|
+
dialog = DeleteBranchDialog(session_id, full_ref, window)
|
|
399
|
+
self.delete_dialog_by_session[session_id] = dialog
|
|
400
|
+
dialog.prepare_requested.connect(lambda intent: self._prepare_delete(session_id, dialog, intent))
|
|
401
|
+
dialog.delete_requested.connect(lambda intent: self.submit_delete(session_id, intent))
|
|
402
|
+
dialog.destination_changed.connect(lambda: self._prepare_delete_target(session_id, dialog))
|
|
403
|
+
dialog.retry_requested.connect(lambda: self._retry_delete(session_id, dialog))
|
|
404
|
+
dialog.finished.connect(lambda: self.delete_dialog_by_session.pop(session_id, None) if self.delete_dialog_by_session.get(session_id) is dialog else None)
|
|
405
|
+
|
|
406
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
407
|
+
|
|
408
|
+
def is_current() -> bool:
|
|
409
|
+
"""Discard reads for a closed or replaced form or a replaced repository session."""
|
|
410
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, context, git_filename)
|
|
411
|
+
|
|
412
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
413
|
+
"""Refuse a stale badge capture before exposing a usable Delete button."""
|
|
414
|
+
if not is_current():
|
|
415
|
+
return
|
|
416
|
+
if error is not None:
|
|
417
|
+
refuse_form_opening(dialog, str(error))
|
|
418
|
+
return
|
|
419
|
+
if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
420
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
|
|
421
|
+
or snapshot.object_oid_by_name.get(full_ref) != target_oid):
|
|
422
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
|
|
423
|
+
return
|
|
424
|
+
dialog.populate(snapshot)
|
|
425
|
+
reveal_form(dialog)
|
|
426
|
+
def remote_complete(remote_snapshot: RemoteSnapshot | None, remote_error: Exception | None) -> None:
|
|
427
|
+
"""Attach remote configuration only when it still describes the same local capture."""
|
|
428
|
+
if not is_current() or dialog.busy or dialog.outcome is not None:
|
|
429
|
+
return
|
|
430
|
+
if remote_error is not None:
|
|
431
|
+
dialog.status_label.setText(dialog.text_filter.redact("Remote destination could not be read: " + str(remote_error)))
|
|
432
|
+
return
|
|
433
|
+
if remote_snapshot.reference_state != snapshot.signature:
|
|
434
|
+
dialog.show_error("References or configuration changed while reading remote destinations")
|
|
435
|
+
return
|
|
436
|
+
dialog.populate_remote(remote_snapshot)
|
|
437
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.remote.capture(snapshot.context, snapshot.git_filename, generation), remote_complete)
|
|
438
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
439
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_branch(session_id, full_ref, target_oid, expected_snapshot=capture))
|
|
440
|
+
return dialog
|
|
441
|
+
|
|
442
|
+
def _open_local_branch_deletion(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> DeleteLocalBranchDialog | None:
|
|
443
|
+
"""Open the Delete branch window on the clicked local branch, from what the repository window already holds.
|
|
444
|
+
|
|
445
|
+
The references come from the window's own capture while their files are unchanged, and the deletable branches
|
|
446
|
+
are computed from them, so opening the window starts no Git process. A capture that no longer matches the
|
|
447
|
+
menu the branch was chosen from refuses the window instead of offering another branch.
|
|
448
|
+
"""
|
|
449
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
450
|
+
if window.context.is_bare:
|
|
451
|
+
return None
|
|
452
|
+
existing = self.local_branch_delete_dialog_by_session.get(session_id)
|
|
453
|
+
if existing is not None and not existing.closed:
|
|
454
|
+
existing.raise_()
|
|
455
|
+
return existing
|
|
456
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
457
|
+
generation = session.generation
|
|
458
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
459
|
+
dialog = DeleteLocalBranchDialog(session_id, window)
|
|
460
|
+
dialog.setAccessibleDescription(f"Delete local branches in {window.context.repository_dir}")
|
|
461
|
+
self.local_branch_delete_dialog_by_session[session_id] = dialog
|
|
462
|
+
dialog.delete_requested.connect(lambda full_refs: self._prepare_branch_deletion(session_id, dialog, full_refs))
|
|
463
|
+
dialog.finished.connect(lambda: self.local_branch_delete_dialog_by_session.pop(session_id, None)
|
|
464
|
+
if self.local_branch_delete_dialog_by_session.get(session_id) is dialog else None)
|
|
465
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
466
|
+
|
|
467
|
+
def is_current() -> bool:
|
|
468
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
469
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.local_branch_delete_dialog_by_session, context, git_filename)
|
|
470
|
+
|
|
471
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
472
|
+
"""Offer the deletable branches only when the references still match the menu the branch was chosen from."""
|
|
473
|
+
if not is_current():
|
|
474
|
+
return
|
|
475
|
+
if error is not None:
|
|
476
|
+
refuse_form_opening(dialog, str(error))
|
|
477
|
+
return
|
|
478
|
+
if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
479
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
|
|
480
|
+
or snapshot.object_oid_by_name.get(full_ref) != target_oid):
|
|
481
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
|
|
482
|
+
return
|
|
483
|
+
dialog.populate(snapshot, deletable_branch_refs(snapshot), full_ref)
|
|
484
|
+
reveal_form(dialog)
|
|
485
|
+
|
|
486
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
487
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_branch(session_id, full_ref, target_oid, expected_snapshot=capture))
|
|
488
|
+
return dialog
|
|
489
|
+
|
|
490
|
+
def _local_branch_deletion_is_current(self, session_id: str, dialog: DeleteLocalBranchDialog) -> bool:
|
|
491
|
+
"""Tell whether the Delete branch window may still act: open, registered for its window, with management rights."""
|
|
492
|
+
return (isValid(dialog) and not dialog.closed and self.local_branch_delete_dialog_by_session.get(session_id) is dialog
|
|
493
|
+
and dialog.snapshot is not None and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE))
|
|
494
|
+
|
|
495
|
+
def _prepare_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, full_refs: tuple[str, ...]) -> None:
|
|
496
|
+
"""Sort the chosen branches into merged and not merged as "git branch -d" would, then ask about the others once.
|
|
497
|
+
|
|
498
|
+
Each branch is compared with its upstream when it has one, and with HEAD otherwise. The history graph in memory
|
|
499
|
+
answers when it is complete and was read for the same HEAD as the form; otherwise Git is asked once per
|
|
500
|
+
distinct base commit. Merged branches alone are deleted without a question.
|
|
501
|
+
"""
|
|
502
|
+
if not self._local_branch_deletion_is_current(session_id, dialog):
|
|
503
|
+
return
|
|
504
|
+
snapshot = dialog.snapshot
|
|
505
|
+
for full_ref in full_refs:
|
|
506
|
+
if full_ref not in dialog.candidate_refs:
|
|
507
|
+
dialog.release()
|
|
508
|
+
dialog.show_error(f"'{short_reference_name(full_ref)}' cannot be deleted from this window")
|
|
509
|
+
return
|
|
510
|
+
base_oid_by_ref = merge_base_oid_by_ref(snapshot, full_refs)
|
|
511
|
+
branch_oid_by_ref: dict[str, str] = {}
|
|
512
|
+
for full_ref in full_refs:
|
|
513
|
+
branch_oid_by_ref[full_ref] = snapshot.object_oid_by_name[full_ref]
|
|
514
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
515
|
+
history_snapshot = window.history_model.snapshot if window is not None and isValid(window) else None
|
|
516
|
+
unmerged_refs = None
|
|
517
|
+
if history_snapshot is not None and history_snapshot.head_oid == snapshot.head_oid:
|
|
518
|
+
unmerged_refs = unmerged_branch_refs_in_memory(branch_oid_by_ref, base_oid_by_ref, history_snapshot.parent_oids_by_oid, history_snapshot.complete)
|
|
519
|
+
if unmerged_refs is not None:
|
|
520
|
+
self._confirm_branch_deletion(session_id, dialog, full_refs, unmerged_refs)
|
|
521
|
+
return
|
|
522
|
+
dialog.classification_generation += 1
|
|
523
|
+
classification_generation = dialog.classification_generation
|
|
524
|
+
|
|
525
|
+
def complete(merged_ref_set: set[str] | None, error: Exception | None) -> None:
|
|
526
|
+
"""Use Git's answer only for the request it was asked for, while the window is still the one that asked."""
|
|
527
|
+
if (not self._local_branch_deletion_is_current(session_id, dialog) or dialog.classification_generation != classification_generation
|
|
528
|
+
or not dialog.busy):
|
|
529
|
+
return
|
|
530
|
+
if error is not None:
|
|
531
|
+
dialog.release()
|
|
532
|
+
dialog.show_error(str(error))
|
|
533
|
+
return
|
|
534
|
+
not_merged_refs: list[str] = []
|
|
535
|
+
for full_ref in full_refs:
|
|
536
|
+
if full_ref not in merged_ref_set:
|
|
537
|
+
not_merged_refs.append(full_ref)
|
|
538
|
+
self._confirm_branch_deletion(session_id, dialog, full_refs, tuple(not_merged_refs))
|
|
539
|
+
|
|
540
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.merged_branch_refs(snapshot, base_oid_by_ref), complete)
|
|
541
|
+
|
|
542
|
+
def _confirm_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, full_refs: tuple[str, ...], unmerged_refs: tuple[str, ...]) -> None:
|
|
543
|
+
"""Delete merged branches at once, or first ask one question that lists every branch that is not merged."""
|
|
544
|
+
unmerged_ref_set = set(unmerged_refs)
|
|
545
|
+
merged_refs: list[str] = []
|
|
546
|
+
for full_ref in full_refs:
|
|
547
|
+
if full_ref not in unmerged_ref_set:
|
|
548
|
+
merged_refs.append(full_ref)
|
|
549
|
+
if not unmerged_refs:
|
|
550
|
+
self._submit_branch_deletion(session_id, dialog, tuple(merged_refs), ())
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
def answer(accepted: bool) -> None:
|
|
554
|
+
"""Delete everything on Yes; on No delete nothing and give the form back."""
|
|
555
|
+
if not accepted:
|
|
556
|
+
dialog.release()
|
|
557
|
+
return
|
|
558
|
+
self._submit_branch_deletion(session_id, dialog, tuple(merged_refs), unmerged_refs)
|
|
559
|
+
|
|
560
|
+
dialog.show_unmerged_question(unmerged_refs, answer)
|
|
561
|
+
|
|
562
|
+
def _submit_branch_deletion(self, session_id: str, dialog: DeleteLocalBranchDialog, merged_refs: tuple[str, ...], unmerged_refs: tuple[str, ...]) -> str | None:
|
|
563
|
+
"""Freeze the classified branches into one request and submit it once, with its console opened before Git starts."""
|
|
564
|
+
if not self._local_branch_deletion_is_current(session_id, dialog):
|
|
565
|
+
return None
|
|
566
|
+
try:
|
|
567
|
+
intent = BranchDeletionIntent.capture(dialog.snapshot, merged_refs, unmerged_refs, session_id=session_id, refresh_advice=refresh_advice_text)
|
|
568
|
+
except ValueError as error:
|
|
569
|
+
dialog.release()
|
|
570
|
+
dialog.show_error(str(error))
|
|
571
|
+
return None
|
|
572
|
+
dialog.intent = intent
|
|
573
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
574
|
+
|
|
575
|
+
def is_current() -> bool:
|
|
576
|
+
"""Recheck the form and repository authority when queued Git actually starts."""
|
|
577
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
578
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
579
|
+
|
|
580
|
+
self.branch_deletion_intent_by_id[intent.operation.operation_id] = intent
|
|
581
|
+
self.open_reference_output(intent, dialog)
|
|
582
|
+
return self.reference.delete_branches(intent, is_current=is_current)
|
|
583
|
+
|
|
584
|
+
def branch_deletion_finished(self, outcome: OperationOutcome) -> None:
|
|
585
|
+
"""Show a group deletion's result in its window and console, then refresh every window of the repository."""
|
|
586
|
+
intent = self.branch_deletion_intent_by_id.pop(outcome.operation_id, None)
|
|
587
|
+
if intent is None:
|
|
588
|
+
return
|
|
589
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
590
|
+
dialog = self.reference_form_by_id.get(outcome.operation_id)
|
|
591
|
+
if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
|
|
592
|
+
dialog.show_outcome(outcome)
|
|
593
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
594
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
595
|
+
if outcome.status != OperationStatus.REFUSED:
|
|
596
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
|
|
597
|
+
self.finish_reference_output(outcome)
|
|
598
|
+
|
|
599
|
+
def _prepare_delete_target(self, session_id: str, dialog: DeleteBranchDialog) -> None:
|
|
600
|
+
"""Capture one effective Push endpoint and its current account without network access."""
|
|
601
|
+
if dialog.closed or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or not dialog.remote_checkbox.isChecked():
|
|
602
|
+
return
|
|
603
|
+
if dialog.remote_snapshot is None or not dialog.remote_selector.currentData() or not dialog.endpoint_selector.currentData():
|
|
604
|
+
dialog.set_push_target(None, "Select a remote repository, remote branch and one Push endpoint")
|
|
605
|
+
return
|
|
606
|
+
try:
|
|
607
|
+
target = self.transport.capture_push_target(dialog.remote_snapshot, dialog.remote_selector.currentData(), dialog.endpoint_selector.currentData())
|
|
608
|
+
dialog.set_push_target(target)
|
|
609
|
+
except (ValueError, OSError) as error:
|
|
610
|
+
dialog.set_push_target(None, str(error))
|
|
611
|
+
|
|
612
|
+
def open_delete_tag(self, session_id: str, full_ref: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> DeleteTagDialog | None:
|
|
613
|
+
"""Open the Delete tag window on the clicked tag, only for a tag of a live management session."""
|
|
614
|
+
if not full_ref.startswith(TAG_PREFIX) or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
615
|
+
return None
|
|
616
|
+
return self._open_tag_deletion(session_id, full_ref, target_oid, expected_snapshot)
|
|
617
|
+
|
|
618
|
+
def _open_tag_deletion(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> DeleteTagDialog | None:
|
|
619
|
+
"""Open the Delete tag window on the clicked tag, from what the repository window already holds.
|
|
620
|
+
|
|
621
|
+
The tags and the remote names come from the window's own capture while its reference files are unchanged, so
|
|
622
|
+
opening the window starts no Git process; the remote configuration is read only once the remote option is
|
|
623
|
+
checked. A capture that no longer matches the menu the tag was chosen from refuses the window instead of
|
|
624
|
+
offering another tag.
|
|
625
|
+
"""
|
|
626
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
627
|
+
if window.context.is_bare:
|
|
628
|
+
return None
|
|
629
|
+
existing = self.delete_dialog_by_session.get(session_id)
|
|
630
|
+
if existing is not None and not existing.closed:
|
|
631
|
+
existing.raise_()
|
|
632
|
+
return existing
|
|
633
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
634
|
+
generation = session.generation
|
|
635
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
636
|
+
dialog = DeleteTagDialog(session_id, window)
|
|
637
|
+
dialog.setAccessibleDescription(f"Delete a tag in {window.context.repository_dir}")
|
|
638
|
+
self.delete_dialog_by_session[session_id] = dialog
|
|
639
|
+
dialog.delete_requested.connect(lambda intent: self._submit_tag_deletion(session_id, dialog, intent))
|
|
640
|
+
dialog.remote_requested.connect(lambda: self._read_tag_delete_remote(session_id, dialog))
|
|
641
|
+
dialog.retry_requested.connect(lambda: self._retry_delete(session_id, dialog))
|
|
642
|
+
dialog.finished.connect(lambda: self.delete_dialog_by_session.pop(session_id, None) if self.delete_dialog_by_session.get(session_id) is dialog else None)
|
|
643
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
644
|
+
|
|
645
|
+
def is_current() -> bool:
|
|
646
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
647
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, context, git_filename)
|
|
648
|
+
|
|
649
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
650
|
+
"""Offer the tags only when the references still match the menu the tag was chosen from."""
|
|
651
|
+
if not is_current():
|
|
652
|
+
return
|
|
653
|
+
if error is not None:
|
|
654
|
+
refuse_form_opening(dialog, str(error))
|
|
655
|
+
return
|
|
656
|
+
if (expected_snapshot is None or snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
657
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context
|
|
658
|
+
or snapshot.object_oid_by_name.get(full_ref) != target_oid):
|
|
659
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_OR_REPOSITORY_CHANGED))
|
|
660
|
+
return
|
|
661
|
+
dialog.populate(snapshot, full_ref)
|
|
662
|
+
reveal_form(dialog)
|
|
663
|
+
|
|
664
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
665
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_delete_tag(session_id, full_ref, target_oid, expected_snapshot=capture))
|
|
666
|
+
return dialog
|
|
667
|
+
|
|
668
|
+
def _read_tag_delete_remote(self, session_id: str, dialog: DeleteTagDialog) -> None:
|
|
669
|
+
"""Read the remote configuration once for a Delete tag window, the first time its remote option is checked.
|
|
670
|
+
|
|
671
|
+
The configuration must describe the very references the window shows; one read after a reference change is
|
|
672
|
+
refused, and checking the option again reads it anew.
|
|
673
|
+
"""
|
|
674
|
+
snapshot = dialog.snapshot
|
|
675
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
676
|
+
if snapshot is None or session is None or dialog.closed or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
677
|
+
dialog.remote_read_requested = False
|
|
678
|
+
return
|
|
679
|
+
generation = session.generation
|
|
680
|
+
|
|
681
|
+
def complete(remote_snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
|
|
682
|
+
"""Hand the configuration to the form only while it still shows the same references and nothing was submitted."""
|
|
683
|
+
if (not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.delete_dialog_by_session, snapshot.context, snapshot.git_filename)
|
|
684
|
+
or dialog.snapshot is not snapshot or dialog.busy or dialog.outcome is not None):
|
|
685
|
+
return
|
|
686
|
+
if error is not None:
|
|
687
|
+
dialog.show_remote_error(str(error))
|
|
688
|
+
return
|
|
689
|
+
if remote_snapshot.reference_state != snapshot.signature:
|
|
690
|
+
dialog.show_remote_error("references or configuration changed while reading it")
|
|
691
|
+
return
|
|
692
|
+
dialog.populate_remote(remote_snapshot)
|
|
693
|
+
|
|
694
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.remote.capture(snapshot.context, snapshot.git_filename, generation), complete)
|
|
695
|
+
|
|
696
|
+
def _tag_delete_request(self, dialog: DeleteTagDialog) -> tuple | str:
|
|
697
|
+
"""Turn the chosen remote into the one server a tag deletion goes to, or answer why it cannot be used.
|
|
698
|
+
|
|
699
|
+
The push address is the remote's only one: a remote pushing to several addresses is refused rather than
|
|
700
|
+
deleting the tag on several servers, and a remote whose configuration is inactive, ambiguous or gone is refused
|
|
701
|
+
too. Nothing has been deleted when a cause is answered.
|
|
702
|
+
"""
|
|
703
|
+
remote_name = dialog.remote_combo.currentData()
|
|
704
|
+
if not remote_name:
|
|
705
|
+
return NO_REMOTE_CAUSE
|
|
706
|
+
remote_snapshot = dialog.remote_snapshot
|
|
707
|
+
entry = remote_snapshot.entry_by_name.get(remote_name) if remote_snapshot is not None else None
|
|
708
|
+
if entry is None or not entry.active or entry.collision:
|
|
709
|
+
return UNUSABLE_REMOTE_CAUSE.format(remote_name)
|
|
710
|
+
endpoint_set = set(entry.push_urls)
|
|
711
|
+
if not endpoint_set:
|
|
712
|
+
return NO_PUSH_ADDRESS_CAUSE.format(remote_name)
|
|
713
|
+
if len(endpoint_set) > 1:
|
|
714
|
+
return SEVERAL_PUSH_ADDRESSES_CAUSE.format(remote_name)
|
|
715
|
+
(endpoint,) = endpoint_set
|
|
716
|
+
try:
|
|
717
|
+
push_target = self.transport.capture_push_target(remote_snapshot, remote_name, endpoint)
|
|
718
|
+
except (ValueError, OSError) as error:
|
|
719
|
+
return str(error)
|
|
720
|
+
return (remote_snapshot, remote_name, dialog.intent.full_ref, endpoint, push_target)
|
|
721
|
+
|
|
722
|
+
def _submit_tag_deletion(self, session_id: str, dialog: DeleteTagDialog, intent: ReferenceDeletionIntent) -> None:
|
|
723
|
+
"""Submit the tag deletion Delete froze, locally only or also on the chosen remote's single push address.
|
|
724
|
+
|
|
725
|
+
A remote that cannot be used is refused before anything is deleted. A form this controller cannot run any more
|
|
726
|
+
is given back with the reason, never left frozen.
|
|
727
|
+
"""
|
|
728
|
+
if dialog.closed or self.delete_dialog_by_session.get(session_id) is not dialog:
|
|
729
|
+
return
|
|
730
|
+
dialog.remote_request = None
|
|
731
|
+
if dialog.remote_checkbox.isChecked():
|
|
732
|
+
request = self._tag_delete_request(dialog)
|
|
733
|
+
if isinstance(request, str):
|
|
734
|
+
dialog.show_error(request)
|
|
735
|
+
return
|
|
736
|
+
dialog.remote_request = request
|
|
737
|
+
if self.submit_delete(session_id, intent) is None and dialog.busy and not dialog.closed:
|
|
738
|
+
dialog.show_error("The deletion could not start. Close this window and try again.")
|
|
739
|
+
|
|
740
|
+
def _retry_delete(self, session_id: str, dialog: DeleteBranchDialog) -> None:
|
|
741
|
+
"""Reread captured reference authority before retrying only its unfinished deletion steps."""
|
|
742
|
+
if dialog.closed or dialog.busy or dialog.outcome is None or dialog.intent is None or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
743
|
+
return
|
|
744
|
+
previous_outcome, previous_intent, previous_request = dialog.outcome, dialog.intent, dialog.remote_request
|
|
745
|
+
if previous_outcome.status not in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.PARTIAL):
|
|
746
|
+
return
|
|
747
|
+
if any(step.operation == DELETE_REMOTE_TAG_OPERATION and step.status == OperationStatus.UNKNOWN for step in previous_outcome.steps):
|
|
748
|
+
return
|
|
749
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
750
|
+
generation = session.generation
|
|
751
|
+
dialog.busy = True
|
|
752
|
+
dialog.retry_button.setEnabled(False)
|
|
753
|
+
dialog.status_label.setText("Rereading the failed step…")
|
|
754
|
+
remote_success = any(step.operation == DELETE_REMOTE_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS for step in previous_outcome.steps)
|
|
755
|
+
local_tag_success = previous_intent.full_ref.startswith(TAG_PREFIX) and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in previous_outcome.steps)
|
|
756
|
+
|
|
757
|
+
def read() -> Generator:
|
|
758
|
+
"""Keep the original OID and destination while acquiring fresh local authority."""
|
|
759
|
+
snapshot = yield from self.reference.capture(session.repository_context, self.repository.git_filename, generation)
|
|
760
|
+
oid = snapshot.object_oid_by_name.get(previous_intent.full_ref)
|
|
761
|
+
if not local_tag_success and oid is not None and oid != previous_intent.target_oid:
|
|
762
|
+
reference_kind = "tag" if previous_intent.full_ref.startswith(TAG_PREFIX) else "branch"
|
|
763
|
+
raise ValueError(f"The original {reference_kind} changed; close this result and select the {reference_kind} again")
|
|
764
|
+
remote_snapshot = None
|
|
765
|
+
if previous_request is not None and (not remote_success or dialog.remote_branch):
|
|
766
|
+
remote_snapshot = yield from self.remote.capture(snapshot.context, snapshot.git_filename, generation)
|
|
767
|
+
if remote_snapshot.reference_state != snapshot.signature:
|
|
768
|
+
raise ValueError("References changed while rereading the failed deletion")
|
|
769
|
+
return snapshot, remote_snapshot
|
|
770
|
+
|
|
771
|
+
def complete(value: tuple | None, error: Exception | None) -> None:
|
|
772
|
+
"""Present any new unmerged warning before dispatching the fresh retry."""
|
|
773
|
+
if dialog.closed or session.is_closed:
|
|
774
|
+
return
|
|
775
|
+
if session.generation != generation:
|
|
776
|
+
dialog.show_error("The repository view changed while rereading; retry the failed step again")
|
|
777
|
+
return
|
|
778
|
+
if error is not None:
|
|
779
|
+
dialog.show_error(str(error))
|
|
780
|
+
return
|
|
781
|
+
snapshot, remote_snapshot = value
|
|
782
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=generation, session_id=session_id,
|
|
783
|
+
action=OperationAction.REF_WRITE, targets=(previous_intent.full_ref, previous_intent.target_oid), operation="Delete tag" if previous_intent.full_ref.startswith(TAG_PREFIX) else "Delete branch",
|
|
784
|
+
from_form=True)
|
|
785
|
+
dialog.snapshot = snapshot
|
|
786
|
+
dialog.intent = replace(previous_intent, operation=operation, snapshot=snapshot, unmerged_confirmed=False)
|
|
787
|
+
dialog.retry_operation_id = previous_outcome.operation_id
|
|
788
|
+
dialog.retry_remote_success = remote_success
|
|
789
|
+
if dialog.remote_branch:
|
|
790
|
+
refreshed_intents: list[ReferenceDeletionIntent] = []
|
|
791
|
+
for candidate in dialog.local_intents:
|
|
792
|
+
local_operation = replace(operation, targets=(candidate.full_ref, candidate.target_oid))
|
|
793
|
+
refreshed_intents.append(replace(candidate, operation=local_operation, snapshot=snapshot, unmerged_confirmed=False))
|
|
794
|
+
dialog.local_intents = tuple(refreshed_intents)
|
|
795
|
+
if previous_request is not None and not remote_success:
|
|
796
|
+
_old_snapshot, name, destination, endpoint, _target = previous_request
|
|
797
|
+
try:
|
|
798
|
+
target = self.transport.capture_push_target(remote_snapshot, name, endpoint)
|
|
799
|
+
except (ValueError, OSError) as failure:
|
|
800
|
+
dialog.show_error(str(failure))
|
|
801
|
+
return
|
|
802
|
+
dialog.remote_snapshot = remote_snapshot
|
|
803
|
+
dialog.remote_request = (remote_snapshot, name, destination, endpoint, target)
|
|
804
|
+
if not dialog.remote_branch:
|
|
805
|
+
# A tag has no merge question to ask: its deletion is checked again when Git starts.
|
|
806
|
+
self.submit_delete(session_id, dialog.intent)
|
|
807
|
+
return
|
|
808
|
+
self._prepare_delete(session_id, dialog, dialog.intent)
|
|
809
|
+
self.worktree_service.run(session.repository_context, self.repository.git_filename, read(), complete)
|
|
810
|
+
|
|
811
|
+
def _prepare_delete(self, session_id: str, dialog: DeleteBranchDialog, intent: ReferenceDeletionIntent) -> None:
|
|
812
|
+
"""Read the merge proof asynchronously without holding an owner across a question."""
|
|
813
|
+
if dialog.closed or dialog.intent != intent or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
814
|
+
return
|
|
815
|
+
|
|
816
|
+
def inspect() -> Generator:
|
|
817
|
+
"""Check every selected candidate before asking for any destructive answer."""
|
|
818
|
+
if not dialog.remote_branch:
|
|
819
|
+
return (yield from self.reference.inspect_delete(intent))
|
|
820
|
+
success_ref_set = self._successful_delete_ref_set(dialog)
|
|
821
|
+
warning_refs: list[str] = []
|
|
822
|
+
for candidate in dialog.local_intents:
|
|
823
|
+
if candidate.full_ref in success_ref_set or dialog.retry_remote_success and candidate.full_ref not in intent.snapshot.object_oid_by_name:
|
|
824
|
+
continue
|
|
825
|
+
merged = yield from self.reference.inspect_delete(candidate)
|
|
826
|
+
if not merged:
|
|
827
|
+
warning_refs.append(candidate.full_ref)
|
|
828
|
+
dialog.warning_refs = warning_refs
|
|
829
|
+
return not warning_refs
|
|
830
|
+
|
|
831
|
+
def complete(merged: bool | None, error: Exception | None) -> None:
|
|
832
|
+
"""Publish the merge result only to the unchanged original form."""
|
|
833
|
+
if dialog.closed or dialog.intent != intent or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
834
|
+
return
|
|
835
|
+
if error is not None:
|
|
836
|
+
dialog.show_error(str(error))
|
|
837
|
+
return
|
|
838
|
+
dialog.prepared(merged)
|
|
839
|
+
self.worktree_service.run(intent.snapshot.context, intent.snapshot.git_filename, inspect(), complete)
|
|
840
|
+
|
|
841
|
+
def _successful_delete_ref_set(self, dialog: DeleteBranchDialog) -> set[str]:
|
|
842
|
+
"""Keep completed local candidates out of later retries even if their refs reappear."""
|
|
843
|
+
if not dialog.retry_remote_success or dialog.outcome is None:
|
|
844
|
+
return set()
|
|
845
|
+
return {step.full_ref for step in dialog.outcome.steps if step.operation == DELETE_LOCAL_BRANCH_OPERATION and step.status == OperationStatus.SUCCESS}
|
|
846
|
+
|
|
847
|
+
def _delete_remote_candidates(self, dialog: DeleteBranchDialog, intent: ReferenceDeletionIntent, is_current: Callable[[], bool]) -> Generator:
|
|
848
|
+
"""Revalidate each remaining tracking branch under the existing server operation owner."""
|
|
849
|
+
success_ref_set = self._successful_delete_ref_set(dialog)
|
|
850
|
+
expected = intent.snapshot
|
|
851
|
+
remote_snapshot, _name, _destination, _endpoint, _target = dialog.remote_request
|
|
852
|
+
steps: list[OperationStepResult] = []
|
|
853
|
+
status, diagnostic = OperationStatus.SUCCESS, None
|
|
854
|
+
for candidate in dialog.local_intents:
|
|
855
|
+
if candidate.full_ref in success_ref_set:
|
|
856
|
+
continue
|
|
857
|
+
if status != OperationStatus.SUCCESS:
|
|
858
|
+
steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.NOT_STARTED, effect="previous local step did not succeed", full_ref=candidate.full_ref))
|
|
859
|
+
continue
|
|
860
|
+
try:
|
|
861
|
+
if not is_current():
|
|
862
|
+
raise ValueError("The deletion form or repository authority is no longer current")
|
|
863
|
+
current = yield from self.reference.capture(expected.context, expected.git_filename, expected.generation)
|
|
864
|
+
if current.signature != expected.signature or current.head_oid != intent.snapshot.head_oid or current.head_ref != intent.snapshot.head_ref:
|
|
865
|
+
raise ValueError("References, HEAD or worktrees changed before local deletion")
|
|
866
|
+
observed = yield from self.remote.capture(expected.context, expected.git_filename, expected.generation)
|
|
867
|
+
if observed.reference_state != current.signature:
|
|
868
|
+
raise ValueError("References changed while checking local tracking")
|
|
869
|
+
absent = candidate.full_ref not in current.object_oid_by_name
|
|
870
|
+
ignored_ref_set = success_ref_set | ({candidate.full_ref} if absent and dialog.retry_remote_success else set())
|
|
871
|
+
ignored_prefixes = tuple("branch." + full_ref.removeprefix(LOCAL_PREFIX) + "." for full_ref in ignored_ref_set)
|
|
872
|
+
expected_values = tuple(value for value in remote_snapshot.values if not value.key.startswith(ignored_prefixes))
|
|
873
|
+
observed_values = tuple(value for value in observed.values if not value.key.startswith(ignored_prefixes))
|
|
874
|
+
if observed_values != expected_values:
|
|
875
|
+
raise ValueError("Remote configuration or branch tracking changed before local deletion")
|
|
876
|
+
if not absent:
|
|
877
|
+
candidates = remote_delete_candidates(current, observed, intent.full_ref, intent.operation.session_id)
|
|
878
|
+
if not any(value.full_ref == candidate.full_ref and value.target_oid == candidate.target_oid for value in candidates):
|
|
879
|
+
raise ValueError("The original local tracking branch changed; select it again")
|
|
880
|
+
operation = replace(intent.operation, targets=(candidate.full_ref, candidate.target_oid))
|
|
881
|
+
local_intent = replace(candidate, operation=operation, snapshot=current, unmerged_confirmed=intent.unmerged_confirmed)
|
|
882
|
+
outcome = yield from self.reference.delete_sequence(local_intent, is_current=is_current, allow_absent=dialog.retry_remote_success)
|
|
883
|
+
for step in outcome.steps:
|
|
884
|
+
steps.append(replace(step, full_ref=candidate.full_ref))
|
|
885
|
+
status, diagnostic = outcome.status, outcome.diagnostic
|
|
886
|
+
if status == OperationStatus.SUCCESS:
|
|
887
|
+
success_ref_set.add(candidate.full_ref)
|
|
888
|
+
*_, last_step = outcome.steps
|
|
889
|
+
expected = last_step.after
|
|
890
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
891
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DELETE_LOCAL_BRANCH_OPERATION, expected.context.repository_dir, expected.git_filename, [], str(error)).prepare()
|
|
892
|
+
status = OperationStatus.REFUSED
|
|
893
|
+
steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, status, effect="not deleted", diagnostic=diagnostic, full_ref=candidate.full_ref))
|
|
894
|
+
return OperationOutcome(intent.operation.operation_id, status, tuple(steps), diagnostic)
|
|
895
|
+
|
|
896
|
+
def submit_delete(self, session_id: str, intent: ReferenceDeletionIntent) -> str | None:
|
|
897
|
+
"""Reserve the exact displayed deletion and recheck the form at native launch."""
|
|
898
|
+
dialog = self.delete_dialog_by_session.get(session_id)
|
|
899
|
+
if (dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id
|
|
900
|
+
or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
|
|
901
|
+
return None
|
|
902
|
+
if intent.operation.operation_id in self.transport.run_by_id:
|
|
903
|
+
return intent.operation.operation_id
|
|
904
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
905
|
+
|
|
906
|
+
def is_current() -> bool:
|
|
907
|
+
"""Reject late writes after authority, executable or form lifetime changes."""
|
|
908
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
909
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
910
|
+
self.delete_intent_by_id[intent.operation.operation_id] = intent
|
|
911
|
+
if dialog.remote_branch and not dialog.remote_checkbox.isChecked():
|
|
912
|
+
dialog.show_error("Select remote repository deletion before continuing")
|
|
913
|
+
return None
|
|
914
|
+
if dialog.remote_checkbox.isChecked():
|
|
915
|
+
if dialog.remote_request is None:
|
|
916
|
+
dialog.show_error("Choose the exact remote deletion destination")
|
|
917
|
+
return None
|
|
918
|
+
snapshot, remote_name, destination_ref, endpoint, target = dialog.remote_request
|
|
919
|
+
request = TransportRequest(intent.operation, remote_name=remote_name, snapshot=snapshot, mode="delete", source_ref=intent.full_ref,
|
|
920
|
+
source_oid=intent.target_oid, destination_ref=destination_ref, endpoint=endpoint, push_target=target,
|
|
921
|
+
delete_local_refs=tuple((candidate.full_ref, candidate.target_oid) for candidate in dialog.local_intents) if dialog.remote_branch else ())
|
|
922
|
+
|
|
923
|
+
def preflight() -> Generator:
|
|
924
|
+
"""Prove the local branch is still deletable before changing its remote."""
|
|
925
|
+
if dialog.remote_branch:
|
|
926
|
+
current = yield from self.reference.capture(intent.snapshot.context, intent.snapshot.git_filename, intent.snapshot.generation)
|
|
927
|
+
if current.signature != intent.snapshot.signature or current.object_oid_by_name.get(intent.full_ref) != intent.target_oid:
|
|
928
|
+
raise ValueError("The remote branch capture changed before deletion")
|
|
929
|
+
if remote_branch_destination(snapshot, intent.full_ref) != (remote_name, destination_ref):
|
|
930
|
+
raise ValueError("The displayed remote branch no longer matches its Fetch mapping")
|
|
931
|
+
for candidate in dialog.local_intents:
|
|
932
|
+
merged = yield from self.reference.inspect_delete(candidate)
|
|
933
|
+
if not merged and not intent.unmerged_confirmed:
|
|
934
|
+
raise ValueError("Explicit confirmation is required for an unmerged local tracking branch")
|
|
935
|
+
return
|
|
936
|
+
merged = yield from self.reference.inspect_delete(intent)
|
|
937
|
+
if not merged and not intent.unmerged_confirmed:
|
|
938
|
+
raise ValueError("Explicit confirmation is required for this unmerged branch")
|
|
939
|
+
|
|
940
|
+
def local_sequence() -> Generator:
|
|
941
|
+
"""Keep server success and the shared owner while performing only the local step."""
|
|
942
|
+
if dialog.remote_branch:
|
|
943
|
+
return (yield from self._delete_remote_candidates(dialog, intent, lambda: is_current() and not run.cancelled))
|
|
944
|
+
return (yield from self.reference.delete_sequence(intent, is_current=lambda: is_current() and not run.cancelled, allow_absent=dialog.retry_remote_success))
|
|
945
|
+
|
|
946
|
+
local_tag_success = intent.full_ref.startswith(TAG_PREFIX) and dialog.outcome is not None and any(step.operation == DELETE_LOCAL_TAG_OPERATION and step.status == OperationStatus.SUCCESS for step in dialog.outcome.steps)
|
|
947
|
+
if dialog.retry_remote_success or local_tag_success:
|
|
948
|
+
run = self.transport.reconcile(request, dialog.retry_operation_id, local_sequence=local_sequence, is_current=is_current)
|
|
949
|
+
else:
|
|
950
|
+
run = self.transport.delete_remote(request, preflight=preflight, local_sequence=local_sequence if not dialog.remote_branch or dialog.local_intents else None, is_current=is_current)
|
|
951
|
+
output_dialog = OperationDialog(run.trace, intent.operation.operation, self.session_registry_controller.window_by_session[session_id], preference=self.preference, console_operation=ConsoleOperation.REMOTE_DELETE)
|
|
952
|
+
self.console_registry_controller.transport_dialog_by_id[intent.operation.operation_id] = output_dialog
|
|
953
|
+
output_dialog.abort_requested.connect(lambda: self.transport.cancel(intent.operation.operation_id))
|
|
954
|
+
output_dialog.finished.connect(run.trace.close)
|
|
955
|
+
output_dialog.set_running()
|
|
956
|
+
open_window_modal(output_dialog, keeps_window_buttons=True)
|
|
957
|
+
return intent.operation.operation_id
|
|
958
|
+
return self.reference.delete(intent, is_current=is_current)
|
|
959
|
+
|
|
960
|
+
def delete_finished(self, outcome: object) -> None:
|
|
961
|
+
"""Retain individual results and refresh every related repository window."""
|
|
962
|
+
intent = self.delete_intent_by_id.pop(outcome.operation_id, None)
|
|
963
|
+
if intent is None:
|
|
964
|
+
return
|
|
965
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
966
|
+
dialog = self.delete_dialog_by_session.get(intent.operation.session_id)
|
|
967
|
+
if dialog is not None and not dialog.closed and dialog.intent == intent:
|
|
968
|
+
dialog.show_outcome(outcome)
|
|
969
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
970
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
971
|
+
if outcome.status == OperationStatus.REFUSED or outcome.operation_id in self.transport.run_by_id:
|
|
972
|
+
return
|
|
973
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.REFERENCES)
|
|
974
|
+
|
|
975
|
+
def open_branch(self, session_id: str, mode: BranchMode, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None,
|
|
976
|
+
full_ref: str = "") -> CreateBranchDialog | RenameBranchDialog | ResetOtherBranchDialog | None:
|
|
977
|
+
"""Open branch editing for one captured commit under a live management authority."""
|
|
978
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or mode not in (BranchMode.CREATE, BranchMode.RENAME, BranchMode.MOVE):
|
|
979
|
+
return None
|
|
980
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
981
|
+
if window.context.is_bare:
|
|
982
|
+
return None
|
|
983
|
+
existing = self.branch_dialog_by_session.get(session_id)
|
|
984
|
+
if existing is not None and not existing.closed:
|
|
985
|
+
existing.raise_()
|
|
986
|
+
return existing
|
|
987
|
+
if mode == BranchMode.CREATE:
|
|
988
|
+
return self._open_create_branch(session_id, target_oid, expected_snapshot)
|
|
989
|
+
if mode == BranchMode.RENAME:
|
|
990
|
+
return self._open_rename_branch(session_id, full_ref, target_oid, expected_snapshot)
|
|
991
|
+
return self._open_reset_other_branch(session_id, target_oid, expected_snapshot)
|
|
992
|
+
|
|
993
|
+
def _open_reset_other_branch(self, session_id: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> ResetOtherBranchDialog:
|
|
994
|
+
"""Open the Reset branch window on one commit, drawn from what the repository window already holds.
|
|
995
|
+
|
|
996
|
+
The references come from the window's own capture while their files are unchanged and the commit box from the
|
|
997
|
+
history graph, so opening on a displayed commit starts no Git process. Whether resetting the chosen branch is
|
|
998
|
+
a fast-forward is answered from the target's ancestors in memory, walked once per window, when the graph is
|
|
999
|
+
complete; otherwise one ``merge-base --is-ancestor`` per chosen branch asks Git.
|
|
1000
|
+
"""
|
|
1001
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1002
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1003
|
+
generation = session.generation
|
|
1004
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
1005
|
+
dialog = ResetOtherBranchDialog(session_id, target_oid, window)
|
|
1006
|
+
dialog.setAccessibleDescription(f"Reset another branch in {window.context.repository_dir}")
|
|
1007
|
+
self.branch_dialog_by_session[session_id] = dialog
|
|
1008
|
+
dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
|
|
1009
|
+
dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
|
|
1010
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
1011
|
+
# The target's ancestors never change, so they are walked at most once for this window.
|
|
1012
|
+
target_ancestry_by_oid: dict[str, set[str] | None] = {}
|
|
1013
|
+
|
|
1014
|
+
def is_current() -> bool:
|
|
1015
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
1016
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) and not dialog.busy
|
|
1017
|
+
|
|
1018
|
+
def target_ancestry() -> set[str] | None:
|
|
1019
|
+
"""Return the target and its ancestors from a complete graph in memory, or None when only Git can tell."""
|
|
1020
|
+
if target_oid not in target_ancestry_by_oid:
|
|
1021
|
+
history_snapshot = window.history_model.snapshot if isValid(window) else None
|
|
1022
|
+
if history_snapshot is None:
|
|
1023
|
+
return None
|
|
1024
|
+
target_ancestry_by_oid[target_oid] = complete_ancestor_oid_set(target_oid, history_snapshot.parent_oids_by_oid, history_snapshot.complete)
|
|
1025
|
+
return target_ancestry_by_oid[target_oid]
|
|
1026
|
+
|
|
1027
|
+
def inspect_ancestry(full_ref: str, old_oid: str) -> None:
|
|
1028
|
+
"""Tell the form whether moving the chosen branch to the target is a fast-forward, from memory or from Git."""
|
|
1029
|
+
snapshot = dialog.snapshot
|
|
1030
|
+
if snapshot is None or not is_current():
|
|
1031
|
+
return
|
|
1032
|
+
fast_forward = is_ancestor_in_memory(old_oid, target_ancestry())
|
|
1033
|
+
if fast_forward is not None:
|
|
1034
|
+
dialog.show_ancestry(full_ref, fast_forward)
|
|
1035
|
+
return
|
|
1036
|
+
|
|
1037
|
+
def inspected(result: bool | None, error: Exception | None) -> None:
|
|
1038
|
+
"""Ignore ancestry from a closed form or replaced reference capture."""
|
|
1039
|
+
if is_current() and dialog.snapshot is snapshot:
|
|
1040
|
+
dialog.show_ancestry(full_ref, result, str(error) if error else "")
|
|
1041
|
+
|
|
1042
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.reference.is_fast_forward(old_oid, target_oid), inspected)
|
|
1043
|
+
|
|
1044
|
+
dialog.ancestry_requested.connect(inspect_ancestry)
|
|
1045
|
+
|
|
1046
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
1047
|
+
"""Offer the branches and fill the commit box only while the form and the references it was asked from are current."""
|
|
1048
|
+
if not is_current():
|
|
1049
|
+
return
|
|
1050
|
+
if error is not None:
|
|
1051
|
+
refuse_form_opening(dialog, str(error))
|
|
1052
|
+
return
|
|
1053
|
+
if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
1054
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
|
|
1055
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
|
|
1056
|
+
return
|
|
1057
|
+
# Git refuses to reset exactly the branches it refuses to delete: the current one and those another worktree,
|
|
1058
|
+
# a rebase or a bisect holds; symbolic branches and branches without a commit cannot be moved either.
|
|
1059
|
+
dialog.populate(snapshot, target_oid, deletable_branch_refs(snapshot))
|
|
1060
|
+
reveal_form(dialog)
|
|
1061
|
+
self._show_target_commit(session_id, dialog, target_oid, is_current)
|
|
1062
|
+
|
|
1063
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
1064
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.MOVE, target_oid, expected_snapshot=capture))
|
|
1065
|
+
return dialog
|
|
1066
|
+
|
|
1067
|
+
def _open_rename_branch(self, session_id: str, full_ref: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> RenameBranchDialog:
|
|
1068
|
+
"""Open the Rename branch window on one local branch, drawn from the references the repository window already holds.
|
|
1069
|
+
|
|
1070
|
+
The window shows no commit, so nothing but the references is read, and those come from the window's own capture
|
|
1071
|
+
while their files are unchanged: opening it starts no Git process.
|
|
1072
|
+
"""
|
|
1073
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1074
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1075
|
+
generation = session.generation
|
|
1076
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
1077
|
+
dialog = RenameBranchDialog(session_id, full_ref, target_oid, window)
|
|
1078
|
+
dialog.setAccessibleDescription(f"Rename a branch in {window.context.repository_dir}")
|
|
1079
|
+
self.branch_dialog_by_session[session_id] = dialog
|
|
1080
|
+
dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
|
|
1081
|
+
dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
|
|
1082
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
1083
|
+
|
|
1084
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
1085
|
+
"""Bind the window to the references only while it is current and its branch is still the one clicked."""
|
|
1086
|
+
if not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) or dialog.busy:
|
|
1087
|
+
return
|
|
1088
|
+
if error is not None:
|
|
1089
|
+
refuse_form_opening(dialog, str(error))
|
|
1090
|
+
return
|
|
1091
|
+
if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
1092
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
|
|
1093
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
|
|
1094
|
+
return
|
|
1095
|
+
reference = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
|
|
1096
|
+
if reference is None or reference.kind != ReferenceKind.HEADS or reference.symbolic_target or reference.target_oid != target_oid:
|
|
1097
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_LOCAL_BRANCH_CHANGED))
|
|
1098
|
+
return
|
|
1099
|
+
dialog.populate(snapshot, full_ref, target_oid)
|
|
1100
|
+
reveal_form(dialog)
|
|
1101
|
+
|
|
1102
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
1103
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.RENAME, target_oid, expected_snapshot=capture, full_ref=full_ref))
|
|
1104
|
+
return dialog
|
|
1105
|
+
|
|
1106
|
+
def _open_create_branch(self, session_id: str, target_oid: str, expected_snapshot: ReferenceSnapshot | None) -> CreateBranchDialog:
|
|
1107
|
+
"""Open the Create branch window on one commit, drawn from what the repository window already holds.
|
|
1108
|
+
|
|
1109
|
+
The references come from the window's own capture while their files are unchanged, the proposed name from
|
|
1110
|
+
those references, and the commit box and position from the history graph in memory, so opening on a displayed
|
|
1111
|
+
commit of a fully loaded graph starts no Git process.
|
|
1112
|
+
"""
|
|
1113
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1114
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1115
|
+
generation = session.generation
|
|
1116
|
+
expected_snapshot = expected_snapshot or self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
1117
|
+
dialog = CreateBranchDialog(session_id, target_oid, window)
|
|
1118
|
+
dialog.setAccessibleDescription(f"Create a branch in {window.context.repository_dir}")
|
|
1119
|
+
self.branch_dialog_by_session[session_id] = dialog
|
|
1120
|
+
dialog.branch_requested.connect(lambda intent: self.submit_branch(session_id, intent))
|
|
1121
|
+
dialog.finished.connect(lambda: self.branch_dialog_by_session.pop(session_id, None) if self.branch_dialog_by_session.get(session_id) is dialog else None)
|
|
1122
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
1123
|
+
|
|
1124
|
+
def is_current() -> bool:
|
|
1125
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
1126
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.branch_dialog_by_session, context, git_filename) and not dialog.busy
|
|
1127
|
+
|
|
1128
|
+
self._bind_revision_picker(session_id, dialog, is_current)
|
|
1129
|
+
|
|
1130
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
1131
|
+
"""Show the commit and the proposed name only while the form and the references it was asked from are current."""
|
|
1132
|
+
if not is_current():
|
|
1133
|
+
return
|
|
1134
|
+
if error is not None:
|
|
1135
|
+
refuse_form_opening(dialog, str(error))
|
|
1136
|
+
return
|
|
1137
|
+
if expected_snapshot is not None and (snapshot.signature != expected_snapshot.signature or snapshot.generation != expected_snapshot.generation
|
|
1138
|
+
or snapshot.git_filename != expected_snapshot.git_filename or snapshot.context != expected_snapshot.context):
|
|
1139
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.REFERENCE_CHANGED_BEFORE_OPENING))
|
|
1140
|
+
return
|
|
1141
|
+
dialog.populate(snapshot, target_oid, suggested_branch_name(snapshot, target_oid))
|
|
1142
|
+
reveal_form(dialog)
|
|
1143
|
+
|
|
1144
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
1145
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_branch(session_id, BranchMode.CREATE, target_oid, expected_snapshot=capture))
|
|
1146
|
+
return dialog
|
|
1147
|
+
|
|
1148
|
+
def _show_target_commit(self, session_id: str, dialog: CreateBranchDialog | ResetOtherBranchDialog, oid: str, is_current: Callable[[], bool]) -> None:
|
|
1149
|
+
"""Fill a form's commit box for its new target from the history graph, and from one Git read only when the graph lacks it.
|
|
1150
|
+
|
|
1151
|
+
A commit the graph shows already carries its subject, author and date. Only a commit not loaded yet, or one
|
|
1152
|
+
whose details the graph could not read, costs a ``git show``, whose answer is dropped once the form targets
|
|
1153
|
+
another commit or went away. A box already filled, or refused, for this very commit is left as it is.
|
|
1154
|
+
"""
|
|
1155
|
+
if dialog.commit_summary.target_oid != oid or dialog.metadata_ready or dialog.metadata_failed:
|
|
1156
|
+
return
|
|
1157
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
1158
|
+
history_model = window.history_model if window is not None else None
|
|
1159
|
+
row = history_model.row_by_oid.get(oid) if history_model is not None else None
|
|
1160
|
+
if row is not None and not history_model.commits[row].detail_error:
|
|
1161
|
+
dialog.show_loaded_commit(history_model.commits[row])
|
|
1162
|
+
return
|
|
1163
|
+
snapshot = dialog.snapshot
|
|
1164
|
+
if snapshot is None:
|
|
1165
|
+
return
|
|
1166
|
+
|
|
1167
|
+
def complete(fields: tuple | None, error: Exception | None) -> None:
|
|
1168
|
+
"""Publish the commit only to the form and target it was read for."""
|
|
1169
|
+
if not is_current() or dialog.commit_summary is None or dialog.commit_summary.target_oid != oid:
|
|
1170
|
+
return
|
|
1171
|
+
dialog.show_metadata(fields, str(error) if error else "")
|
|
1172
|
+
|
|
1173
|
+
self.reference.metadata(snapshot, oid, complete)
|
|
1174
|
+
|
|
1175
|
+
def submit_branch(self, session_id: str, intent: BranchIntent) -> str | None:
|
|
1176
|
+
"""Reserve only the exact branch intention shown by a live management form."""
|
|
1177
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1178
|
+
return None
|
|
1179
|
+
dialog = self.branch_dialog_by_session.get(session_id)
|
|
1180
|
+
if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
|
|
1181
|
+
return None
|
|
1182
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1183
|
+
def is_current() -> bool:
|
|
1184
|
+
"""Recheck live session, executable and context at the actual native launch."""
|
|
1185
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
1186
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
1187
|
+
self.branch_intent_by_id[intent.operation.operation_id] = intent
|
|
1188
|
+
if intent.mode == BranchMode.RENAME:
|
|
1189
|
+
return self.reference.rename_branch(intent, is_current=is_current)
|
|
1190
|
+
self.open_reference_output(intent, dialog)
|
|
1191
|
+
if intent.mode == BranchMode.MOVE:
|
|
1192
|
+
return self.reference.move_branch(intent, is_current=is_current)
|
|
1193
|
+
return self.reference.create_branch(intent, is_current=is_current)
|
|
1194
|
+
|
|
1195
|
+
def branch_finished(self, outcome: object) -> None:
|
|
1196
|
+
"""Show observed effects and refresh related windows after native branch writes."""
|
|
1197
|
+
intent = self.branch_intent_by_id.pop(outcome.operation_id, None)
|
|
1198
|
+
if intent is None:
|
|
1199
|
+
return
|
|
1200
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
1201
|
+
dialog = self.branch_dialog_by_session.get(intent.operation.session_id)
|
|
1202
|
+
expected_form = self.reference_form_by_id.get(outcome.operation_id) if intent.mode != BranchMode.RENAME else dialog
|
|
1203
|
+
if dialog is not None and dialog is expected_form and isValid(dialog) and not dialog.closed and dialog.intent == intent:
|
|
1204
|
+
dialog.show_outcome(outcome)
|
|
1205
|
+
self.finish_reference_output(outcome)
|
|
1206
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
1207
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
1208
|
+
if outcome.status == OperationStatus.REFUSED:
|
|
1209
|
+
return
|
|
1210
|
+
# The scope follows what was asked, not the result: a checkout that started and failed may have changed files.
|
|
1211
|
+
# An orphan branch is always checked out, and replaces the index and working files with the target's tree.
|
|
1212
|
+
checkout_asked = intent.mode in (BranchMode.CREATE_CHECKOUT, BranchMode.CREATE_ORPHAN) or intent.mode == BranchMode.MOVE and intent.checkout_after_reset
|
|
1213
|
+
if outcome.status == OperationStatus.SUCCESS and intent.mode in HISTORY_TOP_BRANCH_MODE_SET and intent.operation.session_id is not None:
|
|
1214
|
+
self.history_read_controller.history_top_session_set.add(intent.operation.session_id)
|
|
1215
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL if checkout_asked else RefreshScope.REFERENCES)
|
|
1216
|
+
|
|
1217
|
+
def open_reference_output(self, intent: TagIntent | BranchIntent | CheckoutIntent | BranchDeletionIntent | ReplayIntent | MergeAbortIntent,
|
|
1218
|
+
form: CheckoutDialog | CheckoutRevisionDialog | CreateBranchDialog | CreateTagDialog | DeleteLocalBranchDialog | ReplayDialog | None,
|
|
1219
|
+
*, modal: bool = True) -> OperationDialog:
|
|
1220
|
+
"""Subscribe before Git starts and keep its output independent of the input form.
|
|
1221
|
+
|
|
1222
|
+
Cherry-pick and Revert consoles are not modal: they can end on a pause that needs the repository window,
|
|
1223
|
+
its banner and the conflict prompt, which a window-modal console would block. A Continue or Abort started
|
|
1224
|
+
from the repository banner has no form, so ``form`` is None there.
|
|
1225
|
+
"""
|
|
1226
|
+
operation = intent.operation
|
|
1227
|
+
trace = OperationTrace(operation.operation_id, self.repository.runner, self.diagnostic_log, native_commands_only=operation.action in NATIVE_ONLY_CONSOLE_ACTION_SET,
|
|
1228
|
+
header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
|
|
1229
|
+
output = OperationDialog(trace, operation.operation, self.session_registry_controller.window_by_session[operation.session_id], preference=self.preference, console_operation=ConsoleOperation.REFERENCE)
|
|
1230
|
+
self.reference_trace_by_id[operation.operation_id] = trace
|
|
1231
|
+
self.console_registry_controller.reference_output_by_id[operation.operation_id] = output
|
|
1232
|
+
self.reference_label_by_id[operation.operation_id] = operation.operation
|
|
1233
|
+
self.reference_form_by_id[operation.operation_id] = form
|
|
1234
|
+
|
|
1235
|
+
def closed() -> None:
|
|
1236
|
+
"""Release retained output only after the user or success policy dismisses it."""
|
|
1237
|
+
self.reference_trace_by_id.pop(operation.operation_id, None)
|
|
1238
|
+
self.console_registry_controller.reference_output_by_id.pop(operation.operation_id, None)
|
|
1239
|
+
self.reference_label_by_id.pop(operation.operation_id, None)
|
|
1240
|
+
self.reference_form_by_id.pop(operation.operation_id, None)
|
|
1241
|
+
self.created_tag_by_id.pop(operation.operation_id, None)
|
|
1242
|
+
trace.close()
|
|
1243
|
+
|
|
1244
|
+
output.finished.connect(closed)
|
|
1245
|
+
self.session_registry_controller.session_by_id[operation.session_id].closed.connect(output.reject)
|
|
1246
|
+
output.set_running()
|
|
1247
|
+
if modal:
|
|
1248
|
+
open_window_modal(output, keeps_window_buttons=True)
|
|
1249
|
+
return output
|
|
1250
|
+
output.show()
|
|
1251
|
+
output.raise_()
|
|
1252
|
+
output.activateWindow()
|
|
1253
|
+
return output
|
|
1254
|
+
|
|
1255
|
+
def finish_reference_output(self, outcome: OperationOutcome, *, failure_text: str = "") -> None:
|
|
1256
|
+
"""Publish the result after all listeners have collected the final native output.
|
|
1257
|
+
|
|
1258
|
+
``failure_text`` is shown in bold red under the status line of a failed result, for a refusal the user must
|
|
1259
|
+
read to act on it rather than find among the console's status lines.
|
|
1260
|
+
|
|
1261
|
+
A refused operation never reached Git's write, so its console ends with one red line that says why, such as
|
|
1262
|
+
``Create tag: not started — <reason>``: the step lines and the diagnostic block are stored and logged as before,
|
|
1263
|
+
but not shown, since they would only repeat that nothing started. Every other result shows its step lines.
|
|
1264
|
+
"""
|
|
1265
|
+
trace = self.reference_trace_by_id.get(outcome.operation_id)
|
|
1266
|
+
output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
|
|
1267
|
+
label = self.reference_label_by_id.get(outcome.operation_id)
|
|
1268
|
+
self.reference_form_by_id.pop(outcome.operation_id, None)
|
|
1269
|
+
if trace is None or output is None or label is None or not isValid(output):
|
|
1270
|
+
return
|
|
1271
|
+
|
|
1272
|
+
def publish_result() -> None:
|
|
1273
|
+
"""Let the trace flush its final fragment before automatic console closure."""
|
|
1274
|
+
if not isValid(output) or output.dismissed:
|
|
1275
|
+
return
|
|
1276
|
+
refused = outcome.status == OperationStatus.REFUSED
|
|
1277
|
+
for step in outcome.steps:
|
|
1278
|
+
# A step about one reference among several names it, so each branch of a group deletion has its own line.
|
|
1279
|
+
subject = step.operation + (" " + short_reference_name(step.full_ref) if step.full_ref else "")
|
|
1280
|
+
step_text = subject + ": " + step.status.value
|
|
1281
|
+
if refused:
|
|
1282
|
+
trace.append_record(outcome.operation_id, "status", step_text, None)
|
|
1283
|
+
else:
|
|
1284
|
+
trace.append_event(outcome.operation_id, "status", step_text)
|
|
1285
|
+
end_text = operation_end_text(label, outcome.status)
|
|
1286
|
+
if outcome.diagnostic is not None and refused:
|
|
1287
|
+
end_text = end_line_with_cause(end_text, trace.unshown_cause(outcome.diagnostic) or "")
|
|
1288
|
+
trace.append_record(outcome.operation_id, "status", outcome.diagnostic.detail, None)
|
|
1289
|
+
elif outcome.diagnostic is not None:
|
|
1290
|
+
trace.append_diagnostic(outcome.operation_id, "status", outcome.diagnostic, outcome.status)
|
|
1291
|
+
failure_reported = outcome.diagnostic is not None
|
|
1292
|
+
trace.append_end_line(outcome.operation_id, "status", REFERENCE_RESULT_LOG_TEMPLATE.format(status=outcome.status.value), end_text,
|
|
1293
|
+
outcome.status, failure_reported=failure_reported)
|
|
1294
|
+
output.show_result(outcome.status, failure_reported=failure_reported, failure_text=failure_text)
|
|
1295
|
+
|
|
1296
|
+
publish_after_trace(publish_result)
|
|
1297
|
+
|
|
1298
|
+
def _bind_revision_picker(self, session_id: str, dialog: RevisionForm, is_current: Callable[[], bool]) -> None:
|
|
1299
|
+
"""Connect a form's revision field to the resolution of what is typed and to the position of each new target.
|
|
1300
|
+
|
|
1301
|
+
Every form built around the Git Extensions revision field uses this same binding. ``is_current`` tells
|
|
1302
|
+
whether an answer may still reach the form. The form connects its own target handling first, so a new
|
|
1303
|
+
target's older position and commit details are invalidated before the new ones are filled. Following the
|
|
1304
|
+
field's target covers every way a target arrives: opening, typing, the choice window and a restored commit.
|
|
1305
|
+
"""
|
|
1306
|
+
def resolve(text: str, final: bool) -> None:
|
|
1307
|
+
"""Resolve the text the field reports after a typing pause or when it is left."""
|
|
1308
|
+
self._resolve_revision_text(session_id, dialog, text, final, is_current)
|
|
1309
|
+
|
|
1310
|
+
def show_position(oid: str | None) -> None:
|
|
1311
|
+
"""Count how far each new target is from HEAD and fill the commit box; a field without a target shows neither."""
|
|
1312
|
+
if oid is None or not is_current():
|
|
1313
|
+
return
|
|
1314
|
+
self._show_revision_position(session_id, dialog, oid)
|
|
1315
|
+
if dialog.commit_summary is not None:
|
|
1316
|
+
self._show_target_commit(session_id, dialog, oid, is_current)
|
|
1317
|
+
|
|
1318
|
+
def close_choice() -> None:
|
|
1319
|
+
"""Close the revision choice window together with the form it chooses for."""
|
|
1320
|
+
choice = dialog.picker.revision_dialog
|
|
1321
|
+
if choice is not None and isValid(choice) and not choice.closed:
|
|
1322
|
+
choice.reject()
|
|
1323
|
+
|
|
1324
|
+
dialog.picker.revision_edited.connect(resolve)
|
|
1325
|
+
dialog.picker.target_changed.connect(show_position)
|
|
1326
|
+
dialog.picker.choose_requested.connect(lambda: self._open_revision_choice(session_id, dialog, is_current))
|
|
1327
|
+
dialog.finished.connect(close_choice)
|
|
1328
|
+
|
|
1329
|
+
def _resolve_revision_text(self, session_id: str, dialog: RevisionForm, text: str, final: bool, is_current: Callable[[], bool]) -> None:
|
|
1330
|
+
"""Turn the text typed in a revision field into the form's target, from memory whenever memory can prove it.
|
|
1331
|
+
|
|
1332
|
+
HEAD, a full commit ID of the loaded graph and an unambiguous reference name are resolved without Git; any
|
|
1333
|
+
other text costs one ``rev-parse``, whose answer is dropped once the text changed or the form went away. A
|
|
1334
|
+
target found for a typing pause keeps the typed text; one found when the field is left shows its short hash.
|
|
1335
|
+
A text already refused is refused again without Git. Leaving the field while the pause's ``rev-parse`` still
|
|
1336
|
+
runs starts no second one: that answer settles the field as if it had been asked when the field was left.
|
|
1337
|
+
"""
|
|
1338
|
+
if not is_current() or dialog.snapshot is None:
|
|
1339
|
+
return
|
|
1340
|
+
picker = dialog.picker
|
|
1341
|
+
problem = revision_text_problem(text)
|
|
1342
|
+
if problem or (text == picker.answered_text and picker.answered_oid is None):
|
|
1343
|
+
self._refuse_revision_text(dialog, text, problem, final)
|
|
1344
|
+
return
|
|
1345
|
+
resolution_generation = picker.resolution_generation
|
|
1346
|
+
|
|
1347
|
+
def found(oid: str | None) -> None:
|
|
1348
|
+
"""Take the answer only for the text and form it was asked for; a field left meanwhile is settled by it."""
|
|
1349
|
+
if not is_current() or picker.resolution_generation != resolution_generation:
|
|
1350
|
+
return
|
|
1351
|
+
settled = final or picker.settle_generation == resolution_generation
|
|
1352
|
+
if oid is None:
|
|
1353
|
+
self._refuse_revision_text(dialog, text, "", settled)
|
|
1354
|
+
return
|
|
1355
|
+
dialog.set_target(oid, rewrite_text=settled)
|
|
1356
|
+
|
|
1357
|
+
self._find_revision(session_id, dialog.snapshot, text, found)
|
|
1358
|
+
|
|
1359
|
+
def _find_revision(self, session_id: str, snapshot: ReferenceSnapshot, text: str, callback: Callable[[str | None], None]) -> None:
|
|
1360
|
+
"""Find the commit a revision text names, from memory when memory can prove it and by one Git read otherwise.
|
|
1361
|
+
|
|
1362
|
+
A memory answer reaches the callback at once, before this returns; a Git answer arrives later. The callback
|
|
1363
|
+
receives None for a text that names no commit, a text Git must never receive, or a failed read.
|
|
1364
|
+
"""
|
|
1365
|
+
if revision_text_problem(text):
|
|
1366
|
+
callback(None)
|
|
1367
|
+
return
|
|
1368
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
1369
|
+
history_snapshot = window.history_model.snapshot if window is not None else None
|
|
1370
|
+
parent_oids_by_oid = history_snapshot.parent_oids_by_oid if history_snapshot is not None else {}
|
|
1371
|
+
oid = resolve_revision_in_memory(text, snapshot.references, snapshot.head_oid, parent_oids_by_oid)
|
|
1372
|
+
if oid is not None:
|
|
1373
|
+
callback(oid)
|
|
1374
|
+
return
|
|
1375
|
+
|
|
1376
|
+
def complete(resolved_oid: str | None, error: Exception | None) -> None:
|
|
1377
|
+
"""Treat a failed read like a text that names no commit."""
|
|
1378
|
+
callback(resolved_oid if error is None else None)
|
|
1379
|
+
|
|
1380
|
+
self.reference.resolve_revision(snapshot, text, complete)
|
|
1381
|
+
|
|
1382
|
+
def _open_revision_choice(self, session_id: str, dialog: RevisionForm, is_current: Callable[[], bool]) -> None:
|
|
1383
|
+
"""Open the "Choose another revision" window of Cherry-pick for a form's revision field.
|
|
1384
|
+
|
|
1385
|
+
The commit clicked in its list, or the revision typed in it, is resolved like the field's own text: a loaded
|
|
1386
|
+
commit needs no Git. The form takes the commit and the window closes; a text naming no commit stays in the
|
|
1387
|
+
window with its explanation.
|
|
1388
|
+
"""
|
|
1389
|
+
if not is_current() or dialog.snapshot is None:
|
|
1390
|
+
return
|
|
1391
|
+
picker = dialog.picker
|
|
1392
|
+
if picker.revision_dialog is not None and isValid(picker.revision_dialog) and not picker.revision_dialog.closed:
|
|
1393
|
+
picker.revision_dialog.raise_()
|
|
1394
|
+
return
|
|
1395
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1396
|
+
selector = RevisionChoiceDialog(window.history_model.commits, dialog)
|
|
1397
|
+
picker.revision_dialog = selector
|
|
1398
|
+
|
|
1399
|
+
def read_revision(text: str) -> None:
|
|
1400
|
+
"""Resolve the chosen revision for this edit of the choice window only."""
|
|
1401
|
+
generation = selector.generation
|
|
1402
|
+
|
|
1403
|
+
def found(oid: str | None) -> None:
|
|
1404
|
+
"""Hand the commit to the form, or explain in the choice window that the text names none."""
|
|
1405
|
+
if not isValid(selector) or selector.closed or selector.generation != generation or not is_current():
|
|
1406
|
+
return
|
|
1407
|
+
if oid is None:
|
|
1408
|
+
selector.show_error()
|
|
1409
|
+
return
|
|
1410
|
+
dialog.set_target(oid)
|
|
1411
|
+
selector.accept()
|
|
1412
|
+
|
|
1413
|
+
self._find_revision(session_id, dialog.snapshot, text, found)
|
|
1414
|
+
|
|
1415
|
+
selector.revision_requested.connect(read_revision)
|
|
1416
|
+
open_window_modal(selector)
|
|
1417
|
+
selector.revision_edit.setFocus()
|
|
1418
|
+
|
|
1419
|
+
def _refuse_revision_text(self, dialog: RevisionForm, text: str, problem: str, final: bool) -> None:
|
|
1420
|
+
"""Answer a text that names no commit: no target while the user types, the previous commit once the field is left.
|
|
1421
|
+
|
|
1422
|
+
Leaving the field explains the refusal in the form's message box, as Git Extensions does. The previous commit
|
|
1423
|
+
comes back after the message, so the form can act on it again.
|
|
1424
|
+
"""
|
|
1425
|
+
if not final:
|
|
1426
|
+
dialog.picker.show_invalid()
|
|
1427
|
+
return
|
|
1428
|
+
cause = problem or "'" + text + "' is not a revision of this repository"
|
|
1429
|
+
dialog.show_error(cause + "; the previous revision is kept.")
|
|
1430
|
+
dialog.picker.restore_last_target()
|
|
1431
|
+
|
|
1432
|
+
def _commit_difference_from_graph(self, session_id: str, snapshot: ReferenceSnapshot, target_oid: str) -> tuple[int, int] | None:
|
|
1433
|
+
"""Count the commits only in HEAD and only in the target from the window's history graph, or None when it cannot prove them.
|
|
1434
|
+
|
|
1435
|
+
The graph answers only when it was read for the same HEAD as the form's references and holds HEAD's whole
|
|
1436
|
+
ancestry and the target; the counts have the order of ``git rev-list --left-right --count HEAD...target``.
|
|
1437
|
+
"""
|
|
1438
|
+
if snapshot.head_oid is None:
|
|
1439
|
+
return None
|
|
1440
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
1441
|
+
history_model = window.history_model if window is not None and isValid(window) else None
|
|
1442
|
+
history_snapshot = history_model.snapshot if history_model is not None else None
|
|
1443
|
+
if history_snapshot is None or history_snapshot.head_oid != snapshot.head_oid:
|
|
1444
|
+
return None
|
|
1445
|
+
return commit_difference_in_memory(target_oid, history_model.relative_oid_set, history_snapshot.parent_oids_by_oid)
|
|
1446
|
+
|
|
1447
|
+
def _show_revision_position(self, session_id: str, dialog: RevisionForm, target_oid: str) -> None:
|
|
1448
|
+
"""Show how far a commit is from HEAD, from the history graph in memory, and from Git only when memory cannot tell.
|
|
1449
|
+
|
|
1450
|
+
HEAD without a commit shows nothing, as in Git Extensions. The graph answers only when it is complete and was
|
|
1451
|
+
read for the same HEAD as the form; otherwise one native count is started, and a late answer for another
|
|
1452
|
+
target or a closed form is dropped.
|
|
1453
|
+
"""
|
|
1454
|
+
snapshot = dialog.snapshot
|
|
1455
|
+
if snapshot is None:
|
|
1456
|
+
return
|
|
1457
|
+
if snapshot.head_oid is None:
|
|
1458
|
+
dialog.picker.show_count("")
|
|
1459
|
+
return
|
|
1460
|
+
difference = self._commit_difference_from_graph(session_id, snapshot, target_oid)
|
|
1461
|
+
if difference is not None:
|
|
1462
|
+
dialog.picker.show_count(commit_count_text(difference))
|
|
1463
|
+
return
|
|
1464
|
+
position_generation = dialog.position_generation
|
|
1465
|
+
|
|
1466
|
+
def complete(difference: tuple[int, int] | None, error: Exception | None) -> None:
|
|
1467
|
+
"""Show Git's count only for the target and form it was asked for."""
|
|
1468
|
+
if not isValid(dialog) or dialog.closed or dialog.position_generation != position_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1469
|
+
return
|
|
1470
|
+
dialog.picker.show_count(commit_count_text(difference if error is None else None))
|
|
1471
|
+
|
|
1472
|
+
self.reference.difference(snapshot, target_oid, complete)
|
|
1473
|
+
|
|
1474
|
+
def open_checkout(self, session_id: str, full_ref: str, *, target_oid: str | None = None,
|
|
1475
|
+
expected_snapshot: ReferenceSnapshot | None = None) -> CheckoutDialog | None:
|
|
1476
|
+
"""Open a captured branch choice only from a live management window."""
|
|
1477
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1478
|
+
return None
|
|
1479
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1480
|
+
if window.context.is_bare:
|
|
1481
|
+
return None
|
|
1482
|
+
existing = self.checkout_dialog_by_session.get(session_id)
|
|
1483
|
+
if existing is not None and not existing.closed:
|
|
1484
|
+
existing.raise_()
|
|
1485
|
+
return existing
|
|
1486
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1487
|
+
generation = session.generation
|
|
1488
|
+
previous_snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
1489
|
+
if expected_snapshot is None:
|
|
1490
|
+
expected_snapshot = previous_snapshot
|
|
1491
|
+
if target_oid is None and previous_snapshot is not None:
|
|
1492
|
+
for reference in previous_snapshot.references:
|
|
1493
|
+
if reference.full_name == full_ref:
|
|
1494
|
+
target_oid = reference.target_oid
|
|
1495
|
+
break
|
|
1496
|
+
dialog = CheckoutDialog(session_id, window)
|
|
1497
|
+
dialog.setWindowTitle(f"Checkout — {os.path.basename(window.context.repository_dir)}")
|
|
1498
|
+
dialog.setAccessibleDescription(f"Check out a branch in {window.context.repository_dir}")
|
|
1499
|
+
dialog.checkout_button.setAccessibleDescription(f"Check out the selected reference in {window.context.repository_dir}")
|
|
1500
|
+
self.checkout_dialog_by_session[session_id] = dialog
|
|
1501
|
+
dialog.checkout_requested.connect(lambda intent: self.submit_checkout(session_id, intent))
|
|
1502
|
+
dialog.selection_changed.connect(lambda oid: self._compare_checkout(session_id, dialog, oid))
|
|
1503
|
+
dialog.lost_commit_count_requested.connect(lambda local_oid, remote_oid: self._count_lost_commits(session_id, dialog, local_oid, remote_oid))
|
|
1504
|
+
dialog.finished.connect(lambda: self.checkout_dialog_by_session.pop(session_id, None) if self.checkout_dialog_by_session.get(session_id) is dialog else None)
|
|
1505
|
+
|
|
1506
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
1507
|
+
|
|
1508
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
1509
|
+
"""Keep the target frozen and prevent publication after closure, refresh or submission."""
|
|
1510
|
+
if not self.reference_read_controller.form_is_current(session_id, generation, dialog, self.checkout_dialog_by_session, context, git_filename) or dialog.busy:
|
|
1511
|
+
return
|
|
1512
|
+
if error is not None:
|
|
1513
|
+
refuse_form_opening(dialog, str(error))
|
|
1514
|
+
return
|
|
1515
|
+
if expected_snapshot is not None and (snapshot.head_oid != expected_snapshot.head_oid or snapshot.head_ref != expected_snapshot.head_ref
|
|
1516
|
+
or snapshot.generation != expected_snapshot.generation or snapshot.git_filename != expected_snapshot.git_filename):
|
|
1517
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.HEAD_CHANGED_BEFORE_OPENING))
|
|
1518
|
+
return
|
|
1519
|
+
if target_oid is not None:
|
|
1520
|
+
selected = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
|
|
1521
|
+
if selected is None or selected.target_oid != target_oid:
|
|
1522
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.SELECTED_REFERENCE_CHANGED))
|
|
1523
|
+
return
|
|
1524
|
+
dialog.populate(snapshot, full_ref)
|
|
1525
|
+
reveal_form(dialog)
|
|
1526
|
+
|
|
1527
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
1528
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_checkout(session_id, full_ref, target_oid=target_oid, expected_snapshot=capture))
|
|
1529
|
+
return dialog
|
|
1530
|
+
|
|
1531
|
+
def _compare_checkout(self, session_id: str, dialog: CheckoutDialog, target_oid: str) -> None:
|
|
1532
|
+
"""Show how the chosen branch differs from HEAD, from the history graph in memory, and from Git only when memory cannot tell.
|
|
1533
|
+
|
|
1534
|
+
The answer is bound to the selected target and the dialog generation, so a late Git count for an older choice
|
|
1535
|
+
is dropped.
|
|
1536
|
+
"""
|
|
1537
|
+
difference = self._commit_difference_from_graph(session_id, dialog.snapshot, target_oid) if dialog.snapshot is not None else None
|
|
1538
|
+
if difference is not None:
|
|
1539
|
+
dialog.show_difference(difference)
|
|
1540
|
+
return
|
|
1541
|
+
generation = dialog.read_generation
|
|
1542
|
+
def complete(difference: object, error: Exception | None) -> None:
|
|
1543
|
+
"""Ignore comparison output after another branch choice or invalidated authority."""
|
|
1544
|
+
if dialog.closed or not isValid(dialog) or generation != dialog.read_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1545
|
+
return
|
|
1546
|
+
dialog.show_difference(difference, str(error) if error else "")
|
|
1547
|
+
self.reference.difference(dialog.snapshot, target_oid, complete)
|
|
1548
|
+
|
|
1549
|
+
def _count_lost_commits(self, session_id: str, dialog: CheckoutDialog, local_oid: str, remote_oid: str) -> None:
|
|
1550
|
+
"""Tell the Checkout window how many local commits resetting the branch onto the remote one would lose.
|
|
1551
|
+
|
|
1552
|
+
The answer is bound to the dialog generation like the commit comparison, so a late count for an older choice
|
|
1553
|
+
is dropped. A count Git could not give shows its cause under the choices and makes Checkout available again.
|
|
1554
|
+
"""
|
|
1555
|
+
if dialog.snapshot is None:
|
|
1556
|
+
return
|
|
1557
|
+
generation = dialog.read_generation
|
|
1558
|
+
|
|
1559
|
+
def complete(count: object, error: Exception | None) -> None:
|
|
1560
|
+
"""Ignore the count after another branch choice, closure or lost authority."""
|
|
1561
|
+
if dialog.closed or not isValid(dialog) or generation != dialog.read_generation or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1562
|
+
return
|
|
1563
|
+
if error is not None:
|
|
1564
|
+
dialog.show_lost_commit_failure(str(error))
|
|
1565
|
+
return
|
|
1566
|
+
dialog.show_lost_commits(count)
|
|
1567
|
+
|
|
1568
|
+
self.reference.commits_only_in(dialog.snapshot, local_oid, remote_oid, complete)
|
|
1569
|
+
|
|
1570
|
+
def submit_checkout(self, session_id: str, intent: CheckoutIntent) -> str | None:
|
|
1571
|
+
"""Recheck authority and the displayed immutable intent before reserving Git writes."""
|
|
1572
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1573
|
+
return None
|
|
1574
|
+
dialog = self.checkout_dialog_by_session.get(session_id)
|
|
1575
|
+
if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
|
|
1576
|
+
return None
|
|
1577
|
+
return self._launch_checkout(session_id, dialog, intent)
|
|
1578
|
+
|
|
1579
|
+
def open_checkout_revision(self, session_id: str, target_oid: str, *, expected_snapshot: ReferenceSnapshot | None = None) -> CheckoutRevisionDialog | None:
|
|
1580
|
+
"""Open the Checkout revision window on one commit, only from a live management window with a working tree.
|
|
1581
|
+
|
|
1582
|
+
The references come from the window's own capture while their files are unchanged, and the commit's position
|
|
1583
|
+
from HEAD from the history graph in memory, so opening on a fully loaded graph starts no Git process.
|
|
1584
|
+
"""
|
|
1585
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1586
|
+
return None
|
|
1587
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
1588
|
+
if window.context.is_bare:
|
|
1589
|
+
return None
|
|
1590
|
+
existing = self.checkout_revision_dialog_by_session.get(session_id)
|
|
1591
|
+
if existing is not None and not existing.closed:
|
|
1592
|
+
existing.raise_()
|
|
1593
|
+
return existing
|
|
1594
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1595
|
+
generation = session.generation
|
|
1596
|
+
if expected_snapshot is None:
|
|
1597
|
+
expected_snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
|
|
1598
|
+
dialog = CheckoutRevisionDialog(session_id, window)
|
|
1599
|
+
dialog.setAccessibleDescription(f"Check out a commit in {window.context.repository_dir}")
|
|
1600
|
+
self.checkout_revision_dialog_by_session[session_id] = dialog
|
|
1601
|
+
dialog.checkout_requested.connect(lambda intent: self.submit_checkout_revision(session_id, intent))
|
|
1602
|
+
dialog.finished.connect(lambda: self.checkout_revision_dialog_by_session.pop(session_id, None)
|
|
1603
|
+
if self.checkout_revision_dialog_by_session.get(session_id) is dialog else None)
|
|
1604
|
+
context, git_filename = window.context, self.repository.git_filename
|
|
1605
|
+
|
|
1606
|
+
def is_current() -> bool:
|
|
1607
|
+
"""Let a result reach the form only while it, its window and its Git are the ones it was opened with."""
|
|
1608
|
+
return self.reference_read_controller.form_is_current(session_id, generation, dialog, self.checkout_revision_dialog_by_session, context, git_filename) and not dialog.busy
|
|
1609
|
+
|
|
1610
|
+
self._bind_revision_picker(session_id, dialog, is_current)
|
|
1611
|
+
|
|
1612
|
+
def complete(snapshot: ReferenceSnapshot | None, error: Exception | None) -> None:
|
|
1613
|
+
"""Show the commit only while the form, its window and the HEAD it was asked from are still current."""
|
|
1614
|
+
if not is_current():
|
|
1615
|
+
return
|
|
1616
|
+
if error is not None:
|
|
1617
|
+
refuse_form_opening(dialog, str(error))
|
|
1618
|
+
return
|
|
1619
|
+
if expected_snapshot is not None and (snapshot.head_oid != expected_snapshot.head_oid or snapshot.head_ref != expected_snapshot.head_ref
|
|
1620
|
+
or snapshot.generation != expected_snapshot.generation or snapshot.git_filename != expected_snapshot.git_filename
|
|
1621
|
+
or snapshot.context != expected_snapshot.context):
|
|
1622
|
+
refuse_form_opening(dialog, refresh_advice_text(SharedText.HEAD_CHANGED_BEFORE_OPENING))
|
|
1623
|
+
return
|
|
1624
|
+
dialog.populate(snapshot, target_oid)
|
|
1625
|
+
reveal_form(dialog)
|
|
1626
|
+
|
|
1627
|
+
self.reference_read_controller.read_form_references(session_id, dialog, context, generation, complete,
|
|
1628
|
+
menu_capture=expected_snapshot, reopen=lambda capture: self.open_checkout_revision(session_id, target_oid, expected_snapshot=capture))
|
|
1629
|
+
return dialog
|
|
1630
|
+
|
|
1631
|
+
def submit_checkout_revision(self, session_id: str, intent: CheckoutIntent) -> str | None:
|
|
1632
|
+
"""Recheck authority and the request the Checkout revision window shows before reserving the Git write."""
|
|
1633
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
1634
|
+
return None
|
|
1635
|
+
dialog = self.checkout_revision_dialog_by_session.get(session_id)
|
|
1636
|
+
if dialog is None or dialog.closed or dialog.intent != intent or intent.operation.session_id != session_id:
|
|
1637
|
+
return None
|
|
1638
|
+
return self._launch_checkout(session_id, dialog, intent)
|
|
1639
|
+
|
|
1640
|
+
def _launch_checkout(self, session_id: str, dialog: CheckoutDialog | CheckoutRevisionDialog, intent: CheckoutIntent) -> str:
|
|
1641
|
+
"""Open the console, then start one checkout that stays current only while its form, window and Git do."""
|
|
1642
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
1643
|
+
|
|
1644
|
+
def is_current() -> bool:
|
|
1645
|
+
"""Keep captured session, executable and dialog authority current until launch."""
|
|
1646
|
+
return (not dialog.closed and self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) and session.generation == intent.snapshot.generation
|
|
1647
|
+
and self.repository.git_filename == intent.snapshot.git_filename and session.repository_context == intent.snapshot.context)
|
|
1648
|
+
|
|
1649
|
+
self.checkout_intent_by_id[intent.operation.operation_id] = intent
|
|
1650
|
+
self.open_reference_output(intent, dialog)
|
|
1651
|
+
return self.reference.checkout(intent, is_current=is_current)
|
|
1652
|
+
|
|
1653
|
+
def checkout_finished(self, outcome: object) -> None:
|
|
1654
|
+
"""Report a checkout's result to its form and console, then refresh the repository's windows unless the form may simply retry.
|
|
1655
|
+
|
|
1656
|
+
A checkout refused only because another operation held the repository wrote nothing, so the windows keep their
|
|
1657
|
+
generation and the form given back can send its request again, as the branch forms do. A checkout refused
|
|
1658
|
+
because its capture no longer matches the repository cannot succeed from that form: the windows read the
|
|
1659
|
+
repository again, which is what the refusal asks for. A checkout that ran, whatever its result, refreshes the
|
|
1660
|
+
repository's windows and its submodules.
|
|
1661
|
+
"""
|
|
1662
|
+
intent = self.checkout_intent_by_id.pop(outcome.operation_id, None)
|
|
1663
|
+
if intent is None:
|
|
1664
|
+
return
|
|
1665
|
+
self.reference.outcome_by_id[outcome.operation_id] = outcome
|
|
1666
|
+
# The form that launched this checkout, read before the console bookkeeping below forgets it: the branch
|
|
1667
|
+
# window and the revision window report their result the same way.
|
|
1668
|
+
dialog = self.reference_form_by_id.get(outcome.operation_id)
|
|
1669
|
+
if dialog is not None and isValid(dialog) and not dialog.closed and dialog.intent == intent:
|
|
1670
|
+
dialog.show_outcome(outcome)
|
|
1671
|
+
self.finish_reference_output(outcome)
|
|
1672
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
1673
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
1674
|
+
if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
|
|
1675
|
+
return
|
|
1676
|
+
self.refresh_controller.refresh_after_reference_operation(intent.operation, outcome, RefreshScope.FULL)
|
|
1677
|
+
self.staging_controller.refresh_submodule_family(intent.snapshot.context)
|