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,747 @@
|
|
|
1
|
+
"""Read the native evidence of a cherry-pick or revert without resuming Git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Self
|
|
12
|
+
|
|
13
|
+
from gitpane.config import DETACHED_HEAD_NAME, GIT_REFTABLE_DIR_BASENAME, LOCAL_PREFIX, MERGE_HEAD_BASENAME, REFERENCE_ROOT_PREFIX, REPLAY_CAPTURE_TIMEOUT_MS, REPLAY_FILE_LIMIT_BYTES, REPLAY_INSTRUCTION_LIMIT, REPLAY_TOTAL_LIMIT_BYTES
|
|
14
|
+
from gitpane.config import REPLAY_REFRESH_INTERVAL_MS as REPLAY_REFRESH_INTERVAL_MS # noqa: PLC0414
|
|
15
|
+
from gitpane.history.repository_value import COMMIT_PEEL_SUFFIX, validate_oid
|
|
16
|
+
from gitpane.process.process_runner import READ_PROFILE, ProcessStatus
|
|
17
|
+
from gitpane.sequencer.rebase_state import checked_metadata, metadata_signature
|
|
18
|
+
from gitpane.system.path_identity import canonical_path
|
|
19
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
|
|
20
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, read_validated_size
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
|
|
25
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
26
|
+
from gitpane.worktree.worktree_snapshot import PassiveObservationSource
|
|
27
|
+
|
|
28
|
+
MERGE_MESSAGE_BASENAME = "MERGE_MSG"
|
|
29
|
+
CHERRY_PICK_HEAD_BASENAME = "CHERRY_PICK_HEAD"
|
|
30
|
+
REVERT_HEAD_BASENAME = "REVERT_HEAD"
|
|
31
|
+
REPLAY_BASENAMES = (CHERRY_PICK_HEAD_BASENAME, REVERT_HEAD_BASENAME, MERGE_MESSAGE_BASENAME, "HEAD", MERGE_HEAD_BASENAME)
|
|
32
|
+
HEAD_BASENAME = "HEAD"
|
|
33
|
+
# The index Git keeps for this worktree, in its own Git directory.
|
|
34
|
+
LIVE_INDEX_BASENAME = "index"
|
|
35
|
+
SEQUENCER_BASENAME = "sequencer"
|
|
36
|
+
# Every native file and folder a replay capture reads, whose place Git decides.
|
|
37
|
+
REPLAY_NATIVE_PATH_BASENAMES = (*REPLAY_BASENAMES, SEQUENCER_BASENAME)
|
|
38
|
+
# Names of the answers a read of the replay facts gives besides the native paths; none of them is a native file name.
|
|
39
|
+
GIT_DIR_ANSWER_NAME = "git-dir"
|
|
40
|
+
SOURCE_ANSWER_NAME = "source"
|
|
41
|
+
HEAD_ANSWER_NAME = "head-oid"
|
|
42
|
+
BRANCH_ANSWER_NAME = "branch"
|
|
43
|
+
# Asked for the full name of a detached HEAD, rev-parse answers with the word HEAD itself.
|
|
44
|
+
DETACHED_HEAD_ANSWER = DETACHED_HEAD_NAME.encode("ascii")
|
|
45
|
+
# Reasons of greyed Continue and Abort buttons, shared by the Cherry-pick/Revert banner and the merge banner.
|
|
46
|
+
BUSY_ACTION_REASON = "Wait for the current operation or merge tool to finish."
|
|
47
|
+
RESOLVE_CONFLICTS_FIRST_REASON = "Resolve and stage all conflicts before continuing."
|
|
48
|
+
PRIVATE_INDEX_VARIABLE = "GIT_INDEX_FILE"
|
|
49
|
+
# rev-parse ends with this status when it cannot resolve a revision it was given, a HEAD without commit included.
|
|
50
|
+
# Whatever it printed before stopping is partial and is never used.
|
|
51
|
+
UNRESOLVED_REVISION_RETURN_CODE = 128
|
|
52
|
+
# Where the grouped answer and the unmerged listing sit in the batch that reads them together.
|
|
53
|
+
GROUPED_ANSWER_POSITION = 0
|
|
54
|
+
UNMERGED_ANSWER_POSITION = 1
|
|
55
|
+
# Splitting an answer whose every line ends with a newline leaves one empty piece after the last line.
|
|
56
|
+
TRAILING_EMPTY_PIECE_COUNT = 1
|
|
57
|
+
REPLAY_SEQUENCER_BASENAMES = ("head", "todo", "opts", "abort-safety")
|
|
58
|
+
# The option Git writes in its sequencer options file when a series was started without a commit.
|
|
59
|
+
SEQUENCER_NO_COMMIT_OPTION = "no-commit"
|
|
60
|
+
# Name of a commit operation whose kind is unknown or gone, also the title of the messages a Cherry-pick or Revert leaves.
|
|
61
|
+
COMMIT_OPERATION_LABEL = "Commit operation"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReplayKind(Enum):
|
|
65
|
+
"""What a commit operation of a worktree is: a Cherry-pick, a Revert, an operation whose kind cannot be told, or none.
|
|
66
|
+
|
|
67
|
+
The value of Cherry-pick and Revert is the Git subcommand that runs them, given to Git by ``.value``. Each kind
|
|
68
|
+
carries the name the banner shows and, for Cherry-pick and Revert, what Git does with a terminal ``--no-commit``
|
|
69
|
+
series paused after a conflict: Git refuses ``git cherry-pick --continue`` there, since the resolved changes stay
|
|
70
|
+
staged and the next pick would overwrite them, so only quitting or aborting ends it; ``git revert --continue`` does go
|
|
71
|
+
on, but it commits the resolved revert, which the ``--no-commit`` choice did not ask for.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
CHERRY_PICK = ("cherry-pick", "Cherry-pick",
|
|
75
|
+
"Git cannot continue a --no-commit series after a conflict. Run git cherry-pick --quit to keep the result or git cherry-pick --abort to undo.")
|
|
76
|
+
REVERT = ("revert", "Revert",
|
|
77
|
+
"Git continues a --no-commit revert series by committing the resolved revert. Run git revert --continue to accept that, git revert --quit to keep the result uncommitted or git revert --abort to undo.")
|
|
78
|
+
UNKNOWN = ("unknown", COMMIT_OPERATION_LABEL, "")
|
|
79
|
+
ABSENT = ("absent", COMMIT_OPERATION_LABEL, "")
|
|
80
|
+
|
|
81
|
+
def __new__(cls, value: str, label: str, no_commit_series_message: str) -> Self:
|
|
82
|
+
"""Keep the Git subcommand (or the state's name) as the value, the banner name and the series message beside it."""
|
|
83
|
+
member = object.__new__(cls)
|
|
84
|
+
member._value_ = value
|
|
85
|
+
member.label = label
|
|
86
|
+
member.no_commit_series_message = no_commit_series_message
|
|
87
|
+
return member
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_replay(self) -> bool:
|
|
91
|
+
"""Tell whether this kind is one of the two operations, Cherry-pick or Revert, rather than an unknown or absent one."""
|
|
92
|
+
return self in (ReplayKind.CHERRY_PICK, ReplayKind.REVERT)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# The kind of each instruction Git writes in its sequencer todo file, keyed by the action word read there.
|
|
96
|
+
REPLAY_KIND_BY_ACTION = {"pick": ReplayKind.CHERRY_PICK, "revert": ReplayKind.REVERT}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ReplayOwnership(Enum):
|
|
100
|
+
"""Who started a pause: GitPane in this run of the program, or anything else, a terminal or an earlier run included."""
|
|
101
|
+
|
|
102
|
+
OWNED = "owned"
|
|
103
|
+
EXTERNAL = "external"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ReplayCommitMode(Enum):
|
|
107
|
+
"""Whether a pause ends with a commit, without one, or in a mode Git's files leave open."""
|
|
108
|
+
|
|
109
|
+
COMMIT = "commit"
|
|
110
|
+
NO_COMMIT = "no-commit"
|
|
111
|
+
UNKNOWN = "unknown"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ReplayAction(Enum):
|
|
115
|
+
"""One of the three buttons of the Cherry-pick/Revert banner, whose availability replay_action_diagnostic decides."""
|
|
116
|
+
|
|
117
|
+
RESOLVE = "resolve"
|
|
118
|
+
CONTINUE = "continue"
|
|
119
|
+
ABORT = "abort"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ReplayEffect(Enum):
|
|
123
|
+
"""Why a Cherry-pick or Revert ended without a commit, as its operation step reports it, and the sentence the user reads for it.
|
|
124
|
+
|
|
125
|
+
The value is what the step writes in its free-text effect, a field other operations fill with their own words.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
ALREADY_PRESENT = ("already_present", "No commit was created because the cherry-picked changes are already present in the current checkout.")
|
|
129
|
+
INITIALLY_EMPTY = ("initially_empty", "No commit was created because the selected commit is empty.")
|
|
130
|
+
REVERT_NO_EFFECT = ("revert_no_effect", "No commit was created because reverting the selected commit makes no changes.")
|
|
131
|
+
RESOLVED_NO_CHANGE = ("resolved_no_change", "No commit was created because the resolution leaves no changes.")
|
|
132
|
+
|
|
133
|
+
def __new__(cls, value: str, message: str) -> Self:
|
|
134
|
+
"""Keep the word the step writes as the value, and the sentence shown for it beside it."""
|
|
135
|
+
member = object.__new__(cls)
|
|
136
|
+
member._value_ = value
|
|
137
|
+
member.message = message
|
|
138
|
+
return member
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def replay_effect_message(effect: str) -> str:
|
|
142
|
+
"""Answer the sentence shown for the effect a step wrote, or "" for an effect that is not a replay's end without a commit.
|
|
143
|
+
|
|
144
|
+
The effect field of a step is free text shared with other operations, so an effect another operation wrote is no error.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
return ReplayEffect(effect).message
|
|
148
|
+
except ValueError:
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# A pause started in a terminal whose files do not tell whether it commits: Git's Continue could commit the whole index, and
|
|
153
|
+
# its Abort could drop changes staged for a commit mode GitPane cannot see.
|
|
154
|
+
REPLAY_EXTERNAL_UNKNOWN_MODE_MESSAGE = "The commit mode of this operation is unknown, so Continue or Abort could commit or drop staged work. Finish it in the terminal."
|
|
155
|
+
# Unmerged entries (or a lone prepared message) that no replay marker or sequencer explains.
|
|
156
|
+
REPLAY_STEP_UNATTRIBUTED_CONFLICT = "unattributed-conflict"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class ReplayIdentity:
|
|
161
|
+
"""Bind native instructions to their exact files and original checkout."""
|
|
162
|
+
|
|
163
|
+
kind: ReplayKind
|
|
164
|
+
original_oid: str
|
|
165
|
+
symbolic_ref: str | None
|
|
166
|
+
signatures: tuple[tuple[int, ...] | None, ...]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class ReplayLaunch:
|
|
171
|
+
"""What GitPane launched in one worktree during this run of the program: the source, the choices of the form and the checkout it started from.
|
|
172
|
+
|
|
173
|
+
It is kept in memory only, never written anywhere. After a restart the same pause is read from Git's own files
|
|
174
|
+
alone, as a pause started outside GitPane.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
kind: ReplayKind
|
|
178
|
+
source_oid: str
|
|
179
|
+
create_commit: bool
|
|
180
|
+
add_reference: bool
|
|
181
|
+
original_head_oid: str
|
|
182
|
+
original_symbolic_ref: str | None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class ReplaySnapshot:
|
|
187
|
+
"""Expose only proven native facts; ambiguous commit modes stay unknown."""
|
|
188
|
+
|
|
189
|
+
key: WorktreeKey
|
|
190
|
+
generation: int
|
|
191
|
+
kind: ReplayKind = ReplayKind.ABSENT
|
|
192
|
+
ownership: ReplayOwnership = ReplayOwnership.EXTERNAL
|
|
193
|
+
identity: ReplayIdentity | None = None
|
|
194
|
+
head_oid: str | None = None
|
|
195
|
+
symbolic_ref: str | None = None
|
|
196
|
+
mode: ReplayCommitMode = ReplayCommitMode.UNKNOWN
|
|
197
|
+
instruction_oids: tuple[str, ...] = ()
|
|
198
|
+
unmerged_paths: tuple[bytes, ...] = ()
|
|
199
|
+
fresh: bool = True
|
|
200
|
+
diagnostic: str = ""
|
|
201
|
+
pending_step: str = ""
|
|
202
|
+
# What GitPane itself launched in this worktree when this pause is one of its own; see ReplayLaunch.
|
|
203
|
+
launch: ReplayLaunch | None = None
|
|
204
|
+
# True only for unmerged entries that come with no replay marker, sequencer or prepared message (a conflicting stash pop leaves exactly that).
|
|
205
|
+
# The default stays False and every state rebuilt after a failed read goes through unreadable_replay_state, which drops it:
|
|
206
|
+
# only the successful read that proved the absence carries it, and replay_may_be_present also checks the state's shape.
|
|
207
|
+
replay_evidence_absent: bool = False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def replay_may_be_present(state: ReplaySnapshot | None) -> bool:
|
|
211
|
+
"""Tell whether the rules reserved for Cherry-pick and Revert apply to this worktree.
|
|
212
|
+
|
|
213
|
+
A real or merely possible replay keeps its session open until its merge tool or command has published, and locks conflict writes from every window while one runs.
|
|
214
|
+
A pause GitPane launched in this run or a native identity counts whatever Git shows now, even nothing. Without either, a worktree where Git shows no operation is
|
|
215
|
+
released, and so is a conflict proven to carry no replay trace at all: it keeps the behavior it had before replays existed.
|
|
216
|
+
"""
|
|
217
|
+
if state is None:
|
|
218
|
+
return False
|
|
219
|
+
if state.identity is not None or state.launch is not None:
|
|
220
|
+
return True
|
|
221
|
+
if state.kind == ReplayKind.ABSENT:
|
|
222
|
+
return False
|
|
223
|
+
# Released only on the exact positive proof: the unattributed conflict itself, read successfully, with no trace and nothing saved.
|
|
224
|
+
# The flag alone is not enough, because it can be copied into a state of another shape that no read ever proved.
|
|
225
|
+
ordinary_conflict = state.replay_evidence_absent and state.kind == ReplayKind.UNKNOWN and state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT and bool(state.unmerged_paths)
|
|
226
|
+
return not ordinary_conflict
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def unreadable_replay_state(state: ReplaySnapshot, diagnostic: str, **changes: object) -> ReplaySnapshot:
|
|
230
|
+
"""Build the state published after a failed, partial or contradictory read, from the last state that was known.
|
|
231
|
+
|
|
232
|
+
The proof that a conflict carries no replay trace belongs to the successful read that produced it. It is dropped here, so a state
|
|
233
|
+
built from an error or a doubt always counts as a possible replay instead of inheriting an older answer.
|
|
234
|
+
"""
|
|
235
|
+
return replace(state, kind=ReplayKind.UNKNOWN, fresh=False, replay_evidence_absent=False, diagnostic=diagnostic, **changes)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def replay_action_diagnostic(state: ReplaySnapshot, action: ReplayAction, *, busy: bool = False, abort_available: bool = False) -> str:
|
|
239
|
+
"""Tell why Resolve, Continue or Abort is unavailable for this pause, or answer "" when Git's own command may run.
|
|
240
|
+
|
|
241
|
+
A pause GitPane launched in this run and a pause started in a terminal in commit mode end with Git's own Continue
|
|
242
|
+
and Abort, the way a terminal ends them. Continue waits until no conflict is left. A terminal `--no-commit` series
|
|
243
|
+
and a terminal pause whose commit mode Git's files leave open keep both disabled, with the reason: there Git's
|
|
244
|
+
Continue could commit the whole index and its Abort could drop staged work.
|
|
245
|
+
"""
|
|
246
|
+
if busy:
|
|
247
|
+
return BUSY_ACTION_REASON
|
|
248
|
+
if action == ReplayAction.RESOLVE and state.unmerged_paths and (state.fresh or state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT):
|
|
249
|
+
return ""
|
|
250
|
+
if not state.fresh or not state.kind.is_replay:
|
|
251
|
+
return state.diagnostic or "Refresh the current commit operation before continuing."
|
|
252
|
+
if action == ReplayAction.RESOLVE:
|
|
253
|
+
return "There are no unresolved merge conflicts."
|
|
254
|
+
if state.ownership == ReplayOwnership.OWNED and state.launch is not None and state.identity is not None:
|
|
255
|
+
if action == ReplayAction.ABORT:
|
|
256
|
+
return "" if abort_available else "Git's own Abort is not available for this operation."
|
|
257
|
+
return RESOLVE_CONFLICTS_FIRST_REASON if state.unmerged_paths else ""
|
|
258
|
+
if state.ownership == ReplayOwnership.EXTERNAL and state.identity is not None:
|
|
259
|
+
series_message = no_commit_series_message(state)
|
|
260
|
+
if series_message:
|
|
261
|
+
return series_message
|
|
262
|
+
if state.mode != ReplayCommitMode.COMMIT:
|
|
263
|
+
return REPLAY_EXTERNAL_UNKNOWN_MODE_MESSAGE
|
|
264
|
+
if action == ReplayAction.ABORT:
|
|
265
|
+
return ""
|
|
266
|
+
return RESOLVE_CONFLICTS_FIRST_REASON if state.unmerged_paths else ""
|
|
267
|
+
return "The identity of this operation is unavailable. Refresh to inspect the native operation."
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def no_commit_series_message(state: ReplaySnapshot) -> str:
|
|
271
|
+
"""Explain what Git does with a terminal `--no-commit` series paused after a conflict and how to end it, or return "" for any other pause.
|
|
272
|
+
|
|
273
|
+
The no-commit mode of an external operation is only ever read from Git's sequencer options, so an external pause in that
|
|
274
|
+
mode is always such a series with instructions left.
|
|
275
|
+
"""
|
|
276
|
+
if state.ownership != ReplayOwnership.EXTERNAL or state.identity is None or state.mode != ReplayCommitMode.NO_COMMIT:
|
|
277
|
+
return ""
|
|
278
|
+
return state.kind.no_commit_series_message
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def replay_auto_continue_ready(state: ReplaySnapshot) -> bool:
|
|
282
|
+
"""Tell whether a pause may be continued without a click once its last conflict was solved in GitPane.
|
|
283
|
+
|
|
284
|
+
Exactly the pauses the banner Continue accepts qualify: a fresh read of a Cherry-pick or Revert with its native
|
|
285
|
+
identity, no conflict left and a known commit mode. That is a pause GitPane started in this run, which keeps the
|
|
286
|
+
choice made in the form, or a pause started in a terminal whose commit mode Git's own files establish. An unknown
|
|
287
|
+
mode, owner or state keeps waiting for the user's own Continue.
|
|
288
|
+
"""
|
|
289
|
+
if not state.fresh or not state.kind.is_replay or state.identity is None or state.unmerged_paths:
|
|
290
|
+
return False
|
|
291
|
+
if state.ownership == ReplayOwnership.OWNED and (state.launch is None or state.mode not in (ReplayCommitMode.COMMIT, ReplayCommitMode.NO_COMMIT)):
|
|
292
|
+
return False
|
|
293
|
+
return not replay_action_diagnostic(state, ReplayAction.CONTINUE, abort_available=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def join_replay_launch(snapshot: ReplaySnapshot, launch: ReplayLaunch | None) -> ReplaySnapshot:
|
|
297
|
+
"""Mark a native pause as GitPane's own when it is exactly the one GitPane launched in this worktree during this run.
|
|
298
|
+
|
|
299
|
+
The pause must be a fresh read of the same kind, holding one instruction whose object ID is a prefix of the
|
|
300
|
+
launched source, on the same HEAD and branch the launch started from. Its commit mode is then the one chosen in
|
|
301
|
+
the form. Any other state is answered as it was read.
|
|
302
|
+
"""
|
|
303
|
+
if launch is None or not snapshot.fresh or snapshot.identity is None or snapshot.ownership != ReplayOwnership.EXTERNAL:
|
|
304
|
+
return snapshot
|
|
305
|
+
if (snapshot.kind != launch.kind or len(snapshot.instruction_oids) != 1 or not launch.source_oid.startswith(next(iter(snapshot.instruction_oids)))
|
|
306
|
+
or snapshot.head_oid != launch.original_head_oid or snapshot.symbolic_ref != launch.original_symbolic_ref):
|
|
307
|
+
return snapshot
|
|
308
|
+
return replace(snapshot, ownership=ReplayOwnership.OWNED, mode=ReplayCommitMode.COMMIT if launch.create_commit else ReplayCommitMode.NO_COMMIT, launch=launch)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class ReplayMetadata:
|
|
313
|
+
"""Keep bounded bytes and file signatures for capture and write revalidation."""
|
|
314
|
+
|
|
315
|
+
content_by_basename: dict[str, str]
|
|
316
|
+
signatures: tuple[tuple[int, ...] | None, ...]
|
|
317
|
+
sequencer: bool
|
|
318
|
+
head_reference_signatures: tuple[tuple[int, ...] | None, ...] = ()
|
|
319
|
+
|
|
320
|
+
def present(self) -> bool:
|
|
321
|
+
"""Detect native replay evidence even when a marker is empty or corrupt."""
|
|
322
|
+
cherry_index = REPLAY_BASENAMES.index("CHERRY_PICK_HEAD")
|
|
323
|
+
revert_index = REPLAY_BASENAMES.index("REVERT_HEAD")
|
|
324
|
+
return self.sequencer or self.signatures[cherry_index] is not None or self.signatures[revert_index] is not None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_replay_metadata(git_dir: str) -> ReplayMetadata:
|
|
328
|
+
"""Reject linked or changing metadata and bound every filesystem read."""
|
|
329
|
+
started = time.monotonic()
|
|
330
|
+
content_by_basename = {}
|
|
331
|
+
signatures = []
|
|
332
|
+
total_bytes = 0
|
|
333
|
+
sequencer_dir = os.path.join(git_dir, SEQUENCER_BASENAME)
|
|
334
|
+
try:
|
|
335
|
+
sequencer_metadata = checked_metadata(sequencer_dir, directory=True)
|
|
336
|
+
except FileNotFoundError:
|
|
337
|
+
sequencer_metadata = None
|
|
338
|
+
filenames = []
|
|
339
|
+
for basename in REPLAY_BASENAMES:
|
|
340
|
+
filenames.append((basename, os.path.join(git_dir, basename)))
|
|
341
|
+
if sequencer_metadata is not None:
|
|
342
|
+
for basename in REPLAY_SEQUENCER_BASENAMES:
|
|
343
|
+
filenames.append((basename, os.path.join(sequencer_dir, basename)))
|
|
344
|
+
for basename, filename in filenames:
|
|
345
|
+
try:
|
|
346
|
+
before = checked_metadata(filename)
|
|
347
|
+
except FileNotFoundError:
|
|
348
|
+
content_by_basename[basename] = ""
|
|
349
|
+
signatures.append(None)
|
|
350
|
+
continue
|
|
351
|
+
if before.st_size > REPLAY_FILE_LIMIT_BYTES:
|
|
352
|
+
raise ValueError("Native replay metadata exceeds the file limit. Refresh after correcting it externally.")
|
|
353
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
354
|
+
with os.fdopen(os.open(filename, flags), "rb") as stream:
|
|
355
|
+
opened = os.fstat(stream.fileno())
|
|
356
|
+
# Windows descriptor and path stat disagree about ctime; compare it only within the same API.
|
|
357
|
+
if metadata_signature(opened, include_change_time=False) != metadata_signature(before, include_change_time=False):
|
|
358
|
+
raise ValueError("Native replay metadata changed before reading.")
|
|
359
|
+
if metadata_signature(checked_metadata(filename)) != metadata_signature(before):
|
|
360
|
+
raise ValueError("Native replay metadata was replaced before reading.")
|
|
361
|
+
if sequencer_metadata is not None and metadata_signature(checked_metadata(sequencer_dir, directory=True)) != metadata_signature(sequencer_metadata):
|
|
362
|
+
raise ValueError("Native replay directory changed before reading.")
|
|
363
|
+
# The size is the one of the descriptor whose identity was just proven, and it is within the limit.
|
|
364
|
+
content = read_validated_size(stream, opened.st_size)
|
|
365
|
+
if metadata_signature(os.fstat(stream.fileno())) != metadata_signature(opened):
|
|
366
|
+
raise ValueError("Native replay metadata changed during reading.")
|
|
367
|
+
if len(content) != opened.st_size:
|
|
368
|
+
raise ValueError("Native replay metadata changed during reading.")
|
|
369
|
+
if metadata_signature(checked_metadata(filename)) != metadata_signature(before):
|
|
370
|
+
raise ValueError("Native replay metadata was replaced during reading.")
|
|
371
|
+
total_bytes += len(content)
|
|
372
|
+
if len(content) > REPLAY_FILE_LIMIT_BYTES or total_bytes > REPLAY_TOTAL_LIMIT_BYTES or (time.monotonic() - started) * 1000 > REPLAY_CAPTURE_TIMEOUT_MS:
|
|
373
|
+
raise ValueError("Native replay metadata exceeds the read budget.")
|
|
374
|
+
try:
|
|
375
|
+
content_by_basename[basename] = content.decode("utf-8")
|
|
376
|
+
except UnicodeDecodeError as error:
|
|
377
|
+
raise ValueError("Native replay metadata has invalid text encoding.") from error
|
|
378
|
+
signatures.append(metadata_signature(before))
|
|
379
|
+
if sequencer_metadata is not None:
|
|
380
|
+
if metadata_signature(checked_metadata(sequencer_dir, directory=True)) != metadata_signature(sequencer_metadata):
|
|
381
|
+
raise ValueError("Native replay directory changed during reading.")
|
|
382
|
+
signatures.append(metadata_signature(sequencer_metadata))
|
|
383
|
+
elif os.path.lexists(sequencer_dir):
|
|
384
|
+
raise ValueError("Native replay directory appeared during reading.")
|
|
385
|
+
return ReplayMetadata(content_by_basename, tuple(signatures), sequencer_metadata is not None)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def replay_snapshot_from_metadata(key: WorktreeKey, generation: int, metadata: ReplayMetadata, *, head_oid: str | None = None,
|
|
389
|
+
symbolic_ref: str | None = None, unmerged_paths: tuple[bytes, ...] = ()) -> ReplaySnapshot:
|
|
390
|
+
"""Classify marker and sequencer evidence without guessing a lost user intention."""
|
|
391
|
+
content_by_basename = metadata.content_by_basename
|
|
392
|
+
head_text = content_by_basename.get("HEAD", "").strip()
|
|
393
|
+
if symbolic_ref is None and head_text.startswith("ref: "):
|
|
394
|
+
symbolic_ref = head_text.removeprefix("ref: ")
|
|
395
|
+
if not metadata.present():
|
|
396
|
+
return ReplaySnapshot(key, generation, head_oid=head_oid, symbolic_ref=symbolic_ref, unmerged_paths=unmerged_paths)
|
|
397
|
+
kind = ReplayKind.UNKNOWN
|
|
398
|
+
mode = ReplayCommitMode.UNKNOWN
|
|
399
|
+
instruction_oids = []
|
|
400
|
+
original_oid = content_by_basename.get("head", "").strip()
|
|
401
|
+
try:
|
|
402
|
+
if symbolic_ref is not None and (not symbolic_ref.startswith(LOCAL_PREFIX) or any(part in ("", ".", "..") for part in symbolic_ref.split("/")) or "\\" in symbolic_ref):
|
|
403
|
+
raise ValueError("Native replay HEAD reference is malformed.")
|
|
404
|
+
kind_set = set()
|
|
405
|
+
for basename, marker_kind in (("CHERRY_PICK_HEAD", ReplayKind.CHERRY_PICK), ("REVERT_HEAD", ReplayKind.REVERT)):
|
|
406
|
+
marker_index = REPLAY_BASENAMES.index(basename)
|
|
407
|
+
if metadata.signatures[marker_index] is None:
|
|
408
|
+
continue
|
|
409
|
+
marker_oid = content_by_basename[basename].strip()
|
|
410
|
+
if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", marker_oid) is None:
|
|
411
|
+
raise ValueError("Native replay marker is malformed.")
|
|
412
|
+
kind_set.add(marker_kind)
|
|
413
|
+
instruction_oids.append(marker_oid)
|
|
414
|
+
if metadata.sequencer:
|
|
415
|
+
if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", original_oid) is None:
|
|
416
|
+
raise ValueError("Native replay origin is missing or malformed.")
|
|
417
|
+
instruction_oids = []
|
|
418
|
+
for line in content_by_basename.get("todo", "").splitlines():
|
|
419
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
420
|
+
continue
|
|
421
|
+
action_index, oid_index, maximum_split = 0, 1, 2
|
|
422
|
+
parts = line.split(maxsplit=maximum_split)
|
|
423
|
+
if len(parts) <= oid_index or parts[action_index] not in REPLAY_KIND_BY_ACTION or re.fullmatch(r"[0-9a-f]{4,64}", parts[oid_index]) is None:
|
|
424
|
+
raise ValueError("Native replay instructions are malformed or unsupported.")
|
|
425
|
+
kind_set.add(REPLAY_KIND_BY_ACTION[parts[action_index]])
|
|
426
|
+
instruction_oids.append(parts[oid_index])
|
|
427
|
+
if len(instruction_oids) > REPLAY_INSTRUCTION_LIMIT:
|
|
428
|
+
raise ValueError("Native replay instructions exceed the supported limit.")
|
|
429
|
+
if not instruction_oids:
|
|
430
|
+
raise ValueError("Native replay instructions are missing.")
|
|
431
|
+
mode = ReplayCommitMode.COMMIT
|
|
432
|
+
option_text = content_by_basename.get("opts", "")
|
|
433
|
+
if option_text:
|
|
434
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
435
|
+
parser.read_string(option_text)
|
|
436
|
+
if parser.sections() != ["options"]:
|
|
437
|
+
raise ValueError("Native replay options are malformed.")
|
|
438
|
+
if parser.has_option("options", SEQUENCER_NO_COMMIT_OPTION):
|
|
439
|
+
mode = ReplayCommitMode.NO_COMMIT if parser.getboolean("options", SEQUENCER_NO_COMMIT_OPTION) else ReplayCommitMode.COMMIT
|
|
440
|
+
safety_oid = content_by_basename.get("abort-safety", "").strip()
|
|
441
|
+
if safety_oid and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", safety_oid) is None:
|
|
442
|
+
raise ValueError("Native replay abort safety is malformed.")
|
|
443
|
+
if len(kind_set) != 1:
|
|
444
|
+
raise ValueError("Native replay contains incompatible operation types.")
|
|
445
|
+
kind = next(iter(kind_set))
|
|
446
|
+
if not metadata.sequencer and kind == ReplayKind.CHERRY_PICK:
|
|
447
|
+
mode = ReplayCommitMode.COMMIT
|
|
448
|
+
identity = ReplayIdentity(kind, original_oid, symbolic_ref, metadata.signatures)
|
|
449
|
+
diagnostic = "Commit mode is unknown. Refresh or resolve conflicts; do not assume the original commit choice." if mode == ReplayCommitMode.UNKNOWN else ""
|
|
450
|
+
return ReplaySnapshot(key, generation, kind, ReplayOwnership.EXTERNAL, identity, head_oid, symbolic_ref, mode, tuple(instruction_oids), unmerged_paths, True, diagnostic,
|
|
451
|
+
"conflict" if unmerged_paths else "paused")
|
|
452
|
+
except (ValueError, configparser.Error) as error:
|
|
453
|
+
diagnostic = str(error) if str(error).startswith("Native replay") else "Native replay options are malformed."
|
|
454
|
+
return ReplaySnapshot(key, generation, ReplayKind.UNKNOWN, head_oid=head_oid, symbolic_ref=symbolic_ref, unmerged_paths=unmerged_paths, fresh=False, diagnostic=diagnostic)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def replay_head_reference_signatures(context: RepositoryContext, metadata: ReplayMetadata) -> tuple[tuple[int, ...] | None, ...]:
|
|
458
|
+
"""Detect a branch moving under an unchanged symbolic HEAD during queued preparation."""
|
|
459
|
+
head_text = metadata.content_by_basename.get("HEAD", "").strip()
|
|
460
|
+
if not head_text.startswith("ref: "):
|
|
461
|
+
return ()
|
|
462
|
+
full_ref = head_text.removeprefix("ref: ")
|
|
463
|
+
parts = full_ref.split("/")
|
|
464
|
+
if not full_ref.startswith(LOCAL_PREFIX) or any(part in ("", ".", "..") for part in parts) or "\\" in full_ref:
|
|
465
|
+
raise ValueError("Native replay HEAD reference is malformed.")
|
|
466
|
+
directory = context.common_git_dir
|
|
467
|
+
for part in parts[:-1]:
|
|
468
|
+
directory = os.path.join(directory, part)
|
|
469
|
+
try:
|
|
470
|
+
checked_metadata(directory, directory=True)
|
|
471
|
+
except FileNotFoundError:
|
|
472
|
+
break
|
|
473
|
+
signatures = []
|
|
474
|
+
for filename in (os.path.join(context.common_git_dir, *parts), os.path.join(context.common_git_dir, "packed-refs")):
|
|
475
|
+
try:
|
|
476
|
+
signatures.append(metadata_signature(checked_metadata(filename)))
|
|
477
|
+
except FileNotFoundError:
|
|
478
|
+
signatures.append(None)
|
|
479
|
+
return tuple(signatures)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _check_native_path(key: WorktreeKey, basename: str, filename: str) -> None:
|
|
483
|
+
"""Refuse a native file that is not the asked one in this worktree's own Git directory."""
|
|
484
|
+
if canonical_path(os.path.dirname(filename)) != key.git_dir or os.path.basename(filename) != basename:
|
|
485
|
+
raise ValueError("Native replay path does not belong to this worktree.")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@dataclass(frozen=True)
|
|
489
|
+
class ReplayFacts:
|
|
490
|
+
"""HEAD, the branch it names and, when asked, the unmerged entries of an index, read together in one round of Git processes.
|
|
491
|
+
|
|
492
|
+
A HEAD without any commit is None, and so is the branch of a detached HEAD; `unmerged_output` is None when the
|
|
493
|
+
unmerged entries were not asked for.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
head_oid: str | None
|
|
497
|
+
symbolic_ref: str | None
|
|
498
|
+
unmerged_output: bytes | None = None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class ReplayCheckoutMismatch(ValueError):
|
|
502
|
+
"""The Git directory or the source commit a launch was prepared for is not the one Git resolves now.
|
|
503
|
+
|
|
504
|
+
A capture never turns this into an unreadable state: the launch it checks is refused with this cause.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _unmerged_command(index_filename: str | None) -> GitCommand:
|
|
509
|
+
"""List the unmerged entries of the live index, or of a private one: only this listing is ever pointed at a private index."""
|
|
510
|
+
environment_by_name = {} if index_filename is None else {PRIVATE_INDEX_VARIABLE: index_filename}
|
|
511
|
+
return GitCommand(["ls-files", "--unmerged", "-z"], environment_by_name=environment_by_name)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _decode_oid(answer: bytes) -> str:
|
|
515
|
+
"""Accept one full object ID answered by Git, refusing anything else."""
|
|
516
|
+
try:
|
|
517
|
+
return validate_oid(answer.decode("ascii"))
|
|
518
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
519
|
+
raise ValueError("Native replay HEAD or source is malformed.") from error
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _decode_branch(answer: bytes) -> str | None:
|
|
523
|
+
"""Accept the full name of the branch HEAD points to, or nothing for a detached HEAD, refusing any other answer."""
|
|
524
|
+
if not answer:
|
|
525
|
+
return None
|
|
526
|
+
try:
|
|
527
|
+
branch = answer.decode("utf-8")
|
|
528
|
+
except UnicodeDecodeError as error:
|
|
529
|
+
raise ValueError("Native replay HEAD reference is malformed.") from error
|
|
530
|
+
if not branch.startswith(REFERENCE_ROOT_PREFIX):
|
|
531
|
+
raise ValueError("Native replay HEAD reference is malformed.")
|
|
532
|
+
return branch
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _checked_facts(key: WorktreeKey, answer_by_name: dict[str, bytes], source_oid: str | None, unmerged_output: bytes | None) -> ReplayFacts:
|
|
536
|
+
"""Check every answer of one read of the replay facts against what was asked, whichever way it was read.
|
|
537
|
+
|
|
538
|
+
The Git directory and each native path must be this worktree's own, the source must resolve to exactly the commit
|
|
539
|
+
that was selected, HEAD must be a full object ID when present and the branch a full reference name.
|
|
540
|
+
"""
|
|
541
|
+
git_dir_answer = answer_by_name.get(GIT_DIR_ANSWER_NAME)
|
|
542
|
+
if git_dir_answer is not None and canonical_path(os.fsdecode(git_dir_answer)) != key.git_dir:
|
|
543
|
+
raise ReplayCheckoutMismatch("The destination worktree changed. Refresh before applying a commit.")
|
|
544
|
+
for basename in REPLAY_NATIVE_PATH_BASENAMES:
|
|
545
|
+
path_answer = answer_by_name.get(basename)
|
|
546
|
+
if path_answer is not None:
|
|
547
|
+
_check_native_path(key, basename, os.fsdecode(path_answer))
|
|
548
|
+
if source_oid is not None and _decode_oid(answer_by_name[SOURCE_ANSWER_NAME]) != source_oid:
|
|
549
|
+
raise ReplayCheckoutMismatch("The selected commit is not the one Git resolves. Refresh before applying a commit.")
|
|
550
|
+
head_answer = answer_by_name[HEAD_ANSWER_NAME]
|
|
551
|
+
head_oid = _decode_oid(head_answer) if head_answer else None
|
|
552
|
+
return ReplayFacts(head_oid, _decode_branch(answer_by_name[BRANCH_ANSWER_NAME]), unmerged_output)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def read_replay_facts(context: RepositoryContext, *, include_paths: bool = False, source_oid: str | None = None, verify_git_dir: bool = False,
|
|
556
|
+
read_unmerged: bool = False, index_filename: str | None = None) -> Generator[GitCommand | ReadBatch, object, ReplayFacts]:
|
|
557
|
+
"""Read HEAD and its branch in one rev-parse, with the native paths, the source and the Git directory when asked.
|
|
558
|
+
|
|
559
|
+
Every Git process costs about 0.1 s on Windows, and this read runs twice in every capture an operation relies on,
|
|
560
|
+
so one process answers every question and the unmerged listing, when asked, runs beside it. Only a complete
|
|
561
|
+
answer, one line per question after a successful exit, is used. A HEAD without commit makes rev-parse fail with
|
|
562
|
+
partial text, and a folder whose name holds a newline gives more lines than questions: both are asked again one
|
|
563
|
+
question per process. A timeout, a cancellation or any other failure stays a failure and is never asked again.
|
|
564
|
+
Nothing read here is kept for a later capture.
|
|
565
|
+
"""
|
|
566
|
+
key = WorktreeKey.from_context(context)
|
|
567
|
+
if source_oid is not None:
|
|
568
|
+
validate_oid(source_oid)
|
|
569
|
+
arguments = ["rev-parse", "--path-format=absolute"]
|
|
570
|
+
answer_names = []
|
|
571
|
+
if verify_git_dir:
|
|
572
|
+
arguments.append("--absolute-git-dir")
|
|
573
|
+
answer_names.append(GIT_DIR_ANSWER_NAME)
|
|
574
|
+
if include_paths:
|
|
575
|
+
for basename in REPLAY_NATIVE_PATH_BASENAMES:
|
|
576
|
+
arguments.extend(["--git-path", basename])
|
|
577
|
+
answer_names.append(basename)
|
|
578
|
+
if source_oid is not None:
|
|
579
|
+
# No --end-of-options here: without --verify, rev-parse copies it into its answer. The object ID was validated above.
|
|
580
|
+
arguments.append(source_oid + COMMIT_PEEL_SUFFIX)
|
|
581
|
+
answer_names.append(SOURCE_ANSWER_NAME)
|
|
582
|
+
# rev-parse answers --symbolic-full-name HEAD with the branch HEAD points to, or with the word HEAD when it is detached.
|
|
583
|
+
arguments.extend(["HEAD", "--symbolic-full-name", "HEAD"])
|
|
584
|
+
answer_names.extend([HEAD_ANSWER_NAME, BRANCH_ANSWER_NAME])
|
|
585
|
+
commands = [GitCommand(arguments, profile=READ_PROFILE)]
|
|
586
|
+
if read_unmerged:
|
|
587
|
+
commands.append(_unmerged_command(index_filename))
|
|
588
|
+
try:
|
|
589
|
+
outputs = yield ReadBatch(tuple(commands))
|
|
590
|
+
except GitCommandError as error:
|
|
591
|
+
if error.process_status != ProcessStatus.FAILED or error.return_code != UNRESOLVED_REVISION_RETURN_CODE:
|
|
592
|
+
raise
|
|
593
|
+
return (yield from _read_replay_facts_separately(key, answer_names, source_oid, read_unmerged, index_filename))
|
|
594
|
+
grouped_output = outputs[GROUPED_ANSWER_POSITION]
|
|
595
|
+
lines = grouped_output.split(b"\n")
|
|
596
|
+
# A complete answer is one line per question, each ended by a newline, so the last piece of the split is empty.
|
|
597
|
+
if len(lines) != len(answer_names) + TRAILING_EMPTY_PIECE_COUNT or lines[-1]:
|
|
598
|
+
return (yield from _read_replay_facts_separately(key, answer_names, source_oid, read_unmerged, index_filename))
|
|
599
|
+
answer_by_name = dict(zip(answer_names, lines, strict=False))
|
|
600
|
+
if answer_by_name[BRANCH_ANSWER_NAME] == DETACHED_HEAD_ANSWER:
|
|
601
|
+
answer_by_name[BRANCH_ANSWER_NAME] = b""
|
|
602
|
+
unmerged_output = outputs[UNMERGED_ANSWER_POSITION] if read_unmerged else None
|
|
603
|
+
return _checked_facts(key, answer_by_name, source_oid, unmerged_output)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _read_replay_facts_separately(key: WorktreeKey, answer_names: list[str], source_oid: str | None, read_unmerged: bool,
|
|
607
|
+
index_filename: str | None) -> Generator[GitCommand | ReadBatch, object, ReplayFacts]:
|
|
608
|
+
"""Ask each question of a grouped read in its own process, when the grouped answer failed or cannot be split safely.
|
|
609
|
+
|
|
610
|
+
HEAD is read with --verify --quiet, so a HEAD without commit answers nothing, and the branch with symbolic-ref;
|
|
611
|
+
only the line ending Git adds is removed from each answer, never a space or a newline that belongs to a folder name.
|
|
612
|
+
"""
|
|
613
|
+
commands = []
|
|
614
|
+
for answer_name in answer_names:
|
|
615
|
+
if answer_name == GIT_DIR_ANSWER_NAME:
|
|
616
|
+
commands.append(GitCommand(["rev-parse", "--absolute-git-dir"]))
|
|
617
|
+
elif answer_name == SOURCE_ANSWER_NAME:
|
|
618
|
+
commands.append(GitCommand(["rev-parse", "--verify", "--end-of-options", source_oid + COMMIT_PEEL_SUFFIX], profile=READ_PROFILE))
|
|
619
|
+
elif answer_name == HEAD_ANSWER_NAME:
|
|
620
|
+
commands.append(GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1)))
|
|
621
|
+
elif answer_name == BRANCH_ANSWER_NAME:
|
|
622
|
+
commands.append(GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1)))
|
|
623
|
+
else:
|
|
624
|
+
commands.append(GitCommand(["rev-parse", "--path-format=absolute", "--git-path", answer_name], profile=READ_PROFILE))
|
|
625
|
+
if read_unmerged:
|
|
626
|
+
commands.append(_unmerged_command(index_filename))
|
|
627
|
+
outputs = yield ReadBatch(tuple(commands))
|
|
628
|
+
answer_by_name = {}
|
|
629
|
+
for answer_name, output in zip(answer_names, outputs, strict=False):
|
|
630
|
+
answer_by_name[answer_name] = output.removesuffix(b"\n")
|
|
631
|
+
unmerged_output = outputs[len(answer_names)] if read_unmerged else None
|
|
632
|
+
return _checked_facts(key, answer_by_name, source_oid, unmerged_output)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _unreadable_capture(key: WorktreeKey, generation: int, error: Exception) -> ReplaySnapshot:
|
|
636
|
+
"""Describe a capture that could not safely read the native state, keeping the native cause when there is one."""
|
|
637
|
+
diagnostic = str(error) if isinstance(error, ValueError) and str(error).startswith("Native replay") else "Cannot safely read the native replay state. Refresh to try again."
|
|
638
|
+
return ReplaySnapshot(key, generation, ReplayKind.UNKNOWN, fresh=False, diagnostic=diagnostic)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _finish_replay_capture(context: RepositoryContext, key: WorktreeKey, generation: int, metadata: ReplayMetadata, head_oid: str | None,
|
|
642
|
+
symbolic_ref: str | None, unmerged_paths: tuple[bytes, ...], *,
|
|
643
|
+
lone_message_is_trace: bool = False) -> Generator[GitCommand | FileTask, object, ReplaySnapshot]:
|
|
644
|
+
"""Classify a replay state whose facts are known, the same way wherever those facts come from.
|
|
645
|
+
|
|
646
|
+
Git's prepared message left alone, without any conflict, is no operation for Git when its references are files:
|
|
647
|
+
`git status` shows a clean checkout, and Git Extensions leaves exactly that after a Revert that brings nothing. It
|
|
648
|
+
is then read as the trace of a replay without a commit only right after GitPane's own launch
|
|
649
|
+
(``lone_message_is_trace``), which ends it with Git's own Quit; anywhere else it is ignored, as Git ignores it,
|
|
650
|
+
rather than locking every write of the repository. A reference table keeps CHERRY_PICK_HEAD and REVERT_HEAD out
|
|
651
|
+
of the files read here, so there the message alone may still be a pause in progress and keeps counting as one.
|
|
652
|
+
"""
|
|
653
|
+
snapshot = replay_snapshot_from_metadata(key, generation, metadata, head_oid=head_oid, symbolic_ref=symbolic_ref, unmerged_paths=unmerged_paths)
|
|
654
|
+
message_trace = False
|
|
655
|
+
if metadata.content_by_basename.get(MERGE_MESSAGE_BASENAME) and not unmerged_paths:
|
|
656
|
+
message_trace = lone_message_is_trace or (yield FileTask(lambda: os.path.isdir(os.path.join(context.common_git_dir, GIT_REFTABLE_DIR_BASENAME))))
|
|
657
|
+
if snapshot.kind == ReplayKind.ABSENT and (unmerged_paths or message_trace) and not metadata.content_by_basename.get(MERGE_HEAD_BASENAME):
|
|
658
|
+
other_operation = yield FileTask(lambda: any(os.path.lexists(os.path.join(context.git_dir, name)) for name in ("rebase-merge", "rebase-apply")))
|
|
659
|
+
if not other_operation:
|
|
660
|
+
# Existence rather than content: an empty prepared message is still a trace of a no-commit replay.
|
|
661
|
+
message_absent = metadata.signatures[REPLAY_BASENAMES.index(MERGE_MESSAGE_BASENAME)] is None
|
|
662
|
+
return ReplaySnapshot(key, generation, ReplayKind.UNKNOWN, head_oid=snapshot.head_oid, symbolic_ref=snapshot.symbolic_ref,
|
|
663
|
+
unmerged_paths=snapshot.unmerged_paths, fresh=False, pending_step=REPLAY_STEP_UNATTRIBUTED_CONFLICT, replay_evidence_absent=message_absent,
|
|
664
|
+
diagnostic="Native conflict evidence has no replay identity. The original operation and commit mode are unknown.")
|
|
665
|
+
if snapshot.kind != ReplayKind.ABSENT:
|
|
666
|
+
incompatible = yield FileTask(lambda: any(os.path.lexists(os.path.join(context.git_dir, name)) for name in ("rebase-merge", "rebase-apply", MERGE_HEAD_BASENAME)))
|
|
667
|
+
if incompatible:
|
|
668
|
+
return unreadable_replay_state(snapshot, "Incompatible native operations are present. Inspect them in the terminal.")
|
|
669
|
+
return snapshot
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def native_file_look(git_dir: str) -> tuple[tuple[int, ...] | None, ...]:
|
|
673
|
+
"""Take the metadata, never the content, of HEAD and of every file and folder Git keeps for a paused cherry-pick or revert.
|
|
674
|
+
|
|
675
|
+
Nothing is opened, so the look is safe before Git has confirmed where these files live. A missing path is None; a
|
|
676
|
+
link or a special file is refused the way the full read refuses it.
|
|
677
|
+
"""
|
|
678
|
+
signatures = []
|
|
679
|
+
for basename in REPLAY_NATIVE_PATH_BASENAMES:
|
|
680
|
+
filename = os.path.join(git_dir, basename)
|
|
681
|
+
try:
|
|
682
|
+
signatures.append(metadata_signature(checked_metadata(filename, directory=basename == SEQUENCER_BASENAME)))
|
|
683
|
+
except FileNotFoundError:
|
|
684
|
+
signatures.append(None)
|
|
685
|
+
return tuple(signatures)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def capture_replay(context: RepositoryContext, generation: int, *, source_oid: str | None = None, verify_git_dir: bool = False,
|
|
689
|
+
lone_message_is_trace: bool = False) -> Generator[GitCommand | FileTask | ReadBatch, object, ReplaySnapshot]:
|
|
690
|
+
"""Read HEAD, its branch, the conflicts and the native files once, the way Git itself shows them.
|
|
691
|
+
|
|
692
|
+
Everything is read through Git: this is the capture operations and the observations a user asks for rely on. One
|
|
693
|
+
grouped read locates the native files before any of them is opened, checks the source commit and the Git directory
|
|
694
|
+
a launch was prepared for, when given, and lists the conflicts beside it; the native files are read once after it.
|
|
695
|
+
Git is trusted to keep its own files consistent, as it is for a rebase, so no Git process runs a second time. Only
|
|
696
|
+
the metadata of the native files, never their content, is looked at before and after the whole read: a pause that
|
|
697
|
+
appears or ends while Git answers would otherwise mix HEAD from before with markers from after.
|
|
698
|
+
|
|
699
|
+
``lone_message_is_trace`` is set only by the read that follows GitPane's own launch; see _finish_replay_capture.
|
|
700
|
+
"""
|
|
701
|
+
key = WorktreeKey.from_context(context)
|
|
702
|
+
if context.is_bare:
|
|
703
|
+
return ReplaySnapshot(key, generation)
|
|
704
|
+
try:
|
|
705
|
+
started = time.monotonic()
|
|
706
|
+
look_before = yield FileTask(lambda: native_file_look(context.git_dir))
|
|
707
|
+
facts = yield from read_replay_facts(context, include_paths=True, source_oid=source_oid, verify_git_dir=verify_git_dir, read_unmerged=True)
|
|
708
|
+
metadata, look_after = yield FileTask(lambda: (read_replay_metadata(context.git_dir), native_file_look(context.git_dir)))
|
|
709
|
+
if look_before != look_after:
|
|
710
|
+
raise ValueError("Native replay changed during observation. Refresh to inspect its current state.")
|
|
711
|
+
if (time.monotonic() - started) * 1000 > REPLAY_CAPTURE_TIMEOUT_MS:
|
|
712
|
+
raise ValueError("Native replay took too long to read. Refresh to inspect its current state.")
|
|
713
|
+
path_set = set()
|
|
714
|
+
for record in facts.unmerged_output.split(b"\0"):
|
|
715
|
+
if record:
|
|
716
|
+
_stage, separator, path = record.partition(b"\t")
|
|
717
|
+
if not separator:
|
|
718
|
+
raise ValueError("Native replay conflict data is malformed.")
|
|
719
|
+
path_set.add(path)
|
|
720
|
+
return (yield from _finish_replay_capture(context, key, generation, metadata, facts.head_oid, facts.symbolic_ref, tuple(sorted(path_set)),
|
|
721
|
+
lone_message_is_trace=lone_message_is_trace))
|
|
722
|
+
except ReplayCheckoutMismatch:
|
|
723
|
+
raise
|
|
724
|
+
except (OSError, ValueError) as error:
|
|
725
|
+
return _unreadable_capture(key, generation, error)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def capture_replay_passive(context: RepositoryContext, generation: int,
|
|
729
|
+
source: PassiveObservationSource) -> Generator[GitCommand | FileTask, object, ReplaySnapshot]:
|
|
730
|
+
"""Follow a cherry-pick or revert state for the banner with what the automatic refresh has just read; never called by an operation."""
|
|
731
|
+
key = WorktreeKey.from_context(context)
|
|
732
|
+
if context.is_bare:
|
|
733
|
+
return ReplaySnapshot(key, generation)
|
|
734
|
+
try:
|
|
735
|
+
for basename in REPLAY_NATIVE_PATH_BASENAMES:
|
|
736
|
+
# The pause probe has no path for HEAD: Git always keeps it in the worktree's own Git directory, and its content
|
|
737
|
+
# only stands in for the branch name.
|
|
738
|
+
if basename == HEAD_BASENAME:
|
|
739
|
+
continue
|
|
740
|
+
filename = source.filename_by_git_path.get(basename)
|
|
741
|
+
if filename is None:
|
|
742
|
+
raise ValueError("Native replay path does not belong to this worktree.")
|
|
743
|
+
_check_native_path(key, basename, filename)
|
|
744
|
+
metadata = yield FileTask(lambda: read_replay_metadata(context.git_dir))
|
|
745
|
+
return (yield from _finish_replay_capture(context, key, generation, metadata, source.head_oid, source.branch_head, source.unmerged_paths))
|
|
746
|
+
except (OSError, ValueError) as error:
|
|
747
|
+
return _unreadable_capture(key, generation, error)
|