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,2030 @@
|
|
|
1
|
+
"""Run the native reference edits of a repository, each one checked against a reference capture read for it.
|
|
2
|
+
|
|
3
|
+
The captures (``capture``, ``read``, ``read_display``) and the signatures that tell whether the references or the
|
|
4
|
+
working files changed before an edit starts are read here too, with the format of ``gitpane.reference.reference_snapshot``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import TYPE_CHECKING, Self
|
|
15
|
+
|
|
16
|
+
from gitpane.config import COMMAND_LINE_CONTROL_CHARACTER_SET, GIT_MUTATION_TIMEOUT_MS, GIT_REFTABLE_DIR_BASENAME, LOCAL_PREFIX, REMOTE_PREFIX, SUBMODULE_RECURSION_OFF_ARGUMENTS, TAG_PREFIX, SharedText
|
|
17
|
+
from gitpane.history.repository_value import COMMIT_PEEL_SUFFIX, Reference, ReferenceKind, validate_oid
|
|
18
|
+
from gitpane.history.revision import revision_text_problem
|
|
19
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
20
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
21
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult, StaleCaptureError, WriteScope
|
|
22
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, ProcessStatus
|
|
23
|
+
from gitpane.reference.reference_snapshot import REFERENCE_FIELD_SEPARATOR, REFERENCE_FORMAT, ReferenceSnapshot, mark_head, parse_reference_read, short_reference_name
|
|
24
|
+
from gitpane.reference.stash_list import read_stash_list
|
|
25
|
+
from gitpane.sequencer.merge_state import read_merge_banner_state
|
|
26
|
+
from gitpane.system.path_identity import canonical_path
|
|
27
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
28
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
|
|
29
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, fingerprint_files, read_index
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable, Generator
|
|
33
|
+
|
|
34
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
35
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
36
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
37
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
38
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
39
|
+
|
|
40
|
+
DELETE_LOCAL_TAG_OPERATION = "Delete local tag"
|
|
41
|
+
DELETE_REMOTE_TAG_OPERATION = "Delete remote tag"
|
|
42
|
+
DELETE_LOCAL_BRANCH_OPERATION = "Delete local branch"
|
|
43
|
+
DELETE_REMOTE_BRANCH_OPERATION = "Delete remote branch"
|
|
44
|
+
# The whole request of the Delete branch window, which deletes one or several local branches.
|
|
45
|
+
DELETE_BRANCH_OPERATION = "Delete branch"
|
|
46
|
+
# The Git options that delete branches: the checked one refuses a branch Git does not see as merged, the forced one
|
|
47
|
+
# deletes whatever it is given. Each deleted branch is written with the option it was deleted with, so the captured
|
|
48
|
+
# request says which branches the user allowed to lose commits.
|
|
49
|
+
CHECKED_BRANCH_DELETE_OPTION = "-d"
|
|
50
|
+
FORCED_BRANCH_DELETE_OPTION = "-D"
|
|
51
|
+
# "git branch -d/-D" exits with 1 when at least one of the named branches could not be deleted; the others still are.
|
|
52
|
+
BRANCH_DELETE_ACCEPTED_CODES = (0, 1)
|
|
53
|
+
# One line per branch: its full name and the commit it points to, separated like the reference listing's fields.
|
|
54
|
+
BRANCH_VALUE_FORMAT = "--format=%(refname)%00%(objectname)"
|
|
55
|
+
CLEAR_ORPHAN_OPERATION = "Clear working directory and index"
|
|
56
|
+
# The ID of the tree without any entry in each object format, told apart by the length of a full object ID. Git
|
|
57
|
+
# knows this tree without storing it, so clearing can move the index to it in any repository.
|
|
58
|
+
SHA1_EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
59
|
+
SHA256_EMPTY_TREE_OID = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"
|
|
60
|
+
EMPTY_TREE_OID_BY_OID_LENGTH = {len(SHA1_EMPTY_TREE_OID): SHA1_EMPTY_TREE_OID, len(SHA256_EMPTY_TREE_OID): SHA256_EMPTY_TREE_OID}
|
|
61
|
+
ORPHAN_CLEAR_REFUSAL = ("The working directory has uncommitted changes. Commit or stash them before creating an orphan branch that clears the "
|
|
62
|
+
"working directory and index.")
|
|
63
|
+
# Clearing removes every entry of the commit's tree from the index and the disk. What happens to a submodule's own
|
|
64
|
+
# files then depends on the Git command and its settings (a forced "git rm" deletes the whole submodule working tree,
|
|
65
|
+
# untracked and ignored files included), and "status" does not even show what is in there. So a commit holding a
|
|
66
|
+
# submodule refuses the clearing before any write rather than rely on it.
|
|
67
|
+
ORPHAN_CLEAR_SUBMODULE_REFUSAL = ("The commit contains submodules. Clearing the working directory and index could delete files inside them, "
|
|
68
|
+
"untracked and ignored ones included. Create the orphan branch without clearing, then remove the files "
|
|
69
|
+
"you do not want yourself.")
|
|
70
|
+
# The mode Git gives a submodule entry (a gitlink) in a tree or in the index.
|
|
71
|
+
GITLINK_MODE = b"160000"
|
|
72
|
+
# One tree entry per record: only its mode, which is all the submodule check needs.
|
|
73
|
+
TREE_ENTRY_MODE_FORMAT = "--format=%(objectmode)"
|
|
74
|
+
TREE_ENTRY_TERMINATOR = b"\0"
|
|
75
|
+
|
|
76
|
+
# The remote Git Extensions offers for a push when the current branch follows no remote branch.
|
|
77
|
+
DEFAULT_PUSH_REMOTE_NAME = "origin"
|
|
78
|
+
TAG_TARGET_TYPE_LINE = b"type commit"
|
|
79
|
+
# "rev-parse --verify --quiet" exits with 1 and prints nothing when the revision does not exist.
|
|
80
|
+
REVISION_READ_EXPECTED_CODES = (0, 1)
|
|
81
|
+
# "check-ref-format --normalize" exits with 1 and prints nothing for a name Git refuses. Accepting that code keeps the
|
|
82
|
+
# refusal from showing as a failed process: GitPane gives the reason itself, in the line that ends the operation.
|
|
83
|
+
NEW_REFERENCE_NAME_CHECK_ACCEPTED_CODES = (0, 1)
|
|
84
|
+
# Git's answer to a refused name is an empty output and an exit code, nothing a user can act on, so GitPane writes the
|
|
85
|
+
# reason. It names the refused name between quotes, so a stray space shows, and recalls what Git refuses in a name.
|
|
86
|
+
REFUSED_REFERENCE_NAME_TEMPLATE = ("'{name}' is not a valid {kind} name: Git refuses spaces, '..', '~', '^', ':', '?', '*', '[', '\\', "
|
|
87
|
+
"a name starting with '/' or '.', and a name ending with '.' or '.lock'")
|
|
88
|
+
TAG_NAME_KIND = "tag"
|
|
89
|
+
BRANCH_NAME_KIND = "branch"
|
|
90
|
+
# Git's reference name rules accept "refs/heads/HEAD"; only Git's branch check refuses that name, and it writes the
|
|
91
|
+
# refusal on its error output, which a console would show in red without GitPane's reason. GitPane refuses this one
|
|
92
|
+
# name itself, before any process, so every other name goes through the same silent reference name check as a tag.
|
|
93
|
+
RESERVED_BRANCH_NAME = "HEAD"
|
|
94
|
+
RESERVED_BRANCH_NAME_REFUSAL = "'HEAD' is not a valid branch name: Git keeps this name for the commit that is checked out"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_commit_metadata(target_oid: str) -> Generator:
|
|
98
|
+
"""Read and validate literal commit fields shared by revision forms."""
|
|
99
|
+
validate_oid(target_oid)
|
|
100
|
+
output = yield GitCommand(["show", "--no-patch", "--format=%H%x00%an%x00%aI%x00%cn%x00%cI%x00%B", target_oid, "--"])
|
|
101
|
+
field_count = 6
|
|
102
|
+
fields = output.decode("utf-8", "replace").split("\0", field_count - 1)
|
|
103
|
+
oid_field_index = 0
|
|
104
|
+
if len(fields) != field_count or fields[oid_field_index] != target_oid:
|
|
105
|
+
raise ValueError("Git could not read complete metadata for the selected commit")
|
|
106
|
+
return tuple(fields)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def empty_tree_oid(object_oid: str) -> str:
|
|
110
|
+
"""Answer the empty tree's ID in the object format of a full object ID from the same repository."""
|
|
111
|
+
validate_oid(object_oid)
|
|
112
|
+
return EMPTY_TREE_OID_BY_OID_LENGTH[len(object_oid)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def verify_git_dir_identity(context: RepositoryContext, git_dir_output: bytes) -> None:
|
|
116
|
+
"""Refuse a read when Git works in another administration directory than the one the window owns.
|
|
117
|
+
|
|
118
|
+
A working tree whose .git file was redirected would otherwise have its references read, and later written,
|
|
119
|
+
under the identity of the repository the window believes it holds.
|
|
120
|
+
"""
|
|
121
|
+
git_dir = git_dir_output.removesuffix(b"\n")
|
|
122
|
+
if not git_dir or canonical_path(os.fsdecode(git_dir)) != WorktreeKey.from_context(context).git_dir:
|
|
123
|
+
raise ValueError("The repository identity changed; reopen the repository")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parent_write_command(arguments: list[str], before_start: Callable[[], None], accepted_codes: tuple[int, ...] = (0,)) -> GitCommand:
|
|
127
|
+
"""Keep reference writes within the parent reservation despite recursive user settings.
|
|
128
|
+
|
|
129
|
+
``accepted_codes`` lists the exit codes that are not an error: a command that works on several branches exits
|
|
130
|
+
with 1 when only some of them failed, and the caller then proves each result from the references themselves.
|
|
131
|
+
"""
|
|
132
|
+
# These commands can start the user's own hooks, and those must finish before the repository is handed
|
|
133
|
+
# back. The profile says so here rather than being inherited, so it holds whatever the surrounding
|
|
134
|
+
# sequence was launched as.
|
|
135
|
+
return GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *arguments], accepted_codes=accepted_codes, timeout_ms=GIT_MUTATION_TIMEOUT_MS,
|
|
136
|
+
idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=before_start, profile=LOCAL_WRITE_PROFILE)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_new_reference_name(full_ref: str, name: str, kind: str) -> Generator:
|
|
140
|
+
"""Ask Git whether a new tag or branch may take this name, and refuse it with a readable reason when Git says no.
|
|
141
|
+
|
|
142
|
+
Git prints the normalized name of a valid reference and nothing for a refused one. The answer must be the full name
|
|
143
|
+
asked, byte for byte: a name only normalization would make valid (doubled or leading slashes) is refused too, so the
|
|
144
|
+
rule stays exactly Git's. ``name`` is the name as the user typed it and ``kind`` names what it is for (tag, branch).
|
|
145
|
+
"""
|
|
146
|
+
answer = yield GitCommand(["check-ref-format", "--normalize", full_ref], accepted_codes=NEW_REFERENCE_NAME_CHECK_ACCEPTED_CODES)
|
|
147
|
+
if answer.removesuffix(b"\n") != full_ref.encode("utf-8", "surrogateescape"):
|
|
148
|
+
raise ValueError(REFUSED_REFERENCE_NAME_TEMPLATE.format(name=name, kind=kind))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_branch_name(name: str) -> Generator:
|
|
152
|
+
"""Check a name for a branch GitPane creates, renames or moves, and refuse it with a readable reason.
|
|
153
|
+
|
|
154
|
+
This is the one rule for every branch name, so a refusal reads the same everywhere and never shows Git's error
|
|
155
|
+
output. The name is placed where Git reads options in some commands, so a leading dash and control characters are
|
|
156
|
+
refused before any process. Git's reference name rules refuse "@{", so a name such as "@{-1}", which Git's branch
|
|
157
|
+
check would replace by another branch, is refused and never adopted. Answers the name, unchanged.
|
|
158
|
+
"""
|
|
159
|
+
if not name or name.startswith("-") or any(character in COMMAND_LINE_CONTROL_CHARACTER_SET for character in name):
|
|
160
|
+
raise ValueError("Enter a valid branch name without options or control characters")
|
|
161
|
+
if name == RESERVED_BRANCH_NAME:
|
|
162
|
+
raise ValueError(RESERVED_BRANCH_NAME_REFUSAL)
|
|
163
|
+
yield from check_new_reference_name(LOCAL_PREFIX + name, name, BRANCH_NAME_KIND)
|
|
164
|
+
return name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def push_remote_name(snapshot: ReferenceSnapshot) -> str:
|
|
168
|
+
"""Name the remote a push from this repository goes to: the remote of the current branch's upstream, else origin.
|
|
169
|
+
|
|
170
|
+
This is the rule Git Extensions follows when it offers a remote for a tag push, so the dialog proposes the same one.
|
|
171
|
+
"""
|
|
172
|
+
return snapshot.upstream_remote_by_name.get(snapshot.head_ref or "", "") or DEFAULT_PUSH_REMOTE_NAME
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# The step that closes an operation; only its observation describes the repository after every write of it.
|
|
176
|
+
TERMINAL_STEP_INDEX = -1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def final_reference_snapshot(outcome: OperationOutcome) -> ReferenceSnapshot | None:
|
|
180
|
+
"""Answer the reference capture that closed an operation, when its last step carries one.
|
|
181
|
+
|
|
182
|
+
Only the last step counts: an earlier capture may predate another write of the same operation. A last step that
|
|
183
|
+
observed a working tree, or observed nothing, gives no answer and the caller reads the references again. This is
|
|
184
|
+
display data only; no write may rely on it.
|
|
185
|
+
"""
|
|
186
|
+
if not outcome.steps:
|
|
187
|
+
return None
|
|
188
|
+
after = outcome.steps[TERMINAL_STEP_INDEX].after
|
|
189
|
+
return after if isinstance(after, ReferenceSnapshot) else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ResetMode(Enum):
|
|
193
|
+
"""A mode of "git reset", with the text of the Reset window choice that offers it.
|
|
194
|
+
|
|
195
|
+
The value is the word Git takes after "--"; the members are in the order the window lists them.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
SOFT = ("soft", "Soft: leave working directory and index untouched")
|
|
199
|
+
MIXED = ("mixed", "Mixed: leave working directory untouched, reset index")
|
|
200
|
+
KEEP = ("keep", "Keep: update working directory to the commit\n(abort if there are local changes), reset index")
|
|
201
|
+
MERGE = ("merge", "Merge: update working directory to the commit and keep local changes\n(abort if there are conflicts), reset index")
|
|
202
|
+
HARD = ("hard", "Hard: reset working directory and index\n(discard ALL local changes, even uncommitted changes)")
|
|
203
|
+
|
|
204
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
205
|
+
"""Keep Git's word as the value and the choice text beside it."""
|
|
206
|
+
member = object.__new__(cls)
|
|
207
|
+
member._value_ = value
|
|
208
|
+
member.label = label
|
|
209
|
+
return member
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class BranchMode(Enum):
|
|
213
|
+
"""What a branch form does to a branch, with the name of the operation that reports it.
|
|
214
|
+
|
|
215
|
+
The value is the word the operation is captured with. A creation asked by a form becomes CREATE_CHECKOUT when the
|
|
216
|
+
new branch is checked out, and CREATE_ORPHAN when it starts without any commit on a commit's tree.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
CREATE = ("create", "Create branch")
|
|
220
|
+
CREATE_CHECKOUT = ("create-checkout", "Create branch")
|
|
221
|
+
CREATE_ORPHAN = ("create-orphan", "Create branch")
|
|
222
|
+
RENAME = ("rename", "Rename branch")
|
|
223
|
+
MOVE = ("move", "Move branch")
|
|
224
|
+
|
|
225
|
+
def __new__(cls, value: str, operation_label: str) -> Self:
|
|
226
|
+
"""Keep the captured word as the value and the operation name beside it."""
|
|
227
|
+
member = object.__new__(cls)
|
|
228
|
+
member._value_ = value
|
|
229
|
+
member.operation_label = operation_label
|
|
230
|
+
return member
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CheckoutMode(Enum):
|
|
234
|
+
"""What a checkout does with the reference it was given, and with the local branch of the same name for a remote one."""
|
|
235
|
+
|
|
236
|
+
LOCAL = "local"
|
|
237
|
+
"""Check out a local branch."""
|
|
238
|
+
DETACHED = "detached"
|
|
239
|
+
"""Check out a commit or a remote branch without any local branch: detached HEAD."""
|
|
240
|
+
SAME = "same"
|
|
241
|
+
"""Create the local branch of the same name as the remote branch, and check it out."""
|
|
242
|
+
RESET_LOCAL = "reset_local"
|
|
243
|
+
"""Move the existing local branch of the same name onto the remote branch, and check it out."""
|
|
244
|
+
CUSTOM = "custom"
|
|
245
|
+
"""Create a local branch with the name the user typed on the remote branch, and check it out."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TagMode(Enum):
|
|
249
|
+
"""Whether a created tag is annotated, with a message, or lightweight, a plain name on a commit."""
|
|
250
|
+
|
|
251
|
+
ANNOTATED = "annotated"
|
|
252
|
+
LIGHTWEIGHT = "lightweight"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class ReferenceDeletionIntent:
|
|
257
|
+
"""Bind the deletion of one branch or tag to the references it was asked on and to the exact object it named.
|
|
258
|
+
|
|
259
|
+
``unmerged_confirmed`` is the user's answer when a local branch is not merged into HEAD: without it the deletion
|
|
260
|
+
is refused.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
operation: OperationIntent
|
|
264
|
+
snapshot: ReferenceSnapshot
|
|
265
|
+
full_ref: str
|
|
266
|
+
target_oid: str
|
|
267
|
+
unmerged_confirmed: bool = False
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def capture(cls, snapshot: ReferenceSnapshot, full_ref: str, *, session_id: str) -> ReferenceDeletionIntent:
|
|
271
|
+
"""Capture one exact reference object without authorizing unmerged branch deletion."""
|
|
272
|
+
reference = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
|
|
273
|
+
if reference is None or reference.kind not in (ReferenceKind.HEADS, ReferenceKind.REMOTES, ReferenceKind.TAGS) or reference.symbolic_target or not reference.target_oid:
|
|
274
|
+
raise ValueError("Select an existing non-symbolic reference")
|
|
275
|
+
target_oid = snapshot.object_oid_by_name[full_ref]
|
|
276
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
277
|
+
targets=(full_ref, target_oid), session_id=session_id, operation="Delete tag" if reference.kind == ReferenceKind.TAGS else "Delete branch",
|
|
278
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, (full_ref,)), from_form=True)
|
|
279
|
+
return cls(operation, snapshot, full_ref, target_oid)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class ResetIntent:
|
|
284
|
+
"""Bind a Reset of the current branch to its branch, its target commit, its mode and the local state it was read with.
|
|
285
|
+
|
|
286
|
+
``worktree_snapshot``, ``working_signature``, ``working_paths`` and ``reset_target_paths`` hold the index and the
|
|
287
|
+
working files read for a mode that changes them; a Soft reset has none. ``hard_confirmed`` is the user's answer
|
|
288
|
+
to the Hard warning.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
operation: OperationIntent
|
|
292
|
+
snapshot: ReferenceSnapshot
|
|
293
|
+
full_ref: str
|
|
294
|
+
target_oid: str
|
|
295
|
+
mode: ResetMode
|
|
296
|
+
worktree_snapshot: WorktreeSnapshot | None = None
|
|
297
|
+
working_signature: tuple = ()
|
|
298
|
+
hard_confirmed: bool = False
|
|
299
|
+
working_paths: tuple[bytes, ...] | None = None
|
|
300
|
+
reset_target_paths: tuple[bytes, ...] = ()
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def capture(cls, snapshot: ReferenceSnapshot, target_oid: str, worktree_snapshot: WorktreeSnapshot | None, working_signature: tuple,
|
|
304
|
+
*, session_id: str, mode: ResetMode = ResetMode.SOFT) -> ResetIntent:
|
|
305
|
+
"""Capture the current branch and require local bytes only for file-changing reset modes."""
|
|
306
|
+
validate_oid(target_oid)
|
|
307
|
+
if not snapshot.head_ref or not snapshot.head_ref.startswith(LOCAL_PREFIX) or not snapshot.head_oid:
|
|
308
|
+
raise ValueError("Reset requires a current local branch with a commit")
|
|
309
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
310
|
+
targets=(snapshot.head_ref, target_oid, mode), session_id=session_id, operation="Reset current branch",
|
|
311
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, (snapshot.head_ref,)), from_form=True)
|
|
312
|
+
return cls(operation, snapshot, snapshot.head_ref, target_oid, mode, worktree_snapshot=worktree_snapshot, working_signature=working_signature)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass(frozen=True)
|
|
316
|
+
class CheckoutIntent:
|
|
317
|
+
"""Bind a checkout to the reference or commit it was asked on, what it does with the local branch, and the Force choice."""
|
|
318
|
+
|
|
319
|
+
operation: OperationIntent
|
|
320
|
+
snapshot: ReferenceSnapshot
|
|
321
|
+
full_ref: str
|
|
322
|
+
target_oid: str
|
|
323
|
+
mode: CheckoutMode
|
|
324
|
+
local_name: str = ""
|
|
325
|
+
force_checkout: bool = False
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def capture(cls, snapshot: ReferenceSnapshot, full_ref: str, *, refresh_advice: Callable[..., str], session_id: str | None = None, mode: CheckoutMode = CheckoutMode.LOCAL,
|
|
329
|
+
local_name: str = "", force_checkout: bool = False) -> CheckoutIntent:
|
|
330
|
+
"""Capture only a reference present in the displayed snapshot, or a full commit ID for a detached checkout.
|
|
331
|
+
|
|
332
|
+
For a remote branch, ``mode`` says what happens to the local branch of the same name: SAME creates it,
|
|
333
|
+
RESET_LOCAL moves the existing one onto the remote branch, and CUSTOM creates ``local_name`` instead.
|
|
334
|
+
``force_checkout`` discards local changes, and only a detached revision checkout offers it. ``refresh_advice``
|
|
335
|
+
writes the refusal of a reference the snapshot no longer holds, naming the Refresh action and its shortcut,
|
|
336
|
+
which only the interface knows.
|
|
337
|
+
"""
|
|
338
|
+
if force_checkout and mode != CheckoutMode.DETACHED:
|
|
339
|
+
raise ValueError("Force applies only to checking out a revision")
|
|
340
|
+
reference = next((reference for reference in snapshot.references if reference.full_name == full_ref), None)
|
|
341
|
+
if reference is None and mode == CheckoutMode.DETACHED:
|
|
342
|
+
validate_oid(full_ref)
|
|
343
|
+
target_oid = full_ref
|
|
344
|
+
full_ref = ""
|
|
345
|
+
elif reference is None or reference.target_oid is None:
|
|
346
|
+
raise ValueError(refresh_advice(SharedText.SELECTED_REFERENCE_CHANGED))
|
|
347
|
+
else:
|
|
348
|
+
target_oid = reference.target_oid
|
|
349
|
+
refs = ()
|
|
350
|
+
if mode in (CheckoutMode.SAME, CheckoutMode.RESET_LOCAL, CheckoutMode.CUSTOM):
|
|
351
|
+
name = local_name if mode == CheckoutMode.CUSTOM else remote_branch_name(snapshot, full_ref)
|
|
352
|
+
refs = (LOCAL_PREFIX + name,)
|
|
353
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
354
|
+
targets=(full_ref, target_oid, str(force_checkout)), session_id=session_id, operation="Checkout",
|
|
355
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, refs), from_form=True)
|
|
356
|
+
return cls(operation, snapshot, full_ref, target_oid, mode, local_name, force_checkout=force_checkout)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass(frozen=True)
|
|
360
|
+
class BranchIntent:
|
|
361
|
+
"""Bind the creation, rename or move of one local branch to its exact names, its commit and its options.
|
|
362
|
+
|
|
363
|
+
``checkout_after_reset`` and ``force_reset`` are the choices of a move; ``orphan_clear`` is the choice of an
|
|
364
|
+
orphan branch creation that also empties the index and removes every tracked file afterwards.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
operation: OperationIntent
|
|
368
|
+
snapshot: ReferenceSnapshot
|
|
369
|
+
full_ref: str
|
|
370
|
+
target_oid: str
|
|
371
|
+
mode: BranchMode
|
|
372
|
+
local_name: str = ""
|
|
373
|
+
checkout_after_reset: bool = False
|
|
374
|
+
force_reset: bool = False
|
|
375
|
+
orphan_clear: bool = False
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def capture(cls, snapshot: ReferenceSnapshot, target_oid: str, *, session_id: str, name: str = "", checkout: bool = True,
|
|
379
|
+
mode: BranchMode = BranchMode.CREATE, full_ref: str = "", force_reset: bool = False, orphan: bool = False, clear: bool = False) -> BranchIntent:
|
|
380
|
+
"""Bind branch editing to immutable commits, exact names and an explicit action.
|
|
381
|
+
|
|
382
|
+
A form asks for CREATE, RENAME or MOVE; a creation is captured as CREATE_CHECKOUT when ``checkout`` is set.
|
|
383
|
+
``orphan`` creates a branch without any commit on the target's tree, which always checks it out, and ``clear``
|
|
384
|
+
then empties the index and removes the tracked files; clearing means nothing without an orphan branch.
|
|
385
|
+
"""
|
|
386
|
+
validate_oid(target_oid)
|
|
387
|
+
if clear and not orphan:
|
|
388
|
+
raise ValueError("Clearing the working directory and index applies only to an orphan branch")
|
|
389
|
+
if orphan and mode != BranchMode.CREATE:
|
|
390
|
+
raise ValueError("Only a branch creation can start an orphan branch")
|
|
391
|
+
if orphan:
|
|
392
|
+
mode = BranchMode.CREATE_ORPHAN
|
|
393
|
+
elif mode == BranchMode.CREATE:
|
|
394
|
+
mode = BranchMode.CREATE_CHECKOUT if checkout else BranchMode.CREATE
|
|
395
|
+
elif mode not in (BranchMode.RENAME, BranchMode.MOVE) or not full_ref.startswith(LOCAL_PREFIX):
|
|
396
|
+
raise ValueError("Select a local branch and a supported branch action")
|
|
397
|
+
targets = (full_ref, target_oid, mode, name)
|
|
398
|
+
if mode == BranchMode.MOVE:
|
|
399
|
+
targets += (str(checkout), str(force_reset))
|
|
400
|
+
if mode == BranchMode.CREATE_ORPHAN:
|
|
401
|
+
targets += (str(clear),)
|
|
402
|
+
refs = (full_ref,) if mode == BranchMode.MOVE else (full_ref, LOCAL_PREFIX + name) if mode == BranchMode.RENAME else (LOCAL_PREFIX + name,)
|
|
403
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
404
|
+
targets=targets, session_id=session_id, operation=mode.operation_label,
|
|
405
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, refs), from_form=True)
|
|
406
|
+
return cls(operation, snapshot, full_ref, target_oid, mode, name, checkout_after_reset=mode == BranchMode.MOVE and checkout, force_reset=force_reset, orphan_clear=clear)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass(frozen=True)
|
|
410
|
+
class TagIntent:
|
|
411
|
+
"""Bind the creation of one tag to the references it was asked on, its exact name and its commit.
|
|
412
|
+
|
|
413
|
+
``message`` is the annotation of an annotated tag, empty for a lightweight one; ``push_after_creation`` is the
|
|
414
|
+
form's Push check box, which opens the Push of the created tag once it exists.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
operation: OperationIntent
|
|
418
|
+
snapshot: ReferenceSnapshot
|
|
419
|
+
full_ref: str
|
|
420
|
+
target_oid: str
|
|
421
|
+
mode: TagMode
|
|
422
|
+
local_name: str
|
|
423
|
+
message: str = ""
|
|
424
|
+
push_after_creation: bool = False
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def capture(cls, snapshot: ReferenceSnapshot, target_oid: str, *, session_id: str, name: str = "", annotated: bool = False, message: str = "", push_after_creation: bool = False) -> TagIntent:
|
|
428
|
+
"""Capture a tag name and immutable commit without carrying unused annotation drafts."""
|
|
429
|
+
validate_oid(target_oid)
|
|
430
|
+
mode = TagMode.ANNOTATED if annotated else TagMode.LIGHTWEIGHT
|
|
431
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
432
|
+
targets=(TAG_PREFIX + name, target_oid, mode), session_id=session_id, operation="Create tag",
|
|
433
|
+
action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, (TAG_PREFIX + name,)), from_form=True)
|
|
434
|
+
return cls(operation, snapshot, TAG_PREFIX + name, target_oid, mode, name, message=message if annotated else "", push_after_creation=push_after_creation)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def branch_names(full_refs: tuple[str, ...]) -> list[str]:
|
|
438
|
+
"""Turn full local branch names into the short names "git branch" takes."""
|
|
439
|
+
names: list[str] = []
|
|
440
|
+
for full_ref in full_refs:
|
|
441
|
+
names.append(full_ref.removeprefix(LOCAL_PREFIX))
|
|
442
|
+
return names
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def branch_deletion_targets(merged_refs: tuple[str, ...], unmerged_refs: tuple[str, ...], target_oid_by_ref: dict[str, str]) -> tuple[str, ...]:
|
|
446
|
+
"""Write a group deletion as the flat list of targets its operation carries: option, full name and commit of each branch.
|
|
447
|
+
|
|
448
|
+
Every branch takes exactly three entries in that order, and neither an option nor a commit ID can be taken for a
|
|
449
|
+
full name, so the list reads back to one request only.
|
|
450
|
+
"""
|
|
451
|
+
targets: list[str] = []
|
|
452
|
+
for option, refs in ((FORCED_BRANCH_DELETE_OPTION, unmerged_refs), (CHECKED_BRANCH_DELETE_OPTION, merged_refs)):
|
|
453
|
+
for full_ref in refs:
|
|
454
|
+
targets.extend((option, full_ref, target_oid_by_ref.get(full_ref, "")))
|
|
455
|
+
return tuple(targets)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass(frozen=True)
|
|
459
|
+
class MergeAbortIntent:
|
|
460
|
+
"""Bind the Abort of a merge to the merge and the HEAD the banner showed when it was clicked.
|
|
461
|
+
|
|
462
|
+
``merge_head_oids`` are the commits Git recorded as being merged and ``head_oid`` the commit the merge started
|
|
463
|
+
from: the Abort is refused before Git runs when either changed, so a click never applies to another merge.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
operation: OperationIntent
|
|
467
|
+
merge_head_oids: tuple[str, ...]
|
|
468
|
+
head_oid: str
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass(frozen=True)
|
|
472
|
+
class BranchDeletionIntent:
|
|
473
|
+
"""Bind the deletion of several local branches to the references it was read on and to the user's answer.
|
|
474
|
+
|
|
475
|
+
``merged_refs`` are deleted by the checked "git branch -d", which still refuses a branch Git does not see as
|
|
476
|
+
merged; ``unmerged_refs`` are the branches the user agreed to lose commits from, deleted by "git branch -D".
|
|
477
|
+
``target_oid_by_ref`` keeps the commit each branch pointed to when the request was frozen.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
operation: OperationIntent
|
|
481
|
+
snapshot: ReferenceSnapshot
|
|
482
|
+
merged_refs: tuple[str, ...]
|
|
483
|
+
unmerged_refs: tuple[str, ...]
|
|
484
|
+
target_oid_by_ref: dict[str, str]
|
|
485
|
+
|
|
486
|
+
@classmethod
|
|
487
|
+
def capture(cls, snapshot: ReferenceSnapshot, merged_refs: tuple[str, ...], unmerged_refs: tuple[str, ...], *, session_id: str | None,
|
|
488
|
+
refresh_advice: Callable[..., str]) -> BranchDeletionIntent:
|
|
489
|
+
"""Freeze local branches of the displayed capture, refusing anything a deletion of local branches must never touch.
|
|
490
|
+
|
|
491
|
+
An empty request, a name the capture does not hold, a remote branch or a tag, a symbolic reference, the
|
|
492
|
+
current branch and a branch named in both lists are all refused before any process. ``refresh_advice`` writes
|
|
493
|
+
the refusal of a name that is not a local branch of the capture, naming the Refresh action and its shortcut.
|
|
494
|
+
"""
|
|
495
|
+
all_refs = unmerged_refs + merged_refs
|
|
496
|
+
if not all_refs:
|
|
497
|
+
raise ValueError("Select at least one branch to delete")
|
|
498
|
+
if len(set(all_refs)) != len(all_refs):
|
|
499
|
+
raise ValueError("A branch is named twice in the deletion request")
|
|
500
|
+
reference_by_name: dict[str, Reference] = {}
|
|
501
|
+
for reference in snapshot.references:
|
|
502
|
+
reference_by_name[reference.full_name] = reference
|
|
503
|
+
target_oid_by_ref: dict[str, str] = {}
|
|
504
|
+
for full_ref in all_refs:
|
|
505
|
+
reference = reference_by_name.get(full_ref)
|
|
506
|
+
if (reference is None or not full_ref.startswith(LOCAL_PREFIX) or reference.symbolic_target or not reference.target_oid
|
|
507
|
+
or full_ref not in snapshot.object_oid_by_name):
|
|
508
|
+
raise ValueError(refresh_advice(SharedText.NOT_A_LOCAL_BRANCH, branch=short_reference_name(full_ref)))
|
|
509
|
+
if full_ref == snapshot.head_ref:
|
|
510
|
+
raise ValueError(f"'{short_reference_name(full_ref)}' is the current branch and cannot be deleted")
|
|
511
|
+
target_oid_by_ref[full_ref] = snapshot.object_oid_by_name[full_ref]
|
|
512
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
513
|
+
targets=branch_deletion_targets(merged_refs, unmerged_refs, target_oid_by_ref), session_id=session_id,
|
|
514
|
+
operation=DELETE_BRANCH_OPERATION, action=OperationAction.REF_WRITE, scope=WriteScope.capture(snapshot.context, all_refs), from_form=True)
|
|
515
|
+
return cls(operation, snapshot, merged_refs, unmerged_refs, target_oid_by_ref)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def working_signature(context: RepositoryContext, paths: tuple[bytes, ...] | None = None, target_paths: tuple[bytes, ...] = ()) -> tuple:
|
|
519
|
+
"""Hash reset-exposed files and directory membership without walking unrelated ignored trees."""
|
|
520
|
+
if context.worktree_dir is None:
|
|
521
|
+
raise ValueError("Reset requires a working tree")
|
|
522
|
+
captured_paths: list[bytes] = []
|
|
523
|
+
for path in paths or ():
|
|
524
|
+
filename = _reset_working_filename(context, path)
|
|
525
|
+
captured_paths.append(os.fsencode(os.path.relpath(filename, context.worktree_dir).replace(os.sep, "/")))
|
|
526
|
+
pending_dirs = [context.worktree_dir] if paths is None else []
|
|
527
|
+
directory_set = {context.worktree_dir}
|
|
528
|
+
for path in target_paths:
|
|
529
|
+
filename = _reset_working_filename(context, path)
|
|
530
|
+
# Hard can remove an ignored directory to replace it with a target file.
|
|
531
|
+
if os.path.isdir(filename) and not os.path.islink(filename):
|
|
532
|
+
pending_dirs.append(filename)
|
|
533
|
+
while pending_dirs:
|
|
534
|
+
directory = pending_dirs.pop()
|
|
535
|
+
directory_set.add(directory)
|
|
536
|
+
with os.scandir(directory) as entries:
|
|
537
|
+
for entry in entries:
|
|
538
|
+
if entry.name == ".git" and paths is None:
|
|
539
|
+
continue
|
|
540
|
+
if entry.is_dir(follow_symlinks=False):
|
|
541
|
+
pending_dirs.append(entry.path)
|
|
542
|
+
else:
|
|
543
|
+
captured_paths.append(os.fsencode(os.path.relpath(entry.path, context.worktree_dir).replace(os.sep, "/")))
|
|
544
|
+
for path in captured_paths:
|
|
545
|
+
directory = os.path.dirname(os.path.join(context.worktree_dir, os.fsdecode(path)))
|
|
546
|
+
while directory != context.worktree_dir:
|
|
547
|
+
directory_set.add(directory)
|
|
548
|
+
parent = os.path.dirname(directory)
|
|
549
|
+
if parent == directory:
|
|
550
|
+
raise ValueError("Reset path is outside the working tree")
|
|
551
|
+
directory = parent
|
|
552
|
+
records: list[tuple] = []
|
|
553
|
+
for directory in sorted(directory_set):
|
|
554
|
+
try:
|
|
555
|
+
with os.scandir(directory) as entries:
|
|
556
|
+
names: list[str] = []
|
|
557
|
+
for entry in entries:
|
|
558
|
+
if entry.name != ".git":
|
|
559
|
+
names.append(entry.name)
|
|
560
|
+
names.sort()
|
|
561
|
+
records.append((directory, tuple(names)))
|
|
562
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
563
|
+
records.append((directory, None))
|
|
564
|
+
return tuple(sorted(fingerprint_files(context, tuple(captured_paths)).items())), tuple(records)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _reset_working_filename(context: RepositoryContext, path: bytes) -> str:
|
|
568
|
+
"""Select the target or its obstructing ancestor without following an external symlink."""
|
|
569
|
+
filename = worktree_filename(context, path)
|
|
570
|
+
parent = os.path.dirname(filename)
|
|
571
|
+
root_dir = os.path.abspath(context.worktree_dir)
|
|
572
|
+
while os.path.normcase(parent) != os.path.normcase(root_dir):
|
|
573
|
+
if os.path.lexists(parent) and (not os.path.isdir(parent) or os.path.islink(parent)):
|
|
574
|
+
filename = parent
|
|
575
|
+
next_parent = os.path.dirname(parent)
|
|
576
|
+
if next_parent == parent:
|
|
577
|
+
raise ValueError("Reset path is outside the working tree")
|
|
578
|
+
parent = next_parent
|
|
579
|
+
return filename
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def occupied_branch_set(snapshot: ReferenceSnapshot) -> set[str]:
|
|
583
|
+
"""Return branches checked out or retained by a detached rebase or bisect."""
|
|
584
|
+
branch_set: set[str] = set()
|
|
585
|
+
for field in snapshot.worktrees.split(b"\0"):
|
|
586
|
+
if field.startswith(b"branch "):
|
|
587
|
+
branch_set.add(field.removeprefix(b"branch ").decode("utf-8", "surrogateescape"))
|
|
588
|
+
if snapshot.head_ref:
|
|
589
|
+
branch_set.add(snapshot.head_ref)
|
|
590
|
+
for directory in reference_git_dirs(snapshot.context):
|
|
591
|
+
for relative_parts in (("rebase-merge", "head-name"), ("rebase-apply", "head-name"), ("BISECT_START",)):
|
|
592
|
+
try:
|
|
593
|
+
with open(os.path.join(directory, *relative_parts), encoding="utf-8", errors="surrogateescape") as stream:
|
|
594
|
+
branch = stream.read().strip()
|
|
595
|
+
except FileNotFoundError:
|
|
596
|
+
continue
|
|
597
|
+
if branch:
|
|
598
|
+
branch_set.add(branch if branch.startswith(LOCAL_PREFIX) else LOCAL_PREFIX + branch)
|
|
599
|
+
return branch_set
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def deletable_branch_refs(snapshot: ReferenceSnapshot) -> tuple[str, ...]:
|
|
603
|
+
"""List the local branches the Delete branch window offers, sorted by name; the Reset branch window offers the same ones.
|
|
604
|
+
|
|
605
|
+
The current branch and every branch another worktree, a rebase or a bisect holds are left out: Git would refuse
|
|
606
|
+
to delete or reset them anyway. Symbolic branches and branches without a commit are left out too.
|
|
607
|
+
"""
|
|
608
|
+
occupied_ref_set = occupied_branch_set(snapshot)
|
|
609
|
+
full_refs: list[str] = []
|
|
610
|
+
for reference in snapshot.references:
|
|
611
|
+
full_ref = reference.full_name
|
|
612
|
+
if (reference.kind != ReferenceKind.HEADS or reference.symbolic_target or not reference.target_oid or full_ref == snapshot.head_ref
|
|
613
|
+
or full_ref in occupied_ref_set):
|
|
614
|
+
continue
|
|
615
|
+
full_refs.append(full_ref)
|
|
616
|
+
full_refs.sort()
|
|
617
|
+
return tuple(full_refs)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def merge_base_oid_by_ref(snapshot: ReferenceSnapshot, branch_refs: tuple[str, ...]) -> dict[str, str | None]:
|
|
621
|
+
"""Name the commit each branch must be merged into for "git branch -d" to delete it, as Git itself decides.
|
|
622
|
+
|
|
623
|
+
Git compares a branch with its upstream when that upstream exists, and with HEAD otherwise, so a branch merged
|
|
624
|
+
into HEAD but not into its upstream is refused. The upstream comes from the listing the capture was built from;
|
|
625
|
+
one that is not configured, or whose reference does not exist, falls back to HEAD like in Git. None means there is
|
|
626
|
+
nothing to compare with (no upstream and HEAD without a commit): Git then refuses the branch as not merged.
|
|
627
|
+
"""
|
|
628
|
+
base_oid_by_ref: dict[str, str | None] = {}
|
|
629
|
+
for full_ref in branch_refs:
|
|
630
|
+
upstream = snapshot.upstream_by_name.get(full_ref)
|
|
631
|
+
upstream_oid = snapshot.object_oid_by_name.get(upstream) if upstream else None
|
|
632
|
+
base_oid_by_ref[full_ref] = upstream_oid or snapshot.head_oid
|
|
633
|
+
return base_oid_by_ref
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def reference_git_dirs(context: RepositoryContext) -> list[str]:
|
|
637
|
+
"""Include each worktree's private administration when protecting branch operations."""
|
|
638
|
+
directories = [context.common_git_dir]
|
|
639
|
+
if context.git_dir != context.common_git_dir:
|
|
640
|
+
directories.append(context.git_dir)
|
|
641
|
+
worktree_dir = os.path.join(context.common_git_dir, "worktrees")
|
|
642
|
+
if os.path.isdir(worktree_dir):
|
|
643
|
+
with os.scandir(worktree_dir) as entries:
|
|
644
|
+
for entry in entries:
|
|
645
|
+
if entry.is_dir(follow_symlinks=False) and entry.path not in directories:
|
|
646
|
+
directories.append(entry.path)
|
|
647
|
+
return directories
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def remote_branch_name(snapshot: ReferenceSnapshot, full_ref: str) -> str:
|
|
651
|
+
"""Remove the actual remote prefix, including remote names containing slashes."""
|
|
652
|
+
for remote_name in sorted(snapshot.remote_names, key=len, reverse=True):
|
|
653
|
+
prefix = REMOTE_PREFIX + remote_name + "/"
|
|
654
|
+
if full_ref.startswith(prefix):
|
|
655
|
+
return full_ref.removeprefix(prefix)
|
|
656
|
+
raise ValueError("The selected remote reference has no configured remote")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def suggested_branch_name(snapshot: ReferenceSnapshot, target_oid: str) -> str:
|
|
660
|
+
"""Propose a branch name for a commit the way Git Extensions fills its Create branch window, without any Git process.
|
|
661
|
+
|
|
662
|
+
The first local branch on the commit gives its name; otherwise the first remote branch gives its name without the
|
|
663
|
+
remote; otherwise the first tag gives its name; otherwise nothing is proposed. References are taken in the order
|
|
664
|
+
Git lists them, by full name, and symbolic ones are skipped.
|
|
665
|
+
"""
|
|
666
|
+
local_names: list[str] = []
|
|
667
|
+
remote_names: list[str] = []
|
|
668
|
+
tag_names: list[str] = []
|
|
669
|
+
for reference in sorted(snapshot.references, key=lambda reference: reference.full_name):
|
|
670
|
+
if not target_oid or reference.target_oid != target_oid or reference.symbolic_target:
|
|
671
|
+
continue
|
|
672
|
+
full_name = reference.full_name
|
|
673
|
+
if full_name.startswith(LOCAL_PREFIX):
|
|
674
|
+
local_names.append(full_name.removeprefix(LOCAL_PREFIX))
|
|
675
|
+
elif full_name.startswith(REMOTE_PREFIX):
|
|
676
|
+
try:
|
|
677
|
+
remote_names.append(remote_branch_name(snapshot, full_name))
|
|
678
|
+
except ValueError:
|
|
679
|
+
# A remote branch whose remote is no longer configured has no name to offer.
|
|
680
|
+
continue
|
|
681
|
+
elif full_name.startswith(TAG_PREFIX):
|
|
682
|
+
tag_names.append(full_name.removeprefix(TAG_PREFIX))
|
|
683
|
+
for names in (local_names, remote_names, tag_names):
|
|
684
|
+
if names:
|
|
685
|
+
first_name_index = 0
|
|
686
|
+
return names[first_name_index]
|
|
687
|
+
return ""
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _refspec_match(pattern: str, full_ref: str) -> str | None:
|
|
691
|
+
"""Return the wildcard replacement, distinguishing no match from an exact match."""
|
|
692
|
+
prefix, wildcard, suffix = pattern.partition("*")
|
|
693
|
+
if not wildcard:
|
|
694
|
+
return "" if pattern == full_ref else None
|
|
695
|
+
if not full_ref.startswith(prefix) or not full_ref.endswith(suffix) or len(full_ref) < len(prefix) + len(suffix):
|
|
696
|
+
return None
|
|
697
|
+
end = len(full_ref) - len(suffix) if suffix else len(full_ref)
|
|
698
|
+
return full_ref[len(prefix):end]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def remote_branch_destination(snapshot: RemoteSnapshot, full_ref: str) -> tuple[str, str]:
|
|
702
|
+
"""Invert configured Fetch mappings and refuse excluded or ambiguous server branches."""
|
|
703
|
+
from gitpane.transport.remote_config import field_values
|
|
704
|
+
|
|
705
|
+
destination_set: set[tuple[str, str]] = set()
|
|
706
|
+
for name, entry in sorted(snapshot.entry_by_name.items()):
|
|
707
|
+
if not entry.active or entry.collision:
|
|
708
|
+
continue
|
|
709
|
+
refspecs = tuple(value.value for value in field_values(snapshot, name, "fetch"))
|
|
710
|
+
for refspec in refspecs:
|
|
711
|
+
if refspec.startswith("^"):
|
|
712
|
+
continue
|
|
713
|
+
source, _separator, destination = refspec.removeprefix("+").partition(":")
|
|
714
|
+
if (not source.startswith(LOCAL_PREFIX) or not destination.startswith(REMOTE_PREFIX) or source.count("*") > 1
|
|
715
|
+
or source.count("*") != destination.count("*")):
|
|
716
|
+
continue
|
|
717
|
+
replacement = _refspec_match(destination, full_ref)
|
|
718
|
+
if replacement is None:
|
|
719
|
+
continue
|
|
720
|
+
server_ref = source.replace("*", replacement)
|
|
721
|
+
if any(_refspec_match(negative.removeprefix("^"), server_ref) is not None for negative in refspecs if negative.startswith("^")):
|
|
722
|
+
continue
|
|
723
|
+
destination_set.add((name, server_ref))
|
|
724
|
+
if len(destination_set) != 1:
|
|
725
|
+
raise ValueError("The selected remote branch has no unique Fetch mapping; review its configuration")
|
|
726
|
+
return next(iter(destination_set))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def remote_delete_candidates(snapshot: ReferenceSnapshot, remote_snapshot: RemoteSnapshot, full_ref: str, session_id: str) -> tuple[ReferenceDeletionIntent, ...]:
|
|
730
|
+
"""Capture local branches whose configured and native upstream matches this remote ref."""
|
|
731
|
+
from gitpane.transport.remote_config import tracking_values
|
|
732
|
+
|
|
733
|
+
name, destination = remote_branch_destination(remote_snapshot, full_ref)
|
|
734
|
+
candidates: list[ReferenceDeletionIntent] = []
|
|
735
|
+
for branch_ref, tracking in sorted(remote_snapshot.tracking_by_ref.items()):
|
|
736
|
+
remotes = tracking_values(remote_snapshot, branch_ref, "remote")
|
|
737
|
+
merges = tracking_values(remote_snapshot, branch_ref, "merge")
|
|
738
|
+
if (len(remotes) != 1 or next(iter(remotes)).value != name or not any(value.value == destination for value in merges)
|
|
739
|
+
or not tracking.observed or tracking.upstream != full_ref or snapshot.object_oid_by_name.get(branch_ref) != tracking.oid):
|
|
740
|
+
continue
|
|
741
|
+
candidates.append(ReferenceDeletionIntent.capture(snapshot, branch_ref, session_id=session_id))
|
|
742
|
+
return tuple(candidates)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _validate_deletion_authority(intent: ReferenceDeletionIntent) -> None:
|
|
746
|
+
"""Refuse a deletion whose operation no longer names the captured reference and object."""
|
|
747
|
+
snapshot = intent.snapshot
|
|
748
|
+
operation = intent.operation
|
|
749
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename
|
|
750
|
+
or operation.generation != snapshot.generation or operation.targets != (intent.full_ref, intent.target_oid)
|
|
751
|
+
or not intent.full_ref.startswith((LOCAL_PREFIX, TAG_PREFIX))):
|
|
752
|
+
raise ValueError("Deletion does not match its captured reference authority")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def reference_signature_paths(context: RepositoryContext) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
756
|
+
"""List every file the reference signature reads, then every folder whose entries decide that list.
|
|
757
|
+
|
|
758
|
+
The folders are the roots of the walk, present or absent, and every folder it entered: a reference created in a
|
|
759
|
+
folder that was empty, or a table added to a reftable store, changes one of them before it appears as a file.
|
|
760
|
+
Both lists are deduplicated and sorted, so two walks of an unchanged repository give the same answer.
|
|
761
|
+
"""
|
|
762
|
+
filename_set: set[str] = set()
|
|
763
|
+
directory_set: set[str] = set()
|
|
764
|
+
seen_dir_set: set[str] = set()
|
|
765
|
+
for directory in reference_git_dirs(context):
|
|
766
|
+
if directory in seen_dir_set:
|
|
767
|
+
continue
|
|
768
|
+
seen_dir_set.add(directory)
|
|
769
|
+
# The shallow list and the graft file change which commits count as ancestors while every object ID stays
|
|
770
|
+
# the same, so a merge proof computed before them must not survive their change.
|
|
771
|
+
for basename in ("HEAD", "packed-refs", "config", "config.worktree", "shallow"):
|
|
772
|
+
filename_set.add(os.path.join(directory, basename))
|
|
773
|
+
for relative_parts in (("rebase-merge", "head-name"), ("rebase-apply", "head-name"), ("BISECT_START",), ("info", "grafts")):
|
|
774
|
+
filename_set.add(os.path.join(directory, *relative_parts))
|
|
775
|
+
worktree_dir = os.path.join(directory, "worktrees")
|
|
776
|
+
directory_set.add(worktree_dir)
|
|
777
|
+
if os.path.isdir(worktree_dir):
|
|
778
|
+
with os.scandir(worktree_dir) as entries:
|
|
779
|
+
for entry in entries:
|
|
780
|
+
if entry.is_dir(follow_symlinks=False):
|
|
781
|
+
for basename in ("HEAD", "gitdir", "locked"):
|
|
782
|
+
filename_set.add(os.path.join(entry.path, basename))
|
|
783
|
+
pending_dirs = [os.path.join(directory, "refs"), os.path.join(directory, GIT_REFTABLE_DIR_BASENAME)]
|
|
784
|
+
directory_set.update(pending_dirs)
|
|
785
|
+
while pending_dirs:
|
|
786
|
+
current_dir = pending_dirs.pop()
|
|
787
|
+
if not os.path.isdir(current_dir):
|
|
788
|
+
continue
|
|
789
|
+
directory_set.add(current_dir)
|
|
790
|
+
with os.scandir(current_dir) as entries:
|
|
791
|
+
for entry in entries:
|
|
792
|
+
if entry.is_dir(follow_symlinks=False):
|
|
793
|
+
pending_dirs.append(entry.path)
|
|
794
|
+
else:
|
|
795
|
+
filename_set.add(entry.path)
|
|
796
|
+
return tuple(sorted(filename_set)), tuple(sorted(directory_set))
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def reference_signature(context: RepositoryContext) -> tuple:
|
|
800
|
+
"""Detect reference or configuration changes while a validated command waits to launch."""
|
|
801
|
+
records: list[tuple] = []
|
|
802
|
+
filenames, _directories = reference_signature_paths(context)
|
|
803
|
+
for filename in filenames:
|
|
804
|
+
try:
|
|
805
|
+
metadata = os.stat(filename)
|
|
806
|
+
with open(filename, "rb") as stream:
|
|
807
|
+
content = stream.read()
|
|
808
|
+
records.append((filename, metadata.st_ino, metadata.st_mtime_ns, content))
|
|
809
|
+
except FileNotFoundError:
|
|
810
|
+
records.append((filename, None))
|
|
811
|
+
return tuple(sorted(records))
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@dataclass(frozen=True)
|
|
815
|
+
class DeleteMergeProof:
|
|
816
|
+
"""Remember whether one branch was merged, for one exact deletion request and the repository view it was read on.
|
|
817
|
+
|
|
818
|
+
It never authorizes a write: a deletion still captures its references, validates them and checks them again at
|
|
819
|
+
launch. It only spares a second ancestry check when that fresh capture matches this one exactly, ancestry view
|
|
820
|
+
included, because the answer could not have changed.
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
operation_id: str
|
|
824
|
+
key: WorktreeKey
|
|
825
|
+
context: RepositoryContext
|
|
826
|
+
git_filename: str
|
|
827
|
+
full_ref: str
|
|
828
|
+
target_oid: str
|
|
829
|
+
head_oid: str
|
|
830
|
+
head_ref: str | None
|
|
831
|
+
signature: tuple
|
|
832
|
+
worktrees: bytes
|
|
833
|
+
merged: bool
|
|
834
|
+
|
|
835
|
+
def matches(self, intent: ReferenceDeletionIntent, current: ReferenceSnapshot) -> bool:
|
|
836
|
+
"""Tell whether this proof was computed for this very request on exactly this captured state."""
|
|
837
|
+
return (self.operation_id == intent.operation.operation_id and self.key == WorktreeKey.from_context(current.context) and self.context == current.context
|
|
838
|
+
and self.git_filename == current.git_filename and self.full_ref == intent.full_ref and self.target_oid == intent.target_oid
|
|
839
|
+
and self.head_oid == current.head_oid and self.head_ref == current.head_ref and self.signature == current.signature and self.worktrees == current.worktrees)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
class ReferenceService:
|
|
843
|
+
"""Use the shared native runner and repository owner for read and write lifetimes."""
|
|
844
|
+
|
|
845
|
+
def __init__(self, worktree_service: WorktreeService, repository_operation: RepositoryOperationService, *, refresh_advice: Callable[..., str]) -> None:
|
|
846
|
+
"""Inject existing owners so reference edits share the common repository reservation.
|
|
847
|
+
|
|
848
|
+
``refresh_advice`` writes the refusals of an outdated capture: they name the Refresh action of the repository
|
|
849
|
+
window and its shortcut on this system, which belong to the interface, so this service receives the writer
|
|
850
|
+
instead of knowing them.
|
|
851
|
+
"""
|
|
852
|
+
self.worktree_service = worktree_service
|
|
853
|
+
self.repository_operation = repository_operation
|
|
854
|
+
self.refresh_advice = refresh_advice
|
|
855
|
+
self.outcome_by_id: dict[str, OperationOutcome] = {}
|
|
856
|
+
# At most one merge result, left by the last deletion preparation for that request's next attempt.
|
|
857
|
+
self.delete_merge_proof: DeleteMergeProof | None = None
|
|
858
|
+
|
|
859
|
+
def _submit(self, operation: OperationIntent, sequence: Generator) -> str:
|
|
860
|
+
"""Retain immediate policy refusals alongside completed native reference results."""
|
|
861
|
+
operation_id = self.repository_operation.submit(operation, sequence)
|
|
862
|
+
outcome = self.repository_operation.outcome_by_id.get(operation_id)
|
|
863
|
+
if outcome is not None:
|
|
864
|
+
self.outcome_by_id[operation_id] = outcome
|
|
865
|
+
return operation_id
|
|
866
|
+
|
|
867
|
+
def capture(self, context: RepositoryContext, git_filename: str, generation: int = 0) -> Generator:
|
|
868
|
+
"""Read full names, object identities, branch upstreams and HEAD without guessing missing or partial values.
|
|
869
|
+
|
|
870
|
+
Git also names the administration directory it really read, which proves the repository identity. HEAD
|
|
871
|
+
comes from the listing when a local branch carries the current marker; a detached or unborn HEAD is asked
|
|
872
|
+
of Git separately, because the listing alone cannot tell either state apart from a missing marker.
|
|
873
|
+
"""
|
|
874
|
+
signature = yield FileTask(lambda: reference_signature(context))
|
|
875
|
+
outputs = yield ReadBatch((GitCommand(["for-each-ref", "--format=" + REFERENCE_FORMAT]), GitCommand(["worktree", "list", "--porcelain", "-z"]),
|
|
876
|
+
GitCommand(["remote"]), GitCommand(["rev-parse", "--path-format=absolute", "--git-dir"])))
|
|
877
|
+
output, worktrees, remote_output, git_dir_output = outputs
|
|
878
|
+
verify_git_dir_identity(context, git_dir_output)
|
|
879
|
+
reference_read = parse_reference_read(output)
|
|
880
|
+
references, object_oid_by_name, head_oid, head_ref = reference_read.references, reference_read.object_oid_by_name, reference_read.head_oid, reference_read.head_ref
|
|
881
|
+
if head_ref is None:
|
|
882
|
+
head_outputs = yield ReadBatch((GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1)),
|
|
883
|
+
GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))))
|
|
884
|
+
head, symbolic = head_outputs
|
|
885
|
+
head_oid = head.decode().strip() or None
|
|
886
|
+
head_ref = symbolic.decode().strip() or None
|
|
887
|
+
references = mark_head(references, head_ref)
|
|
888
|
+
final_signature = yield FileTask(lambda: reference_signature(context))
|
|
889
|
+
if signature != final_signature:
|
|
890
|
+
raise ValueError(self.refresh_advice(SharedText.REFERENCE_CHANGED_DURING_READING))
|
|
891
|
+
return ReferenceSnapshot(context, git_filename, generation, references, object_oid_by_name, head_oid, head_ref,
|
|
892
|
+
worktrees, signature, tuple(remote_output.decode("utf-8", "surrogateescape").splitlines()), reference_read,
|
|
893
|
+
reference_read.upstream_by_name, reference_read.upstream_remote_by_name)
|
|
894
|
+
|
|
895
|
+
def _with_stash_list(self, context: RepositoryContext, snapshot: ReferenceSnapshot) -> Generator:
|
|
896
|
+
"""Attach the stash list, read from the stash reflog off the Qt thread, to a capture shown in a window.
|
|
897
|
+
|
|
898
|
+
The reflog is read on its own rather than added to the reference signature: that signature also checks the
|
|
899
|
+
launch of writes such as rebase or replay, which a concurrent stash must not make fail.
|
|
900
|
+
"""
|
|
901
|
+
stash_list = yield FileTask(lambda: read_stash_list(context))
|
|
902
|
+
if snapshot.stash_list == stash_list:
|
|
903
|
+
return snapshot
|
|
904
|
+
return replace(snapshot, stash_list=stash_list)
|
|
905
|
+
|
|
906
|
+
def read(self, context: RepositoryContext, git_filename: str, generation: int, callback: Callable, *, with_stash_list: bool = False) -> None:
|
|
907
|
+
"""Publish a captured reference set asynchronously to its owning view, with the stash list when a window shows it."""
|
|
908
|
+
def sequence() -> Generator:
|
|
909
|
+
"""Capture the references, then read the stash list when asked."""
|
|
910
|
+
snapshot = yield from self.capture(context, git_filename, generation)
|
|
911
|
+
if not with_stash_list:
|
|
912
|
+
return snapshot
|
|
913
|
+
return (yield from self._with_stash_list(context, snapshot))
|
|
914
|
+
|
|
915
|
+
self.worktree_service.run(context, git_filename, sequence(), callback)
|
|
916
|
+
|
|
917
|
+
def read_display(self, context: RepositoryContext, git_filename: str, generation: int, callback: Callable, *, previous: ReferenceSnapshot | None = None,
|
|
918
|
+
with_stash_list: bool = False) -> None:
|
|
919
|
+
"""Publish references for display, showing a previous capture again only when nothing it was read from has changed.
|
|
920
|
+
|
|
921
|
+
A previous capture of this very repository, Git and generation is checked against a fresh reading of every
|
|
922
|
+
reference file, off the Qt thread. Any difference, or any failure to read those files, falls back to the
|
|
923
|
+
usual native capture, whose own errors are reported. Nothing that writes uses this: every write captures its
|
|
924
|
+
references again and checks them at launch.
|
|
925
|
+
|
|
926
|
+
The stash list, when asked, is read again even for a reused capture: the reference files do not cover the
|
|
927
|
+
stash reflog, and a capture handed over by an operation or a navigation carries no list at all.
|
|
928
|
+
"""
|
|
929
|
+
def sequence() -> Generator:
|
|
930
|
+
"""Answer the same capture when its reference files are unchanged, and a fresh capture otherwise."""
|
|
931
|
+
snapshot = None
|
|
932
|
+
if previous is not None and previous.context == context and previous.git_filename == git_filename and previous.generation == generation:
|
|
933
|
+
try:
|
|
934
|
+
signature = yield FileTask(lambda: reference_signature(context))
|
|
935
|
+
except OSError:
|
|
936
|
+
signature = None
|
|
937
|
+
if signature == previous.signature:
|
|
938
|
+
snapshot = previous
|
|
939
|
+
if snapshot is None:
|
|
940
|
+
snapshot = yield from self.capture(context, git_filename, generation)
|
|
941
|
+
if not with_stash_list:
|
|
942
|
+
return snapshot
|
|
943
|
+
return (yield from self._with_stash_list(context, snapshot))
|
|
944
|
+
|
|
945
|
+
self.worktree_service.run(context, git_filename, sequence(), callback)
|
|
946
|
+
|
|
947
|
+
def difference(self, snapshot: ReferenceSnapshot, target_oid: str, callback: Callable) -> None:
|
|
948
|
+
"""Count commits unique to the captured HEAD and target, preserving unknown results."""
|
|
949
|
+
def sequence() -> Generator:
|
|
950
|
+
"""Return only complete native left/right counts for the captured commits."""
|
|
951
|
+
if snapshot.head_oid is None:
|
|
952
|
+
if not snapshot.head_ref or not snapshot.head_ref.startswith(LOCAL_PREFIX) or snapshot.head_ref in snapshot.object_oid_by_name:
|
|
953
|
+
raise ValueError("HEAD could not be read as a valid unborn branch")
|
|
954
|
+
output = yield GitCommand(["rev-list", "--count", target_oid, "--"])
|
|
955
|
+
return 0, int(output.strip())
|
|
956
|
+
output = yield GitCommand(["rev-list", "--left-right", "--count", snapshot.head_oid + "..." + target_oid, "--"])
|
|
957
|
+
fields = output.split()
|
|
958
|
+
expected_count = 2
|
|
959
|
+
if len(fields) != expected_count:
|
|
960
|
+
raise ValueError("Incomplete commit difference returned by Git")
|
|
961
|
+
left, right = fields
|
|
962
|
+
return int(left), int(right)
|
|
963
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, sequence(), callback)
|
|
964
|
+
|
|
965
|
+
def commits_only_in(self, snapshot: ReferenceSnapshot, local_oid: str, remote_oid: str, callback: Callable) -> None:
|
|
966
|
+
"""Count the commits of a local branch that its remote branch lacks: the ones resetting it onto the remote would lose.
|
|
967
|
+
|
|
968
|
+
Two branches on the same commit lose nothing, so the callback gets 0 at once without starting Git.
|
|
969
|
+
"""
|
|
970
|
+
if local_oid == remote_oid:
|
|
971
|
+
callback(0, None)
|
|
972
|
+
return
|
|
973
|
+
|
|
974
|
+
def sequence() -> Generator:
|
|
975
|
+
"""Return the count Git prints, refusing anything that is not a plain number."""
|
|
976
|
+
output = yield GitCommand(["rev-list", "--count", remote_oid + ".." + local_oid, "--"])
|
|
977
|
+
text = output.decode("ascii", "replace").strip()
|
|
978
|
+
if not text.isdigit():
|
|
979
|
+
raise ValueError("Git returned no commit count")
|
|
980
|
+
return int(text)
|
|
981
|
+
|
|
982
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, sequence(), callback)
|
|
983
|
+
|
|
984
|
+
def resolve_revision(self, snapshot: ReferenceSnapshot, text: str, callback: Callable) -> None:
|
|
985
|
+
"""Ask Git once which commit a typed revision names; the callback receives its full ID, or None when it names none.
|
|
986
|
+
|
|
987
|
+
A text Git must never receive is refused here, before any process. Git is told where its options end, so the
|
|
988
|
+
remaining text can never be read as one, and ``^{commit}`` makes an annotated tag or any other expression answer
|
|
989
|
+
with the commit it leads to, or with nothing.
|
|
990
|
+
"""
|
|
991
|
+
problem = revision_text_problem(text)
|
|
992
|
+
if problem:
|
|
993
|
+
raise ValueError(problem)
|
|
994
|
+
|
|
995
|
+
def sequence() -> Generator:
|
|
996
|
+
"""Return the validated commit ID Git prints, or None when Git finds no such commit."""
|
|
997
|
+
output = yield GitCommand(["rev-parse", "--verify", "--quiet", "--end-of-options", text + COMMIT_PEEL_SUFFIX], accepted_codes=REVISION_READ_EXPECTED_CODES)
|
|
998
|
+
oid = output.decode("utf-8", "replace").strip()
|
|
999
|
+
if not oid:
|
|
1000
|
+
return None
|
|
1001
|
+
return validate_oid(oid)
|
|
1002
|
+
|
|
1003
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, sequence(), callback)
|
|
1004
|
+
|
|
1005
|
+
def checkout(self, intent: CheckoutIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1006
|
+
"""Submit one checkout once and retain native failures with the observed final state."""
|
|
1007
|
+
return self._submit(intent.operation, self._checkout(intent, is_current))
|
|
1008
|
+
|
|
1009
|
+
def merge_work_at_risk(self, context: RepositoryContext, git_filename: str, callback: Callable[[object, Exception | None], None]) -> None:
|
|
1010
|
+
"""Answer True when aborting the merge in progress would throw away changes made since the merge stopped.
|
|
1011
|
+
|
|
1012
|
+
Git keeps the tree its merge left in AUTO_MERGE; the working tree still matching it means nothing was resolved
|
|
1013
|
+
or edited since. One Git process answers: no difference is False, while differences, a missing AUTO_MERGE
|
|
1014
|
+
(a merge strategy that does not write it) or a read that fails all count as work to lose, so the user is asked.
|
|
1015
|
+
A submodule whose own content changed is left out: the Abort does not touch submodule content.
|
|
1016
|
+
"""
|
|
1017
|
+
def sequence() -> Generator:
|
|
1018
|
+
"""Compare the working tree with the tree the merge left, treating any failure as a difference."""
|
|
1019
|
+
try:
|
|
1020
|
+
# Without these options the user's external diff program could run and its answer become the exit code.
|
|
1021
|
+
yield GitCommand(["diff", "--quiet", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", "AUTO_MERGE", "--"])
|
|
1022
|
+
except (OSError, ValueError, RuntimeError):
|
|
1023
|
+
return True
|
|
1024
|
+
return False
|
|
1025
|
+
|
|
1026
|
+
self.worktree_service.run(context, git_filename, sequence(), callback)
|
|
1027
|
+
|
|
1028
|
+
def abort_merge(self, intent: MergeAbortIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1029
|
+
"""Submit Git's own merge --abort once, under the repository reservation, and retain its observed result."""
|
|
1030
|
+
return self._submit(intent.operation, self._abort_merge(intent, is_current))
|
|
1031
|
+
|
|
1032
|
+
def capture_reset(self, snapshot: ReferenceSnapshot, target_oid: str) -> Generator:
|
|
1033
|
+
"""Capture a coherent local state without reserving a mutation during user review."""
|
|
1034
|
+
validate_oid(target_oid)
|
|
1035
|
+
outputs = yield ReadBatch((GitCommand(["ls-files", "-z", "--cached", "--others", "--exclude-standard"]),
|
|
1036
|
+
GitCommand(["ls-tree", "-r", "-z", "--format=%(objecttype)%x09%(path)", target_oid])))
|
|
1037
|
+
index_output, tree_output = outputs
|
|
1038
|
+
path_set = set(index_output.split(b"\0"))
|
|
1039
|
+
target_paths: list[bytes] = []
|
|
1040
|
+
for record in tree_output.split(b"\0"):
|
|
1041
|
+
kind, _, path = record.partition(b"\t")
|
|
1042
|
+
if kind == b"blob":
|
|
1043
|
+
target_paths.append(path)
|
|
1044
|
+
path_set.add(path)
|
|
1045
|
+
path_set.discard(b"")
|
|
1046
|
+
paths = tuple(sorted(path_set))
|
|
1047
|
+
target_paths = tuple(target_paths)
|
|
1048
|
+
signature = yield FileTask(lambda: working_signature(snapshot.context, paths, target_paths))
|
|
1049
|
+
local = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1050
|
+
final_signature = yield FileTask(lambda: working_signature(snapshot.context, paths, target_paths))
|
|
1051
|
+
if signature != final_signature or local.head_oid != snapshot.head_oid or reference_signature(snapshot.context) != snapshot.signature:
|
|
1052
|
+
raise ValueError("Repository changed during reading; reopen Reset")
|
|
1053
|
+
return local, signature, paths, target_paths
|
|
1054
|
+
|
|
1055
|
+
def reset(self, intent: ResetIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1056
|
+
"""Run the captured native reset once and retain its observed effects."""
|
|
1057
|
+
return self._submit(intent.operation, self._reset(intent, is_current))
|
|
1058
|
+
|
|
1059
|
+
def _reset(self, intent: ResetIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1060
|
+
"""Validate the form and local bytes again when the native process actually starts.
|
|
1061
|
+
|
|
1062
|
+
A reset Git completed is proven by the final branch and commit, read from a fresh reference capture. Only a
|
|
1063
|
+
native reset that started and then failed reads the whole working tree again: it may have rewritten the
|
|
1064
|
+
index or files before stopping, and only that read compared with the initial one tells what it changed.
|
|
1065
|
+
"""
|
|
1066
|
+
snapshot = intent.snapshot
|
|
1067
|
+
before = intent.worktree_snapshot
|
|
1068
|
+
after = None
|
|
1069
|
+
refreshed = None
|
|
1070
|
+
diagnostic = None
|
|
1071
|
+
process_status = None
|
|
1072
|
+
status = OperationStatus.REFUSED
|
|
1073
|
+
native_started = False
|
|
1074
|
+
try:
|
|
1075
|
+
operation = intent.operation
|
|
1076
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1077
|
+
or operation.targets != (intent.full_ref, intent.target_oid, intent.mode) or intent.full_ref != snapshot.head_ref
|
|
1078
|
+
or not snapshot.head_ref or not snapshot.head_oid
|
|
1079
|
+
or before is None and intent.mode != ResetMode.SOFT or intent.mode == ResetMode.HARD and not intent.hard_confirmed
|
|
1080
|
+
or before is not None and (before.context != snapshot.context or before.generation != snapshot.generation or before.git_filename != snapshot.git_filename)):
|
|
1081
|
+
raise ValueError("Reset does not match its captured branch and local state")
|
|
1082
|
+
validate_oid(intent.target_oid)
|
|
1083
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1084
|
+
if current.signature != snapshot.signature or current.worktrees != snapshot.worktrees:
|
|
1085
|
+
raise ValueError("References, HEAD or worktrees changed; reopen Reset")
|
|
1086
|
+
checkout_count = current.worktrees.split(b"\0").count(b"branch " + intent.full_ref.encode("utf-8", "surrogateescape"))
|
|
1087
|
+
if checkout_count != 1:
|
|
1088
|
+
raise ValueError("The current branch is checked out in another worktree; reset it only when it is exclusively checked out here")
|
|
1089
|
+
yield GitCommand(["cat-file", "-e", intent.target_oid + COMMIT_PEEL_SUFFIX])
|
|
1090
|
+
|
|
1091
|
+
def validate_launch() -> None:
|
|
1092
|
+
"""Reject external changes during confirmation or while the process is queued."""
|
|
1093
|
+
nonlocal native_started
|
|
1094
|
+
self.repository_operation.validate(operation)
|
|
1095
|
+
if (is_current is not None and not is_current() or reference_signature(snapshot.context) != snapshot.signature
|
|
1096
|
+
or before is not None and (read_index(before.index_filename) != before.index_content
|
|
1097
|
+
or working_signature(snapshot.context, intent.working_paths, intent.reset_target_paths) != intent.working_signature)):
|
|
1098
|
+
raise ValueError("HEAD, references, index, working files or reset authority changed; reopen Reset")
|
|
1099
|
+
native_started = True
|
|
1100
|
+
|
|
1101
|
+
yield _parent_write_command(["reset", "--" + intent.mode.value, intent.target_oid, "--"], validate_launch)
|
|
1102
|
+
status = OperationStatus.SUCCESS
|
|
1103
|
+
process_status = ProcessStatus.SUCCESS
|
|
1104
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1105
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Reset current branch", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1106
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1107
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1108
|
+
try:
|
|
1109
|
+
if status == OperationStatus.FAILED and before is not None:
|
|
1110
|
+
after = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1111
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1112
|
+
if status == OperationStatus.SUCCESS and (refreshed.head_oid != intent.target_oid or refreshed.head_ref != snapshot.head_ref):
|
|
1113
|
+
raise ValueError("Git exited successfully but the expected branch and commit were not observed")
|
|
1114
|
+
changed = native_started and (refreshed.head_oid != snapshot.head_oid or before is not None and after is not None and
|
|
1115
|
+
(after.index_content != before.index_content or after.fingerprint_by_path != before.fingerprint_by_path))
|
|
1116
|
+
if status == OperationStatus.FAILED and changed:
|
|
1117
|
+
status = OperationStatus.PARTIAL
|
|
1118
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1119
|
+
if diagnostic is None:
|
|
1120
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Reset current branch", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1121
|
+
status = OperationStatus.UNKNOWN
|
|
1122
|
+
step = OperationStepResult("Reset current branch", status, before, after if after is not None else refreshed, process_status,
|
|
1123
|
+
"reset to " + intent.target_oid if status == OperationStatus.SUCCESS else "inspect final state", diagnostic)
|
|
1124
|
+
outcome = OperationOutcome(intent.operation.operation_id, status, (step,), diagnostic)
|
|
1125
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
1126
|
+
return outcome
|
|
1127
|
+
|
|
1128
|
+
def metadata(self, snapshot: ReferenceSnapshot, target_oid: str, callback: Callable) -> None:
|
|
1129
|
+
"""Read the selected commit's full metadata without using the current graph selection."""
|
|
1130
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, read_commit_metadata(target_oid), callback)
|
|
1131
|
+
|
|
1132
|
+
def create_branch(self, intent: BranchIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1133
|
+
"""Reserve branch creation and report its observed effect independently of checkout.
|
|
1134
|
+
|
|
1135
|
+
An orphan branch has no reference until its first commit, so it follows its own sequence and proof.
|
|
1136
|
+
"""
|
|
1137
|
+
if intent.mode == BranchMode.CREATE_ORPHAN:
|
|
1138
|
+
return self._submit(intent.operation, self._create_orphan(intent, is_current))
|
|
1139
|
+
return self._submit(intent.operation, self._edit_branch(intent, is_current, (BranchMode.CREATE, BranchMode.CREATE_CHECKOUT)))
|
|
1140
|
+
|
|
1141
|
+
def inspect_delete(self, intent: ReferenceDeletionIntent) -> Generator:
|
|
1142
|
+
"""Revalidate the captured object and require a merge proof only for local branches.
|
|
1143
|
+
|
|
1144
|
+
The merge result is left for the next attempt of this same request, so a submission right after the form
|
|
1145
|
+
prepared it does not ask Git the same question again on an unchanged repository.
|
|
1146
|
+
"""
|
|
1147
|
+
_validate_deletion_authority(intent)
|
|
1148
|
+
snapshot = intent.snapshot
|
|
1149
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1150
|
+
merged = yield from self._inspect_captured_deletion(intent, current, preparing=True)
|
|
1151
|
+
return merged
|
|
1152
|
+
|
|
1153
|
+
def _inspect_captured_deletion(self, intent: ReferenceDeletionIntent, current: ReferenceSnapshot, *, preparing: bool) -> Generator:
|
|
1154
|
+
"""Check one deletion against a capture the caller just made, without reading the references again.
|
|
1155
|
+
|
|
1156
|
+
A preparation computes the merge result and leaves it for its request. An attempt takes its own request's
|
|
1157
|
+
result, and uses it only when its fresh capture matches the one the result was computed on; otherwise it
|
|
1158
|
+
asks Git again. A failed or stopped ancestry check is an error, never a remembered answer.
|
|
1159
|
+
"""
|
|
1160
|
+
snapshot = intent.snapshot
|
|
1161
|
+
if current.signature != snapshot.signature or current.worktrees != snapshot.worktrees:
|
|
1162
|
+
raise ValueError("References, HEAD or worktrees changed; reopen the deletion form")
|
|
1163
|
+
if current.object_oid_by_name.get(intent.full_ref) != intent.target_oid:
|
|
1164
|
+
raise ValueError(self.refresh_advice(SharedText.SELECTED_REFERENCE_CHANGED))
|
|
1165
|
+
if intent.full_ref.startswith(TAG_PREFIX):
|
|
1166
|
+
yield GitCommand(["check-ref-format", intent.full_ref])
|
|
1167
|
+
return True
|
|
1168
|
+
if intent.full_ref in occupied_branch_set(current):
|
|
1169
|
+
worktree_dir = current.context.repository_dir
|
|
1170
|
+
for field in current.worktrees.split(b"\0"):
|
|
1171
|
+
if field.startswith(b"worktree "):
|
|
1172
|
+
worktree_dir = field.removeprefix(b"worktree ").decode("utf-8", "surrogateescape")
|
|
1173
|
+
if field == ("branch " + intent.full_ref).encode("utf-8", "surrogateescape"):
|
|
1174
|
+
break
|
|
1175
|
+
branch = intent.full_ref.removeprefix(LOCAL_PREFIX)
|
|
1176
|
+
raise ValueError(f"Branch '{branch}' is checked out in '{worktree_dir}'. Switch that worktree to another branch before trying again.")
|
|
1177
|
+
if not current.head_oid:
|
|
1178
|
+
raise ValueError("A current HEAD commit is required to check whether the branch is merged")
|
|
1179
|
+
proof = self.delete_merge_proof
|
|
1180
|
+
if not preparing and proof is not None and proof.operation_id == intent.operation.operation_id:
|
|
1181
|
+
self.delete_merge_proof = None
|
|
1182
|
+
if proof.matches(intent, current):
|
|
1183
|
+
return proof.merged
|
|
1184
|
+
try:
|
|
1185
|
+
yield GitCommand(["merge-base", "--is-ancestor", intent.target_oid, current.head_oid])
|
|
1186
|
+
merged = True
|
|
1187
|
+
except GitCommandError as error:
|
|
1188
|
+
if error.return_code != 1 or error.process_status != ProcessStatus.FAILED:
|
|
1189
|
+
raise
|
|
1190
|
+
merged = False
|
|
1191
|
+
final_signature = yield FileTask(lambda: reference_signature(current.context))
|
|
1192
|
+
if final_signature != current.signature:
|
|
1193
|
+
raise ValueError("References or the ancestry view changed while checking the merge; reopen the deletion form")
|
|
1194
|
+
if preparing:
|
|
1195
|
+
self.delete_merge_proof = DeleteMergeProof(intent.operation.operation_id, WorktreeKey.from_context(current.context), current.context, current.git_filename,
|
|
1196
|
+
intent.full_ref, intent.target_oid, current.head_oid, current.head_ref, current.signature, current.worktrees, merged)
|
|
1197
|
+
return merged
|
|
1198
|
+
|
|
1199
|
+
def merged_branch_refs(self, snapshot: ReferenceSnapshot, base_oid_by_ref: dict[str, str | None]) -> Generator:
|
|
1200
|
+
"""Ask Git which branches are merged into their base, when the history in memory cannot tell.
|
|
1201
|
+
|
|
1202
|
+
One read per distinct base commit, limited to the branches compared with it; a branch without a base is
|
|
1203
|
+
never merged. The answer is the set of full names Git lists as merged.
|
|
1204
|
+
"""
|
|
1205
|
+
refs_by_base_oid: dict[str, list[str]] = {}
|
|
1206
|
+
for full_ref, base_oid in base_oid_by_ref.items():
|
|
1207
|
+
if base_oid is not None:
|
|
1208
|
+
refs_by_base_oid.setdefault(base_oid, []).append(full_ref)
|
|
1209
|
+
merged_ref_set: set[str] = set()
|
|
1210
|
+
for base_oid in sorted(refs_by_base_oid):
|
|
1211
|
+
validate_oid(base_oid)
|
|
1212
|
+
requested_refs = refs_by_base_oid[base_oid]
|
|
1213
|
+
output = yield GitCommand(["for-each-ref", "--format=%(refname)", "--merged=" + base_oid, "--", *requested_refs])
|
|
1214
|
+
# A pattern also matches the references below it, so only the names asked for are kept.
|
|
1215
|
+
requested_ref_set = set(requested_refs)
|
|
1216
|
+
for line in output.decode("utf-8", "surrogateescape").splitlines():
|
|
1217
|
+
if line in requested_ref_set:
|
|
1218
|
+
merged_ref_set.add(line)
|
|
1219
|
+
return merged_ref_set
|
|
1220
|
+
|
|
1221
|
+
def delete_branches(self, intent: BranchDeletionIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1222
|
+
"""Delete the captured local branches once and keep the result of each one."""
|
|
1223
|
+
return self._submit(intent.operation, self._delete_branches(intent, is_current))
|
|
1224
|
+
|
|
1225
|
+
def _delete_branches(self, intent: BranchDeletionIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1226
|
+
"""Delete merged branches with one "git branch -d", then confirmed unmerged ones with one "git branch -D".
|
|
1227
|
+
|
|
1228
|
+
The checked deletion goes first so Git sees the very references the branches were sorted on: it compares each
|
|
1229
|
+
branch with its upstream, and an upstream that is itself a confirmed unmerged branch of the same request still
|
|
1230
|
+
exists at that moment. Run the other way, the forced deletion would remove that upstream, Git would compare the
|
|
1231
|
+
branch with HEAD instead and refuse a branch the confirmation never listed. The checked deletion starts only
|
|
1232
|
+
while every reference file is still byte for byte the one the request was frozen on; Git checks the merge
|
|
1233
|
+
again itself, and checks every branch of the command before deleting any, so one that moved is refused
|
|
1234
|
+
rather than lost.
|
|
1235
|
+
|
|
1236
|
+
The forced deletion checks nothing itself: neither the merge nor the commit the branch pointed to. When it
|
|
1237
|
+
runs alone it starts under the same frozen reference files. After a checked deletion those files changed on
|
|
1238
|
+
purpose, so the confirmed branches are read again once: each must still point to the commit the user
|
|
1239
|
+
answered about, and no reference file may change between that read and the launch.
|
|
1240
|
+
|
|
1241
|
+
A checked deletion that Git refuses for some branches (exit code 1) does not stop the forced one. A refused
|
|
1242
|
+
launch, a confirmed branch that moved, or any other failure stops the request there, and the forced deletion
|
|
1243
|
+
does not run. A reference Git cannot lock fails the whole command it belongs to, since Git deletes the
|
|
1244
|
+
branches of one command in a single transaction. The result of each branch comes from one capture taken
|
|
1245
|
+
after the writes, never from Git's messages: a branch that is gone is deleted, one that is still there is not.
|
|
1246
|
+
"""
|
|
1247
|
+
snapshot = intent.snapshot
|
|
1248
|
+
operation = intent.operation
|
|
1249
|
+
all_refs = intent.unmerged_refs + intent.merged_refs
|
|
1250
|
+
diagnostic = None
|
|
1251
|
+
refreshed = None
|
|
1252
|
+
started_ref_set: set[str] = set()
|
|
1253
|
+
try:
|
|
1254
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1255
|
+
or not all_refs or operation.targets != branch_deletion_targets(intent.merged_refs, intent.unmerged_refs, intent.target_oid_by_ref)):
|
|
1256
|
+
raise ValueError("Delete branch does not match its captured branches")
|
|
1257
|
+
for full_ref in all_refs:
|
|
1258
|
+
if (not full_ref.startswith(LOCAL_PREFIX) or full_ref == snapshot.head_ref
|
|
1259
|
+
or snapshot.object_oid_by_name.get(full_ref) != intent.target_oid_by_ref.get(full_ref)):
|
|
1260
|
+
raise ValueError("Delete branch does not match its captured branches")
|
|
1261
|
+
if is_current is not None and not is_current():
|
|
1262
|
+
raise ValueError("The deletion request is no longer current")
|
|
1263
|
+
signature = yield FileTask(lambda: reference_signature(snapshot.context))
|
|
1264
|
+
if signature != snapshot.signature:
|
|
1265
|
+
raise ValueError("References changed; reopen Delete branch")
|
|
1266
|
+
occupied_ref_set = occupied_branch_set(snapshot)
|
|
1267
|
+
for full_ref in all_refs:
|
|
1268
|
+
if full_ref in occupied_ref_set:
|
|
1269
|
+
raise ValueError(f"Branch '{short_reference_name(full_ref)}' is checked out in a worktree. Switch that worktree to another branch before trying again.")
|
|
1270
|
+
|
|
1271
|
+
def validate_owner() -> None:
|
|
1272
|
+
"""Keep a waiting deletion bound to its live request and repository owner."""
|
|
1273
|
+
self.repository_operation.validate(operation)
|
|
1274
|
+
if is_current is not None and not is_current():
|
|
1275
|
+
raise ValueError("The deletion request is no longer current")
|
|
1276
|
+
|
|
1277
|
+
def validate_checked_launch() -> None:
|
|
1278
|
+
"""Start the checked deletion only while no reference moved since the request was frozen."""
|
|
1279
|
+
validate_owner()
|
|
1280
|
+
if reference_signature(snapshot.context) != snapshot.signature:
|
|
1281
|
+
raise ValueError("References changed before launch; reopen Delete branch")
|
|
1282
|
+
started_ref_set.update(intent.merged_refs)
|
|
1283
|
+
|
|
1284
|
+
# The reference files the forced deletion must still find at its launch.
|
|
1285
|
+
forced_signature = snapshot.signature
|
|
1286
|
+
if intent.merged_refs:
|
|
1287
|
+
yield _parent_write_command(["branch", CHECKED_BRANCH_DELETE_OPTION, "--", *branch_names(intent.merged_refs)], validate_checked_launch,
|
|
1288
|
+
accepted_codes=BRANCH_DELETE_ACCEPTED_CODES)
|
|
1289
|
+
if intent.unmerged_refs:
|
|
1290
|
+
# Signature first, then the values: a branch moved before the signature shows in the values, one
|
|
1291
|
+
# moved after it changes the signature and the launch check refuses.
|
|
1292
|
+
forced_signature = yield FileTask(lambda: reference_signature(snapshot.context))
|
|
1293
|
+
unchanged = yield from self._branches_still_at(intent.unmerged_refs, intent.target_oid_by_ref)
|
|
1294
|
+
if not unchanged:
|
|
1295
|
+
raise ValueError("A branch confirmed for deletion changed after the merged branches were deleted, so it was kept; reopen Delete branch")
|
|
1296
|
+
|
|
1297
|
+
def validate_forced_launch() -> None:
|
|
1298
|
+
"""Start the forced deletion only while no reference moved since the confirmed branches were last proven unchanged."""
|
|
1299
|
+
validate_owner()
|
|
1300
|
+
if reference_signature(snapshot.context) != forced_signature:
|
|
1301
|
+
raise ValueError("References changed before launch; reopen Delete branch")
|
|
1302
|
+
started_ref_set.update(intent.unmerged_refs)
|
|
1303
|
+
|
|
1304
|
+
if intent.unmerged_refs:
|
|
1305
|
+
yield _parent_write_command(["branch", FORCED_BRANCH_DELETE_OPTION, "--", *branch_names(intent.unmerged_refs)], validate_forced_launch,
|
|
1306
|
+
accepted_codes=BRANCH_DELETE_ACCEPTED_CODES)
|
|
1307
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1308
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DELETE_BRANCH_OPERATION, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1309
|
+
if started_ref_set:
|
|
1310
|
+
try:
|
|
1311
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1312
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1313
|
+
diagnostic = diagnostic or (error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DELETE_BRANCH_OPERATION, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare())
|
|
1314
|
+
steps: list[OperationStepResult] = []
|
|
1315
|
+
deleted_count = 0
|
|
1316
|
+
failed_names: list[str] = []
|
|
1317
|
+
for full_ref in sorted(all_refs):
|
|
1318
|
+
started = full_ref in started_ref_set
|
|
1319
|
+
if started and refreshed is None:
|
|
1320
|
+
steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.UNKNOWN, snapshot, None, effect="inspect final state", full_ref=full_ref))
|
|
1321
|
+
continue
|
|
1322
|
+
deleted = started and full_ref not in refreshed.object_oid_by_name
|
|
1323
|
+
if deleted:
|
|
1324
|
+
deleted_count += 1
|
|
1325
|
+
elif started:
|
|
1326
|
+
failed_names.append(short_reference_name(full_ref))
|
|
1327
|
+
step_status = OperationStatus.SUCCESS if deleted else OperationStatus.FAILED if started else OperationStatus.REFUSED
|
|
1328
|
+
steps.append(OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, step_status, snapshot, refreshed, effect="deleted" if deleted else "not deleted", full_ref=full_ref))
|
|
1329
|
+
if not started_ref_set:
|
|
1330
|
+
status = OperationStatus.REFUSED
|
|
1331
|
+
elif refreshed is None:
|
|
1332
|
+
status = OperationStatus.UNKNOWN
|
|
1333
|
+
elif deleted_count == len(all_refs):
|
|
1334
|
+
status = OperationStatus.SUCCESS
|
|
1335
|
+
elif deleted_count:
|
|
1336
|
+
status = OperationStatus.PARTIAL
|
|
1337
|
+
else:
|
|
1338
|
+
status = OperationStatus.FAILED
|
|
1339
|
+
if diagnostic is None and failed_names:
|
|
1340
|
+
diagnostic = Diagnostic(DELETE_BRANCH_OPERATION, snapshot.context.repository_dir, snapshot.git_filename, [],
|
|
1341
|
+
"Git did not delete: " + ", ".join(failed_names) + ". See the Git output above.").prepare()
|
|
1342
|
+
outcome = OperationOutcome(operation.operation_id, status, tuple(steps), diagnostic)
|
|
1343
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1344
|
+
return outcome
|
|
1345
|
+
|
|
1346
|
+
def _branches_still_at(self, full_refs: tuple[str, ...], target_oid_by_ref: dict[str, str]) -> Generator:
|
|
1347
|
+
"""Answer whether every listed local branch still exists and points to its expected commit, in one read."""
|
|
1348
|
+
output = yield GitCommand(["for-each-ref", BRANCH_VALUE_FORMAT, "--", *full_refs])
|
|
1349
|
+
current_oid_by_ref: dict[str, str] = {}
|
|
1350
|
+
for line in output.decode("utf-8", "surrogateescape").splitlines():
|
|
1351
|
+
full_ref, _separator, oid = line.partition(REFERENCE_FIELD_SEPARATOR)
|
|
1352
|
+
current_oid_by_ref[full_ref] = oid
|
|
1353
|
+
# A pattern also matches the references below it; only the names asked for count.
|
|
1354
|
+
for full_ref in full_refs:
|
|
1355
|
+
if current_oid_by_ref.get(full_ref) != target_oid_by_ref.get(full_ref):
|
|
1356
|
+
return False
|
|
1357
|
+
return True
|
|
1358
|
+
|
|
1359
|
+
def delete(self, intent: ReferenceDeletionIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1360
|
+
"""Delete one captured reference once, preserving native branch worktree protections."""
|
|
1361
|
+
operation = replace(intent.operation, scope=WriteScope.capture(intent.operation.context, (intent.full_ref,)), action=OperationAction.REF_WRITE)
|
|
1362
|
+
intent = replace(intent, operation=operation)
|
|
1363
|
+
return self._submit(intent.operation, self.delete_sequence(intent, is_current=is_current))
|
|
1364
|
+
|
|
1365
|
+
def _validate_deletion_owner(self, intent: ReferenceDeletionIntent) -> None:
|
|
1366
|
+
"""Accept a captured local continuation only inside its existing transport owner's scope."""
|
|
1367
|
+
owner = self.repository_operation.active_by_id.get(intent.operation.operation_id)
|
|
1368
|
+
if owner is None or owner.scope is None or not owner.scope.touches(intent.full_ref):
|
|
1369
|
+
raise ValueError("This deletion no longer owns its captured reference")
|
|
1370
|
+
expected = replace(intent.operation, targets=owner.targets, scope=owner.scope, action=owner.action)
|
|
1371
|
+
if expected != owner:
|
|
1372
|
+
raise ValueError("The deletion authority changed before its local continuation")
|
|
1373
|
+
self.repository_operation.validate(owner)
|
|
1374
|
+
|
|
1375
|
+
def delete_sequence(self, intent: ReferenceDeletionIntent, *, is_current: Callable[[], bool] | None = None, allow_absent: bool = False) -> Generator:
|
|
1376
|
+
"""Revalidate at native start and report deletion separately from command failure.
|
|
1377
|
+
|
|
1378
|
+
Every attempt starts from a fresh capture. The capture taken right after the deletion proves the absence;
|
|
1379
|
+
it is also the final observation unless a configuration section is then removed, which is a second write
|
|
1380
|
+
and needs a capture of its own.
|
|
1381
|
+
"""
|
|
1382
|
+
snapshot = intent.snapshot
|
|
1383
|
+
tag = intent.full_ref.startswith(TAG_PREFIX)
|
|
1384
|
+
operation_name = "Delete tag" if tag else "Delete branch"
|
|
1385
|
+
diagnostic = None
|
|
1386
|
+
refreshed = None
|
|
1387
|
+
status = OperationStatus.REFUSED
|
|
1388
|
+
native_started = False
|
|
1389
|
+
reference_deleted = False
|
|
1390
|
+
process_status = None
|
|
1391
|
+
try:
|
|
1392
|
+
if is_current is not None and not is_current():
|
|
1393
|
+
raise ValueError("The deletion request is no longer current")
|
|
1394
|
+
if allow_absent and intent.full_ref not in snapshot.object_oid_by_name:
|
|
1395
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1396
|
+
if (current.signature != snapshot.signature or intent.operation.context != snapshot.context or intent.operation.git_filename != snapshot.git_filename
|
|
1397
|
+
or intent.operation.targets != (intent.full_ref, intent.target_oid) or not intent.full_ref.startswith(LOCAL_PREFIX)
|
|
1398
|
+
or intent.full_ref == current.head_ref or is_current is not None and not is_current()):
|
|
1399
|
+
raise ValueError("The absent branch no longer matches the captured deletion")
|
|
1400
|
+
step = OperationStepResult(DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.SUCCESS, snapshot, current, effect="already absent")
|
|
1401
|
+
return OperationOutcome(intent.operation.operation_id, OperationStatus.SUCCESS, (step,))
|
|
1402
|
+
_validate_deletion_authority(intent)
|
|
1403
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1404
|
+
merged = yield from self._inspect_captured_deletion(intent, current, preparing=False)
|
|
1405
|
+
if not merged and not intent.unmerged_confirmed:
|
|
1406
|
+
raise ValueError("The branch is not fully merged into current HEAD; explicit confirmation is required")
|
|
1407
|
+
|
|
1408
|
+
def validate_launch() -> None:
|
|
1409
|
+
"""Bind a queued deletion to the same branch, HEAD, worktrees and live owner."""
|
|
1410
|
+
nonlocal native_started
|
|
1411
|
+
self._validate_deletion_owner(intent)
|
|
1412
|
+
if is_current is not None and not is_current() or reference_signature(snapshot.context) != snapshot.signature:
|
|
1413
|
+
raise ValueError("References, HEAD or deletion authority changed before launch")
|
|
1414
|
+
native_started = True
|
|
1415
|
+
|
|
1416
|
+
# The old object is checked under Git's reference lock, closing the race
|
|
1417
|
+
# between application validation and an external client's replacement.
|
|
1418
|
+
arguments = ["update-ref", "--no-deref", "-d", intent.full_ref, intent.target_oid]
|
|
1419
|
+
yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS,
|
|
1420
|
+
idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
|
|
1421
|
+
reference_deleted = True
|
|
1422
|
+
process_status = ProcessStatus.SUCCESS
|
|
1423
|
+
status = OperationStatus.SUCCESS
|
|
1424
|
+
deleted_snapshot = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1425
|
+
refreshed = deleted_snapshot
|
|
1426
|
+
if not tag:
|
|
1427
|
+
if intent.full_ref in deleted_snapshot.object_oid_by_name:
|
|
1428
|
+
raise ValueError("The branch was recreated after deletion; its configuration was preserved")
|
|
1429
|
+
branch_name = intent.full_ref.removeprefix(LOCAL_PREFIX)
|
|
1430
|
+
configuration = yield GitCommand(["config", "--local", "--name-only", "--get-regexp", "^branch\\." + re.escape(branch_name) + "\\.[^.]+$"], accepted_codes=(0, 1))
|
|
1431
|
+
if configuration:
|
|
1432
|
+
# Removing the section is a second write: what it leaves behind needs a capture of its own.
|
|
1433
|
+
refreshed = None
|
|
1434
|
+
|
|
1435
|
+
def validate_cleanup() -> None:
|
|
1436
|
+
"""Preserve metadata when another client recreates or edits the deleted branch."""
|
|
1437
|
+
self._validate_deletion_owner(intent)
|
|
1438
|
+
if reference_signature(snapshot.context) != deleted_snapshot.signature:
|
|
1439
|
+
raise ValueError("References or configuration changed after deletion; branch configuration was preserved")
|
|
1440
|
+
yield GitCommand(["config", "--local", "--remove-section", "branch." + branch_name], timeout_ms=GIT_MUTATION_TIMEOUT_MS,
|
|
1441
|
+
idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_cleanup, profile=LOCAL_WRITE_PROFILE)
|
|
1442
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1443
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1444
|
+
if not reference_deleted:
|
|
1445
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1446
|
+
status = OperationStatus.PARTIAL if reference_deleted else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1447
|
+
if refreshed is None:
|
|
1448
|
+
try:
|
|
1449
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1450
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1451
|
+
diagnostic = diagnostic or (error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare())
|
|
1452
|
+
status = OperationStatus.UNKNOWN if native_started else status
|
|
1453
|
+
deleted = native_started and refreshed is not None and intent.full_ref not in refreshed.object_oid_by_name
|
|
1454
|
+
if native_started and not reference_deleted and refreshed is not None and refreshed.object_oid_by_name.get(intent.full_ref) not in (None, intent.target_oid):
|
|
1455
|
+
status = OperationStatus.REFUSED
|
|
1456
|
+
diagnostic = Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], "The selected reference changed before deletion; its replacement was preserved.").prepare()
|
|
1457
|
+
if deleted and (status != OperationStatus.SUCCESS or refreshed.head_oid != snapshot.head_oid or refreshed.head_ref != snapshot.head_ref):
|
|
1458
|
+
status = OperationStatus.PARTIAL
|
|
1459
|
+
elif status == OperationStatus.SUCCESS and not deleted:
|
|
1460
|
+
status = OperationStatus.UNKNOWN
|
|
1461
|
+
step = OperationStepResult(DELETE_LOCAL_TAG_OPERATION if tag else DELETE_LOCAL_BRANCH_OPERATION, OperationStatus.SUCCESS if deleted or reference_deleted else status, snapshot, refreshed, process_status,
|
|
1462
|
+
"deleted" if deleted else "deleted; name recreated" if reference_deleted and refreshed is not None else "not deleted" if refreshed is not None else "inspect final state", diagnostic, full_ref=intent.full_ref)
|
|
1463
|
+
outcome = OperationOutcome(intent.operation.operation_id, status, (step,), diagnostic)
|
|
1464
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
1465
|
+
return outcome
|
|
1466
|
+
|
|
1467
|
+
def create_tag(self, intent: TagIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1468
|
+
"""Create one native tag and retain its result so a refresh cannot repeat the write."""
|
|
1469
|
+
return self._submit(intent.operation, self._create_tag(intent, is_current))
|
|
1470
|
+
|
|
1471
|
+
def _create_tag(self, intent: TagIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1472
|
+
"""Validate exact authority, create without signing and inspect the stored tag object."""
|
|
1473
|
+
snapshot = intent.snapshot
|
|
1474
|
+
operation = intent.operation
|
|
1475
|
+
current = None
|
|
1476
|
+
refreshed = None
|
|
1477
|
+
diagnostic = None
|
|
1478
|
+
native_started = False
|
|
1479
|
+
process_status = None
|
|
1480
|
+
status = OperationStatus.REFUSED
|
|
1481
|
+
observed = False
|
|
1482
|
+
try:
|
|
1483
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1484
|
+
or operation.targets != (intent.full_ref, intent.target_oid, intent.mode) or intent.full_ref != TAG_PREFIX + intent.local_name
|
|
1485
|
+
or intent.mode not in (TagMode.LIGHTWEIGHT, TagMode.ANNOTATED)):
|
|
1486
|
+
raise ValueError("Tag creation does not match its captured authority")
|
|
1487
|
+
if is_current is not None and not is_current():
|
|
1488
|
+
raise ValueError("The tag request is no longer current")
|
|
1489
|
+
name = intent.local_name
|
|
1490
|
+
if not name or name.startswith("-") or any(character in COMMAND_LINE_CONTROL_CHARACTER_SET for character in name):
|
|
1491
|
+
raise ValueError("Enter a valid tag name without options or control characters")
|
|
1492
|
+
if intent.mode == TagMode.LIGHTWEIGHT and intent.message:
|
|
1493
|
+
raise ValueError("A lightweight tag cannot have an annotation")
|
|
1494
|
+
validate_oid(intent.target_oid)
|
|
1495
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1496
|
+
if current.signature != snapshot.signature:
|
|
1497
|
+
raise ValueError("References or HEAD changed; reopen the form after refreshing")
|
|
1498
|
+
yield from check_new_reference_name(intent.full_ref, name, TAG_NAME_KIND)
|
|
1499
|
+
if intent.full_ref in current.object_oid_by_name:
|
|
1500
|
+
raise ValueError("A tag with this name already exists; choose another name")
|
|
1501
|
+
object_type = yield GitCommand(["cat-file", "-t", intent.target_oid])
|
|
1502
|
+
if object_type.strip() != b"commit":
|
|
1503
|
+
raise ValueError("The selected revision is not a commit")
|
|
1504
|
+
|
|
1505
|
+
def validate_launch() -> None:
|
|
1506
|
+
"""Bind queued writes to the same live form, references and repository configuration."""
|
|
1507
|
+
nonlocal native_started
|
|
1508
|
+
self.repository_operation.validate(operation)
|
|
1509
|
+
allows = self.repository_operation.allows
|
|
1510
|
+
if (is_current is not None and not is_current() or allows is not None and not allows(operation)
|
|
1511
|
+
or reference_signature(snapshot.context) != current.signature):
|
|
1512
|
+
raise ValueError("References, HEAD or tag authority changed before launch; reopen the form")
|
|
1513
|
+
native_started = True
|
|
1514
|
+
|
|
1515
|
+
arguments = ["-c", "tag.gpgSign=false", "tag"]
|
|
1516
|
+
stdin = None
|
|
1517
|
+
if intent.mode == TagMode.ANNOTATED:
|
|
1518
|
+
arguments.extend(["--annotate", "--cleanup=verbatim", "--file=-"])
|
|
1519
|
+
stdin = intent.message.encode("utf-8")
|
|
1520
|
+
arguments.extend(["--", name, intent.target_oid])
|
|
1521
|
+
yield GitCommand(arguments, stdin=stdin, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
|
|
1522
|
+
process_status = ProcessStatus.SUCCESS
|
|
1523
|
+
status = OperationStatus.SUCCESS
|
|
1524
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1525
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Create tag", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1526
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1527
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1528
|
+
try:
|
|
1529
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1530
|
+
reference = next((reference for reference in refreshed.references if reference.full_name == intent.full_ref), None)
|
|
1531
|
+
object_oid = refreshed.object_oid_by_name.get(intent.full_ref)
|
|
1532
|
+
observed = native_started and current is not None and intent.full_ref not in current.object_oid_by_name and reference is not None and reference.target_oid == intent.target_oid
|
|
1533
|
+
if observed and intent.mode == TagMode.ANNOTATED:
|
|
1534
|
+
# Git refuses to print anything but a tag object here, so this one read proves the type as well as
|
|
1535
|
+
# the target and the exact message.
|
|
1536
|
+
content = yield GitCommand(["cat-file", "tag", object_oid])
|
|
1537
|
+
header, separator, message = content.partition(b"\n\n")
|
|
1538
|
+
header_lines = header.split(b"\n")
|
|
1539
|
+
if (not separator or message != intent.message.encode("utf-8") or ("object " + intent.target_oid).encode("ascii") not in header_lines
|
|
1540
|
+
or TAG_TARGET_TYPE_LINE not in header_lines):
|
|
1541
|
+
raise ValueError("The created tag does not contain the exact requested annotation")
|
|
1542
|
+
elif observed and object_oid != intent.target_oid:
|
|
1543
|
+
# A lightweight tag is the validated commit itself; any other object is not what was requested.
|
|
1544
|
+
raise ValueError("The created tag does not have the requested object type")
|
|
1545
|
+
if status == OperationStatus.SUCCESS and (not observed or refreshed.head_oid != snapshot.head_oid or refreshed.head_ref != snapshot.head_ref):
|
|
1546
|
+
raise ValueError("Git exited successfully but the expected tag or unchanged HEAD was not observed")
|
|
1547
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1548
|
+
if diagnostic is None:
|
|
1549
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Read created tag", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1550
|
+
status = OperationStatus.PARTIAL if process_status == ProcessStatus.SUCCESS or observed else OperationStatus.UNKNOWN
|
|
1551
|
+
creation_status = OperationStatus.SUCCESS if observed or process_status == ProcessStatus.SUCCESS else status
|
|
1552
|
+
if observed and status != OperationStatus.SUCCESS:
|
|
1553
|
+
status = OperationStatus.PARTIAL
|
|
1554
|
+
creation = OperationStepResult("Create tag", creation_status, current, refreshed, process_status,
|
|
1555
|
+
"created" if observed else "Git completed; verify the tag" if process_status == ProcessStatus.SUCCESS else "not observed", diagnostic)
|
|
1556
|
+
refresh = OperationStepResult("Read created tag", OperationStatus.SUCCESS if status == OperationStatus.SUCCESS else OperationStatus.STEP_NOT_STARTED if status == OperationStatus.REFUSED else OperationStatus.UNKNOWN,
|
|
1557
|
+
after=refreshed, effect="verified" if status == OperationStatus.SUCCESS else "inspect final state", diagnostic=diagnostic)
|
|
1558
|
+
outcome = OperationOutcome(operation.operation_id, status, (creation, refresh), diagnostic)
|
|
1559
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1560
|
+
return outcome
|
|
1561
|
+
|
|
1562
|
+
def rename_branch(self, intent: BranchIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1563
|
+
"""Reserve a native rename, preserving its configuration and unrelated references."""
|
|
1564
|
+
return self._submit(intent.operation, self._edit_branch(intent, is_current, (BranchMode.RENAME,)))
|
|
1565
|
+
|
|
1566
|
+
def move_branch(self, intent: BranchIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
1567
|
+
"""Move only an unoccupied local branch and retain native force-branch protections."""
|
|
1568
|
+
return self._submit(intent.operation, self._edit_branch(intent, is_current, (BranchMode.MOVE,)))
|
|
1569
|
+
|
|
1570
|
+
def is_fast_forward(self, old_oid: str, target_oid: str) -> Generator:
|
|
1571
|
+
"""Require an ancestry proof before moving a branch without force consent."""
|
|
1572
|
+
validate_oid(old_oid)
|
|
1573
|
+
validate_oid(target_oid)
|
|
1574
|
+
try:
|
|
1575
|
+
yield GitCommand(["merge-base", "--is-ancestor", old_oid, target_oid])
|
|
1576
|
+
except GitCommandError as error:
|
|
1577
|
+
if error.return_code == 1 and error.process_status == ProcessStatus.FAILED:
|
|
1578
|
+
return False
|
|
1579
|
+
raise
|
|
1580
|
+
return True
|
|
1581
|
+
|
|
1582
|
+
def _branch_config(self, name: str) -> Generator:
|
|
1583
|
+
"""Read local native branch settings to verify a rename did not lose its tracking."""
|
|
1584
|
+
output = yield GitCommand(["config", "--null", "--local", "--list"])
|
|
1585
|
+
prefix = ("branch." + name + ".").encode("utf-8", "surrogateescape")
|
|
1586
|
+
settings: list[tuple[bytes, bytes]] = []
|
|
1587
|
+
for record in output.split(b"\0"):
|
|
1588
|
+
key, _, value = record.partition(b"\n")
|
|
1589
|
+
if key.startswith(prefix):
|
|
1590
|
+
settings.append((key.removeprefix(prefix), value))
|
|
1591
|
+
return tuple(sorted(settings))
|
|
1592
|
+
|
|
1593
|
+
def _create_orphan(self, intent: BranchIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1594
|
+
"""Start a branch without any commit on the target's tree and check it out, like "git checkout --orphan".
|
|
1595
|
+
|
|
1596
|
+
The checks before the write are those of any branch creation. When clearing is asked, a tracked file with
|
|
1597
|
+
uncommitted changes refuses the whole request before any write: once the orphan branch exists every file
|
|
1598
|
+
shows as added, and removing them would destroy those changes. A target commit holding a submodule refuses
|
|
1599
|
+
it as well, because removing a submodule entry could take the submodule's own files with it. Clearing then
|
|
1600
|
+
removes every tracked file from the index and the disk, and never touches an untracked file; Git checks each
|
|
1601
|
+
file again as it removes them, so a change made after the first check refuses the clearing instead of being
|
|
1602
|
+
lost. The new branch has no reference until its first
|
|
1603
|
+
commit, so the result is proven by the final capture showing HEAD on that branch with no commit, never by a
|
|
1604
|
+
reference in the list.
|
|
1605
|
+
"""
|
|
1606
|
+
snapshot = intent.snapshot
|
|
1607
|
+
operation = intent.operation
|
|
1608
|
+
operation_name = BranchMode.CREATE_ORPHAN.operation_label
|
|
1609
|
+
created_ref = LOCAL_PREFIX + intent.local_name
|
|
1610
|
+
current = None
|
|
1611
|
+
refreshed = None
|
|
1612
|
+
diagnostic = None
|
|
1613
|
+
process_status = None
|
|
1614
|
+
clear_process_status = None
|
|
1615
|
+
status = OperationStatus.REFUSED
|
|
1616
|
+
native_started = False
|
|
1617
|
+
orphan_completed = False
|
|
1618
|
+
clear_started = False
|
|
1619
|
+
clear_completed = False
|
|
1620
|
+
observed = False
|
|
1621
|
+
try:
|
|
1622
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1623
|
+
or intent.mode != BranchMode.CREATE_ORPHAN or operation.targets != (intent.full_ref, intent.target_oid, intent.mode, intent.local_name, str(intent.orphan_clear))):
|
|
1624
|
+
raise ValueError("Branch action does not match its captured authority")
|
|
1625
|
+
if is_current is not None and not is_current():
|
|
1626
|
+
raise ValueError("The branch request is no longer current")
|
|
1627
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1628
|
+
if current.signature != snapshot.signature or current.worktrees != snapshot.worktrees:
|
|
1629
|
+
raise ValueError("References, HEAD or worktrees changed; reopen the form after refreshing")
|
|
1630
|
+
validate_oid(intent.target_oid)
|
|
1631
|
+
yield GitCommand(["cat-file", "-e", intent.target_oid + COMMIT_PEEL_SUFFIX])
|
|
1632
|
+
name = yield from check_branch_name(intent.local_name)
|
|
1633
|
+
# An unborn current branch has no reference either, so its name is compared with HEAD as well.
|
|
1634
|
+
if created_ref in current.object_oid_by_name or current.head_ref == created_ref:
|
|
1635
|
+
raise ValueError("A local branch with this name already exists; choose another name")
|
|
1636
|
+
if intent.orphan_clear:
|
|
1637
|
+
# Staged and unstaged changes to tracked files both count; untracked files are left alone by the removal.
|
|
1638
|
+
changes = yield GitCommand(["status", "--porcelain", "-z", "--untracked-files=no"])
|
|
1639
|
+
if changes:
|
|
1640
|
+
raise ValueError(ORPHAN_CLEAR_REFUSAL)
|
|
1641
|
+
# The clearing works on the target's tree, which the orphan checkout puts in the index; a submodule
|
|
1642
|
+
# the current commit holds and the target does not is left on disk by that checkout, never cleared.
|
|
1643
|
+
tree_modes = yield GitCommand(["ls-tree", "-r", "-z", TREE_ENTRY_MODE_FORMAT, intent.target_oid])
|
|
1644
|
+
if GITLINK_MODE in tree_modes.split(TREE_ENTRY_TERMINATOR):
|
|
1645
|
+
raise ValueError(ORPHAN_CLEAR_SUBMODULE_REFUSAL)
|
|
1646
|
+
|
|
1647
|
+
def validate_launch() -> None:
|
|
1648
|
+
"""Keep a waiting creation bound to the exact repository, references and live form."""
|
|
1649
|
+
nonlocal native_started
|
|
1650
|
+
self.repository_operation.validate(operation)
|
|
1651
|
+
if is_current is not None and not is_current() or reference_signature(snapshot.context) != current.signature:
|
|
1652
|
+
raise ValueError("References, HEAD or branch authority changed before launch; reopen the form")
|
|
1653
|
+
native_started = True
|
|
1654
|
+
|
|
1655
|
+
# No "--" is possible before the start point of this command; the name was validated above and cannot be
|
|
1656
|
+
# an option, and the start point is a complete commit ID.
|
|
1657
|
+
yield _parent_write_command(["checkout", "--orphan", name, intent.target_oid], validate_launch)
|
|
1658
|
+
orphan_completed = True
|
|
1659
|
+
process_status = ProcessStatus.SUCCESS
|
|
1660
|
+
if intent.orphan_clear:
|
|
1661
|
+
|
|
1662
|
+
def validate_clear() -> None:
|
|
1663
|
+
"""Remove files only while the request that created the orphan branch is still live."""
|
|
1664
|
+
nonlocal clear_started
|
|
1665
|
+
self.repository_operation.validate(operation)
|
|
1666
|
+
if is_current is not None and not is_current():
|
|
1667
|
+
raise ValueError("The orphan branch was created, but the request ended before clearing the working directory and index")
|
|
1668
|
+
clear_started = True
|
|
1669
|
+
|
|
1670
|
+
# The check for uncommitted changes ran before the checkout: a file saved or staged since then, by an
|
|
1671
|
+
# editor or by the checkout's own hook, must not be lost. A forced "git rm" would delete it without a
|
|
1672
|
+
# word. Moving the index from the target's tree to the empty tree with "read-tree -m -u" lets Git check
|
|
1673
|
+
# every file again at the very moment it removes them: one that differs from the index, or an index
|
|
1674
|
+
# entry that differs from the target, refuses the whole move before anything is removed. Untracked
|
|
1675
|
+
# and ignored files are never touched, and an entry added to the index meanwhile is kept.
|
|
1676
|
+
yield _parent_write_command(["read-tree", "-m", "-u", intent.target_oid, empty_tree_oid(intent.target_oid)], validate_clear)
|
|
1677
|
+
clear_completed = True
|
|
1678
|
+
clear_process_status = ProcessStatus.SUCCESS
|
|
1679
|
+
status = OperationStatus.SUCCESS
|
|
1680
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1681
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1682
|
+
error_process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1683
|
+
if orphan_completed:
|
|
1684
|
+
clear_process_status = error_process_status
|
|
1685
|
+
else:
|
|
1686
|
+
process_status = error_process_status
|
|
1687
|
+
status = OperationStatus.PARTIAL if orphan_completed else OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1688
|
+
try:
|
|
1689
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1690
|
+
observed = native_started and refreshed.head_ref == created_ref and refreshed.head_oid is None
|
|
1691
|
+
if status == OperationStatus.SUCCESS and not observed:
|
|
1692
|
+
raise ValueError("Git exited successfully but HEAD is not on the new branch without a commit")
|
|
1693
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1694
|
+
if diagnostic is None:
|
|
1695
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1696
|
+
status = OperationStatus.UNKNOWN
|
|
1697
|
+
if observed and status not in (OperationStatus.SUCCESS, OperationStatus.PARTIAL):
|
|
1698
|
+
status = OperationStatus.PARTIAL
|
|
1699
|
+
creation_status = OperationStatus.SUCCESS if observed else OperationStatus.UNKNOWN if orphan_completed else status
|
|
1700
|
+
creation_effect = "created orphan" if observed else "Git created the branch; inspect HEAD" if orphan_completed else "not observed"
|
|
1701
|
+
creation = OperationStepResult(operation_name, creation_status, current, refreshed, process_status, creation_effect, diagnostic)
|
|
1702
|
+
steps = (creation,)
|
|
1703
|
+
if intent.orphan_clear:
|
|
1704
|
+
clear_status = OperationStatus.SUCCESS if clear_completed else OperationStatus.FAILED if clear_started else OperationStatus.STEP_NOT_STARTED
|
|
1705
|
+
clear_effect = "cleared" if clear_completed else "inspect the working directory and index" if clear_started else "not cleared"
|
|
1706
|
+
clearing = OperationStepResult(CLEAR_ORPHAN_OPERATION, clear_status, after=refreshed, process_status=clear_process_status, effect=clear_effect, diagnostic=diagnostic)
|
|
1707
|
+
steps = (creation, clearing)
|
|
1708
|
+
outcome = OperationOutcome(operation.operation_id, status, steps, diagnostic)
|
|
1709
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1710
|
+
return outcome
|
|
1711
|
+
|
|
1712
|
+
def _edit_branch(self, intent: BranchIntent, is_current: Callable[[], bool] | None, allowed_modes: tuple[BranchMode, ...]) -> Generator:
|
|
1713
|
+
"""Validate exact authority at submission and launch, then reconcile native effects.
|
|
1714
|
+
|
|
1715
|
+
The branch and checkout steps report the fresh reference captures taken before and after the writes: a
|
|
1716
|
+
branch edit and the checkout that may follow it change nothing but references and HEAD.
|
|
1717
|
+
"""
|
|
1718
|
+
snapshot = intent.snapshot
|
|
1719
|
+
operation = intent.operation
|
|
1720
|
+
current = None
|
|
1721
|
+
refreshed = None
|
|
1722
|
+
diagnostic = None
|
|
1723
|
+
process_status = None
|
|
1724
|
+
status = OperationStatus.REFUSED
|
|
1725
|
+
native_started = False
|
|
1726
|
+
operation_name = intent.mode.operation_label
|
|
1727
|
+
created_ref = LOCAL_PREFIX + intent.local_name
|
|
1728
|
+
checkout = intent.mode == BranchMode.CREATE_CHECKOUT or intent.mode == BranchMode.MOVE and intent.checkout_after_reset
|
|
1729
|
+
creation = intent.mode in (BranchMode.CREATE, BranchMode.CREATE_CHECKOUT)
|
|
1730
|
+
resulting_ref = created_ref if intent.mode != BranchMode.MOVE else intent.full_ref
|
|
1731
|
+
branch_settings = None
|
|
1732
|
+
branch_completed = False
|
|
1733
|
+
checkout_started = False
|
|
1734
|
+
try:
|
|
1735
|
+
targets = (intent.full_ref, intent.target_oid, intent.mode, intent.local_name)
|
|
1736
|
+
if intent.mode == BranchMode.MOVE:
|
|
1737
|
+
targets += (str(intent.checkout_after_reset), str(intent.force_reset))
|
|
1738
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1739
|
+
or operation.targets != targets or intent.mode not in allowed_modes):
|
|
1740
|
+
raise ValueError("Branch action does not match its captured authority")
|
|
1741
|
+
if is_current is not None and not is_current():
|
|
1742
|
+
raise ValueError("The branch request is no longer current")
|
|
1743
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1744
|
+
if current.signature != snapshot.signature or current.worktrees != snapshot.worktrees:
|
|
1745
|
+
raise ValueError("References, HEAD or worktrees changed; reopen the form after refreshing")
|
|
1746
|
+
validate_oid(intent.target_oid)
|
|
1747
|
+
yield GitCommand(["cat-file", "-e", intent.target_oid + COMMIT_PEEL_SUFFIX])
|
|
1748
|
+
name = intent.local_name if intent.mode != BranchMode.MOVE else intent.full_ref.removeprefix(LOCAL_PREFIX)
|
|
1749
|
+
yield from check_branch_name(name)
|
|
1750
|
+
if intent.mode != BranchMode.MOVE and created_ref in current.object_oid_by_name:
|
|
1751
|
+
raise ValueError("A local branch with this name already exists; choose another name")
|
|
1752
|
+
if not creation:
|
|
1753
|
+
reference = next((reference for reference in current.references if reference.full_name == intent.full_ref), None)
|
|
1754
|
+
original_oid = snapshot.object_oid_by_name.get(intent.full_ref)
|
|
1755
|
+
if not intent.full_ref.startswith(LOCAL_PREFIX) or reference is None or reference.symbolic_target or current.object_oid_by_name.get(intent.full_ref) != original_oid:
|
|
1756
|
+
raise ValueError(self.refresh_advice(SharedText.SELECTED_LOCAL_BRANCH_CHANGED))
|
|
1757
|
+
occupied_ref_set = occupied_branch_set(current)
|
|
1758
|
+
if intent.full_ref in occupied_ref_set and (intent.mode == BranchMode.MOVE or intent.full_ref != current.head_ref):
|
|
1759
|
+
raise ValueError("The selected branch is checked out in a worktree and cannot be changed here")
|
|
1760
|
+
if intent.mode == BranchMode.RENAME:
|
|
1761
|
+
if reference.target_oid != intent.target_oid:
|
|
1762
|
+
raise ValueError("The renamed branch no longer points to the captured commit")
|
|
1763
|
+
branch_settings = yield from self._branch_config(intent.full_ref.removeprefix(LOCAL_PREFIX))
|
|
1764
|
+
if intent.mode == BranchMode.MOVE and not (yield from self.is_fast_forward(original_oid, intent.target_oid)) and not intent.force_reset:
|
|
1765
|
+
raise ValueError("This reset is not a fast-forward; enable Force reset for a non-fast-forward reset")
|
|
1766
|
+
|
|
1767
|
+
def validate_launch() -> None:
|
|
1768
|
+
"""Keep waiting writes bound to the exact repository and live form."""
|
|
1769
|
+
nonlocal native_started
|
|
1770
|
+
self.repository_operation.validate(operation)
|
|
1771
|
+
if is_current is not None and not is_current() or reference_signature(snapshot.context) != current.signature:
|
|
1772
|
+
raise ValueError("References, HEAD or branch authority changed before launch; reopen the form")
|
|
1773
|
+
native_started = True
|
|
1774
|
+
|
|
1775
|
+
arguments = ["switch", "--no-guess", "-c", name, "--", intent.target_oid] if checkout else ["branch", "--", name, intent.target_oid]
|
|
1776
|
+
if intent.mode == BranchMode.RENAME:
|
|
1777
|
+
arguments = ["branch", "-m", "--", intent.full_ref.removeprefix(LOCAL_PREFIX), name]
|
|
1778
|
+
elif intent.mode == BranchMode.MOVE:
|
|
1779
|
+
arguments = ["branch", "-f", "--", name, intent.target_oid]
|
|
1780
|
+
yield _parent_write_command(arguments, validate_launch)
|
|
1781
|
+
branch_completed = True
|
|
1782
|
+
if intent.mode == BranchMode.MOVE and checkout:
|
|
1783
|
+
moved = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1784
|
+
if (moved.object_oid_by_name.get(resulting_ref) != intent.target_oid or moved.head_ref != snapshot.head_ref
|
|
1785
|
+
or moved.head_oid != snapshot.head_oid or resulting_ref in occupied_branch_set(moved)):
|
|
1786
|
+
raise ValueError("Branch moved, but references changed before checkout; inspect the repository")
|
|
1787
|
+
|
|
1788
|
+
def validate_checkout() -> None:
|
|
1789
|
+
"""Keep checkout bound to the branch just moved, including while queued."""
|
|
1790
|
+
nonlocal checkout_started
|
|
1791
|
+
self.repository_operation.validate(operation)
|
|
1792
|
+
if is_current is not None and not is_current() or reference_signature(snapshot.context) != moved.signature:
|
|
1793
|
+
raise ValueError("Branch moved, but checkout authority changed before launch")
|
|
1794
|
+
checkout_started = True
|
|
1795
|
+
|
|
1796
|
+
yield _parent_write_command(["switch", "--no-guess", "--", name], validate_checkout)
|
|
1797
|
+
process_status = ProcessStatus.SUCCESS
|
|
1798
|
+
status = OperationStatus.SUCCESS
|
|
1799
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1800
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1801
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1802
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1803
|
+
try:
|
|
1804
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1805
|
+
observed = native_started and current is not None and refreshed.object_oid_by_name.get(resulting_ref) == intent.target_oid
|
|
1806
|
+
if intent.mode == BranchMode.MOVE:
|
|
1807
|
+
observed = observed and (branch_completed or current.object_oid_by_name.get(resulting_ref) != intent.target_oid)
|
|
1808
|
+
if intent.mode != BranchMode.MOVE:
|
|
1809
|
+
observed = observed and resulting_ref not in current.object_oid_by_name
|
|
1810
|
+
if intent.mode == BranchMode.RENAME:
|
|
1811
|
+
observed = observed and intent.full_ref not in refreshed.object_oid_by_name
|
|
1812
|
+
expected_ref = resulting_ref if checkout or intent.mode == BranchMode.RENAME and snapshot.head_ref == intent.full_ref else snapshot.head_ref
|
|
1813
|
+
expected_oid = intent.target_oid if checkout else snapshot.head_oid
|
|
1814
|
+
if status == OperationStatus.SUCCESS and (not observed or refreshed.head_ref != expected_ref or refreshed.head_oid != expected_oid):
|
|
1815
|
+
raise ValueError("Git exited successfully but the expected branch or HEAD was not observed")
|
|
1816
|
+
if intent.mode == BranchMode.RENAME and observed:
|
|
1817
|
+
renamed_settings = yield from self._branch_config(intent.local_name)
|
|
1818
|
+
if renamed_settings != branch_settings:
|
|
1819
|
+
raise ValueError("The branch was renamed but its local configuration was not fully preserved; inspect Git configuration")
|
|
1820
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1821
|
+
if diagnostic is None:
|
|
1822
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation_name, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1823
|
+
status = OperationStatus.UNKNOWN
|
|
1824
|
+
observed = (native_started and refreshed is not None and refreshed.object_oid_by_name.get(resulting_ref) == intent.target_oid
|
|
1825
|
+
and (intent.mode == BranchMode.MOVE or current is not None and resulting_ref not in current.object_oid_by_name))
|
|
1826
|
+
effect = "created" if creation else "renamed" if intent.mode == BranchMode.RENAME else "moved"
|
|
1827
|
+
move_completed = intent.mode == BranchMode.MOVE and branch_completed
|
|
1828
|
+
branch_status = OperationStatus.SUCCESS if observed else OperationStatus.UNKNOWN if move_completed else status
|
|
1829
|
+
branch_effect = effect if observed else "Git moved the branch; inspect its final target" if move_completed else "not observed"
|
|
1830
|
+
branch_step = OperationStepResult(operation_name, branch_status, current, refreshed, ProcessStatus.SUCCESS if branch_completed else process_status, branch_effect, diagnostic)
|
|
1831
|
+
steps = (branch_step,)
|
|
1832
|
+
if intent.mode == BranchMode.RENAME and observed:
|
|
1833
|
+
# Once the configuration is proven preserved, the capture taken after the rename is still the final state:
|
|
1834
|
+
# reading the configuration changes no reference.
|
|
1835
|
+
settings_step = OperationStepResult("Preserve branch configuration", OperationStatus.SUCCESS if status == OperationStatus.SUCCESS else OperationStatus.UNKNOWN,
|
|
1836
|
+
after=refreshed if status == OperationStatus.SUCCESS else None,
|
|
1837
|
+
effect="preserved" if status == OperationStatus.SUCCESS else "inspect Git configuration", diagnostic=diagnostic)
|
|
1838
|
+
steps = (branch_step, settings_step)
|
|
1839
|
+
if checkout:
|
|
1840
|
+
step = OperationStepResult("Checkout", status if checkout_started or intent.mode == BranchMode.CREATE_CHECKOUT and native_started else OperationStatus.STEP_NOT_STARTED, current, refreshed, process_status,
|
|
1841
|
+
"checked out" if refreshed is not None and refreshed.head_ref == resulting_ref else "inspect final state", diagnostic)
|
|
1842
|
+
steps = (branch_step, step)
|
|
1843
|
+
if (observed or move_completed) and status != OperationStatus.SUCCESS:
|
|
1844
|
+
status = OperationStatus.PARTIAL
|
|
1845
|
+
outcome = OperationOutcome(operation.operation_id, status, steps, diagnostic)
|
|
1846
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1847
|
+
return outcome
|
|
1848
|
+
|
|
1849
|
+
def _abort_merge(self, intent: MergeAbortIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1850
|
+
"""Check that the merge shown is still the one in progress, run Git's merge --abort, then read the result back.
|
|
1851
|
+
|
|
1852
|
+
Git restores the state from before the merge by itself, so nothing is copied or reset here: the checks are a few
|
|
1853
|
+
cheap reads (the merge files and the references), the only write is merge --abort, with submodule recursion
|
|
1854
|
+
off so no submodule content is touched. The result counts as done only when the merge is gone and HEAD is
|
|
1855
|
+
the commit the merge started from.
|
|
1856
|
+
"""
|
|
1857
|
+
operation = intent.operation
|
|
1858
|
+
context = operation.context
|
|
1859
|
+
diagnostic = None
|
|
1860
|
+
status = OperationStatus.REFUSED
|
|
1861
|
+
process_status = None
|
|
1862
|
+
current = None
|
|
1863
|
+
refreshed = None
|
|
1864
|
+
native_started = False
|
|
1865
|
+
|
|
1866
|
+
def merge_shown_is_current() -> bool:
|
|
1867
|
+
"""Tell whether Git still records the very merge the banner showed, with no other operation paused beside it."""
|
|
1868
|
+
state = read_merge_banner_state(context.git_dir)
|
|
1869
|
+
return state.in_progress and state.merge is not None and state.merge.parent_oids == intent.merge_head_oids
|
|
1870
|
+
|
|
1871
|
+
try:
|
|
1872
|
+
if operation.action != OperationAction.MERGE_ABORT or operation.targets != (*intent.merge_head_oids, intent.head_oid):
|
|
1873
|
+
raise ValueError("Merge abort identity does not match its captured authority")
|
|
1874
|
+
if is_current is not None and not is_current():
|
|
1875
|
+
raise ValueError("The merge abort request is no longer current")
|
|
1876
|
+
still_shown = yield FileTask(merge_shown_is_current)
|
|
1877
|
+
if not still_shown:
|
|
1878
|
+
raise ValueError("The merge in progress changed or ended; refresh before aborting it")
|
|
1879
|
+
current = yield from self.capture(context, operation.git_filename, operation.generation)
|
|
1880
|
+
if current.head_oid != intent.head_oid:
|
|
1881
|
+
raise ValueError("HEAD changed since the merge was shown; refresh before aborting it")
|
|
1882
|
+
|
|
1883
|
+
def validate_launch() -> None:
|
|
1884
|
+
"""Refuse a queued Abort after the merge, the references or the request changed."""
|
|
1885
|
+
nonlocal native_started
|
|
1886
|
+
self.repository_operation.validate(operation)
|
|
1887
|
+
if is_current is not None and not is_current() or reference_signature(context) != current.signature or not merge_shown_is_current():
|
|
1888
|
+
raise ValueError("The merge, HEAD or the request changed before launch; refresh before aborting it")
|
|
1889
|
+
native_started = True
|
|
1890
|
+
|
|
1891
|
+
yield _parent_write_command(["merge", "--abort"], validate_launch)
|
|
1892
|
+
process_status = ProcessStatus.SUCCESS
|
|
1893
|
+
status = OperationStatus.SUCCESS
|
|
1894
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1895
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
1896
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1897
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1898
|
+
try:
|
|
1899
|
+
remaining = yield FileTask(lambda: read_merge_banner_state(context.git_dir))
|
|
1900
|
+
refreshed = yield from self.capture(context, operation.git_filename, operation.generation)
|
|
1901
|
+
if status == OperationStatus.SUCCESS and (remaining.merge is not None or remaining.cause or refreshed.head_oid != intent.head_oid):
|
|
1902
|
+
raise ValueError("Git exited successfully but the merge is still recorded or HEAD moved")
|
|
1903
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1904
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
1905
|
+
status = OperationStatus.UNKNOWN
|
|
1906
|
+
effect = "merge abandoned" if status == OperationStatus.SUCCESS else "inspect final state"
|
|
1907
|
+
step = OperationStepResult(operation.operation, status, current, refreshed, process_status, effect, diagnostic)
|
|
1908
|
+
outcome = OperationOutcome(operation.operation_id, status, (step,), diagnostic)
|
|
1909
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
1910
|
+
return outcome
|
|
1911
|
+
|
|
1912
|
+
def _checkout(self, intent: CheckoutIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
1913
|
+
"""Recheck captured identities, run native switch and observe effects before release.
|
|
1914
|
+
|
|
1915
|
+
The step reports the fresh reference captures taken before and after the switch: they hold HEAD's branch
|
|
1916
|
+
and commit, which is everything a checkout promises, so no complete working tree read is spent on it.
|
|
1917
|
+
|
|
1918
|
+
Resetting an existing local branch onto a remote branch is Git's own ``switch -C``: Git refuses by itself
|
|
1919
|
+
when local changes would be overwritten or the branch is checked out in another worktree, so nothing is
|
|
1920
|
+
discarded or stashed here. The Checkout window keeps Checkout greyed for a reset until it shows how many
|
|
1921
|
+
commits the local branch loses, or why that number is unknown; the reference files are checked again just
|
|
1922
|
+
before launch, so a branch moved meanwhile is refused.
|
|
1923
|
+
"""
|
|
1924
|
+
operation = intent.operation
|
|
1925
|
+
snapshot = intent.snapshot
|
|
1926
|
+
diagnostic = None
|
|
1927
|
+
status = OperationStatus.REFUSED
|
|
1928
|
+
process_status = None
|
|
1929
|
+
current = None
|
|
1930
|
+
refreshed = None
|
|
1931
|
+
expected_head_ref = intent.full_ref
|
|
1932
|
+
created_ref = ""
|
|
1933
|
+
native_started = False
|
|
1934
|
+
stale_capture = False
|
|
1935
|
+
try:
|
|
1936
|
+
if (operation.context != snapshot.context or operation.git_filename != snapshot.git_filename or operation.generation != snapshot.generation
|
|
1937
|
+
or operation.targets != (intent.full_ref, intent.target_oid, str(intent.force_checkout))):
|
|
1938
|
+
raise StaleCaptureError("Checkout identity does not match its captured authority")
|
|
1939
|
+
if intent.force_checkout and intent.mode != CheckoutMode.DETACHED:
|
|
1940
|
+
raise ValueError("Force applies only to checking out a revision")
|
|
1941
|
+
if is_current is not None and not is_current():
|
|
1942
|
+
raise StaleCaptureError("The checkout request is no longer current")
|
|
1943
|
+
current = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1944
|
+
if current.head_oid != snapshot.head_oid or current.head_ref != snapshot.head_ref or current.signature != snapshot.signature:
|
|
1945
|
+
raise StaleCaptureError(self.refresh_advice(SharedText.CHECKOUT_REFERENCES_OR_HEAD_CHANGED))
|
|
1946
|
+
reference = next((reference for reference in current.references if reference.full_name == intent.full_ref), None)
|
|
1947
|
+
if intent.full_ref and (reference is None or reference.target_oid != intent.target_oid or reference.symbolic_target):
|
|
1948
|
+
raise StaleCaptureError(self.refresh_advice(SharedText.SELECTED_REFERENCE_CHANGED_OR_SYMBOLIC))
|
|
1949
|
+
validate_oid(intent.target_oid)
|
|
1950
|
+
if intent.mode == CheckoutMode.DETACHED and intent.force_checkout:
|
|
1951
|
+
# "git switch --discard-changes" still refuses when an untracked file is in the way; "checkout --force"
|
|
1952
|
+
# overwrites it as the Force choice promises. No "--" here: after it the commit would be read as a path,
|
|
1953
|
+
# and the full commit ID validated above can never be taken for an option.
|
|
1954
|
+
arguments = ["checkout", "--force", "--detach", intent.target_oid]
|
|
1955
|
+
expected_head_ref = None
|
|
1956
|
+
elif intent.mode == CheckoutMode.DETACHED:
|
|
1957
|
+
arguments = ["switch", "--detach", "--", intent.target_oid]
|
|
1958
|
+
expected_head_ref = None
|
|
1959
|
+
elif intent.mode == CheckoutMode.LOCAL and intent.full_ref.startswith(LOCAL_PREFIX):
|
|
1960
|
+
arguments = ["switch", "--no-guess", "--", intent.full_ref.removeprefix(LOCAL_PREFIX)]
|
|
1961
|
+
elif intent.mode == CheckoutMode.RESET_LOCAL and intent.full_ref.startswith(REMOTE_PREFIX):
|
|
1962
|
+
name = remote_branch_name(snapshot, intent.full_ref)
|
|
1963
|
+
yield from check_branch_name(name)
|
|
1964
|
+
created_ref = LOCAL_PREFIX + name
|
|
1965
|
+
if created_ref not in current.object_oid_by_name:
|
|
1966
|
+
raise StaleCaptureError(self.refresh_advice(SharedText.CHECKOUT_LOCAL_BRANCH_GONE))
|
|
1967
|
+
expected_head_ref = created_ref
|
|
1968
|
+
arguments = ["switch", "--no-guess", "-C", name, "--track=direct", "--", intent.full_ref]
|
|
1969
|
+
elif intent.mode in (CheckoutMode.SAME, CheckoutMode.CUSTOM) and intent.full_ref.startswith(REMOTE_PREFIX):
|
|
1970
|
+
name = remote_branch_name(snapshot, intent.full_ref) if intent.mode == CheckoutMode.SAME else intent.local_name
|
|
1971
|
+
yield from check_branch_name(name)
|
|
1972
|
+
created_ref = LOCAL_PREFIX + name
|
|
1973
|
+
if created_ref in current.object_oid_by_name:
|
|
1974
|
+
raise ValueError("A local branch with this name already exists; choose another name")
|
|
1975
|
+
expected_head_ref = created_ref
|
|
1976
|
+
arguments = ["switch", "--no-guess", "-c", name, "--track=direct", "--", intent.full_ref]
|
|
1977
|
+
else:
|
|
1978
|
+
raise ValueError("Select a local branch, a remote branch creation mode or detached HEAD")
|
|
1979
|
+
|
|
1980
|
+
def validate_launch() -> None:
|
|
1981
|
+
"""Refuse a queued command after a changed reference or closed intention."""
|
|
1982
|
+
nonlocal native_started
|
|
1983
|
+
self.repository_operation.validate(operation)
|
|
1984
|
+
if is_current is not None and not is_current() or reference_signature(snapshot.context) != current.signature:
|
|
1985
|
+
raise StaleCaptureError(self.refresh_advice(SharedText.CHECKOUT_CHANGED_BEFORE_LAUNCH))
|
|
1986
|
+
native_started = True
|
|
1987
|
+
|
|
1988
|
+
yield _parent_write_command(arguments, validate_launch)
|
|
1989
|
+
process_status = ProcessStatus.SUCCESS
|
|
1990
|
+
status = OperationStatus.SUCCESS
|
|
1991
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
1992
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Checkout", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1993
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
1994
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
1995
|
+
stale_capture = isinstance(error, StaleCaptureError) and not native_started
|
|
1996
|
+
try:
|
|
1997
|
+
refreshed = yield from self.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
1998
|
+
if status == OperationStatus.SUCCESS and (refreshed.head_oid != intent.target_oid or refreshed.head_ref != expected_head_ref):
|
|
1999
|
+
raise ValueError("Git exited successfully but checkout did not leave the expected HEAD")
|
|
2000
|
+
if created_ref and status == OperationStatus.SUCCESS:
|
|
2001
|
+
upstream = yield GitCommand(["for-each-ref", "--format=%(refname)%00%(upstream)", created_ref])
|
|
2002
|
+
if (created_ref + "\0" + intent.full_ref) not in upstream.decode().splitlines():
|
|
2003
|
+
raise ValueError("The created branch does not track the selected remote reference")
|
|
2004
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
2005
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Checkout", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
2006
|
+
status = OperationStatus.UNKNOWN
|
|
2007
|
+
step = OperationStepResult("Checkout", status, current, refreshed, process_status, "checked out" if status == OperationStatus.SUCCESS else "inspect final state", diagnostic)
|
|
2008
|
+
steps = (step,)
|
|
2009
|
+
if created_ref and intent.mode == CheckoutMode.RESET_LOCAL:
|
|
2010
|
+
# A branch that already sat on the target proves nothing about a failed Git, so only a successful
|
|
2011
|
+
# switch or an observed move counts as the reset.
|
|
2012
|
+
before_oid = current.object_oid_by_name.get(created_ref) if current is not None else None
|
|
2013
|
+
exists = (native_started and refreshed is not None and refreshed.object_oid_by_name.get(created_ref) == intent.target_oid
|
|
2014
|
+
and (before_oid != intent.target_oid or status == OperationStatus.SUCCESS))
|
|
2015
|
+
reset = OperationStepResult("Reset branch", OperationStatus.SUCCESS if exists else OperationStatus.STEP_NOT_STARTED, current, refreshed,
|
|
2016
|
+
effect="reset" if exists else "not observed")
|
|
2017
|
+
steps = (reset, step)
|
|
2018
|
+
if exists and status != OperationStatus.SUCCESS:
|
|
2019
|
+
status = OperationStatus.PARTIAL
|
|
2020
|
+
elif created_ref:
|
|
2021
|
+
exists = (native_started and refreshed is not None and current is not None and created_ref not in current.object_oid_by_name
|
|
2022
|
+
and refreshed.object_oid_by_name.get(created_ref) == intent.target_oid)
|
|
2023
|
+
creation = OperationStepResult("Create branch", OperationStatus.SUCCESS if exists else OperationStatus.STEP_NOT_STARTED, current, refreshed,
|
|
2024
|
+
effect="created" if exists else "not observed")
|
|
2025
|
+
steps = (creation, step)
|
|
2026
|
+
if exists and status != OperationStatus.SUCCESS:
|
|
2027
|
+
status = OperationStatus.PARTIAL
|
|
2028
|
+
outcome = OperationOutcome(operation.operation_id, status, steps, diagnostic, stale_capture=stale_capture and status == OperationStatus.REFUSED)
|
|
2029
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
2030
|
+
return outcome
|