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,750 @@
|
|
|
1
|
+
"""Own asynchronous rebase observations independently of repository windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QObject, Signal
|
|
10
|
+
|
|
11
|
+
from gitpane.config import DETACHED_HEAD_NAME, GIT_COMMIT_TIMEOUT_MS, LINE_SEPARATOR, REFERENCE_ROOT_PREFIX, SUBMODULE_RECURSION_OFF_ARGUMENTS, ProcessPriority
|
|
12
|
+
from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE, validate_oid
|
|
13
|
+
from gitpane.operation.console_line import REBASE_LABEL
|
|
14
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
15
|
+
from gitpane.operation.launch_signature import capture_write_launch_signature, prepare_write_launch_signature, verify_launch_signature
|
|
16
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
17
|
+
from gitpane.operation.repository_operation import OperationAction, OperationOutcome, OperationStepResult, native_rebase_capture
|
|
18
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
|
|
19
|
+
from gitpane.reference.reference_edit import reference_signature, reference_signature_paths
|
|
20
|
+
from gitpane.sequencer.rebase_state import REBASE_CONTINUABLE_STATE_SET, REBASE_STOPPED_STATE_SET, RebaseSnapshot, can_abort_rebase, capture_rebase, capture_rebase_passive, ended_outside_state
|
|
21
|
+
from gitpane.system.path_identity import canonical_path
|
|
22
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, observation_priority
|
|
23
|
+
from gitpane.worktree.worktree_service import STATUS_ARGUMENTS, read_worktree_probe
|
|
24
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, parse_status, read_index
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable, Generator
|
|
28
|
+
|
|
29
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
30
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
31
|
+
from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
|
|
32
|
+
from gitpane.tool.editor import EditorService
|
|
33
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
34
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, PassiveObservationSource, WorktreeProbe
|
|
35
|
+
|
|
36
|
+
# The one read that resolves the rebase target, HEAD and the branch HEAD is on answers exactly these three lines.
|
|
37
|
+
INTENT_ANSWER_LINE_COUNT = 3
|
|
38
|
+
UNREADABLE_INTENT_CAUSE = "The rebase target or HEAD could not be read. Refresh before rebasing."
|
|
39
|
+
# Without it, a repository set with i18n.commitEncoding gets its rebase list written in that encoding, which the GitPane
|
|
40
|
+
# editor and the rebase state reader can no longer read as UTF-8. It only adds arguments to the rebase commands, no process.
|
|
41
|
+
UTF8_LOG_OUTPUT_ARGUMENTS = ("-c", "i18n.logOutputEncoding=UTF-8")
|
|
42
|
+
# The status that proves how a rebase ended leaves out untracked files, which Git leaves alone, and rename detection,
|
|
43
|
+
# which would only relabel a change that must not exist anyway. Every other option, submodules included, is the shared one.
|
|
44
|
+
FINAL_STATUS_OPTION_BY_SHARED_OPTION = {"--untracked-files=all": "--untracked-files=no", "--renames": "--no-renames"}
|
|
45
|
+
ABORT_REBASE_LABEL = "Abort rebase"
|
|
46
|
+
REBASE_ABORTED_TEXT = "Rebase aborted."
|
|
47
|
+
REBASE_ABORT_UNVERIFIED_CAUSE = "The original rebase HEAD, branch or index could not be verified. Refresh to inspect the result."
|
|
48
|
+
REBASE_ABORT_LEFT_CHANGES_CAUSE = "The index or tracked files still differ from the original commit after Abort. Refresh to inspect the result."
|
|
49
|
+
EDITOR_ABORT_CHANGED_CAUSE = "The rebase left after Abort in the GitPane editor is not the one this command started from. Refresh to inspect it."
|
|
50
|
+
# Git names the command that corrects its instructions in each refusal of them, and in nothing else: at launch and after
|
|
51
|
+
# a list edit ("You can fix this with 'git rebase --edit-todo' and then run 'git rebase --continue'."), and on Continue
|
|
52
|
+
# ("error: please fix this using 'git rebase --edit-todo'."). Texts of Git 2.53; GitPane runs Git in the C locale, so
|
|
53
|
+
# they are never translated.
|
|
54
|
+
INSTRUCTION_ERROR_MARKER = "'git rebase --edit-todo'"
|
|
55
|
+
# Start of each line Git writes for an error: the lines the editor shows above the list to correct.
|
|
56
|
+
GIT_ERROR_PREFIX = "error: "
|
|
57
|
+
# Git's exit status when it refuses the instructions. A stop on a conflict exits with the same status; the fresh read of
|
|
58
|
+
# the rebase tells the two apart.
|
|
59
|
+
INSTRUCTION_ERROR_RETURN_CODE = 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def final_status_arguments() -> list[str]:
|
|
63
|
+
"""Build the one-off status that proves how a rebase ended from the arguments every worktree read uses."""
|
|
64
|
+
arguments = []
|
|
65
|
+
for argument in STATUS_ARGUMENTS:
|
|
66
|
+
arguments.append(FINAL_STATUS_OPTION_BY_SHARED_OPTION.get(argument, argument))
|
|
67
|
+
return arguments
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_rebase_final_status() -> Generator[GitCommand, bytes, tuple[WorktreeProbe, tuple[LocalEntry, ...]]]:
|
|
71
|
+
"""Read HEAD, its branch, the conflicts and the tracked changes of the worktree from one status, once a rebase ended.
|
|
72
|
+
|
|
73
|
+
Both answers come from the very same output. A branch header Git cannot name costs the probe its usual second read.
|
|
74
|
+
"""
|
|
75
|
+
output = yield GitCommand(final_status_arguments(), profile=READ_PROFILE)
|
|
76
|
+
probe = yield from read_worktree_probe(output)
|
|
77
|
+
return probe, parse_status(output)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parent_differences(entries: tuple[LocalEntry, ...]) -> tuple[LocalEntry, ...]:
|
|
81
|
+
"""Keep the final status entries where the index or a file of the parent repository differs.
|
|
82
|
+
|
|
83
|
+
A submodule whose working side alone differs, a gitlink on both sides with changed content or a folder on another
|
|
84
|
+
commit, is left out: a write of the parent never touches a submodule folder, so that difference is not the parent's.
|
|
85
|
+
A gitlink changed in the index, or a submodule replaced by a file, still counts.
|
|
86
|
+
"""
|
|
87
|
+
differences = []
|
|
88
|
+
for entry in entries:
|
|
89
|
+
if entry.side == StagingSide.UNSTAGED and entry.is_gitlink and entry.old_mode == entry.new_mode:
|
|
90
|
+
continue
|
|
91
|
+
differences.append(entry)
|
|
92
|
+
return tuple(differences)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prove_rebase_restored(state: RebaseSnapshot, expected_head_oid: str | None, expected_branch: str | None) -> Generator[GitCommand, bytes, RebaseSnapshot]:
|
|
96
|
+
"""Prove from one status that an abandoned rebase left HEAD, its branch, the index and the tracked files as they were before it.
|
|
97
|
+
|
|
98
|
+
The answer is the state to publish, saying the rebase was aborted; a difference raises ValueError with its cause.
|
|
99
|
+
Untracked files are Git's to leave alone and are not listed. A submodule folder that alone differs does not count
|
|
100
|
+
either: an Abort never touches it, so its own changes and the commit it has checked out stay as they were.
|
|
101
|
+
"""
|
|
102
|
+
probe, entries = yield from read_rebase_final_status()
|
|
103
|
+
state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
|
|
104
|
+
if probe.head_oid != expected_head_oid or probe.branch_head != expected_branch or probe.unmerged_records:
|
|
105
|
+
raise ValueError(REBASE_ABORT_UNVERIFIED_CAUSE)
|
|
106
|
+
if parent_differences(entries):
|
|
107
|
+
raise ValueError(REBASE_ABORT_LEFT_CHANGES_CAUSE)
|
|
108
|
+
return replace(state, diagnostic=REBASE_ABORTED_TEXT)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_intent_answer(output: bytes) -> tuple[str, str, str | None]:
|
|
112
|
+
"""Split the grouped target and HEAD read into the peeled target, HEAD and its full branch name, None when detached.
|
|
113
|
+
|
|
114
|
+
Anything but two full object IDs followed by a full reference name or HEAD is refused; nothing is guessed.
|
|
115
|
+
"""
|
|
116
|
+
lines = output.decode().removesuffix("\n").split("\n")
|
|
117
|
+
if len(lines) != INTENT_ANSWER_LINE_COUNT:
|
|
118
|
+
raise ValueError(UNREADABLE_INTENT_CAUSE)
|
|
119
|
+
target_line, head_line, name_line = lines
|
|
120
|
+
try:
|
|
121
|
+
target_oid, head_oid = validate_oid(target_line), validate_oid(head_line)
|
|
122
|
+
except ValueError as error:
|
|
123
|
+
raise ValueError(UNREADABLE_INTENT_CAUSE) from error
|
|
124
|
+
if name_line == DETACHED_HEAD_NAME:
|
|
125
|
+
return target_oid, head_oid, None
|
|
126
|
+
if not name_line.startswith(REFERENCE_ROOT_PREFIX):
|
|
127
|
+
raise ValueError(UNREADABLE_INTENT_CAUSE)
|
|
128
|
+
return target_oid, head_oid, name_line
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def stopped_on_conflict(state: RebaseSnapshot, return_code: int | None) -> bool:
|
|
132
|
+
"""Tell whether Git stopped the rebase on a conflict that waits for the user, an expected pause rather than a failure.
|
|
133
|
+
|
|
134
|
+
Git reports such a stop with its conflict exit status. Only a fresh read showing the rebase in conflict with named
|
|
135
|
+
unmerged paths turns it into a pause; any other failure (a refusing hook, a fatal error) stays one. The caller
|
|
136
|
+
checks that the rebase read is the one its command started or continued.
|
|
137
|
+
"""
|
|
138
|
+
return return_code == GIT_CONFLICT_RETURN_CODE and state.fresh and state.state == "conflict" and bool(state.conflict_paths)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_error_lines(text: str) -> list[str]:
|
|
142
|
+
"""Keep the error lines Git writes about the list itself, leaving out the line that only names the correction."""
|
|
143
|
+
lines = []
|
|
144
|
+
for line in text.split(LINE_SEPARATOR):
|
|
145
|
+
if line.startswith(GIT_ERROR_PREFIX) and INSTRUCTION_ERROR_MARKER not in line:
|
|
146
|
+
lines.append(line)
|
|
147
|
+
return lines
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def instruction_error_text(state: RebaseSnapshot, return_code: int | None, stderr: str, error_before_edit: str = "") -> str:
|
|
151
|
+
"""Answer the error lines of instructions Git refused and kept for a correction, or an empty text for any other end.
|
|
152
|
+
|
|
153
|
+
Git refuses a list it cannot run with its failure status, keeps the rebase waiting for a corrected list, and names
|
|
154
|
+
git rebase --edit-todo in its message. A stop on a conflict, an editor that failed, a rebase Git removed, a read
|
|
155
|
+
that is not fresh or any other exit status is no list to correct. The caller checks that the rebase read is the one
|
|
156
|
+
its command started or continued.
|
|
157
|
+
|
|
158
|
+
The text is the error lines of the last block of Git's messages, a block ending at the line that names the
|
|
159
|
+
correction; that line itself is left out, since the window already runs the command it advises. Before opening the editor on a list it refused, Git checks that list again and writes its errors once
|
|
160
|
+
more with no such line: when they are the ones error_before_edit already showed, they are left out, so only the
|
|
161
|
+
errors of the edited list remain.
|
|
162
|
+
"""
|
|
163
|
+
if return_code != INSTRUCTION_ERROR_RETURN_CODE or not state.fresh or state.identity is None or stopped_on_conflict(state, return_code):
|
|
164
|
+
return ""
|
|
165
|
+
if INSTRUCTION_ERROR_MARKER not in stderr:
|
|
166
|
+
return ""
|
|
167
|
+
error_lines: list[str] = []
|
|
168
|
+
block_lines: list[str] = []
|
|
169
|
+
for line in stderr.split(LINE_SEPARATOR):
|
|
170
|
+
# Git writes the line naming the correction after the errors of the list, and the window already runs for the
|
|
171
|
+
# user the command it advises: that line closes the block without entering it.
|
|
172
|
+
if line.startswith(GIT_ERROR_PREFIX) and INSTRUCTION_ERROR_MARKER not in line:
|
|
173
|
+
block_lines.append(line)
|
|
174
|
+
if INSTRUCTION_ERROR_MARKER in line:
|
|
175
|
+
if block_lines:
|
|
176
|
+
error_lines = block_lines
|
|
177
|
+
block_lines = []
|
|
178
|
+
known_lines = list_error_lines(error_before_edit)
|
|
179
|
+
known_count = len(known_lines)
|
|
180
|
+
if known_lines and len(error_lines) > known_count and error_lines[:known_count] == known_lines:
|
|
181
|
+
error_lines = error_lines[known_count:]
|
|
182
|
+
return LINE_SEPARATOR.join(error_lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _expected_branch(identity_head_name: str) -> str | None:
|
|
186
|
+
"""Answer the branch a rebase started from, None for a detached HEAD."""
|
|
187
|
+
return identity_head_name if identity_head_name.startswith(REFERENCE_ROOT_PREFIX) else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class RebaseAbortResult:
|
|
192
|
+
"""What one abandonment of a rebase ended with, before it is published: status, state to show, cause and the Abort command's exit."""
|
|
193
|
+
|
|
194
|
+
status: OperationStatus
|
|
195
|
+
state: RebaseSnapshot
|
|
196
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
197
|
+
process_status: ProcessStatus | None = None
|
|
198
|
+
return_code: int | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class RebaseOutcome(OperationOutcome):
|
|
203
|
+
"""The result of a rebase command, with the error lines of the instructions Git refused and kept for a correction, when it did."""
|
|
204
|
+
|
|
205
|
+
instruction_error: str = field(default="", kw_only=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class RebaseIntent:
|
|
210
|
+
"""Keep the graph target distinct from the loaded branch or detached HEAD."""
|
|
211
|
+
|
|
212
|
+
operation: OperationIntent
|
|
213
|
+
target_oid: str
|
|
214
|
+
source_head_oid: str | None
|
|
215
|
+
source_symbolic_ref: str | None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RebaseService(QObject):
|
|
219
|
+
"""Keep the latest native observation for each independent worktree."""
|
|
220
|
+
|
|
221
|
+
changed = Signal(object)
|
|
222
|
+
|
|
223
|
+
def __init__(self, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None, editor: EditorService | None = None) -> None:
|
|
224
|
+
"""Inject the existing bounded command and filesystem scheduler."""
|
|
225
|
+
super().__init__(parent)
|
|
226
|
+
self.worktree_service = worktree_service
|
|
227
|
+
self.state_by_key: dict[WorktreeKey, RebaseSnapshot] = {}
|
|
228
|
+
self.generation_by_key: dict[WorktreeKey, int] = {}
|
|
229
|
+
# The generation of the last result a command published for each worktree, as opposed to a read.
|
|
230
|
+
self.published_generation_by_key: dict[WorktreeKey, int] = {}
|
|
231
|
+
# Each caller keeps the queue priority it asked with, so a shared read waits at the level of its most urgent caller.
|
|
232
|
+
self.observers_by_key: dict[WorktreeKey, list[tuple[Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]]] = {}
|
|
233
|
+
# Each waiting caller keeps the generation it asked at, so a command result can only answer callers it was published after.
|
|
234
|
+
self.waiting_observers_by_key: dict[WorktreeKey, list[tuple[int, Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]]] = {}
|
|
235
|
+
self.outcome_by_id: dict[str, OperationOutcome] = {}
|
|
236
|
+
self.repository_operation = repository_operation
|
|
237
|
+
self.editor = editor
|
|
238
|
+
self.closed = False
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
"""Stop accepting observations while existing workers drain without publishing to closing views."""
|
|
242
|
+
self.closed = True
|
|
243
|
+
|
|
244
|
+
def _publish_result(self, state: RebaseSnapshot) -> RebaseSnapshot:
|
|
245
|
+
"""Supersede every pending observation before publishing a reconciled command result."""
|
|
246
|
+
latest = self.state_by_key.get(state.key)
|
|
247
|
+
generation = max(state.generation, self.generation_by_key.get(state.key, 0), latest.generation if latest is not None else 0) + 1
|
|
248
|
+
self.generation_by_key[state.key] = generation
|
|
249
|
+
self.published_generation_by_key[state.key] = generation
|
|
250
|
+
state = replace(state, generation=generation)
|
|
251
|
+
self.state_by_key[state.key] = state
|
|
252
|
+
self.changed.emit(state)
|
|
253
|
+
return state
|
|
254
|
+
|
|
255
|
+
def prepare(self, intent: RebaseIntent, *, is_current: Callable[[], bool], callback: Callable[[RebaseIntent | None, Exception | None], None]) -> None:
|
|
256
|
+
"""Resolve the selected commit and reject stale source identity before confirmation."""
|
|
257
|
+
self.worktree_service.run(intent.operation.context, intent.operation.git_filename, self._validate_intent(intent, is_current), callback)
|
|
258
|
+
|
|
259
|
+
def _validate_intent(self, intent: RebaseIntent, is_current: Callable[[], bool]) -> Generator:
|
|
260
|
+
"""Read native target, worktree and HEAD facts without changing the checkout."""
|
|
261
|
+
operation = intent.operation
|
|
262
|
+
if operation.context.is_bare or not intent.source_head_oid or not is_current():
|
|
263
|
+
raise ValueError("The rebase source is unavailable or changed. Refresh before rebasing.")
|
|
264
|
+
validate_oid(intent.target_oid)
|
|
265
|
+
# One read resolves the target as a commit, HEAD and the branch HEAD is on; a full object ID never reads as an option.
|
|
266
|
+
answer = yield GitCommand(["rev-parse", intent.target_oid + "^{commit}", "HEAD", "--symbolic-full-name", "HEAD"])
|
|
267
|
+
target_oid, head_oid, head_ref = _parse_intent_answer(answer)
|
|
268
|
+
git_directory = yield GitCommand(["rev-parse", "--absolute-git-dir"])
|
|
269
|
+
if canonical_path(os.fsdecode(git_directory).strip()) != operation.key.git_dir:
|
|
270
|
+
raise ValueError("The rebase worktree changed. Refresh before rebasing.")
|
|
271
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
272
|
+
if not state.fresh or state.state != "absent":
|
|
273
|
+
raise ValueError("A native rebase already exists or cannot be read. Refresh its state.")
|
|
274
|
+
if head_oid != intent.source_head_oid or head_ref != intent.source_symbolic_ref or not is_current():
|
|
275
|
+
raise ValueError("HEAD or its branch changed. Refresh before rebasing.")
|
|
276
|
+
return replace(intent, target_oid=target_oid)
|
|
277
|
+
|
|
278
|
+
def start(self, intent: RebaseIntent, *, is_current: Callable[[], bool]) -> str:
|
|
279
|
+
"""Submit exactly one native rebase through the shared writer and editor owners; every edit Git asks for waits for a GitPane window."""
|
|
280
|
+
if self.repository_operation is None or self.editor is None:
|
|
281
|
+
raise ValueError("The rebase services are unavailable.")
|
|
282
|
+
return self.repository_operation.submit(intent.operation, self._start(intent, is_current))
|
|
283
|
+
|
|
284
|
+
def edit_todo(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool], error_before_edit: str = "") -> str:
|
|
285
|
+
"""Edit the captured native list in a GitPane window under a new writer reservation without continuing Git.
|
|
286
|
+
|
|
287
|
+
error_before_edit is the error Git gave for the list this edit corrects, so that a list still refused after the
|
|
288
|
+
edit reports only its own errors.
|
|
289
|
+
"""
|
|
290
|
+
if self.repository_operation is None or self.editor is None or operation.action != OperationAction.REBASE_CONTINUE:
|
|
291
|
+
raise ValueError("The rebase instruction editor is unavailable.")
|
|
292
|
+
return self.repository_operation.submit(operation, self._edit_todo(operation, state, is_current, error_before_edit))
|
|
293
|
+
|
|
294
|
+
def continue_rebase(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool]) -> str:
|
|
295
|
+
"""Capture the index at the explicit Continue request and reserve one native command."""
|
|
296
|
+
if self.repository_operation is None or self.editor is None or operation.action != OperationAction.REBASE_CONTINUE:
|
|
297
|
+
raise ValueError("The rebase services are unavailable.")
|
|
298
|
+
index_filename = os.path.join(operation.context.git_dir, "index")
|
|
299
|
+
index_content = read_index(index_filename)
|
|
300
|
+
return self.repository_operation.submit(operation, self._continue(operation, state, is_current, index_filename, index_content))
|
|
301
|
+
|
|
302
|
+
def abort(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool]) -> str:
|
|
303
|
+
"""Reserve one native Abort while preserving the observed rebase, HEAD and requester."""
|
|
304
|
+
if self.repository_operation is None or operation.action != OperationAction.REBASE_ABORT:
|
|
305
|
+
raise ValueError("The native rebase abort service is unavailable.")
|
|
306
|
+
|
|
307
|
+
def current() -> bool:
|
|
308
|
+
"""Accept unchanged refreshes while rejecting changed native state or editor ownership."""
|
|
309
|
+
latest = self.state_by_key.get(operation.key)
|
|
310
|
+
# A background read advances the observation counter even when Git has not changed.
|
|
311
|
+
# Bind Abort to native identity and HEAD so that read cannot invalidate the user's click.
|
|
312
|
+
if latest is not None and (latest.identity != state.identity or latest.head_oid != state.head_oid or not can_abort_rebase(latest)):
|
|
313
|
+
return False
|
|
314
|
+
if self.editor is not None:
|
|
315
|
+
for context in self.editor.context_by_id.values():
|
|
316
|
+
if not context.closed and context.worktree_key == operation.key:
|
|
317
|
+
return False
|
|
318
|
+
return is_current()
|
|
319
|
+
|
|
320
|
+
return self.repository_operation.submit(operation, self._abort(operation, state, current))
|
|
321
|
+
|
|
322
|
+
def _abort(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool]) -> Generator:
|
|
323
|
+
"""Let Git restore its origin and report abandonment only after reading the resulting files and references."""
|
|
324
|
+
result = yield from self._restore_native_rebase(operation, previous, is_current, lambda: self.repository_operation.validate(operation))
|
|
325
|
+
return self._publish_abort(operation, previous, result)
|
|
326
|
+
|
|
327
|
+
def _publish_abort(self, operation: OperationIntent, before: RebaseSnapshot | None, result: RebaseAbortResult) -> OperationOutcome:
|
|
328
|
+
"""Publish the state an abandonment ended with and keep its outcome, one Abort step."""
|
|
329
|
+
state = self._publish_result(result.state)
|
|
330
|
+
step = OperationStepResult(ABORT_REBASE_LABEL, result.status, before, state, result.process_status, result.status.value, result.diagnostic, result.return_code)
|
|
331
|
+
outcome = OperationOutcome(operation.operation_id, result.status, (step,), result.diagnostic)
|
|
332
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
333
|
+
return outcome
|
|
334
|
+
|
|
335
|
+
def _restore_native_rebase(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool],
|
|
336
|
+
validate_reservation: Callable[[], None]) -> Generator[object, object, RebaseAbortResult]:
|
|
337
|
+
"""Run git rebase --abort on the rebase previous describes, kept out of submodules, then prove from the files that everything is back.
|
|
338
|
+
|
|
339
|
+
The rebase must still be the one previous read, with the same HEAD, and validate_reservation must accept the
|
|
340
|
+
launch; nothing is published here.
|
|
341
|
+
"""
|
|
342
|
+
diagnostic = process_status = return_code = None
|
|
343
|
+
native_started = False
|
|
344
|
+
state = previous
|
|
345
|
+
status = OperationStatus.REFUSED
|
|
346
|
+
try:
|
|
347
|
+
changed_cause = "HEAD or the Abort request changed before launch. Refresh first."
|
|
348
|
+
baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths))
|
|
349
|
+
state = yield from capture_rebase(operation.context, previous.generation)
|
|
350
|
+
if (not is_current() or not can_abort_rebase(previous) or not can_abort_rebase(state) or previous.key != operation.key or state.key != operation.key
|
|
351
|
+
or previous.identity is None or state.identity != previous.identity or state.head_oid != previous.head_oid):
|
|
352
|
+
raise ValueError("The native rebase or HEAD changed. Refresh before aborting.")
|
|
353
|
+
signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
|
|
354
|
+
|
|
355
|
+
def validate_launch() -> None:
|
|
356
|
+
"""Reject changed native metadata, HEAD, session or activity after scheduler waiting."""
|
|
357
|
+
nonlocal native_started
|
|
358
|
+
validate_reservation()
|
|
359
|
+
if not is_current():
|
|
360
|
+
raise ValueError(changed_cause)
|
|
361
|
+
verify_launch_signature(signature.metadata, changed_cause)
|
|
362
|
+
native_started = True
|
|
363
|
+
|
|
364
|
+
# Without recursion, restoring the original commit leaves the files of every submodule as they are.
|
|
365
|
+
yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "rebase", "--abort"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
366
|
+
before_start=validate_launch)
|
|
367
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
368
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
369
|
+
if isinstance(error, GitCommandError):
|
|
370
|
+
diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
|
|
371
|
+
else:
|
|
372
|
+
diagnostic = Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
373
|
+
try:
|
|
374
|
+
state = yield from capture_rebase(operation.context, previous.generation)
|
|
375
|
+
if native_started and return_code == 0 and state.fresh and state.state == "absent":
|
|
376
|
+
state = yield from prove_rebase_restored(state, previous.identity.original_oid, _expected_branch(previous.identity.head_name))
|
|
377
|
+
status = OperationStatus.ABORTED
|
|
378
|
+
elif native_started:
|
|
379
|
+
status = OperationStatus.FAILED if return_code != 0 else OperationStatus.UNKNOWN
|
|
380
|
+
if diagnostic is None:
|
|
381
|
+
diagnostic = Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [],
|
|
382
|
+
"Git exited but the native rebase restoration could not be verified. Refresh to inspect the result.").prepare()
|
|
383
|
+
elif state.state == "absent":
|
|
384
|
+
# The rebase ended elsewhere before this Abort could start: the refusal stays in the console, the views
|
|
385
|
+
# follow the ended rebase.
|
|
386
|
+
state = replace(ended_outside_state(state), native_observations=None)
|
|
387
|
+
if diagnostic is not None and not state.ended_outside:
|
|
388
|
+
# The published message is the command's, not the capture's own, so the state no longer describes a plain capture.
|
|
389
|
+
state = replace(state, diagnostic=diagnostic.cause, native_observations=None)
|
|
390
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
391
|
+
status = OperationStatus.UNKNOWN
|
|
392
|
+
if diagnostic is None:
|
|
393
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
394
|
+
state = replace(previous, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None)
|
|
395
|
+
return RebaseAbortResult(status, state, diagnostic, process_status, return_code)
|
|
396
|
+
|
|
397
|
+
def _abort_after_editor(self, operation: OperationIntent, previous: RebaseSnapshot | None, expected_head_oid: str | None, expected_branch: str | None) -> Generator:
|
|
398
|
+
"""Cancel the whole rebase the user aborted in the GitPane editor, inside the reservation of the command Git answered.
|
|
399
|
+
|
|
400
|
+
Git exits as soon as the editor answers Abort. On the first list it has already removed its state, so running
|
|
401
|
+
git rebase --abort would only fail: the result is proven from the files alone. On any later edit Git waits
|
|
402
|
+
paused, and its own git rebase --abort brings everything back. Either way HEAD and its branch must be the ones
|
|
403
|
+
the rebase started from. Git's lines about the editor stay in the console; the outcome carries no failure cause.
|
|
404
|
+
|
|
405
|
+
The caller's own currency check is not asked again: it judges the action it was captured for (a start, a
|
|
406
|
+
continue, a list edit), which the rebase Git left paused no longer matches. The Abort is judged instead by the
|
|
407
|
+
reservation, still this command's, and by the application's rules for an Abort of the rebase read now.
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
411
|
+
if state.fresh and state.state == "absent":
|
|
412
|
+
state = yield from prove_rebase_restored(state, expected_head_oid, expected_branch)
|
|
413
|
+
result = RebaseAbortResult(OperationStatus.ABORTED, state)
|
|
414
|
+
elif state.identity is None or state.identity.original_oid != expected_head_oid or _expected_branch(state.identity.head_name) != expected_branch:
|
|
415
|
+
raise ValueError(EDITOR_ABORT_CHANGED_CAUSE)
|
|
416
|
+
else:
|
|
417
|
+
# The reservation is still this command's, but Git changed the rebase it was captured on: the Abort is judged
|
|
418
|
+
# on the rebase Git left, read now, so a change made by anyone else before its launch still refuses it.
|
|
419
|
+
rebase_capture = yield FileTask(lambda: native_rebase_capture(operation.context))
|
|
420
|
+
follow_up = replace(operation, action=OperationAction.REBASE_ABORT, rebase_capture=rebase_capture)
|
|
421
|
+
result = yield from self._restore_native_rebase(operation, state, lambda: True, lambda: self.repository_operation.validate_follow_up(operation, follow_up))
|
|
422
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
423
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
424
|
+
fallback = previous or self.state_by_key.get(operation.key) or RebaseSnapshot(operation.key, operation.generation)
|
|
425
|
+
result = RebaseAbortResult(OperationStatus.UNKNOWN, replace(fallback, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None), diagnostic)
|
|
426
|
+
return self._publish_abort(operation, previous, result)
|
|
427
|
+
|
|
428
|
+
def _continue(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool], index_filename: str, index_content: bytes | None) -> Generator:
|
|
429
|
+
"""Recheck the stopped commit before launch and prove completion from native effects."""
|
|
430
|
+
diagnostic = process_status = return_code = editor_context = None
|
|
431
|
+
native_started = aborted_in_editor = False
|
|
432
|
+
instruction_error = ""
|
|
433
|
+
state = previous
|
|
434
|
+
status = OperationStatus.REFUSED
|
|
435
|
+
try:
|
|
436
|
+
changed_cause = "HEAD or the index changed before Continue. Refresh first."
|
|
437
|
+
baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths, index_filename, index_content))
|
|
438
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
439
|
+
if (not is_current() or not previous.fresh or not state.fresh or previous.key != operation.key or state.identity is None
|
|
440
|
+
or state.identity != previous.identity or state.head_oid != previous.head_oid or state.current_step != previous.current_step
|
|
441
|
+
or state.state not in REBASE_CONTINUABLE_STATE_SET):
|
|
442
|
+
raise ValueError("The paused rebase or HEAD changed. Refresh before continuing.")
|
|
443
|
+
# This fresh capture, read inside the reservation, is the authority; a displayed or passive state never is.
|
|
444
|
+
if state.unmerged:
|
|
445
|
+
raise ValueError("Resolve and stage all conflicts before continuing the rebase.")
|
|
446
|
+
signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
|
|
447
|
+
editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
|
|
448
|
+
|
|
449
|
+
def validate_launch() -> None:
|
|
450
|
+
"""Reject index, HEAD and native-state changes during scheduler waiting."""
|
|
451
|
+
nonlocal native_started
|
|
452
|
+
self.repository_operation.validate(operation)
|
|
453
|
+
if not is_current():
|
|
454
|
+
raise ValueError(changed_cause)
|
|
455
|
+
verify_launch_signature(signature.metadata, changed_cause)
|
|
456
|
+
native_started = True
|
|
457
|
+
|
|
458
|
+
# Without recursion, the remaining steps leave the files of every submodule as they are.
|
|
459
|
+
yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "rebase", "--continue"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
460
|
+
before_start=validate_launch, editor_context=editor_context)
|
|
461
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
462
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
463
|
+
if isinstance(error, GitCommandError):
|
|
464
|
+
diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
|
|
465
|
+
aborted_in_editor = editor_context is not None and editor_context.abort_requested
|
|
466
|
+
else:
|
|
467
|
+
diagnostic = Diagnostic("Continue rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
468
|
+
if aborted_in_editor:
|
|
469
|
+
return (yield from self._abort_after_editor(operation, previous, previous.identity.original_oid, _expected_branch(previous.identity.head_name)))
|
|
470
|
+
try:
|
|
471
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
472
|
+
if native_started:
|
|
473
|
+
status = OperationStatus.PAUSED if state.state != "absent" and return_code == 0 else OperationStatus.FAILED
|
|
474
|
+
if state.state == "absent" and state.fresh and return_code == 0:
|
|
475
|
+
# The status ties the branch to HEAD itself, so the branch needs no read of its own.
|
|
476
|
+
probe, _entries = yield from read_rebase_final_status()
|
|
477
|
+
state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
|
|
478
|
+
if probe.branch_head != _expected_branch(previous.identity.head_name) or probe.unmerged_records or not probe.head_oid:
|
|
479
|
+
raise ValueError("The final rebase branch or index could not be verified. Refresh to inspect it.")
|
|
480
|
+
yield GitCommand(["merge-base", "--is-ancestor", previous.identity.onto_oid, probe.head_oid])
|
|
481
|
+
status = OperationStatus.SUCCESS
|
|
482
|
+
elif state.identity != previous.identity:
|
|
483
|
+
status = OperationStatus.UNKNOWN
|
|
484
|
+
elif stopped_on_conflict(state, return_code):
|
|
485
|
+
# The next commit conflicts: Git waits for the user again. Its message is already in the console, so
|
|
486
|
+
# no failure cause is attached to the pause.
|
|
487
|
+
status, diagnostic = OperationStatus.PAUSED, None
|
|
488
|
+
elif diagnostic is not None:
|
|
489
|
+
instruction_error = instruction_error_text(state, return_code, diagnostic.stderr)
|
|
490
|
+
if instruction_error:
|
|
491
|
+
# Git refused the remaining instructions and waits for a corrected list: a pause for the editor,
|
|
492
|
+
# whose error lines are already in the console.
|
|
493
|
+
status, diagnostic = OperationStatus.PAUSED, None
|
|
494
|
+
elif state.state == "absent":
|
|
495
|
+
# The rebase ended elsewhere before this Continue could start: the refusal stays in the console.
|
|
496
|
+
state = ended_outside_state(state)
|
|
497
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
498
|
+
status = OperationStatus.UNKNOWN
|
|
499
|
+
if diagnostic is None:
|
|
500
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Continue rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
501
|
+
state = replace(previous, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None)
|
|
502
|
+
state = self._publish_result(state)
|
|
503
|
+
step = OperationStepResult("Continue rebase", status, previous, state, process_status, status.value, diagnostic, return_code)
|
|
504
|
+
outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
|
|
505
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
506
|
+
return outcome
|
|
507
|
+
|
|
508
|
+
def _edit_todo(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool], error_before_edit: str) -> Generator:
|
|
509
|
+
"""Revalidate native identity at launch and publish the resulting pause after editor exit.
|
|
510
|
+
|
|
511
|
+
An edited list Git refuses again leaves the rebase paused, with the errors Git wrote after the edit.
|
|
512
|
+
"""
|
|
513
|
+
diagnostic = process_status = return_code = editor_context = None
|
|
514
|
+
aborted_in_editor = False
|
|
515
|
+
instruction_error = ""
|
|
516
|
+
status = OperationStatus.REFUSED
|
|
517
|
+
state = previous
|
|
518
|
+
try:
|
|
519
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
520
|
+
if (not is_current() or not state.fresh or not previous.fresh or state.key != previous.key or state.identity is None
|
|
521
|
+
or state.identity != previous.identity or state.identity.backend != "rebase-merge"
|
|
522
|
+
or state.state not in REBASE_STOPPED_STATE_SET):
|
|
523
|
+
raise ValueError("The native rebase changed. Refresh before editing its instructions.")
|
|
524
|
+
editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
|
|
525
|
+
|
|
526
|
+
def validate_launch() -> None:
|
|
527
|
+
"""Reject a replaced capture or closed session after scheduler waiting."""
|
|
528
|
+
self.repository_operation.validate(operation)
|
|
529
|
+
if not is_current():
|
|
530
|
+
raise ValueError("The rebase instruction request is no longer current. Refresh first.")
|
|
531
|
+
|
|
532
|
+
# Saving the list replays nothing, but like every other write it starts with Git kept out of the submodules.
|
|
533
|
+
yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "rebase", "--edit-todo"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
534
|
+
before_start=validate_launch, editor_context=editor_context)
|
|
535
|
+
process_status, return_code, status = ProcessStatus.SUCCESS, 0, OperationStatus.PAUSED
|
|
536
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
537
|
+
if isinstance(error, GitCommandError):
|
|
538
|
+
diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
|
|
539
|
+
status = OperationStatus.FAILED
|
|
540
|
+
aborted_in_editor = editor_context is not None and editor_context.abort_requested
|
|
541
|
+
else:
|
|
542
|
+
diagnostic = Diagnostic("Edit rebase instructions", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
543
|
+
if aborted_in_editor:
|
|
544
|
+
return (yield from self._abort_after_editor(operation, previous, previous.identity.original_oid, _expected_branch(previous.identity.head_name)))
|
|
545
|
+
try:
|
|
546
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
547
|
+
if not state.fresh or state.identity != previous.identity:
|
|
548
|
+
status = OperationStatus.UNKNOWN
|
|
549
|
+
elif status == OperationStatus.FAILED and diagnostic is not None:
|
|
550
|
+
instruction_error = instruction_error_text(state, return_code, diagnostic.stderr, error_before_edit)
|
|
551
|
+
if instruction_error:
|
|
552
|
+
# Git kept the rebase paused on the list it refused: a pause for the editor, whose error lines are already in the console.
|
|
553
|
+
status, diagnostic = OperationStatus.PAUSED, None
|
|
554
|
+
if state.state == "absent":
|
|
555
|
+
# Editing the instructions never ends a rebase: one that is gone was ended elsewhere.
|
|
556
|
+
state = ended_outside_state(state)
|
|
557
|
+
except (ValueError, OSError, GitCommandError):
|
|
558
|
+
state = replace(state, state="unknown", fresh=False, diagnostic="Cannot reread the native rebase after editing. Refresh to inspect it.", native_observations=None)
|
|
559
|
+
status = OperationStatus.UNKNOWN
|
|
560
|
+
state = self._publish_result(state)
|
|
561
|
+
step = OperationStepResult("Edit rebase instructions", status, operation, state, process_status, status.value, diagnostic, return_code)
|
|
562
|
+
outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
|
|
563
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
564
|
+
return outcome
|
|
565
|
+
|
|
566
|
+
def _start(self, intent: RebaseIntent, is_current: Callable[[], bool]) -> Generator:
|
|
567
|
+
"""Reconcile native state after every exit before releasing command ownership."""
|
|
568
|
+
operation = intent.operation
|
|
569
|
+
diagnostic = process_status = return_code = editor_context = None
|
|
570
|
+
native_started = aborted_in_editor = False
|
|
571
|
+
instruction_error = ""
|
|
572
|
+
state = None
|
|
573
|
+
status = OperationStatus.REFUSED
|
|
574
|
+
try:
|
|
575
|
+
changed_cause = "HEAD or repository identity changed before rebase launch. Refresh first."
|
|
576
|
+
baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths))
|
|
577
|
+
intent = yield from self._validate_intent(intent, is_current)
|
|
578
|
+
signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
|
|
579
|
+
editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
|
|
580
|
+
|
|
581
|
+
def validate_launch() -> None:
|
|
582
|
+
"""Reject changes made while the checked command waited in the scheduler."""
|
|
583
|
+
nonlocal native_started
|
|
584
|
+
self.repository_operation.validate(operation)
|
|
585
|
+
if not is_current():
|
|
586
|
+
raise ValueError(changed_cause)
|
|
587
|
+
verify_launch_signature(signature.metadata, changed_cause)
|
|
588
|
+
native_started = True
|
|
589
|
+
|
|
590
|
+
# Without recursion, a step that moves a gitlink changes what the branch points to, never the files checked out in the submodule.
|
|
591
|
+
arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "-c", "rebase.abbreviateCommands=false", "-c", "rebase.instructionFormat=%s", "rebase", "--interactive", "--no-autosquash", "--no-autostash",
|
|
592
|
+
"--no-rebase-merges", "--no-update-refs", "--no-fork-point", intent.target_oid]
|
|
593
|
+
yield GitCommand(arguments, profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
594
|
+
before_start=validate_launch, editor_context=editor_context)
|
|
595
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
596
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
597
|
+
if isinstance(error, GitCommandError):
|
|
598
|
+
diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
|
|
599
|
+
aborted_in_editor = editor_context is not None and editor_context.abort_requested
|
|
600
|
+
else:
|
|
601
|
+
diagnostic = Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
602
|
+
if aborted_in_editor:
|
|
603
|
+
return (yield from self._abort_after_editor(operation, None, intent.source_head_oid, intent.source_symbolic_ref))
|
|
604
|
+
try:
|
|
605
|
+
state = yield from capture_rebase(operation.context, operation.generation)
|
|
606
|
+
completed = False
|
|
607
|
+
if native_started and return_code == 0 and state.fresh and state.state == "absent":
|
|
608
|
+
# The status ties the branch to HEAD itself, so the branch needs no read of its own.
|
|
609
|
+
probe, _entries = yield from read_rebase_final_status()
|
|
610
|
+
state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
|
|
611
|
+
completed = bool(probe.head_oid) and not probe.unmerged_records and probe.branch_head == intent.source_symbolic_ref
|
|
612
|
+
if completed:
|
|
613
|
+
yield GitCommand(["merge-base", "--is-ancestor", intent.target_oid, state.head_oid])
|
|
614
|
+
status = OperationStatus.SUCCESS
|
|
615
|
+
elif native_started:
|
|
616
|
+
status = OperationStatus.PAUSED if state.state != "absent" and return_code == 0 else OperationStatus.FAILED
|
|
617
|
+
started_here = state.identity is not None and state.identity.original_oid == intent.source_head_oid and state.identity.onto_oid == intent.target_oid
|
|
618
|
+
if started_here and stopped_on_conflict(state, return_code):
|
|
619
|
+
# The rebase this command started stopped on a conflict and waits for the user: an expected pause
|
|
620
|
+
# whose message is already in the console, not a failure.
|
|
621
|
+
status, diagnostic = OperationStatus.PAUSED, None
|
|
622
|
+
elif started_here and diagnostic is not None:
|
|
623
|
+
instruction_error = instruction_error_text(state, return_code, diagnostic.stderr)
|
|
624
|
+
if instruction_error:
|
|
625
|
+
# Git refused the instructions of the rebase this command started and keeps them for a
|
|
626
|
+
# correction: a pause for the editor, whose error lines are already in the console.
|
|
627
|
+
status, diagnostic = OperationStatus.PAUSED, None
|
|
628
|
+
if status == OperationStatus.FAILED and diagnostic is None:
|
|
629
|
+
# Git exited cleanly and removed its native directory, yet the branch or index does not match the launch: name the cause rather than a bare Failed.
|
|
630
|
+
diagnostic = Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [],
|
|
631
|
+
"Git exited but the final branch or index could not be verified. Refresh to inspect the result.").prepare()
|
|
632
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
633
|
+
status = OperationStatus.UNKNOWN if native_started else OperationStatus.REFUSED
|
|
634
|
+
if diagnostic is None:
|
|
635
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
636
|
+
state = replace(state or self.state_by_key.get(operation.key) or RebaseSnapshot(operation.key, operation.generation), state="unknown", fresh=False,
|
|
637
|
+
diagnostic=diagnostic.cause, native_observations=None)
|
|
638
|
+
state = self._publish_result(state)
|
|
639
|
+
step = OperationStepResult(REBASE_LABEL, status, intent, state, process_status, status.value, diagnostic, return_code)
|
|
640
|
+
outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
|
|
641
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
642
|
+
return outcome
|
|
643
|
+
|
|
644
|
+
def observe(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[RebaseSnapshot], None],
|
|
645
|
+
priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
646
|
+
"""Publish only a current requester and the newest read for this worktree.
|
|
647
|
+
|
|
648
|
+
The priority is the caller's place in the process queue: a click by default, background for an automatic look.
|
|
649
|
+
"""
|
|
650
|
+
key = WorktreeKey.from_context(context)
|
|
651
|
+
if self.closed:
|
|
652
|
+
return
|
|
653
|
+
if key in self.observers_by_key:
|
|
654
|
+
# The read already running may have started before the change this caller wants to see (a conflict staged in between).
|
|
655
|
+
# Answering from it could report a state that no longer exists, so late callers share one read started after it ends.
|
|
656
|
+
self.waiting_observers_by_key.setdefault(key, []).append((self.generation_by_key.get(key, 0), is_current, callback, priority))
|
|
657
|
+
return
|
|
658
|
+
self._start_read(context, git_filename, [(is_current, callback, priority)])
|
|
659
|
+
|
|
660
|
+
def observe_passive(self, context: RepositoryContext, git_filename: str, source: PassiveObservationSource, *, is_current: Callable[[], bool],
|
|
661
|
+
callback: Callable[[RebaseSnapshot], None], priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
662
|
+
"""Follow the rebase state for the banner with what the automatic refresh has just read; only that refresh calls it.
|
|
663
|
+
|
|
664
|
+
A request that has to wait for a read already running is answered by a complete read through Git, at the same priority.
|
|
665
|
+
"""
|
|
666
|
+
if self.closed:
|
|
667
|
+
return
|
|
668
|
+
if WorktreeKey.from_context(context) in self.observers_by_key:
|
|
669
|
+
# The facts in hand may be older than the change this caller waits for, and a waiting request is never kept
|
|
670
|
+
# with them: it becomes a request through Git.
|
|
671
|
+
self.observe(context, git_filename, is_current=is_current, callback=callback, priority=priority)
|
|
672
|
+
return
|
|
673
|
+
self._start_read(context, git_filename, [(is_current, callback, priority)], source)
|
|
674
|
+
|
|
675
|
+
def _start_read(self, context: RepositoryContext, git_filename: str, observers: list[tuple[Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]],
|
|
676
|
+
source: PassiveObservationSource | None = None) -> None:
|
|
677
|
+
"""Run one native state read for these callers, then one more for the callers that arrived while it ran."""
|
|
678
|
+
key = WorktreeKey.from_context(context)
|
|
679
|
+
self.observers_by_key[key] = observers
|
|
680
|
+
generation = self.generation_by_key.get(key, 0) + 1
|
|
681
|
+
self.generation_by_key[key] = generation
|
|
682
|
+
|
|
683
|
+
def complete(result: RebaseSnapshot | None, error: Exception | None) -> None:
|
|
684
|
+
"""Publish this read first, so starting the follow-up read cannot mark it as superseded."""
|
|
685
|
+
running_observers = self.observers_by_key.get(key)
|
|
686
|
+
if self.generation_by_key.get(key) != generation and running_observers is not None:
|
|
687
|
+
# A command published its own verified result while this read ran. That result answers every caller that asked before
|
|
688
|
+
# the publication, so their refresh request ends here; a caller that asked after it needs a read newer than its request.
|
|
689
|
+
still_waiting_observers = []
|
|
690
|
+
for asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
|
|
691
|
+
if asked_generation < self.generation_by_key.get(key, 0):
|
|
692
|
+
running_observers.append((current, observer_callback, observer_priority))
|
|
693
|
+
else:
|
|
694
|
+
still_waiting_observers.append((asked_generation, current, observer_callback, observer_priority))
|
|
695
|
+
if still_waiting_observers:
|
|
696
|
+
self.waiting_observers_by_key[key] = still_waiting_observers
|
|
697
|
+
try:
|
|
698
|
+
publish(result, error)
|
|
699
|
+
finally:
|
|
700
|
+
waiting_observers = []
|
|
701
|
+
for _asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
|
|
702
|
+
if current():
|
|
703
|
+
waiting_observers.append((current, observer_callback, observer_priority))
|
|
704
|
+
next_observers = self.observers_by_key.get(key)
|
|
705
|
+
if next_observers is not None:
|
|
706
|
+
# A callback of this read already started the next one, after these callers asked, so it answers them too.
|
|
707
|
+
next_observers.extend(waiting_observers)
|
|
708
|
+
elif waiting_observers and not self.closed:
|
|
709
|
+
# The follow-up read answers requests made after the previous read started: the facts in hand then no longer
|
|
710
|
+
# hold, so it reads everything through Git.
|
|
711
|
+
self._start_read(context, git_filename, waiting_observers)
|
|
712
|
+
|
|
713
|
+
def publish(result: RebaseSnapshot | None, error: Exception | None) -> None:
|
|
714
|
+
"""Retain known steps on read failure without reporting an invented completion."""
|
|
715
|
+
observers = self.observers_by_key.pop(key, [])
|
|
716
|
+
callbacks = []
|
|
717
|
+
for current, observer_callback, _observer_priority in observers:
|
|
718
|
+
if current():
|
|
719
|
+
callbacks.append(observer_callback)
|
|
720
|
+
if self.closed or not callbacks:
|
|
721
|
+
return
|
|
722
|
+
if self.generation_by_key.get(key) != generation:
|
|
723
|
+
latest = self.state_by_key.get(key)
|
|
724
|
+
if latest is not None and latest.generation == self.generation_by_key.get(key):
|
|
725
|
+
for observer_callback in callbacks:
|
|
726
|
+
observer_callback(latest)
|
|
727
|
+
return
|
|
728
|
+
previous = self.state_by_key.get(key)
|
|
729
|
+
owned = self.repository_operation is not None and any(operation.key == key for operation in self.repository_operation.active_by_id.values())
|
|
730
|
+
if error is not None or result is None:
|
|
731
|
+
cause = str(error) if isinstance(error, ValueError) and str(error).startswith("Native rebase") else "Cannot read the native rebase state. Refresh to try again."
|
|
732
|
+
result = replace(previous or RebaseSnapshot(key, generation), generation=generation, state="unknown", fresh=False, diagnostic=cause, native_observations=None)
|
|
733
|
+
elif result.state == "absent":
|
|
734
|
+
if previous is not None and previous.state == "absent" and previous.diagnostic == REBASE_ABORTED_TEXT:
|
|
735
|
+
result = replace(result, head_oid=previous.head_oid, diagnostic=previous.diagnostic)
|
|
736
|
+
elif previous is not None and previous.identity is not None and not owned:
|
|
737
|
+
# The rebase is gone and no command of GitPane ended it. The read is published as it is, with no
|
|
738
|
+
# rebase in it, so the next read has nothing left to compare with and the views stop showing one.
|
|
739
|
+
result = ended_outside_state(result)
|
|
740
|
+
if (not owned and previous is not None and previous.identity is not None and result.identity is not None
|
|
741
|
+
and (previous.identity != result.identity or previous.head_oid != result.head_oid or previous.current_step != result.current_step)):
|
|
742
|
+
result = replace(result, diagnostic="State changed outside GitPane. The current native rebase state has been read."
|
|
743
|
+
+ ("\n" + result.diagnostic if result.diagnostic else ""))
|
|
744
|
+
self.state_by_key[key] = result
|
|
745
|
+
self.changed.emit(result)
|
|
746
|
+
for observer_callback in callbacks:
|
|
747
|
+
observer_callback(result)
|
|
748
|
+
|
|
749
|
+
self.worktree_service.run(context, git_filename, capture_rebase(context, generation) if source is None else capture_rebase_passive(context, generation, source),
|
|
750
|
+
complete, priority=observation_priority(observers))
|