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,1435 @@
|
|
|
1
|
+
"""Share native replay observations between current worktree views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import string
|
|
7
|
+
import tempfile
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, Signal
|
|
13
|
+
|
|
14
|
+
from gitpane.commit.commit_metadata import read_path_metadata
|
|
15
|
+
from gitpane.config import GIT_COMMIT_TIMEOUT_MS, GIT_PATH_SEPARATOR, SUBMODULE_RECURSION_OFF_ARGUMENTS, ProcessPriority
|
|
16
|
+
from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE, SUCCESS_EXIT_CODE
|
|
17
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
18
|
+
from gitpane.operation.launch_signature import (
|
|
19
|
+
capture_write_launch_signature,
|
|
20
|
+
changed_launch_paths,
|
|
21
|
+
exclude_covered_launch_paths,
|
|
22
|
+
prepare_write_launch_signature,
|
|
23
|
+
sign_launch_paths,
|
|
24
|
+
verify_launch_signature,
|
|
25
|
+
)
|
|
26
|
+
from gitpane.operation.operation_status import OperationStatus, operation_status
|
|
27
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult, WriteScope
|
|
28
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
|
|
29
|
+
from gitpane.reference.reference_edit import GITLINK_MODE, read_commit_metadata, reference_signature, reference_signature_paths, validate_oid
|
|
30
|
+
from gitpane.sequencer import replay_legacy
|
|
31
|
+
from gitpane.sequencer.commit_message_rule import AUTOMATIC_COMMENT_PREFIX, DEFAULT_COMMENT_PREFIX
|
|
32
|
+
from gitpane.sequencer.rebase_state import REBASE_BASENAMES
|
|
33
|
+
from gitpane.sequencer.replay_state import (
|
|
34
|
+
LIVE_INDEX_BASENAME,
|
|
35
|
+
MERGE_MESSAGE_BASENAME,
|
|
36
|
+
REPLAY_BASENAMES,
|
|
37
|
+
REPLAY_SEQUENCER_BASENAMES,
|
|
38
|
+
REPLAY_STEP_UNATTRIBUTED_CONFLICT,
|
|
39
|
+
SEQUENCER_BASENAME,
|
|
40
|
+
TRAILING_EMPTY_PIECE_COUNT,
|
|
41
|
+
ReplayAction,
|
|
42
|
+
ReplayCommitMode,
|
|
43
|
+
ReplayEffect,
|
|
44
|
+
ReplayKind,
|
|
45
|
+
ReplayLaunch,
|
|
46
|
+
ReplayOwnership,
|
|
47
|
+
ReplaySnapshot,
|
|
48
|
+
capture_replay,
|
|
49
|
+
capture_replay_passive,
|
|
50
|
+
join_replay_launch,
|
|
51
|
+
read_replay_metadata,
|
|
52
|
+
replay_action_diagnostic,
|
|
53
|
+
unreadable_replay_state,
|
|
54
|
+
)
|
|
55
|
+
from gitpane.submodule.submodule_loss import gitlink_paths, submodule_loss_refusal
|
|
56
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch, observation_priority
|
|
57
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, read_index
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from collections.abc import Callable, Generator
|
|
61
|
+
|
|
62
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
63
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
64
|
+
from gitpane.operation.launch_signature import LaunchSignature, WriteLaunchSignature
|
|
65
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
66
|
+
from gitpane.sequencer.replay_state import ReplayMetadata
|
|
67
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
68
|
+
from gitpane.worktree.worktree_snapshot import PassiveObservationSource
|
|
69
|
+
|
|
70
|
+
FIRST_PARENT_NUMBER = 1
|
|
71
|
+
MERGE_PARENT_MINIMUM = 2
|
|
72
|
+
# The folders in which Git keeps a paused rebase and the instructions of a cherry-pick or revert series.
|
|
73
|
+
NATIVE_REBASE_DIR_BASENAMES = ("rebase-merge", "rebase-apply")
|
|
74
|
+
# One grouped show describes several commits: NUL between the fields of one commit, and the 0x01 marker at the end of
|
|
75
|
+
# each, which Git follows with the line end it writes after every commit. The message comes last so that it may hold
|
|
76
|
+
# any other byte.
|
|
77
|
+
REPLAY_SOURCE_FORMAT = "%H%x00%P%x00%an%x00%aI%x00%cn%x00%cI%x00%B%x01"
|
|
78
|
+
REPLAY_RECORD_END = b"\x01\n"
|
|
79
|
+
REPLAY_FIELD_SEPARATOR = b"\0"
|
|
80
|
+
RECORD_OID_POSITION = 0
|
|
81
|
+
SOURCE_FIELD_COUNT = 7
|
|
82
|
+
SOURCE_PARENTS_POSITION = 1
|
|
83
|
+
SOURCE_AUTHOR_POSITION = 2
|
|
84
|
+
SOURCE_AUTHOR_DATE_POSITION = 3
|
|
85
|
+
SOURCE_COMMITTER_POSITION = 4
|
|
86
|
+
SOURCE_COMMIT_DATE_POSITION = 5
|
|
87
|
+
SOURCE_MESSAGE_POSITION = 6
|
|
88
|
+
# The line end Git writes after the description of one commit, and after one answer of rev-parse.
|
|
89
|
+
COMMIT_TERMINATOR = b"\n"
|
|
90
|
+
# How a path of a replayed commit is split and checked before Git applies it. Windows reads both slashes, ignores dots
|
|
91
|
+
# and spaces at the end of a name, and may open Git's folder under its short name.
|
|
92
|
+
# Paths are read from Git as bytes; this form is drawn from the one text separator rather than written again.
|
|
93
|
+
GIT_PATH_SEPARATOR_BYTES = GIT_PATH_SEPARATOR.encode("ascii")
|
|
94
|
+
WINDOWS_PATH_SEPARATOR = b"\\"
|
|
95
|
+
PATH_SEPARATORS = (GIT_PATH_SEPARATOR_BYTES, WINDOWS_PATH_SEPARATOR)
|
|
96
|
+
DRIVE_LETTER_POSITION = 0
|
|
97
|
+
DRIVE_SEPARATOR_POSITION = 1
|
|
98
|
+
DRIVE_SEPARATOR = b":"
|
|
99
|
+
RELATIVE_COMPONENT_SET = frozenset((b".", b".."))
|
|
100
|
+
WINDOWS_IGNORED_TRAILING_CHARACTERS = b". "
|
|
101
|
+
GIT_DIRECTORY_COMPONENT_SET = frozenset((b".git", b"git~1"))
|
|
102
|
+
REPLAY_PATH_OUTSIDE_CAUSE = "The selected commit changes a path outside the working tree. It was not applied."
|
|
103
|
+
REPLAY_PATH_GIT_DIRECTORY_CAUSE = "The selected commit changes a path inside the .git folder. It was not applied."
|
|
104
|
+
REPLAY_CHANGES_UNREADABLE_CAUSE = "The changes of the selected commit could not be read. Refresh before applying it."
|
|
105
|
+
# One record of a raw, NUL-framed diff listing (diff-tree, or diff --cached) is its metadata then its path: a colon,
|
|
106
|
+
# both modes, both objects and the status, separated by spaces.
|
|
107
|
+
RAW_DIFF_PREFIX = b":"
|
|
108
|
+
RAW_DIFF_FIELD_COUNT = 5
|
|
109
|
+
RAW_DIFF_OLD_MODE_POSITION = 0
|
|
110
|
+
RAW_DIFF_NEW_MODE_POSITION = 1
|
|
111
|
+
RAW_DIFF_PIECES_PER_RECORD = 2
|
|
112
|
+
# The mode Git writes for the missing side of an added or removed entry. A change whose two sides are only a submodule
|
|
113
|
+
# or nothing moves, adds or removes a submodule entry without Git touching the submodule's folder.
|
|
114
|
+
RAW_DIFF_ABSENT_MODE = b"000000"
|
|
115
|
+
SUBMODULE_ENTRY_MODE_SET = frozenset((GITLINK_MODE, RAW_DIFF_ABSENT_MODE))
|
|
116
|
+
# Git's pathspec magic for a path kept out of a command, read literally whatever characters it holds.
|
|
117
|
+
LITERAL_EXCLUSION_PATHSPEC_PREFIX = ":(exclude,literal)"
|
|
118
|
+
# A command stopped by its time limit or by the user: the process status then names the result on its own.
|
|
119
|
+
STOPPED_PROCESS_STATUSES = (ProcessStatus.TIMEOUT, ProcessStatus.IDLE_TIMEOUT, ProcessStatus.CANCELLED)
|
|
120
|
+
PAUSE_CHANGED_CAUSE = "The commit operation, HEAD or the index changed. Refresh before trying again."
|
|
121
|
+
CONTINUE_UNVERIFIED_CAUSE = "Continue finished without a verified commit. Inspect the current operation."
|
|
122
|
+
ABORT_UNVERIFIED_CAUSE = "Abort did not bring back the checkout the operation started from. Inspect the current state."
|
|
123
|
+
# rev-list --parents writes each commit followed by its parents; a commit of the chain Continue creates has exactly one.
|
|
124
|
+
CHAIN_COMMIT_FIELD_COUNT = 2
|
|
125
|
+
CHAIN_PARENT_POSITION = 1
|
|
126
|
+
# The background write that removes the anchor references earlier versions left. It opens no console: its only trace
|
|
127
|
+
# is the process log.
|
|
128
|
+
LEGACY_ANCHOR_RETIREMENT_OPERATION = "Retire earlier replay references"
|
|
129
|
+
LEGACY_ANCHOR_RETIREMENT_REFUSED_CAUSE = "A cherry-pick or revert is in progress. The earlier replay references were kept."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def native_pause_paths(context: RepositoryContext) -> tuple[list[str], list[str]]:
|
|
133
|
+
"""Name every file Git keeps for a paused cherry-pick, revert or rebase in one worktree, then the folders holding them.
|
|
134
|
+
|
|
135
|
+
The names are the ones the native readers already open, joined to the worktree's own Git folder; nothing is read.
|
|
136
|
+
"""
|
|
137
|
+
filenames = []
|
|
138
|
+
for basename in REPLAY_BASENAMES:
|
|
139
|
+
filenames.append(os.path.join(context.git_dir, basename))
|
|
140
|
+
sequencer_dir = os.path.join(context.git_dir, SEQUENCER_BASENAME)
|
|
141
|
+
for basename in REPLAY_SEQUENCER_BASENAMES:
|
|
142
|
+
filenames.append(os.path.join(sequencer_dir, basename))
|
|
143
|
+
watched_dirs = [sequencer_dir]
|
|
144
|
+
for rebase_dir_basename in NATIVE_REBASE_DIR_BASENAMES:
|
|
145
|
+
state_dir = os.path.join(context.git_dir, rebase_dir_basename)
|
|
146
|
+
watched_dirs.append(state_dir)
|
|
147
|
+
for basename in REBASE_BASENAMES:
|
|
148
|
+
filenames.append(os.path.join(state_dir, basename))
|
|
149
|
+
return filenames, watched_dirs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def replay_path_refusal(path: bytes) -> str:
|
|
153
|
+
"""Name why a path a replayed commit changes must not be handed to Git, or answer "" for an ordinary path of the working tree.
|
|
154
|
+
|
|
155
|
+
A path is refused when it is empty or absolute, when one of its components is empty, "." or "..", or when one of
|
|
156
|
+
them names Git's own folder in any spelling Windows opens as that folder: any case, trailing dots and spaces, or
|
|
157
|
+
its short name. Both slashes count as separators, since Windows reads either.
|
|
158
|
+
"""
|
|
159
|
+
drive_form = path[DRIVE_LETTER_POSITION:DRIVE_LETTER_POSITION + 1].isalpha() and path[DRIVE_SEPARATOR_POSITION:].startswith(DRIVE_SEPARATOR)
|
|
160
|
+
if not path or path.startswith(PATH_SEPARATORS) or drive_form:
|
|
161
|
+
return REPLAY_PATH_OUTSIDE_CAUSE
|
|
162
|
+
for component in path.replace(WINDOWS_PATH_SEPARATOR, GIT_PATH_SEPARATOR_BYTES).split(GIT_PATH_SEPARATOR_BYTES):
|
|
163
|
+
if not component or component in RELATIVE_COMPONENT_SET:
|
|
164
|
+
return REPLAY_PATH_OUTSIDE_CAUSE
|
|
165
|
+
folded = component.rstrip(WINDOWS_IGNORED_TRAILING_CHARACTERS).lower()
|
|
166
|
+
if not folded:
|
|
167
|
+
return REPLAY_PATH_OUTSIDE_CAUSE
|
|
168
|
+
if folded in GIT_DIRECTORY_COMPONENT_SET:
|
|
169
|
+
return REPLAY_PATH_GIT_DIRECTORY_CAUSE
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def replay_change_refusal(output: bytes) -> str:
|
|
174
|
+
"""Read the raw, NUL-framed diff-tree listing of the changes a replay applies and name the first reason to refuse it, or answer "".
|
|
175
|
+
|
|
176
|
+
Every path replay_path_refusal refuses is refused, the path of a submodule included, and a listing that does not
|
|
177
|
+
split into whole records is refused as unreadable. What a change does to the folder of a submodule is checked
|
|
178
|
+
separately, by replaced_submodule_paths.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
records = _split_raw_diff_records(output)
|
|
182
|
+
except ValueError as error:
|
|
183
|
+
return str(error)
|
|
184
|
+
for _fields, path in records:
|
|
185
|
+
refusal = replay_path_refusal(path)
|
|
186
|
+
if refusal:
|
|
187
|
+
return refusal
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def replaced_submodule_paths(output: bytes, index_gitlink_paths: list[bytes]) -> list[bytes]:
|
|
192
|
+
"""Name the submodules of the index whose folder the replayed changes of a raw, NUL-framed listing would remove or write into, in index order.
|
|
193
|
+
|
|
194
|
+
Git, launched without descending into submodules, handles a change that only moves, adds or removes a submodule
|
|
195
|
+
entry without touching the submodule's folder. Any other change on the path of a submodule of the index (turned
|
|
196
|
+
into a file or a link, or a file changed there), inside it (a folder of files taking its place) or on a folder
|
|
197
|
+
that holds it makes Git remove that folder or write into it. Direction does not matter, so a Revert that turns a
|
|
198
|
+
submodule back into a file is found like a Cherry-pick. Paths are compared the way Windows opens them. A listing
|
|
199
|
+
that does not split into whole records raises ValueError.
|
|
200
|
+
"""
|
|
201
|
+
records = _split_raw_diff_records(output)
|
|
202
|
+
folded_records = []
|
|
203
|
+
for fields, path in records:
|
|
204
|
+
folded_records.append((fields, _folded_path(path)))
|
|
205
|
+
replaced_paths = []
|
|
206
|
+
for gitlink_path in index_gitlink_paths:
|
|
207
|
+
folded_gitlink_path = _folded_path(gitlink_path)
|
|
208
|
+
for fields, folded_path in folded_records:
|
|
209
|
+
if _change_replaces_submodule(fields, folded_path, folded_gitlink_path):
|
|
210
|
+
replaced_paths.append(gitlink_path)
|
|
211
|
+
break
|
|
212
|
+
return replaced_paths
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _change_replaces_submodule(fields: list[bytes], folded_path: bytes, folded_gitlink_path: bytes) -> bool:
|
|
216
|
+
"""Tell whether one change removes the folder of a submodule or writes into it, rather than only moving, adding or removing its entry."""
|
|
217
|
+
if folded_path == folded_gitlink_path:
|
|
218
|
+
return not {fields[RAW_DIFF_OLD_MODE_POSITION], fields[RAW_DIFF_NEW_MODE_POSITION]} <= SUBMODULE_ENTRY_MODE_SET
|
|
219
|
+
return folded_path.startswith(folded_gitlink_path + GIT_PATH_SEPARATOR_BYTES) or folded_gitlink_path.startswith(folded_path + GIT_PATH_SEPARATOR_BYTES)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _folded_path(path: bytes) -> bytes:
|
|
223
|
+
"""Spell a path the way Windows opens it, so two spellings of one folder compare equal: either slash, any case, trailing dots and spaces dropped."""
|
|
224
|
+
components = []
|
|
225
|
+
for component in path.replace(WINDOWS_PATH_SEPARATOR, GIT_PATH_SEPARATOR_BYTES).split(GIT_PATH_SEPARATOR_BYTES):
|
|
226
|
+
text = component.rstrip(WINDOWS_IGNORED_TRAILING_CHARACTERS).decode("utf-8", "surrogateescape")
|
|
227
|
+
components.append(text.casefold().encode("utf-8", "surrogateescape"))
|
|
228
|
+
return GIT_PATH_SEPARATOR_BYTES.join(components)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _split_raw_diff_records(output: bytes) -> list[tuple[list[bytes], bytes]]:
|
|
232
|
+
"""Split a raw, NUL-framed diff listing into the metadata fields and the path of each record, in Git's order.
|
|
233
|
+
|
|
234
|
+
A listing that does not split into whole records, or a record whose metadata is not the colon then five fields,
|
|
235
|
+
is refused as unreadable rather than read partly.
|
|
236
|
+
"""
|
|
237
|
+
pieces = output.split(b"\0")
|
|
238
|
+
if pieces[-1] or len(pieces) % RAW_DIFF_PIECES_PER_RECORD != TRAILING_EMPTY_PIECE_COUNT:
|
|
239
|
+
raise ValueError(REPLAY_CHANGES_UNREADABLE_CAUSE)
|
|
240
|
+
records = []
|
|
241
|
+
for position in range(0, len(pieces) - TRAILING_EMPTY_PIECE_COUNT, RAW_DIFF_PIECES_PER_RECORD):
|
|
242
|
+
metadata, path = pieces[position], pieces[position + 1]
|
|
243
|
+
fields = metadata.removeprefix(RAW_DIFF_PREFIX).split(b" ")
|
|
244
|
+
if not metadata.startswith(RAW_DIFF_PREFIX) or len(fields) != RAW_DIFF_FIELD_COUNT:
|
|
245
|
+
raise ValueError(REPLAY_CHANGES_UNREADABLE_CAUSE)
|
|
246
|
+
records.append((fields, path))
|
|
247
|
+
return records
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def staged_gitlink_paths(raw_output: bytes) -> list[bytes]:
|
|
251
|
+
"""Name the submodule entries a "diff --cached --raw -z --no-renames" listing shows as staged, in the order Git lists them.
|
|
252
|
+
|
|
253
|
+
An entry counts when either side of its change is a submodule: added, moved to another commit, removed, or
|
|
254
|
+
turned from or into a file. A listing that does not split into whole records raises ValueError.
|
|
255
|
+
"""
|
|
256
|
+
paths = []
|
|
257
|
+
for fields, path in _split_raw_diff_records(raw_output):
|
|
258
|
+
if GITLINK_MODE in (fields[RAW_DIFF_OLD_MODE_POSITION], fields[RAW_DIFF_NEW_MODE_POSITION]):
|
|
259
|
+
paths.append(path)
|
|
260
|
+
return paths
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def reset_keeping_gitlinks_arguments(gitlink_paths: list[bytes]) -> list[str]:
|
|
264
|
+
"""Name the reset that sets the index back to HEAD, except for the given submodule entries, which stay staged.
|
|
265
|
+
|
|
266
|
+
Each submodule path is kept out by an exact, literal exclusion. GitPane hands every command to Git with literal
|
|
267
|
+
pathspecs, which would read the exclusion as a file name, so this reset reads pathspec magic again: its only
|
|
268
|
+
pathspecs are the exclusions built here. A path that is not UTF-8 raises ValueError before anything runs.
|
|
269
|
+
"""
|
|
270
|
+
if not gitlink_paths:
|
|
271
|
+
return ["reset", "--quiet"]
|
|
272
|
+
arguments = ["--no-literal-pathspecs", "reset", "--quiet", "--"]
|
|
273
|
+
for path in gitlink_paths:
|
|
274
|
+
arguments.append(LITERAL_EXCLUSION_PATHSPEC_PREFIX + path.decode("utf-8"))
|
|
275
|
+
return arguments
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def native_replay_message(message: str, source_oid: str, add_reference: bool, comment_prefix: str = "#") -> str:
|
|
279
|
+
"""Remove only Git's trailing conflict notes and retain every authored message line."""
|
|
280
|
+
if comment_prefix == "auto":
|
|
281
|
+
before, separator, _notes = message.rpartition(" Conflicts:\n")
|
|
282
|
+
_body, _newline, comment_prefix = before.rpartition("\n")
|
|
283
|
+
if not separator or len(comment_prefix) != 1:
|
|
284
|
+
raise ValueError("The automatic native conflict comment prefix could not be verified. The message was preserved.")
|
|
285
|
+
body, separator, notes = message.rpartition("\n" + comment_prefix + " Conflicts:\n")
|
|
286
|
+
if separator and all(not line.strip() or line.startswith(comment_prefix + "\t") for line in notes.splitlines()):
|
|
287
|
+
message = body
|
|
288
|
+
message = message.rstrip("\n") + "\n"
|
|
289
|
+
trailer = "(cherry picked from commit " + source_oid + ")"
|
|
290
|
+
if add_reference and trailer not in message.splitlines():
|
|
291
|
+
message = message.rstrip("\n") + "\n\n" + trailer + "\n"
|
|
292
|
+
return message
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def native_message_comment_prefix(message: str) -> str:
|
|
296
|
+
"""Choose a temporary Git comment prefix that cannot remove an authored message line."""
|
|
297
|
+
lines = message.splitlines()
|
|
298
|
+
for character in string.punctuation + string.ascii_letters + string.digits:
|
|
299
|
+
if not any(line.startswith(character) for line in lines):
|
|
300
|
+
return character
|
|
301
|
+
raise ValueError("The native message needs an unused comment prefix. Its original contents were preserved.")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def publish_replay_message(context: RepositoryContext, metadata: ReplayMetadata, message: str, validate: Callable[[], None]) -> ReplayMetadata:
|
|
305
|
+
"""Compare native bytes immediately before replacing the message with an owned prepared file."""
|
|
306
|
+
if metadata.content_by_basename.get(MERGE_MESSAGE_BASENAME) == message:
|
|
307
|
+
return metadata
|
|
308
|
+
descriptor, candidate_filename = tempfile.mkstemp(prefix="gitpane-replay-message-", dir=context.git_dir)
|
|
309
|
+
try:
|
|
310
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
311
|
+
stream.write(message.encode("utf-8"))
|
|
312
|
+
stream.flush()
|
|
313
|
+
os.fsync(stream.fileno())
|
|
314
|
+
validate()
|
|
315
|
+
if read_replay_metadata(context.git_dir) != metadata:
|
|
316
|
+
raise ValueError("The native commit message changed. Its external contents were preserved; refresh before continuing.")
|
|
317
|
+
os.replace(candidate_filename, os.path.join(context.git_dir, MERGE_MESSAGE_BASENAME))
|
|
318
|
+
return read_replay_metadata(context.git_dir)
|
|
319
|
+
finally:
|
|
320
|
+
if os.path.exists(candidate_filename):
|
|
321
|
+
os.unlink(candidate_filename)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ReplayRecordAmbiguity(ValueError):
|
|
325
|
+
"""A grouped commit description holds its record separator inside a message, so its records cannot be told apart.
|
|
326
|
+
|
|
327
|
+
Only this answer may be asked again one question at a time; any other malformed answer is a refusal.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _split_replay_commit_records(output: bytes, field_count: int, expected_oids: list[str]) -> dict[str, tuple[bytes, ...]]:
|
|
332
|
+
"""Split one grouped show answer into the raw fields of each asked commit, bound to its object ID, never to its position.
|
|
333
|
+
|
|
334
|
+
Each record ends with the 0x01 marker of the format followed by the line end Git writes after every commit; only
|
|
335
|
+
that protocol ending is removed, never a line end of a message. The first field of each record must be one of the
|
|
336
|
+
asked object IDs, each exactly once, and every record must hold all of its fields; the message, last, keeps any
|
|
337
|
+
further NUL byte. More records than asked means a message holds the separator: that answer is ambiguous and
|
|
338
|
+
refused as such, to be asked again one question at a time.
|
|
339
|
+
"""
|
|
340
|
+
expected_oid_set = set(expected_oids)
|
|
341
|
+
if not expected_oids or len(expected_oid_set) != len(expected_oids):
|
|
342
|
+
raise ValueError("A grouped commit description must name distinct commits.")
|
|
343
|
+
if not output.endswith(REPLAY_RECORD_END):
|
|
344
|
+
raise ValueError("Git gave back an incomplete commit description.")
|
|
345
|
+
records = output.removesuffix(REPLAY_RECORD_END).split(REPLAY_RECORD_END)
|
|
346
|
+
if len(records) > len(expected_oids):
|
|
347
|
+
raise ReplayRecordAmbiguity("A commit message holds the record separator of the grouped description.")
|
|
348
|
+
if len(records) != len(expected_oids):
|
|
349
|
+
raise ValueError("Git did not describe every asked commit.")
|
|
350
|
+
fields_by_oid = {}
|
|
351
|
+
for record in records:
|
|
352
|
+
fields = record.split(REPLAY_FIELD_SEPARATOR, field_count - 1)
|
|
353
|
+
if len(fields) != field_count:
|
|
354
|
+
raise ValueError("Git gave back an incomplete commit description.")
|
|
355
|
+
oid = validate_oid(fields[RECORD_OID_POSITION].decode("ascii"))
|
|
356
|
+
if oid not in expected_oid_set or oid in fields_by_oid:
|
|
357
|
+
raise ValueError("Git described a commit that was not asked for.")
|
|
358
|
+
fields_by_oid[oid] = tuple(fields)
|
|
359
|
+
return fields_by_oid
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _decode_parent_oids(field: bytes) -> tuple[str, ...]:
|
|
363
|
+
"""Read the space-separated parents of one commit description, in Git's order, refusing anything but full object IDs."""
|
|
364
|
+
parent_oids = []
|
|
365
|
+
for parent in field.decode("ascii").split(" ") if field else ():
|
|
366
|
+
parent_oids.append(validate_oid(parent))
|
|
367
|
+
return tuple(parent_oids)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def replay_executable_signature(git_filename: str) -> tuple:
|
|
371
|
+
"""Identify the Git executable a replay launches by its normalized path and the metadata of that file.
|
|
372
|
+
|
|
373
|
+
A capability of Git is proven for exactly this file: another path, or the same file rewritten in place (its Windows
|
|
374
|
+
change time moves even when size and modification time are put back), is another executable. A file that cannot be
|
|
375
|
+
found identifies nothing and is refused.
|
|
376
|
+
"""
|
|
377
|
+
filename = os.path.normcase(os.path.abspath(git_filename))
|
|
378
|
+
identity = read_path_metadata(filename)
|
|
379
|
+
if identity is None:
|
|
380
|
+
raise ValueError("The Git executable could not be identified. Check the configured Git.")
|
|
381
|
+
return (filename, identity)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(frozen=True)
|
|
385
|
+
class ReplaySourceRecord:
|
|
386
|
+
"""The six displayed fields of one commit, in the order the form has always received them, and its parents in Git's order."""
|
|
387
|
+
|
|
388
|
+
fields: tuple[str, ...]
|
|
389
|
+
parent_oids: tuple[str, ...]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_replay_source_records(output: bytes, expected_oids: list[str]) -> dict[str, ReplaySourceRecord]:
|
|
393
|
+
"""Read a grouped show written with REPLAY_SOURCE_FORMAT into one record per asked commit, bound to its object ID.
|
|
394
|
+
|
|
395
|
+
Text is decoded the way the form always decoded it, unreadable bytes replaced, and the message keeps the line end
|
|
396
|
+
Git writes after the description of a single commit, so each field reads exactly as it did when every commit had a
|
|
397
|
+
show of its own.
|
|
398
|
+
"""
|
|
399
|
+
record_by_oid = {}
|
|
400
|
+
for oid, fields in _split_replay_commit_records(output, SOURCE_FIELD_COUNT, expected_oids).items():
|
|
401
|
+
texts = []
|
|
402
|
+
for position in (SOURCE_AUTHOR_POSITION, SOURCE_AUTHOR_DATE_POSITION, SOURCE_COMMITTER_POSITION, SOURCE_COMMIT_DATE_POSITION):
|
|
403
|
+
texts.append(fields[position].decode("utf-8", "replace"))
|
|
404
|
+
message = (fields[SOURCE_MESSAGE_POSITION] + COMMIT_TERMINATOR).decode("utf-8", "replace")
|
|
405
|
+
record_by_oid[oid] = ReplaySourceRecord((oid, *texts, message), _decode_parent_oids(fields[SOURCE_PARENTS_POSITION]))
|
|
406
|
+
return record_by_oid
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def read_replay_source_records(oids: list[str], *, read_parents: bool = True) -> Generator[GitCommand, object, dict[str, ReplaySourceRecord]]:
|
|
410
|
+
"""Describe the given commits, each named once, in one show, the answers bound to their object IDs.
|
|
411
|
+
|
|
412
|
+
An answer made ambiguous by a message holding the separator is asked again commit by commit through the historical
|
|
413
|
+
metadata read, plus the parent list when `read_parents` asks for it; without it the parents of that fallback stay
|
|
414
|
+
empty, and the caller must not read them. A failure of Git is never retried.
|
|
415
|
+
"""
|
|
416
|
+
for oid in oids:
|
|
417
|
+
validate_oid(oid)
|
|
418
|
+
output = yield GitCommand(["show", "--no-patch", "--format=" + REPLAY_SOURCE_FORMAT, *oids, "--"], profile=READ_PROFILE)
|
|
419
|
+
try:
|
|
420
|
+
return parse_replay_source_records(output, oids)
|
|
421
|
+
except ReplayRecordAmbiguity:
|
|
422
|
+
record_by_oid = {}
|
|
423
|
+
for oid in oids:
|
|
424
|
+
fields = yield from read_commit_metadata(oid)
|
|
425
|
+
parent_oids = ()
|
|
426
|
+
if read_parents:
|
|
427
|
+
parent_output = yield GitCommand(["show", "--no-patch", "--format=%P", oid, "--"])
|
|
428
|
+
if not parent_output.endswith(COMMIT_TERMINATOR):
|
|
429
|
+
raise ValueError("Git could not read complete metadata for the selected commit") from None
|
|
430
|
+
parent_oids = _decode_parent_oids(parent_output.removesuffix(COMMIT_TERMINATOR))
|
|
431
|
+
record_by_oid[oid] = ReplaySourceRecord(tuple(fields), parent_oids)
|
|
432
|
+
return record_by_oid
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True)
|
|
436
|
+
class ReplayParent:
|
|
437
|
+
"""Identify a parent by its native position and full commit identity."""
|
|
438
|
+
|
|
439
|
+
number: int
|
|
440
|
+
oid: str
|
|
441
|
+
subject: str
|
|
442
|
+
author: str
|
|
443
|
+
commit_date: str
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass(frozen=True)
|
|
447
|
+
class ReplaySource:
|
|
448
|
+
"""Bind displayed metadata and ordered parents to one resolved source."""
|
|
449
|
+
|
|
450
|
+
oid: str
|
|
451
|
+
fields: tuple[str, ...]
|
|
452
|
+
parents: tuple[ReplayParent, ...]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass(frozen=True)
|
|
456
|
+
class ReplayIntent:
|
|
457
|
+
"""Keep the selected source distinct from the checkout receiving its changes."""
|
|
458
|
+
|
|
459
|
+
operation: OperationIntent
|
|
460
|
+
kind: ReplayKind
|
|
461
|
+
source_oid: str
|
|
462
|
+
original_head_oid: str | None
|
|
463
|
+
original_symbolic_ref: str | None
|
|
464
|
+
create_commit: bool = True
|
|
465
|
+
add_reference: bool = False
|
|
466
|
+
mainline_parent_number: int | None = None
|
|
467
|
+
mainline_parent_oid: str | None = None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclass
|
|
471
|
+
class NativeCommandRun:
|
|
472
|
+
"""What became of the one native command an operation hands to Git: whether it started, how it ended and why it failed."""
|
|
473
|
+
|
|
474
|
+
started: bool = False
|
|
475
|
+
process_status: ProcessStatus | None = None
|
|
476
|
+
return_code: int | None = None
|
|
477
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
478
|
+
|
|
479
|
+
def record_failure(self, operation: OperationIntent, error: GitCommandError | ValueError | OSError) -> None:
|
|
480
|
+
"""Keep Git's own diagnostic of a failed command, or turn a refusal before Git into the first diagnostic of this run."""
|
|
481
|
+
if isinstance(error, GitCommandError):
|
|
482
|
+
self.diagnostic, self.process_status, self.return_code = error.diagnostic, error.process_status, error.return_code
|
|
483
|
+
return
|
|
484
|
+
if self.diagnostic is None:
|
|
485
|
+
self.diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def commit_chain_from(output: bytes, base_oid: str, head_oid: str) -> bool:
|
|
489
|
+
"""Tell whether a "rev-list --parents --reverse base..HEAD" listing is one line of new commits on `base_oid` ending at `head_oid`.
|
|
490
|
+
|
|
491
|
+
Each commit must have exactly one parent, the commit listed before it, the first one standing on `base_oid`.
|
|
492
|
+
"""
|
|
493
|
+
parent_oid = base_oid
|
|
494
|
+
for line in output.decode("ascii").splitlines():
|
|
495
|
+
fields = line.split()
|
|
496
|
+
if len(fields) != CHAIN_COMMIT_FIELD_COUNT or fields[CHAIN_PARENT_POSITION] != parent_oid:
|
|
497
|
+
return False
|
|
498
|
+
parent_oid = fields[RECORD_OID_POSITION]
|
|
499
|
+
return parent_oid == head_oid and parent_oid != base_oid
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def empty_result_effect(kind: ReplayKind, changes: bytes) -> str:
|
|
503
|
+
"""Name why a replay that brings nothing created no commit: an empty source, a Revert without effect, or changes already present.
|
|
504
|
+
|
|
505
|
+
`changes` is the listing of what the source changes against the parent it is replayed from; an empty listing is
|
|
506
|
+
an empty source whatever the operation. The answer is the word the step writes in its free-text effect.
|
|
507
|
+
"""
|
|
508
|
+
if not changes:
|
|
509
|
+
return ReplayEffect.INITIALLY_EMPTY.value
|
|
510
|
+
if kind == ReplayKind.REVERT:
|
|
511
|
+
return ReplayEffect.REVERT_NO_EFFECT.value
|
|
512
|
+
return ReplayEffect.ALREADY_PRESENT.value
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def pause_origin(state: ReplaySnapshot) -> tuple[str | None, str | None]:
|
|
516
|
+
"""Name the HEAD and the branch Git's Abort brings a pause back to.
|
|
517
|
+
|
|
518
|
+
A pause GitPane launched keeps the checkout of its launch; a series keeps its starting HEAD in Git's sequencer; a
|
|
519
|
+
single instruction never moves HEAD while it waits, so its current HEAD is its origin.
|
|
520
|
+
"""
|
|
521
|
+
if state.launch is not None:
|
|
522
|
+
return state.launch.original_head_oid, state.launch.original_symbolic_ref
|
|
523
|
+
if state.identity is not None and state.identity.original_oid:
|
|
524
|
+
return state.identity.original_oid, state.symbolic_ref
|
|
525
|
+
return state.head_oid, state.symbolic_ref
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def continue_message(state: ReplaySnapshot, metadata: ReplayMetadata, comment_output: bytes) -> str:
|
|
529
|
+
"""Prepare the message Git's Continue commits: Git's conflict notes removed, every authored line kept, the reference added when asked.
|
|
530
|
+
|
|
531
|
+
The comment character is the one the configuration sets last. When Git picks it per message and this message holds
|
|
532
|
+
no conflict notes any more (a Continue tried before already removed them), nothing is left to remove with it.
|
|
533
|
+
"""
|
|
534
|
+
message = metadata.content_by_basename.get(MERGE_MESSAGE_BASENAME, "")
|
|
535
|
+
comment_prefix = DEFAULT_COMMENT_PREFIX
|
|
536
|
+
for line in comment_output.decode("utf-8").splitlines():
|
|
537
|
+
_name, _separator, comment_prefix = line.partition(" ")
|
|
538
|
+
if comment_prefix == AUTOMATIC_COMMENT_PREFIX and " Conflicts:\n" not in message:
|
|
539
|
+
comment_prefix = DEFAULT_COMMENT_PREFIX
|
|
540
|
+
launch = state.launch
|
|
541
|
+
source_oid = launch.source_oid if launch is not None else next(iter(state.instruction_oids))
|
|
542
|
+
add_reference = launch is not None and launch.add_reference and state.kind == ReplayKind.CHERRY_PICK
|
|
543
|
+
message = native_replay_message(message, source_oid, add_reference, comment_prefix)
|
|
544
|
+
if not message.strip():
|
|
545
|
+
raise ValueError("The prepared commit message is empty. Write it before continuing.")
|
|
546
|
+
return message
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def publish_continue_message(context: RepositoryContext, baseline: WriteLaunchSignature, metadata: ReplayMetadata, message: str) -> WriteLaunchSignature:
|
|
550
|
+
"""Write the prepared message over Git's MERGE_MSG, then take the look the Continue is compared with when it launches.
|
|
551
|
+
|
|
552
|
+
Runs on the file worker. Between the first look and the new one, only that message may differ: the references,
|
|
553
|
+
the index and every other pause file must be exactly as the checks found them.
|
|
554
|
+
"""
|
|
555
|
+
publish_replay_message(context, metadata, message, lambda: verify_launch_signature(baseline.metadata, PAUSE_CHANGED_CAUSE))
|
|
556
|
+
pause_filenames, pause_dirs = native_pause_paths(context)
|
|
557
|
+
prepared = capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths, baseline.index_filename,
|
|
558
|
+
baseline.index_content, extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs)
|
|
559
|
+
message_filename = os.path.join(context.git_dir, MERGE_MESSAGE_BASENAME)
|
|
560
|
+
for filename in changed_launch_paths(baseline.metadata, prepared.metadata):
|
|
561
|
+
if filename != message_filename:
|
|
562
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
563
|
+
if prepared.reference_state != baseline.reference_state:
|
|
564
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
565
|
+
return prepared
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class ReplayService(QObject):
|
|
569
|
+
"""Run Cherry-pick and Revert on Git's own commands and share one observation of their pause per worktree."""
|
|
570
|
+
|
|
571
|
+
changed = Signal(object)
|
|
572
|
+
|
|
573
|
+
def __init__(self, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None) -> None:
|
|
574
|
+
"""Inject the shared scheduler so file reads drain with existing Git work."""
|
|
575
|
+
super().__init__(parent)
|
|
576
|
+
self.worktree_service = worktree_service
|
|
577
|
+
self.state_by_key: dict[WorktreeKey, ReplaySnapshot] = {}
|
|
578
|
+
self.generation_by_key: dict[WorktreeKey, int] = {}
|
|
579
|
+
# Each caller keeps the queue priority it asked with, so a shared read waits at the level of its most urgent caller.
|
|
580
|
+
self.observers_by_key: dict[WorktreeKey, list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]] = {}
|
|
581
|
+
# A caller waiting for the read after the running one, with the generation known when it asked.
|
|
582
|
+
self.waiting_observers_by_key: dict[WorktreeKey, list[tuple[int, Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]] = {}
|
|
583
|
+
# The generation of the last state an operation published for each worktree, as opposed to a state a read published.
|
|
584
|
+
self.published_generation_by_key: dict[WorktreeKey, int] = {}
|
|
585
|
+
self.closed = False
|
|
586
|
+
self.repository_operation = repository_operation
|
|
587
|
+
self.outcome_by_id: dict[str, OperationOutcome] = {}
|
|
588
|
+
self.submitted_id_set: set[str] = set()
|
|
589
|
+
# The pause GitPane launched in each worktree during this run, kept in memory only and dropped once an
|
|
590
|
+
# operation or a read finds no pause left. A restart forgets it: the pause is then read as an external one.
|
|
591
|
+
self.launch_by_key: dict[WorktreeKey, ReplayLaunch] = {}
|
|
592
|
+
# What each Git executable this service launched has proven about a source named twice to rev-list --no-walk:
|
|
593
|
+
# True when it keeps one instruction, False when it keeps two. A capability of Git, never a fact of a repository.
|
|
594
|
+
self.no_walk_support_by_executable: dict[tuple, bool] = {}
|
|
595
|
+
|
|
596
|
+
def read_source(self, context: RepositoryContext, git_filename: str, revision: str, *, callback: Callable[[ReplaySource | None, Exception | None], None],
|
|
597
|
+
resolved: bool = False) -> None:
|
|
598
|
+
"""Resolve a local revision and read its real parents without changing HEAD.
|
|
599
|
+
|
|
600
|
+
`resolved` says `revision` is the full object ID of a commit a successful preparation has just resolved and
|
|
601
|
+
checked, so it is not resolved again; any revision typed or chosen by the user keeps the default and is resolved.
|
|
602
|
+
"""
|
|
603
|
+
self.worktree_service.run(context, git_filename, self._read_source(revision, resolved=resolved), callback)
|
|
604
|
+
|
|
605
|
+
def _read_source(self, revision: str, *, resolved: bool = False) -> Generator:
|
|
606
|
+
"""Read the source and all its parents in two shows, building the parents in the source's own parent order.
|
|
607
|
+
|
|
608
|
+
The metadata of a parent named twice is asked once, while the list and numbering of the parents stay those of
|
|
609
|
+
the source. A root source asks nothing for its parents.
|
|
610
|
+
"""
|
|
611
|
+
if resolved:
|
|
612
|
+
oid = validate_oid(revision)
|
|
613
|
+
else:
|
|
614
|
+
resolved_output = yield GitCommand(["rev-parse", "--verify", "--end-of-options", revision + "^{commit}"])
|
|
615
|
+
oid = validate_oid(resolved_output.decode().strip())
|
|
616
|
+
source = (yield from read_replay_source_records([oid]))[oid]
|
|
617
|
+
requested_oids = []
|
|
618
|
+
for parent_oid in source.parent_oids:
|
|
619
|
+
if parent_oid not in requested_oids:
|
|
620
|
+
requested_oids.append(parent_oid)
|
|
621
|
+
parent_record_by_oid = (yield from read_replay_source_records(requested_oids, read_parents=False)) if requested_oids else {}
|
|
622
|
+
parents = []
|
|
623
|
+
for number, parent_oid in enumerate(source.parent_oids, start=FIRST_PARENT_NUMBER):
|
|
624
|
+
actual_oid, author, _author_date, _committer, commit_date, message = parent_record_by_oid[parent_oid].fields
|
|
625
|
+
subject, _separator, _body = message.partition("\n")
|
|
626
|
+
parents.append(ReplayParent(number, actual_oid, subject, author, commit_date))
|
|
627
|
+
return ReplaySource(oid, source.fields, tuple(parents))
|
|
628
|
+
|
|
629
|
+
def prepare(self, intent: ReplayIntent, *, is_current: Callable[[], bool], callback: Callable[[ReplayIntent | None, Exception | None], None]) -> None:
|
|
630
|
+
"""Resolve the source and inspect destination identity without mutating Git."""
|
|
631
|
+
self.worktree_service.run(intent.operation.context, intent.operation.git_filename, self._validate_intent(intent, is_current), callback)
|
|
632
|
+
|
|
633
|
+
def _check_start_request(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> None:
|
|
634
|
+
"""Refuse a launch request that no longer has a live service, a checkout, a known HEAD, a caller or a valid source."""
|
|
635
|
+
operation = intent.operation
|
|
636
|
+
if self.closed or operation.context.is_bare or not intent.original_head_oid or not is_current():
|
|
637
|
+
raise ValueError("The destination checkout is unavailable or changed. Refresh before applying a commit.")
|
|
638
|
+
if not intent.kind.is_replay or operation.action != OperationAction.REPLAY_START:
|
|
639
|
+
raise ValueError("This commit operation is unavailable.")
|
|
640
|
+
validate_oid(intent.source_oid)
|
|
641
|
+
|
|
642
|
+
def _validate_intent(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> Generator:
|
|
643
|
+
"""Refuse changed checkout identity and incompatible native operations before the form is confirmed."""
|
|
644
|
+
operation = intent.operation
|
|
645
|
+
self._check_start_request(intent, is_current)
|
|
646
|
+
# The first grouped read of the capture also resolves the source and the Git directory, and refuses the launch
|
|
647
|
+
# when either is not exactly the one this intent names.
|
|
648
|
+
state = yield from capture_replay(operation.context, operation.generation, source_oid=intent.source_oid, verify_git_dir=True)
|
|
649
|
+
if not state.fresh or state.kind != ReplayKind.ABSENT:
|
|
650
|
+
raise ValueError("A native commit operation already exists or cannot be read. Refresh its state.")
|
|
651
|
+
if state.head_oid != intent.original_head_oid or state.symbolic_ref != intent.original_symbolic_ref or not is_current():
|
|
652
|
+
raise ValueError("HEAD or its branch changed. Refresh before applying a commit.")
|
|
653
|
+
return intent
|
|
654
|
+
|
|
655
|
+
def _verify_no_walk(self, context: RepositoryContext, git_filename: str, source_oid: str) -> Generator:
|
|
656
|
+
"""Prove, once per Git executable, that a source named twice gives one instruction, and answer that executable's identity.
|
|
657
|
+
|
|
658
|
+
A launch without a commit names its source twice so that Git keeps one abortable instruction, which holds only
|
|
659
|
+
when rev-list deduplicates the two. The answer is kept for exactly the executable identity found before and
|
|
660
|
+
after the probe; a failed, interrupted or unreadable probe proves nothing and is not kept. An executable known
|
|
661
|
+
to keep both instructions is refused without asking again. The caller compares the identity again at launch.
|
|
662
|
+
"""
|
|
663
|
+
signature = yield FileTask(lambda: replay_executable_signature(git_filename))
|
|
664
|
+
supported = self.no_walk_support_by_executable.get(signature)
|
|
665
|
+
if supported is None:
|
|
666
|
+
validate_oid(source_oid)
|
|
667
|
+
output = yield GitCommand(["rev-list", "--no-walk=unsorted", source_oid, source_oid, "--"])
|
|
668
|
+
after = yield FileTask(lambda: replay_executable_signature(git_filename))
|
|
669
|
+
if after != signature:
|
|
670
|
+
raise ValueError("The Git executable changed while it was checked. Refresh before applying a commit.")
|
|
671
|
+
if not output.endswith(COMMIT_TERMINATOR):
|
|
672
|
+
raise ValueError("Git gave back an incomplete instruction list.")
|
|
673
|
+
answers = output.removesuffix(COMMIT_TERMINATOR).decode("ascii").split("\n")
|
|
674
|
+
if answers == [source_oid]:
|
|
675
|
+
supported = True
|
|
676
|
+
elif answers == [source_oid, source_oid]:
|
|
677
|
+
supported = False
|
|
678
|
+
else:
|
|
679
|
+
raise ValueError("Git gave back an unexpected instruction list.")
|
|
680
|
+
self.no_walk_support_by_executable[signature] = supported
|
|
681
|
+
if not supported:
|
|
682
|
+
raise ValueError("This Git version cannot safely select one replay instruction.")
|
|
683
|
+
return signature
|
|
684
|
+
|
|
685
|
+
def _validate_launch_authority(self, operation: OperationIntent, is_current: Callable[[], bool], *, session_policy: bool = True) -> None:
|
|
686
|
+
"""Refuse a launch whose replay service, write owner, exact reservation or caller is no longer current.
|
|
687
|
+
|
|
688
|
+
Called on the Qt thread, both before a launch preparation leaves for the file worker and in the launch check.
|
|
689
|
+
With session_policy the application's permission for this action is asked again. The paths that compare their
|
|
690
|
+
own native state on purpose (a Skip or the end without a commit after a launch, the steps after a prepared message) do
|
|
691
|
+
not ask it: the native state captured with their authority predates the pause or message GitPane itself wrote.
|
|
692
|
+
"""
|
|
693
|
+
owner = self.repository_operation
|
|
694
|
+
if self.closed or owner is None or owner.closed or owner.active_by_id.get(operation.operation_id) is not operation:
|
|
695
|
+
raise ValueError("This replay no longer owns the repository. Refresh before trying again.")
|
|
696
|
+
if not is_current():
|
|
697
|
+
raise ValueError("The caller changed before launch. Refresh first.")
|
|
698
|
+
if session_policy and owner.allows is not None and not owner.allows(operation):
|
|
699
|
+
raise ValueError("This action is no longer authorized. Refresh before writing.")
|
|
700
|
+
|
|
701
|
+
def _policy_launch_signature(self, operation: OperationIntent) -> LaunchSignature:
|
|
702
|
+
"""Freeze the native pause files the write policy reads, in this worktree and in every related one.
|
|
703
|
+
|
|
704
|
+
It is taken before the policy check on the Qt thread, never after: a signature taken once the policy has run
|
|
705
|
+
could already describe a state that policy never saw. Only metadata is read. The launch check compares it again,
|
|
706
|
+
without the paths the launch signature itself covers, so a pause started in another worktree while the command
|
|
707
|
+
waited in the queue is still refused.
|
|
708
|
+
"""
|
|
709
|
+
context_by_key = {}
|
|
710
|
+
for context in (operation.context, *operation.reserved_contexts, *self.repository_operation.context_by_key.values()):
|
|
711
|
+
context_by_key[WorktreeKey.from_context(context)] = context
|
|
712
|
+
filenames = []
|
|
713
|
+
watched_dirs = []
|
|
714
|
+
for context in context_by_key.values():
|
|
715
|
+
if context.is_bare:
|
|
716
|
+
continue
|
|
717
|
+
pause_filenames, pause_dirs = native_pause_paths(context)
|
|
718
|
+
filenames.extend(pause_filenames)
|
|
719
|
+
watched_dirs.extend(pause_dirs)
|
|
720
|
+
return sign_launch_paths(filenames, watched_dirs, ())
|
|
721
|
+
|
|
722
|
+
def start(self, intent: ReplayIntent, *, is_current: Callable[[], bool]) -> str:
|
|
723
|
+
"""Submit one captured operation once, retaining ownership until observation finishes."""
|
|
724
|
+
if self.repository_operation is None or self.closed:
|
|
725
|
+
raise ValueError("The commit operation service is unavailable.")
|
|
726
|
+
operation_id = intent.operation.operation_id
|
|
727
|
+
if operation_id in self.submitted_id_set:
|
|
728
|
+
return operation_id
|
|
729
|
+
self.submitted_id_set.add(operation_id)
|
|
730
|
+
return self.repository_operation.submit(intent.operation, self._start(intent, is_current))
|
|
731
|
+
|
|
732
|
+
def _start(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> Generator:
|
|
733
|
+
"""Apply one commit with Git's own cherry-pick or revert, the way a terminal does, and publish what Git left.
|
|
734
|
+
|
|
735
|
+
Only quick checks run before Git: HEAD, its branch and the index unchanged since the form was confirmed, no
|
|
736
|
+
native operation and no conflict in progress, nothing staged, and no changed path outside the working tree or
|
|
737
|
+
inside its .git folder. Git runs with submodule recursion turned off, so a change that only moves, adds or
|
|
738
|
+
removes a submodule entry never writes in the submodule's folder. A change that removes that folder or writes
|
|
739
|
+
into it (a submodule turned into a file, a link or a folder of files, or the reverse through a Revert) is only
|
|
740
|
+
let through when nothing in the folder, nested submodules included, would be lost; otherwise it is refused and
|
|
741
|
+
the files at stake are named. Git's answer is
|
|
742
|
+
then read once and ended the way the form asked: a new commit on the original HEAD, a pause Git keeps for the
|
|
743
|
+
user, a result that brings nothing (ended without a commit, with its reason), changes left unstaged when no
|
|
744
|
+
commit was asked for, or a refusal that wrote nothing. Going back from a pause is left to Git's own Abort.
|
|
745
|
+
"""
|
|
746
|
+
# No file of the working tree is signed, listed or copied here. Git itself refuses to overwrite a local change
|
|
747
|
+
# the commit would touch, and then stops before writing anything. Its rename detection may carry a change onto
|
|
748
|
+
# a renamed path: that is still Git's own result, on a path it found inside the working tree, never outside it
|
|
749
|
+
# or inside .git, so it is left to Git and not checked again afterwards.
|
|
750
|
+
operation = intent.operation
|
|
751
|
+
context = operation.context
|
|
752
|
+
run = NativeCommandRun()
|
|
753
|
+
state = None
|
|
754
|
+
status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
|
|
755
|
+
changes = b""
|
|
756
|
+
index_filename = os.path.join(context.git_dir, LIVE_INDEX_BASENAME)
|
|
757
|
+
pause_filenames, pause_dirs = native_pause_paths(context)
|
|
758
|
+
try:
|
|
759
|
+
self._check_start_request(intent, is_current)
|
|
760
|
+
changed_cause = "HEAD, the index or a native operation changed before launch. Refresh before applying a commit."
|
|
761
|
+
# The index is read on the same worker that signs it, so the signature describes exactly the bytes kept here.
|
|
762
|
+
baseline = yield FileTask(lambda: capture_write_launch_signature(context, changed_cause, reference_signature, reference_signature_paths, index_filename,
|
|
763
|
+
read_index(index_filename), extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs))
|
|
764
|
+
before = yield from capture_replay(context, operation.generation, source_oid=intent.source_oid, verify_git_dir=True)
|
|
765
|
+
if before.unmerged_paths:
|
|
766
|
+
raise ValueError("The index has unresolved conflicts. Resolve them before applying a commit.")
|
|
767
|
+
if not before.fresh or before.kind != ReplayKind.ABSENT:
|
|
768
|
+
raise ValueError("A native commit operation already exists or cannot be read. Refresh its state.")
|
|
769
|
+
if before.head_oid != intent.original_head_oid or before.symbolic_ref != intent.original_symbolic_ref or not is_current():
|
|
770
|
+
raise ValueError("HEAD or its branch changed. Refresh before applying a commit.")
|
|
771
|
+
# Without rename detection both sides of a staged rename are named, and a staged deletion is never hidden behind one.
|
|
772
|
+
parent_output, staged, index_entries = yield ReadBatch((GitCommand(["show", "--no-patch", "--format=%P", intent.source_oid, "--"]),
|
|
773
|
+
GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"]),
|
|
774
|
+
GitCommand(["ls-files", "--stage", "-z"])))
|
|
775
|
+
compared_oid = self._validated_compared_parent(intent, parent_output)
|
|
776
|
+
if staged:
|
|
777
|
+
raise ValueError("The index contains staged changes. Commit or unstage them before applying a commit.")
|
|
778
|
+
# The two options make Git compare the objects itself, so no diff program or text converter of the user runs.
|
|
779
|
+
if compared_oid is None:
|
|
780
|
+
changes = yield GitCommand(["diff-tree", "-r", "-z", "--root", "--no-commit-id", "--no-renames", "--no-ext-diff", "--no-textconv", intent.source_oid, "--"])
|
|
781
|
+
else:
|
|
782
|
+
changes = yield GitCommand(["diff-tree", "-r", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", compared_oid, intent.source_oid, "--"])
|
|
783
|
+
refusal = replay_change_refusal(changes)
|
|
784
|
+
if refusal:
|
|
785
|
+
raise ValueError(refusal)
|
|
786
|
+
# The submodules are read from the current index rather than from the replayed commit, so a folder that
|
|
787
|
+
# replaces a submodule on this branch only is found too.
|
|
788
|
+
replaced_paths = replaced_submodule_paths(changes, gitlink_paths(index_entries))
|
|
789
|
+
executable_signature = None
|
|
790
|
+
if not intent.create_commit:
|
|
791
|
+
# Only the launch that names its source twice relies on Git keeping one instruction for it.
|
|
792
|
+
executable_signature = yield from self._verify_no_walk(context, operation.git_filename, intent.source_oid)
|
|
793
|
+
# Checked as late as a Git read can run, just before the launch checks that happen on the Qt thread.
|
|
794
|
+
for submodule_path in replaced_paths:
|
|
795
|
+
loss = yield from submodule_loss_refusal(context, submodule_path)
|
|
796
|
+
if loss:
|
|
797
|
+
raise ValueError(loss)
|
|
798
|
+
signature = yield FileTask(lambda: prepare_write_launch_signature(context, baseline, changed_cause, reference_signature))
|
|
799
|
+
# The policy reads native files on the Qt thread; the signature taken just before it lets the launch check
|
|
800
|
+
# prove those files, in every related worktree, still describe the state the policy accepted.
|
|
801
|
+
policy = self._policy_launch_signature(operation)
|
|
802
|
+
self.repository_operation.validate(operation)
|
|
803
|
+
self._validate_launch_authority(operation, is_current)
|
|
804
|
+
related_policy = exclude_covered_launch_paths(policy, signature.metadata)
|
|
805
|
+
|
|
806
|
+
def validate_launch() -> None:
|
|
807
|
+
"""Compare only current authority, the metadata of the checked Git files and related pauses, and the Git executable, when the queued command launches."""
|
|
808
|
+
self._validate_launch_authority(operation, is_current)
|
|
809
|
+
verify_launch_signature(related_policy, changed_cause)
|
|
810
|
+
verify_launch_signature(signature.metadata, changed_cause)
|
|
811
|
+
if executable_signature is not None and replay_executable_signature(operation.git_filename) != executable_signature:
|
|
812
|
+
raise ValueError("The Git executable changed before launch. Refresh before applying a commit.")
|
|
813
|
+
run.started = True
|
|
814
|
+
|
|
815
|
+
arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, intent.kind.value]
|
|
816
|
+
if not intent.create_commit:
|
|
817
|
+
arguments.append("--no-commit")
|
|
818
|
+
arguments.append("--no-edit")
|
|
819
|
+
if intent.create_commit and intent.add_reference and intent.kind == ReplayKind.CHERRY_PICK:
|
|
820
|
+
arguments.append("-x")
|
|
821
|
+
if intent.mainline_parent_number is not None:
|
|
822
|
+
arguments.extend(["-m", str(intent.mainline_parent_number)])
|
|
823
|
+
arguments.append(intent.source_oid)
|
|
824
|
+
if not intent.create_commit:
|
|
825
|
+
# Named once, a replay without a commit leaves Git no operation its own Abort can end. Named twice, Git
|
|
826
|
+
# keeps the one instruction the two names give in its sequencer, which Abort ends like any series.
|
|
827
|
+
arguments.append(intent.source_oid)
|
|
828
|
+
yield GitCommand(arguments, profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
829
|
+
before_start=validate_launch, suppress_editor=True)
|
|
830
|
+
run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
|
|
831
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
832
|
+
run.record_failure(operation, error)
|
|
833
|
+
try:
|
|
834
|
+
after_look = None
|
|
835
|
+
if run.started:
|
|
836
|
+
# The look is taken before the reading of what Git left, so a step that ends that result compares the
|
|
837
|
+
# very state this reading accepted.
|
|
838
|
+
after_look = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths,
|
|
839
|
+
index_filename, read_index(index_filename), extra_filenames=pause_filenames,
|
|
840
|
+
extra_watched_dirs=pause_dirs))
|
|
841
|
+
state = yield from capture_replay(context, operation.generation, lone_message_is_trace=run.started)
|
|
842
|
+
if run.started:
|
|
843
|
+
state, status, effect, run = yield from self._launch_result(intent, after_look, state, run, changes, is_current)
|
|
844
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
845
|
+
status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
|
|
846
|
+
run.record_failure(operation, error)
|
|
847
|
+
state = unreadable_replay_state(state or ReplaySnapshot(operation.key, operation.generation), run.diagnostic.cause)
|
|
848
|
+
if status in (OperationStatus.SUCCESS, OperationStatus.PAUSED) and (status == OperationStatus.SUCCESS or state.unmerged_paths and run.return_code == GIT_CONFLICT_RETURN_CODE):
|
|
849
|
+
# A conflict is an expected stop that waits for the user, not a failure: Git's exit status and its message
|
|
850
|
+
# are already in the console, so no failure cause is attached to the pause.
|
|
851
|
+
run.diagnostic = None
|
|
852
|
+
elif run.started and run.diagnostic is None:
|
|
853
|
+
run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [],
|
|
854
|
+
"Git finished without a verified new commit. Refresh to inspect the current checkout.").prepare()
|
|
855
|
+
state = self._publish_result(state)
|
|
856
|
+
step = OperationStepResult(operation.operation, status, intent, state, run.process_status, effect, run.diagnostic, run.return_code)
|
|
857
|
+
outcome = OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
|
|
858
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
859
|
+
return outcome
|
|
860
|
+
|
|
861
|
+
def _validated_compared_parent(self, intent: ReplayIntent, parent_output: bytes) -> str | None:
|
|
862
|
+
"""Check the parent choice of the form against the real parents of the source, and name the parent its changes are read against.
|
|
863
|
+
|
|
864
|
+
A merge needs the parent the form selected, still at the same place; any other source must not name one. The
|
|
865
|
+
answer is None for a source without parent, whose changes are read against the empty tree.
|
|
866
|
+
"""
|
|
867
|
+
parent_oids = []
|
|
868
|
+
for parent_oid in parent_output.decode("ascii").split():
|
|
869
|
+
parent_oids.append(validate_oid(parent_oid))
|
|
870
|
+
if len(parent_oids) >= MERGE_PARENT_MINIMUM:
|
|
871
|
+
parent_number = intent.mainline_parent_number
|
|
872
|
+
if parent_number is None or not FIRST_PARENT_NUMBER <= parent_number <= len(parent_oids):
|
|
873
|
+
raise ValueError("Select a parent of this merge before applying it.")
|
|
874
|
+
parent_index = parent_number - FIRST_PARENT_NUMBER
|
|
875
|
+
if parent_oids[parent_index] != intent.mainline_parent_oid:
|
|
876
|
+
raise ValueError("The selected merge parent changed. Select its current parent again.")
|
|
877
|
+
return parent_oids[parent_index]
|
|
878
|
+
if intent.mainline_parent_number is not None or intent.mainline_parent_oid is not None:
|
|
879
|
+
raise ValueError("This source is not a merge and cannot use a mainline parent.")
|
|
880
|
+
return next(iter(parent_oids), None)
|
|
881
|
+
|
|
882
|
+
def _launch_result(self, intent: ReplayIntent, after_look: WriteLaunchSignature, state: ReplaySnapshot, run: NativeCommandRun, changes: bytes,
|
|
883
|
+
is_current: Callable[[], bool]) -> Generator:
|
|
884
|
+
"""Read what Git left after a launch and end what GitPane ends itself; answer the state, the status, the effect and the last run.
|
|
885
|
+
|
|
886
|
+
A new commit whose only parent is the original HEAD, on the same branch, is a success. A pause Git keeps on a
|
|
887
|
+
conflict is remembered as GitPane's own for this run of the program and waits for the user. A commit that
|
|
888
|
+
would bring nothing ends without one: Git's own Skip when Git keeps its pause, its Quit when it kept only its
|
|
889
|
+
message, then the reason. A launch without a commit ends with its changes unstaged. A failure that left HEAD
|
|
890
|
+
and the branch where they were is Git's own refusal. Anything else is unknown.
|
|
891
|
+
"""
|
|
892
|
+
operation = intent.operation
|
|
893
|
+
if state.fresh and state.kind != ReplayKind.ABSENT:
|
|
894
|
+
self.launch_by_key[state.key] = ReplayLaunch(intent.kind, intent.source_oid, intent.create_commit, intent.add_reference,
|
|
895
|
+
intent.original_head_oid, intent.original_symbolic_ref)
|
|
896
|
+
owned = join_replay_launch(state, self.launch_by_key.get(state.key))
|
|
897
|
+
origin = (intent.original_head_oid, intent.original_symbolic_ref)
|
|
898
|
+
origin_kept = (state.head_oid, state.symbolic_ref) == origin and not state.unmerged_paths
|
|
899
|
+
# A cherry-pick without a commit, and a revert with nothing to commit, leave only Git's prepared message behind.
|
|
900
|
+
message_only = state.kind == ReplayKind.UNKNOWN and state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT and not state.unmerged_paths
|
|
901
|
+
own_pause = owned.ownership == ReplayOwnership.OWNED
|
|
902
|
+
finished = run.process_status == ProcessStatus.SUCCESS and run.return_code == SUCCESS_EXIT_CODE
|
|
903
|
+
stopped_empty = run.process_status == ProcessStatus.FAILED and run.return_code == GIT_CONFLICT_RETURN_CODE
|
|
904
|
+
effect_when_empty = empty_result_effect(intent.kind, changes)
|
|
905
|
+
# Git always leaves its message, and for a Revert its marker, after a replay without a commit; a clean exit
|
|
906
|
+
# that left no trace at all proves nothing about what was applied.
|
|
907
|
+
if origin_kept and not intent.create_commit and finished and (own_pause or message_only):
|
|
908
|
+
return (yield from self._finish_without_commit(operation, after_look, owned, origin, intent.kind, effect_when_empty, is_current, check_policy=False))
|
|
909
|
+
if origin_kept and intent.create_commit and stopped_empty and (own_pause or message_only):
|
|
910
|
+
staged = yield GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"])
|
|
911
|
+
if not staged and own_pause:
|
|
912
|
+
return (yield from self._skip(operation, after_look, owned, effect_when_empty, is_current, check_policy=False))
|
|
913
|
+
if not staged:
|
|
914
|
+
return (yield from self._finish_without_commit(operation, after_look, owned, origin, intent.kind, effect_when_empty, is_current, check_policy=False))
|
|
915
|
+
if not state.fresh:
|
|
916
|
+
return state, OperationStatus.UNKNOWN, "unknown", run
|
|
917
|
+
if state.kind != ReplayKind.ABSENT:
|
|
918
|
+
return state, operation_status(run.process_status) if run.process_status in STOPPED_PROCESS_STATUSES else OperationStatus.PAUSED, "unknown", run
|
|
919
|
+
if state.unmerged_paths or state.symbolic_ref != intent.original_symbolic_ref:
|
|
920
|
+
return state, OperationStatus.UNKNOWN, "unknown", run
|
|
921
|
+
if finished and state.head_oid and state.head_oid != intent.original_head_oid:
|
|
922
|
+
parent_output = yield GitCommand(["show", "--no-patch", "--format=%P", state.head_oid, "--"])
|
|
923
|
+
if parent_output.split() == [intent.original_head_oid.encode("ascii")]:
|
|
924
|
+
return state, OperationStatus.SUCCESS, "changed", run
|
|
925
|
+
return state, OperationStatus.UNKNOWN, "unknown", run
|
|
926
|
+
if run.process_status == ProcessStatus.FAILED and state.head_oid == intent.original_head_oid:
|
|
927
|
+
return state, OperationStatus.FAILED, "unknown", run
|
|
928
|
+
return state, OperationStatus.UNKNOWN, "unknown", run
|
|
929
|
+
|
|
930
|
+
def _skip(self, operation: OperationIntent, baseline: WriteLaunchSignature, paused: ReplaySnapshot, effect: str, is_current: Callable[[], bool], *,
|
|
931
|
+
check_policy: bool) -> Generator:
|
|
932
|
+
"""End a checked pause whose instruction brings nothing with Git's own Skip; answer the state, the status, the effect and the run.
|
|
933
|
+
|
|
934
|
+
The Skip succeeds once no pause is left on the same HEAD and branch, without a new commit. When the pause is
|
|
935
|
+
one instruction of a longer series, Git goes on with the next ones as it does in a terminal, and the result is
|
|
936
|
+
read the way a Continue's is.
|
|
937
|
+
"""
|
|
938
|
+
run = NativeCommandRun()
|
|
939
|
+
try:
|
|
940
|
+
yield from self._launch_pause_command(operation, baseline, [paused.kind.value, "--skip"], is_current, run, check_policy=check_policy)
|
|
941
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
942
|
+
run.record_failure(operation, error)
|
|
943
|
+
after = yield from self._read_after(operation, paused, run)
|
|
944
|
+
if not run.started:
|
|
945
|
+
return after, OperationStatus.UNKNOWN, "unknown", run
|
|
946
|
+
if (run.process_status == ProcessStatus.SUCCESS and after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths
|
|
947
|
+
and (after.head_oid, after.symbolic_ref) == (paused.head_oid, paused.symbolic_ref)):
|
|
948
|
+
return after, OperationStatus.SUCCESS, effect, run
|
|
949
|
+
status = yield from self._continue_status(paused, after, run)
|
|
950
|
+
return after, status, status.value, run
|
|
951
|
+
|
|
952
|
+
def _finish_without_commit(self, operation: OperationIntent, baseline: WriteLaunchSignature, state: ReplaySnapshot, origin: tuple[str | None, str | None],
|
|
953
|
+
kind: ReplayKind, effect_when_empty: str, is_current: Callable[[], bool], *, check_policy: bool) -> Generator:
|
|
954
|
+
"""End a replay without a commit: Git's own Quit when a pause or its message is left, then the index set back to HEAD.
|
|
955
|
+
|
|
956
|
+
The index held exactly HEAD when the replay was launched, since a staged change refuses the launch, so setting
|
|
957
|
+
it back to HEAD leaves the replay's changes in the working tree only, as Unstaged changes; a staged change made
|
|
958
|
+
during the pause is unstaged with them, and nothing is written in a working file. A moved submodule pointer is
|
|
959
|
+
the exception: it stays staged, the only form it has. The reset starts only when something is staged, and is
|
|
960
|
+
compared with a look taken after the Quit. Answers the state, the status, the effect ("changed", or
|
|
961
|
+
`effect_when_empty` when the replay brought nothing) and the run.
|
|
962
|
+
"""
|
|
963
|
+
context = operation.context
|
|
964
|
+
run = NativeCommandRun()
|
|
965
|
+
# What may be left is Git's pause of this very kind, its prepared message alone, or nothing at all.
|
|
966
|
+
message_only = state.kind == ReplayKind.UNKNOWN and state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT
|
|
967
|
+
if not is_current() or state.unmerged_paths or (state.head_oid, state.symbolic_ref) != origin or state.kind not in (ReplayKind.ABSENT, kind) and not message_only:
|
|
968
|
+
run.record_failure(operation, ValueError(PAUSE_CHANGED_CAUSE))
|
|
969
|
+
return state, OperationStatus.UNKNOWN, "unknown", run
|
|
970
|
+
index_filename = baseline.index_filename
|
|
971
|
+
try:
|
|
972
|
+
if state.kind != ReplayKind.ABSENT:
|
|
973
|
+
yield from self._launch_pause_command(operation, baseline, [kind.value, "--quit"], is_current, run, check_policy=check_policy)
|
|
974
|
+
staged = yield GitCommand(["diff", "--cached", "--raw", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", "HEAD", "--"])
|
|
975
|
+
if staged:
|
|
976
|
+
# A submodule pointer has no unstaged form while the submodule is not checked out on the new commit,
|
|
977
|
+
# which GitPane never does: setting it back to HEAD would lose it without a trace. So the reset keeps
|
|
978
|
+
# every staged submodule entry out and sets only the rest back to HEAD.
|
|
979
|
+
reset_arguments = reset_keeping_gitlinks_arguments(staged_gitlink_paths(staged))
|
|
980
|
+
pause_filenames, pause_dirs = native_pause_paths(context)
|
|
981
|
+
reset_look = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths,
|
|
982
|
+
index_filename, read_index(index_filename), extra_filenames=pause_filenames,
|
|
983
|
+
extra_watched_dirs=pause_dirs))
|
|
984
|
+
if reset_look.reference_state != baseline.reference_state or reset_look.index_content != baseline.index_content:
|
|
985
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
986
|
+
# The Quit changed Git's pause files and nothing else, so the reset is compared with the look taken after it.
|
|
987
|
+
yield from self._launch_pause_command(operation, reset_look, reset_arguments, is_current, run, check_policy=False)
|
|
988
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
989
|
+
run.record_failure(operation, error)
|
|
990
|
+
after = yield from self._read_after(operation, state, run)
|
|
991
|
+
return after, OperationStatus.UNKNOWN, "unknown", run
|
|
992
|
+
after = yield from self._read_after(operation, state, run)
|
|
993
|
+
if after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths and (after.head_oid, after.symbolic_ref) == origin:
|
|
994
|
+
return after, OperationStatus.SUCCESS, "changed" if staged else effect_when_empty, run
|
|
995
|
+
run.record_failure(operation, ValueError("The operation without a commit did not end on the checkout it started from. Inspect the current state."))
|
|
996
|
+
return after, OperationStatus.UNKNOWN, "unknown", run
|
|
997
|
+
|
|
998
|
+
def _read_after(self, operation: OperationIntent, fallback: ReplaySnapshot, run: NativeCommandRun) -> Generator:
|
|
999
|
+
"""Read what a native command left, or answer the last known state marked unreadable when Git cannot answer that read."""
|
|
1000
|
+
try:
|
|
1001
|
+
return (yield from capture_replay(operation.context, operation.generation))
|
|
1002
|
+
except GitCommandError as error:
|
|
1003
|
+
run.record_failure(operation, error)
|
|
1004
|
+
return unreadable_replay_state(fallback, error.diagnostic.cause)
|
|
1005
|
+
|
|
1006
|
+
def continue_replay(self, operation: OperationIntent, state: ReplaySnapshot, *, is_current: Callable[[], bool]) -> str:
|
|
1007
|
+
"""Reserve one explicit Continue of the pause the user acted on, which Git's own Continue then ends."""
|
|
1008
|
+
if self.repository_operation is None or self.closed:
|
|
1009
|
+
raise ValueError("The commit operation service is unavailable.")
|
|
1010
|
+
if operation.operation_id in self.submitted_id_set:
|
|
1011
|
+
return operation.operation_id
|
|
1012
|
+
self.submitted_id_set.add(operation.operation_id)
|
|
1013
|
+
return self.repository_operation.submit(operation, self._continue(operation, state, is_current))
|
|
1014
|
+
|
|
1015
|
+
def abort(self, operation: OperationIntent, state: ReplaySnapshot, *, is_current: Callable[[], bool]) -> str:
|
|
1016
|
+
"""Reserve one explicit Abort of the pause the user acted on, which Git's own Abort then ends."""
|
|
1017
|
+
if self.repository_operation is None or self.closed:
|
|
1018
|
+
raise ValueError("The commit operation service is unavailable.")
|
|
1019
|
+
if operation.operation_id in self.submitted_id_set:
|
|
1020
|
+
return operation.operation_id
|
|
1021
|
+
self.submitted_id_set.add(operation.operation_id)
|
|
1022
|
+
return self.repository_operation.submit(operation, self._abort(operation, state, is_current))
|
|
1023
|
+
|
|
1024
|
+
def _same_pause(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
|
|
1025
|
+
"""Take the first look a command on a pause relies on, then read the pause once and require it to be the one the user acted on.
|
|
1026
|
+
|
|
1027
|
+
The look covers the references, the index and Git's pause files of this worktree. The read must show a fresh
|
|
1028
|
+
Cherry-pick or Revert with the same native identity, HEAD, branch, owner and commit mode. Answers the look and
|
|
1029
|
+
the state read.
|
|
1030
|
+
"""
|
|
1031
|
+
context = operation.context
|
|
1032
|
+
index_filename = os.path.join(context.git_dir, LIVE_INDEX_BASENAME)
|
|
1033
|
+
pause_filenames, pause_dirs = native_pause_paths(context)
|
|
1034
|
+
# The index is read on the same worker that signs it, so the look describes exactly the bytes kept here.
|
|
1035
|
+
baseline = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths, index_filename,
|
|
1036
|
+
read_index(index_filename), extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs))
|
|
1037
|
+
state = join_replay_launch((yield from capture_replay(context, operation.generation)), self.launch_by_key.get(operation.key))
|
|
1038
|
+
if (not is_current() or previous.key != operation.key or state.key != operation.key or not state.fresh or not state.kind.is_replay
|
|
1039
|
+
or state.identity != previous.identity or state.head_oid != previous.head_oid or state.symbolic_ref != previous.symbolic_ref
|
|
1040
|
+
or state.ownership != previous.ownership or state.mode != previous.mode):
|
|
1041
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
1042
|
+
return baseline, state
|
|
1043
|
+
|
|
1044
|
+
def _launch_pause_command(self, operation: OperationIntent, baseline: WriteLaunchSignature, arguments: list[str], is_current: Callable[[], bool],
|
|
1045
|
+
run: NativeCommandRun, *, check_policy: bool, prepared_message: tuple[ReplayMetadata, str] | None = None) -> Generator:
|
|
1046
|
+
"""Hand one native write to the launcher once nothing moved since the first look, with submodule recursion turned off.
|
|
1047
|
+
|
|
1048
|
+
With `check_policy` the write policy is asked on the Qt thread the way a launch asks it, and the pause files of
|
|
1049
|
+
related worktrees are compared again when the command starts. A step that follows GitPane's own command (the
|
|
1050
|
+
Skip or the end without a commit of a launch) compares the pause GitPane itself left, so it skips that policy. A
|
|
1051
|
+
prepared message is written over Git's MERGE_MSG only once the policy accepted the pause as the user saw it;
|
|
1052
|
+
from then on the new message is part of what is compared. `run` records whether Git started and how it ended.
|
|
1053
|
+
"""
|
|
1054
|
+
context = operation.context
|
|
1055
|
+
policy = None
|
|
1056
|
+
if check_policy:
|
|
1057
|
+
policy = self._policy_launch_signature(operation)
|
|
1058
|
+
self.repository_operation.validate(operation)
|
|
1059
|
+
self._validate_launch_authority(operation, is_current, session_policy=check_policy)
|
|
1060
|
+
# The policy compares the pause files with the ones captured at the click, so it is not asked again once
|
|
1061
|
+
# GitPane rewrote the message itself.
|
|
1062
|
+
session_policy = check_policy and prepared_message is None
|
|
1063
|
+
if prepared_message is not None:
|
|
1064
|
+
metadata, message = prepared_message
|
|
1065
|
+
baseline = yield FileTask(lambda: publish_continue_message(context, baseline, metadata, message))
|
|
1066
|
+
signature = yield FileTask(lambda: prepare_write_launch_signature(context, baseline, PAUSE_CHANGED_CAUSE, reference_signature))
|
|
1067
|
+
related_policy = exclude_covered_launch_paths(policy, signature.metadata) if policy is not None else None
|
|
1068
|
+
self._validate_launch_authority(operation, is_current, session_policy=session_policy)
|
|
1069
|
+
|
|
1070
|
+
def validate_launch() -> None:
|
|
1071
|
+
"""Compare only current authority and the metadata of the checked files and related pauses when the queued command launches."""
|
|
1072
|
+
self._validate_launch_authority(operation, is_current, session_policy=session_policy)
|
|
1073
|
+
if related_policy is not None:
|
|
1074
|
+
verify_launch_signature(related_policy, PAUSE_CHANGED_CAUSE)
|
|
1075
|
+
verify_launch_signature(signature.metadata, PAUSE_CHANGED_CAUSE)
|
|
1076
|
+
run.started = True
|
|
1077
|
+
|
|
1078
|
+
yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *arguments], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
|
|
1079
|
+
before_start=validate_launch, suppress_editor=True)
|
|
1080
|
+
run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
|
|
1081
|
+
|
|
1082
|
+
def _finish_pause_operation(self, operation: OperationIntent, previous: ReplaySnapshot, state: ReplaySnapshot, status: OperationStatus, effect: str,
|
|
1083
|
+
run: NativeCommandRun) -> OperationOutcome:
|
|
1084
|
+
"""Publish the state an operation on a pause ends with and record its outcome, the diagnostic of a failure shown with that state."""
|
|
1085
|
+
if run.diagnostic is not None:
|
|
1086
|
+
state = replace(state, diagnostic=run.diagnostic.cause)
|
|
1087
|
+
state = self._publish_result(state)
|
|
1088
|
+
step = OperationStepResult(operation.operation, status, previous, state, run.process_status, effect, run.diagnostic, run.return_code)
|
|
1089
|
+
outcome = OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
|
|
1090
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1091
|
+
return outcome
|
|
1092
|
+
|
|
1093
|
+
def _continue(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
|
|
1094
|
+
"""End a pause the way the mode chosen at its start asks, with Git's own commands.
|
|
1095
|
+
|
|
1096
|
+
In commit mode Git's own Continue commits the staged resolution with the prepared message and no editor; a
|
|
1097
|
+
resolution that brings nothing is ended by Git's own Skip instead, without a commit. A pause GitPane launched
|
|
1098
|
+
without a commit ends without one, its changes left unstaged. Only quick checks run first: the same pause, HEAD
|
|
1099
|
+
and branch the user acted on, and no conflict left. A staged change unrelated to the replay is not refused: Git
|
|
1100
|
+
commits the whole index, as it does in a terminal. Git's conflict notes leave the message; Git then runs with an
|
|
1101
|
+
unused comment character, so no line the author wrote is taken for a comment.
|
|
1102
|
+
"""
|
|
1103
|
+
context = operation.context
|
|
1104
|
+
run = NativeCommandRun()
|
|
1105
|
+
state = previous
|
|
1106
|
+
status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
|
|
1107
|
+
ended = None
|
|
1108
|
+
try:
|
|
1109
|
+
if operation.action != OperationAction.REPLAY_CONTINUE:
|
|
1110
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
1111
|
+
baseline, state = yield from self._same_pause(operation, previous, is_current)
|
|
1112
|
+
refusal = replay_action_diagnostic(state, ReplayAction.CONTINUE, abort_available=True)
|
|
1113
|
+
if refusal:
|
|
1114
|
+
raise ValueError(refusal)
|
|
1115
|
+
if state.mode == ReplayCommitMode.NO_COMMIT:
|
|
1116
|
+
ended = yield from self._finish_without_commit(operation, baseline, state, pause_origin(state), state.kind, ReplayEffect.RESOLVED_NO_CHANGE.value, is_current,
|
|
1117
|
+
check_policy=True)
|
|
1118
|
+
else:
|
|
1119
|
+
staged = yield GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"])
|
|
1120
|
+
if not staged:
|
|
1121
|
+
# The resolution brings nothing to this instruction, so Git would need an empty commit: its own Skip
|
|
1122
|
+
# ends the instruction without one.
|
|
1123
|
+
ended = yield from self._skip(operation, baseline, state, ReplayEffect.RESOLVED_NO_CHANGE.value, is_current, check_policy=True)
|
|
1124
|
+
else:
|
|
1125
|
+
metadata = yield FileTask(lambda: read_replay_metadata(context.git_dir))
|
|
1126
|
+
comment_output = yield GitCommand(["config", "--get-regexp", r"^core\.comment(char|string)$"], accepted_codes=(0, 1))
|
|
1127
|
+
message = continue_message(state, metadata, comment_output)
|
|
1128
|
+
arguments = ["-c", "core.commentChar=" + native_message_comment_prefix(message), state.kind.value, "--continue"]
|
|
1129
|
+
yield from self._launch_pause_command(operation, baseline, arguments, is_current, run, check_policy=True, prepared_message=(metadata, message))
|
|
1130
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
1131
|
+
run.record_failure(operation, error)
|
|
1132
|
+
if ended is not None:
|
|
1133
|
+
state, status, effect, run = ended
|
|
1134
|
+
if status == OperationStatus.UNKNOWN and not run.started:
|
|
1135
|
+
# No command was handed to Git, so the pause is exactly as the user left it: the Continue was refused,
|
|
1136
|
+
# whichever step refused it.
|
|
1137
|
+
status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
|
|
1138
|
+
if status not in (OperationStatus.SUCCESS, OperationStatus.REFUSED) and run.diagnostic is None:
|
|
1139
|
+
run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], CONTINUE_UNVERIFIED_CAUSE).prepare()
|
|
1140
|
+
return self._finish_pause_operation(operation, previous, state, status, effect, run)
|
|
1141
|
+
try:
|
|
1142
|
+
after = yield from capture_replay(context, operation.generation)
|
|
1143
|
+
if run.started:
|
|
1144
|
+
status = yield from self._continue_status(previous, after, run)
|
|
1145
|
+
effect = status.value
|
|
1146
|
+
state = after
|
|
1147
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
1148
|
+
status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
|
|
1149
|
+
run.record_failure(operation, error)
|
|
1150
|
+
state = unreadable_replay_state(state, run.diagnostic.cause)
|
|
1151
|
+
if status not in (OperationStatus.SUCCESS, OperationStatus.REFUSED) and run.diagnostic is None:
|
|
1152
|
+
run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], CONTINUE_UNVERIFIED_CAUSE).prepare()
|
|
1153
|
+
return self._finish_pause_operation(operation, previous, state, status, effect, run)
|
|
1154
|
+
|
|
1155
|
+
def _continue_status(self, previous: ReplaySnapshot, after: ReplaySnapshot, run: NativeCommandRun) -> Generator:
|
|
1156
|
+
"""Read what Git's Continue left: new commits standing on the paused HEAD, the next pause of a series, or an uncertain state.
|
|
1157
|
+
|
|
1158
|
+
Success needs Git's exit status 0, no pause left, the same branch and a line of new commits whose first one has
|
|
1159
|
+
the paused HEAD as its only parent.
|
|
1160
|
+
"""
|
|
1161
|
+
if not after.fresh:
|
|
1162
|
+
return OperationStatus.UNKNOWN
|
|
1163
|
+
if after.kind != ReplayKind.ABSENT or after.unmerged_paths:
|
|
1164
|
+
return operation_status(run.process_status) if run.process_status in STOPPED_PROCESS_STATUSES else OperationStatus.PAUSED
|
|
1165
|
+
if run.process_status != ProcessStatus.SUCCESS or after.symbolic_ref != previous.symbolic_ref or not after.head_oid or not previous.head_oid:
|
|
1166
|
+
return OperationStatus.UNKNOWN
|
|
1167
|
+
chain = yield GitCommand(["rev-list", "--first-parent", "--parents", "--reverse", previous.head_oid + "..HEAD", "--"])
|
|
1168
|
+
return OperationStatus.SUCCESS if commit_chain_from(chain, previous.head_oid, after.head_oid) else OperationStatus.UNKNOWN
|
|
1169
|
+
|
|
1170
|
+
def _abort(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
|
|
1171
|
+
"""End a pause with Git's own Abort, which brings back the checkout the operation started from.
|
|
1172
|
+
|
|
1173
|
+
Git itself refuses to overwrite a local change it would lose, and keeps changes it does not need to touch. The
|
|
1174
|
+
result is accepted once no pause is left and HEAD and its branch are back where the operation started; anything
|
|
1175
|
+
else is reported with Git's own diagnostic.
|
|
1176
|
+
"""
|
|
1177
|
+
context = operation.context
|
|
1178
|
+
run = NativeCommandRun()
|
|
1179
|
+
state = previous
|
|
1180
|
+
status = OperationStatus.REFUSED
|
|
1181
|
+
try:
|
|
1182
|
+
if operation.action != OperationAction.REPLAY_ABORT:
|
|
1183
|
+
raise ValueError(PAUSE_CHANGED_CAUSE)
|
|
1184
|
+
baseline, state = yield from self._same_pause(operation, previous, is_current)
|
|
1185
|
+
refusal = replay_action_diagnostic(state, ReplayAction.ABORT, abort_available=True)
|
|
1186
|
+
if refusal:
|
|
1187
|
+
raise ValueError(refusal)
|
|
1188
|
+
yield from self._launch_pause_command(operation, baseline, [state.kind.value, "--abort"], is_current, run, check_policy=True)
|
|
1189
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
1190
|
+
run.record_failure(operation, error)
|
|
1191
|
+
try:
|
|
1192
|
+
after = yield from capture_replay(context, operation.generation)
|
|
1193
|
+
if run.started:
|
|
1194
|
+
origin_oid, origin_ref = pause_origin(state)
|
|
1195
|
+
restored = after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths and after.head_oid == origin_oid and after.symbolic_ref == origin_ref
|
|
1196
|
+
status = OperationStatus.ABORTED if restored and run.process_status == ProcessStatus.SUCCESS else OperationStatus.UNKNOWN
|
|
1197
|
+
state = after
|
|
1198
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
1199
|
+
status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
|
|
1200
|
+
run.record_failure(operation, error)
|
|
1201
|
+
state = unreadable_replay_state(state, run.diagnostic.cause)
|
|
1202
|
+
if status == OperationStatus.UNKNOWN and run.diagnostic is None:
|
|
1203
|
+
run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], ABORT_UNVERIFIED_CAUSE).prepare()
|
|
1204
|
+
return self._finish_pause_operation(operation, previous, state, status, status.value, run)
|
|
1205
|
+
|
|
1206
|
+
def retire_legacy_anchors(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[tuple[str, ...]], None],
|
|
1207
|
+
session_id: str | None = None, generation: int = 0) -> None:
|
|
1208
|
+
"""Remove, in the background, the anchor references earlier versions left in this repository, and tell which of their backups stay.
|
|
1209
|
+
|
|
1210
|
+
The folder of earlier journals is read first on the file worker; when it holds no working tree subfolder,
|
|
1211
|
+
nothing else runs. Otherwise the anchors are listed once, and the ones legacy_anchor_deletions chooses are
|
|
1212
|
+
removed by one reserved write, refused while a Cherry-pick or Revert is in progress. No file of that folder is
|
|
1213
|
+
ever changed. `callback` is called once, after everything ended, with the journal folders an earlier version
|
|
1214
|
+
left in the middle of a restoration whose anchor this repository still holds, wherever the repository was when
|
|
1215
|
+
they were written. A failure removes nothing and is written to the process log only.
|
|
1216
|
+
"""
|
|
1217
|
+
answered = []
|
|
1218
|
+
|
|
1219
|
+
def answer(kept_dirs: tuple[str, ...]) -> None:
|
|
1220
|
+
"""Call the caller back once, whichever path ends the retirement first."""
|
|
1221
|
+
if answered:
|
|
1222
|
+
return
|
|
1223
|
+
answered.append(True)
|
|
1224
|
+
callback(kept_dirs)
|
|
1225
|
+
|
|
1226
|
+
if self.closed or context.is_bare:
|
|
1227
|
+
answer(())
|
|
1228
|
+
return
|
|
1229
|
+
store_dir = replay_legacy.legacy_replay_store_dir()
|
|
1230
|
+
|
|
1231
|
+
def found(result: tuple[tuple[tuple[str, str], ...], tuple[str, ...]] | None, error: Exception | None) -> None:
|
|
1232
|
+
"""Submit the removal of the chosen anchors, or answer at once when there is nothing to remove or nobody left to ask."""
|
|
1233
|
+
if error is not None or result is None:
|
|
1234
|
+
answer(())
|
|
1235
|
+
return
|
|
1236
|
+
deletions, kept_dirs = result
|
|
1237
|
+
if not deletions or self.closed or self.repository_operation is None or not is_current():
|
|
1238
|
+
answer(kept_dirs)
|
|
1239
|
+
return
|
|
1240
|
+
refs = []
|
|
1241
|
+
for ref, _oid in deletions:
|
|
1242
|
+
refs.append(ref)
|
|
1243
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, context, git_filename, session_id=session_id, generation=generation, operation=LEGACY_ANCHOR_RETIREMENT_OPERATION,
|
|
1244
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(context, tuple(refs)))
|
|
1245
|
+
self.repository_operation.submit(operation, self._retire_legacy_anchors(operation, deletions, is_current, lambda: answer(kept_dirs)))
|
|
1246
|
+
if operation.operation_id in self.repository_operation.outcome_by_id:
|
|
1247
|
+
# Refused or ended before its first process: a refused sequence never runs, so it cannot answer by itself.
|
|
1248
|
+
answer(kept_dirs)
|
|
1249
|
+
|
|
1250
|
+
self.worktree_service.run(context, git_filename, self._find_legacy_anchors(store_dir, context.common_git_dir), found, priority=ProcessPriority.BACKGROUND)
|
|
1251
|
+
|
|
1252
|
+
def _find_legacy_anchors(self, store_dir: str, common_git_dir: str) -> Generator:
|
|
1253
|
+
"""Read the earlier journals, then list the anchors only when an earlier version left a working tree subfolder; answer the deletions and the kept folders.
|
|
1254
|
+
|
|
1255
|
+
The same listing decides both answers: a kept anchor always names its folder, and a restoration recorded for
|
|
1256
|
+
this repository is named even when its anchor is gone.
|
|
1257
|
+
"""
|
|
1258
|
+
in_use, records = yield FileTask(lambda: (replay_legacy.legacy_replay_store_in_use(store_dir), replay_legacy.read_legacy_replay_records(store_dir)))
|
|
1259
|
+
if not in_use:
|
|
1260
|
+
return (), ()
|
|
1261
|
+
listing = yield GitCommand(["for-each-ref", "--format=" + replay_legacy.LEGACY_ANCHOR_LISTING_FORMAT, replay_legacy.LEGACY_ANCHOR_REF_PREFIX])
|
|
1262
|
+
target_oid_by_ref = replay_legacy.parse_legacy_anchor_listing(listing)
|
|
1263
|
+
return (yield FileTask(lambda: (replay_legacy.legacy_anchor_deletions(target_oid_by_ref, records), replay_legacy.kept_legacy_backup_dirs(records, target_oid_by_ref, common_git_dir))))
|
|
1264
|
+
|
|
1265
|
+
def _retire_legacy_anchors(self, operation: OperationIntent, deletions: tuple[tuple[str, str], ...], is_current: Callable[[], bool],
|
|
1266
|
+
answer: Callable[[], None]) -> Generator:
|
|
1267
|
+
"""Remove the chosen anchors in one update-ref transaction, each only while it still points to its listed object.
|
|
1268
|
+
|
|
1269
|
+
The replay state is read again under the reservation first, and the removal is refused while a Cherry-pick or
|
|
1270
|
+
Revert is in progress. Only references are written: no index, no working file and no submodule is touched.
|
|
1271
|
+
"""
|
|
1272
|
+
run = NativeCommandRun()
|
|
1273
|
+
status = OperationStatus.REFUSED
|
|
1274
|
+
try:
|
|
1275
|
+
state = yield from capture_replay(operation.context, operation.generation)
|
|
1276
|
+
if not state.fresh or state.kind != ReplayKind.ABSENT or state.unmerged_paths:
|
|
1277
|
+
raise ValueError(LEGACY_ANCHOR_RETIREMENT_REFUSED_CAUSE)
|
|
1278
|
+
self.repository_operation.validate(operation)
|
|
1279
|
+
self._validate_launch_authority(operation, is_current)
|
|
1280
|
+
|
|
1281
|
+
def validate_launch() -> None:
|
|
1282
|
+
"""Compare only current authority when the queued removal launches."""
|
|
1283
|
+
self._validate_launch_authority(operation, is_current)
|
|
1284
|
+
run.started = True
|
|
1285
|
+
|
|
1286
|
+
yield GitCommand(["update-ref", "--no-deref", "--stdin", "-z"], stdin=replay_legacy.legacy_anchor_deletion_input(deletions), profile=LOCAL_WRITE_PROFILE,
|
|
1287
|
+
before_start=validate_launch)
|
|
1288
|
+
run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
|
|
1289
|
+
status = OperationStatus.SUCCESS
|
|
1290
|
+
except (GitCommandError, ValueError, OSError) as error:
|
|
1291
|
+
run.record_failure(operation, error)
|
|
1292
|
+
status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
|
|
1293
|
+
answer()
|
|
1294
|
+
step = OperationStepResult(operation.operation, status, process_status=run.process_status, effect="changed" if status == OperationStatus.SUCCESS else "unknown",
|
|
1295
|
+
diagnostic=run.diagnostic, return_code=run.return_code)
|
|
1296
|
+
return OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
|
|
1297
|
+
|
|
1298
|
+
def close(self) -> None:
|
|
1299
|
+
"""Stop publishing while scheduled filesystem and process work drains."""
|
|
1300
|
+
self.closed = True
|
|
1301
|
+
|
|
1302
|
+
def _publish_result(self, state: ReplaySnapshot) -> ReplaySnapshot:
|
|
1303
|
+
"""Publish the reconciled state an operation ends with, under a generation newer than any read still running.
|
|
1304
|
+
|
|
1305
|
+
The generation is remembered as an operation's publication: a read that was running meanwhile answers its callers
|
|
1306
|
+
with this state instead of its own result, and the replay controller may show it without reading it again.
|
|
1307
|
+
"""
|
|
1308
|
+
generation = max(state.generation, self.generation_by_key.get(state.key, 0)) + 1
|
|
1309
|
+
state = replace(self._joined_launch(state), generation=generation)
|
|
1310
|
+
self.generation_by_key[state.key] = generation
|
|
1311
|
+
self.published_generation_by_key[state.key] = generation
|
|
1312
|
+
self.state_by_key[state.key] = state
|
|
1313
|
+
self.changed.emit(state)
|
|
1314
|
+
return state
|
|
1315
|
+
|
|
1316
|
+
def _joined_launch(self, state: ReplaySnapshot) -> ReplaySnapshot:
|
|
1317
|
+
"""Mark a state as GitPane's own pause when it is the one launched here, and forget that launch once a fresh read shows no pause left."""
|
|
1318
|
+
if state.fresh and state.kind == ReplayKind.ABSENT:
|
|
1319
|
+
self.launch_by_key.pop(state.key, None)
|
|
1320
|
+
return state
|
|
1321
|
+
return join_replay_launch(state, self.launch_by_key.get(state.key))
|
|
1322
|
+
|
|
1323
|
+
def observe(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[ReplaySnapshot], None],
|
|
1324
|
+
priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
1325
|
+
"""Share one worktree read between concurrent callers and reject replaced requester sessions.
|
|
1326
|
+
|
|
1327
|
+
The priority is the caller's place in the process queue: a click by default, background for an automatic look.
|
|
1328
|
+
"""
|
|
1329
|
+
if self.closed:
|
|
1330
|
+
return
|
|
1331
|
+
key = WorktreeKey.from_context(context)
|
|
1332
|
+
if key in self.observers_by_key:
|
|
1333
|
+
# The read already running may have started before the change this caller wants to see (a native pause that appeared in between).
|
|
1334
|
+
# Answering from it could report a state that no longer exists, so late callers share one read started after it ends,
|
|
1335
|
+
# unless an operation publishes its verified result first: that result is newer than the request and answers it.
|
|
1336
|
+
self.waiting_observers_by_key.setdefault(key, []).append((self.generation_by_key.get(key, 0), is_current, callback, priority))
|
|
1337
|
+
return
|
|
1338
|
+
self._start_read(context, git_filename, [(is_current, callback, priority)])
|
|
1339
|
+
|
|
1340
|
+
def observe_passive(self, context: RepositoryContext, git_filename: str, source: PassiveObservationSource, *, is_current: Callable[[], bool],
|
|
1341
|
+
callback: Callable[[ReplaySnapshot], None], priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
1342
|
+
"""Follow a cherry-pick or revert state for the banner with what the automatic refresh has just read; only that refresh calls it.
|
|
1343
|
+
|
|
1344
|
+
A request that has to wait for a read already running is answered by a complete read through Git, at the same priority.
|
|
1345
|
+
"""
|
|
1346
|
+
if self.closed:
|
|
1347
|
+
return
|
|
1348
|
+
if WorktreeKey.from_context(context) in self.observers_by_key:
|
|
1349
|
+
# The facts in hand may be older than the change this caller waits for, and a waiting request is never kept
|
|
1350
|
+
# with them: it becomes a request through Git.
|
|
1351
|
+
self.observe(context, git_filename, is_current=is_current, callback=callback, priority=priority)
|
|
1352
|
+
return
|
|
1353
|
+
self._start_read(context, git_filename, [(is_current, callback, priority)], source)
|
|
1354
|
+
|
|
1355
|
+
def _start_read(self, context: RepositoryContext, git_filename: str, observers: list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]],
|
|
1356
|
+
source: PassiveObservationSource | None = None) -> None:
|
|
1357
|
+
"""Run one native state read for these callers, then one more for the callers that arrived while it ran.
|
|
1358
|
+
|
|
1359
|
+
An operation that publishes its verified result while this read runs answers every caller who asked before that
|
|
1360
|
+
publication, this read's callers included, with the published state; this read's own result or error is then
|
|
1361
|
+
dropped. Only callers who asked after the publication need a new read, and they share one.
|
|
1362
|
+
"""
|
|
1363
|
+
key = WorktreeKey.from_context(context)
|
|
1364
|
+
self.observers_by_key[key] = observers
|
|
1365
|
+
generation = self.generation_by_key.get(key, 0) + 1
|
|
1366
|
+
self.generation_by_key[key] = generation
|
|
1367
|
+
|
|
1368
|
+
def complete(result: ReplaySnapshot | None, error: Exception | None) -> None:
|
|
1369
|
+
"""Publish this read first, so starting the follow-up read cannot mark it as superseded."""
|
|
1370
|
+
finished_observers = self.observers_by_key.pop(key, [])
|
|
1371
|
+
next_observers = []
|
|
1372
|
+
published_generation = self.published_generation_by_key.get(key, 0)
|
|
1373
|
+
for asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
|
|
1374
|
+
if published_generation > generation and asked_generation < published_generation:
|
|
1375
|
+
finished_observers.append((current, observer_callback, observer_priority))
|
|
1376
|
+
else:
|
|
1377
|
+
next_observers.append((current, observer_callback, observer_priority))
|
|
1378
|
+
try:
|
|
1379
|
+
# Callers of a superseded read that no publication answers still need an answer, so they join the follow-up read.
|
|
1380
|
+
next_observers = publish(finished_observers, result, error) + next_observers
|
|
1381
|
+
finally:
|
|
1382
|
+
current_observers = []
|
|
1383
|
+
for current, observer_callback, observer_priority in next_observers:
|
|
1384
|
+
if current():
|
|
1385
|
+
current_observers.append((current, observer_callback, observer_priority))
|
|
1386
|
+
running_observers = self.observers_by_key.get(key)
|
|
1387
|
+
if running_observers is not None:
|
|
1388
|
+
# A callback of this read already started the next one, after these callers asked, so it answers them too.
|
|
1389
|
+
running_observers.extend(current_observers)
|
|
1390
|
+
elif current_observers and not self.closed:
|
|
1391
|
+
# The follow-up read answers requests made after the previous read started: the facts in hand then no longer
|
|
1392
|
+
# hold, so it reads everything through Git.
|
|
1393
|
+
self._start_read(context, git_filename, current_observers)
|
|
1394
|
+
|
|
1395
|
+
def publish(observers: list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]], result: ReplaySnapshot | None,
|
|
1396
|
+
error: Exception | None) -> list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]:
|
|
1397
|
+
"""Keep errors and external disappearance explicit without claiming success; return the callers left without an answer."""
|
|
1398
|
+
callbacks = []
|
|
1399
|
+
for current, observer_callback, _observer_priority in observers:
|
|
1400
|
+
if current():
|
|
1401
|
+
callbacks.append(observer_callback)
|
|
1402
|
+
if self.closed or not callbacks:
|
|
1403
|
+
return []
|
|
1404
|
+
if self.generation_by_key.get(key) != generation:
|
|
1405
|
+
latest = self.state_by_key.get(key)
|
|
1406
|
+
if latest is not None and latest.generation == self.generation_by_key.get(key) == self.published_generation_by_key.get(key):
|
|
1407
|
+
# An operation published while this read was pending: its result is newer than every request here and
|
|
1408
|
+
# answers it as published, unknown included, while this read's own result or error is dropped.
|
|
1409
|
+
for observer_callback in callbacks:
|
|
1410
|
+
observer_callback(latest)
|
|
1411
|
+
return []
|
|
1412
|
+
return observers
|
|
1413
|
+
previous = self.state_by_key.get(key)
|
|
1414
|
+
if error is not None or result is None:
|
|
1415
|
+
result = unreadable_replay_state(previous or ReplaySnapshot(key, generation), "Cannot read the native replay state. Refresh to try again.", generation=generation)
|
|
1416
|
+
else:
|
|
1417
|
+
result = self._joined_launch(result)
|
|
1418
|
+
if previous is not None and previous.identity is not None:
|
|
1419
|
+
if result.kind == ReplayKind.ABSENT:
|
|
1420
|
+
result = replace(result, diagnostic="Native commit operation disappeared externally. Refresh to inspect the result.", pending_step="disappeared")
|
|
1421
|
+
elif previous.identity != result.identity or previous.head_oid != result.head_oid:
|
|
1422
|
+
result = replace(result, diagnostic="Native commit operation changed externally. " + result.diagnostic)
|
|
1423
|
+
self.state_by_key[key] = result
|
|
1424
|
+
try:
|
|
1425
|
+
# The callers who asked for this read hear its answer before it is announced to everyone: a request that
|
|
1426
|
+
# asked for it (an automatic Continue waiting for its qualifying read) is answered by this very read
|
|
1427
|
+
# before anything reacting to the announcement acts on the same state.
|
|
1428
|
+
for observer_callback in callbacks:
|
|
1429
|
+
observer_callback(result)
|
|
1430
|
+
finally:
|
|
1431
|
+
self.changed.emit(result)
|
|
1432
|
+
return []
|
|
1433
|
+
|
|
1434
|
+
reading = capture_replay(context, generation) if source is None else capture_replay_passive(context, generation, source)
|
|
1435
|
+
self.worktree_service.run(context, git_filename, reading, complete, priority=observation_priority(observers))
|