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,447 @@
|
|
|
1
|
+
"""Calculate side-specific file resets, each proven to preserve the other side before any publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from difflib import SequenceMatcher
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS
|
|
14
|
+
from gitpane.history.repository_value import FileStatus
|
|
15
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
|
|
16
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
17
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
18
|
+
from gitpane.worktree.partial_patch import byte_lines, filtered_line_slices, line_slices
|
|
19
|
+
from gitpane.worktree.worktree_file import PRIVATE_DIRECTORY_MODE, SINGLE_LINK_COUNT, FileImage, capture_image, materialize_blob, stat_identity
|
|
20
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, read_index
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
|
|
25
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
26
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FILEMODE_SETTING_KEY = b"core.filemode"
|
|
30
|
+
SYMLINKS_SETTING_KEY = b"core.symlinks"
|
|
31
|
+
ENABLED_SETTING_VALUE = b"true"
|
|
32
|
+
DISABLED_SETTING_VALUE = b"false"
|
|
33
|
+
# Git writes a split index with a literal "link" extension signature. A false match in object or path bytes only
|
|
34
|
+
# costs one harmless expansion of the private copy, while a missed one would publish an index sharing foreign data.
|
|
35
|
+
SPLIT_INDEX_EXTENSION_SIGNATURE = b"link"
|
|
36
|
+
# Refusals of the Reset of a rename whose original path already leads to a file.
|
|
37
|
+
RENAME_ORIGIN_OCCUPIED_MESSAGE = "The rename's original path is occupied; Reset will not overwrite that file"
|
|
38
|
+
OTHER_HARD_LINKS_MESSAGE = "This file has other hard links; Reset will not replace it"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def may_carry_split_index(index_content: bytes | None) -> bool:
|
|
42
|
+
"""Tell whether a private index copy must be flattened before Git rewrites it, without parsing Git's binary format."""
|
|
43
|
+
return index_content is not None and SPLIT_INDEX_EXTENSION_SIGNATURE in index_content
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ResetFilePolicy:
|
|
48
|
+
"""Hold the two repository settings that decide how Reset restores links and executable bits.
|
|
49
|
+
|
|
50
|
+
Only an explicit false disables a feature, which is what Git itself does when a key is absent.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
symlinks: bool = True
|
|
54
|
+
filemode: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_reset_file_policy(output: bytes) -> ResetFilePolicy:
|
|
58
|
+
"""Decode the grouped NUL records of both settings by key name.
|
|
59
|
+
|
|
60
|
+
Records of different keys may come in any order; for repeated definitions of one key the later record wins, which
|
|
61
|
+
is the order in which Git applies its configuration files. Anything but a normalized boolean is refused rather
|
|
62
|
+
than guessed, because a wrong guess would publish the wrong file kind or executable bit.
|
|
63
|
+
"""
|
|
64
|
+
if output and not output.endswith(b"\0"):
|
|
65
|
+
raise ValueError("Git returned an incomplete core.filemode or core.symlinks setting; Reset refused")
|
|
66
|
+
enabled_by_key: dict[bytes, bool] = {}
|
|
67
|
+
records = output.removesuffix(b"\0").split(b"\0") if output else []
|
|
68
|
+
for record in records:
|
|
69
|
+
key, separator, value = record.partition(b"\n")
|
|
70
|
+
if not separator or key not in (FILEMODE_SETTING_KEY, SYMLINKS_SETTING_KEY) or value not in (ENABLED_SETTING_VALUE, DISABLED_SETTING_VALUE):
|
|
71
|
+
raise ValueError("Git returned an unexpected core.filemode or core.symlinks setting; Reset refused")
|
|
72
|
+
enabled_by_key[key] = value == ENABLED_SETTING_VALUE
|
|
73
|
+
default = ResetFilePolicy()
|
|
74
|
+
return ResetFilePolicy(symlinks=enabled_by_key.get(SYMLINKS_SETTING_KEY, default.symlinks), filemode=enabled_by_key.get(FILEMODE_SETTING_KEY, default.filemode))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_reset_file_policy() -> Generator[GitCommand, bytes, ResetFilePolicy]:
|
|
78
|
+
"""Read both settings with one Git command for a whole Reset, instead of once per file."""
|
|
79
|
+
try:
|
|
80
|
+
# Exit code 1 means neither key is defined anywhere, so both keep Git's default.
|
|
81
|
+
output = yield GitCommand(["config", "--bool", "--null", "--get-regexp", r"^core\.(filemode|symlinks)$"], accepted_codes=(0, 1))
|
|
82
|
+
except GitCommandError as error:
|
|
83
|
+
raise ValueError("Git cannot read core.filemode or core.symlinks as a boolean; Reset refused") from error
|
|
84
|
+
return parse_reset_file_policy(output)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class FileChange:
|
|
89
|
+
"""Keep one exact destination's before and after images for confirmation and recovery."""
|
|
90
|
+
|
|
91
|
+
path: bytes
|
|
92
|
+
before: FileImage
|
|
93
|
+
after: FileImage
|
|
94
|
+
original_path: bytes | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ResetCandidate:
|
|
99
|
+
"""Bind a confirmed result to its source capture and owned recovery files."""
|
|
100
|
+
|
|
101
|
+
operation_id: str
|
|
102
|
+
snapshot: WorktreeSnapshot
|
|
103
|
+
side: StagingSide
|
|
104
|
+
changes: tuple[FileChange, ...]
|
|
105
|
+
before_entries: bytes
|
|
106
|
+
after_entries: bytes
|
|
107
|
+
index_content: bytes | None
|
|
108
|
+
operation_dir: str
|
|
109
|
+
record_by_name: dict[str, object]
|
|
110
|
+
explanation: str
|
|
111
|
+
operation: str = "Reset"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Edit:
|
|
116
|
+
"""Represent a replacement at exact base and resulting line coordinates."""
|
|
117
|
+
|
|
118
|
+
start: int
|
|
119
|
+
end: int
|
|
120
|
+
result_start: int
|
|
121
|
+
result_end: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ResetSource(Enum):
|
|
125
|
+
"""Where a line of a staged Reset's result comes from: HEAD's version, or the working file that keeps its own edits."""
|
|
126
|
+
|
|
127
|
+
HEAD = "head"
|
|
128
|
+
WORKING = "working"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compare_lines(base: list[bytes], result: list[bytes]) -> tuple[list[Edit], dict[int, int]]:
|
|
132
|
+
"""Extract base intervals and unchanged-line correspondence without text decoding."""
|
|
133
|
+
edits: list[Edit] = []
|
|
134
|
+
result_index_by_base_index: dict[int, int] = {}
|
|
135
|
+
for kind, start, end, result_start, result_end in SequenceMatcher(None, base, result, autojunk=False).get_opcodes():
|
|
136
|
+
if kind != "equal":
|
|
137
|
+
edits.append(Edit(start, end, result_start, result_end))
|
|
138
|
+
continue
|
|
139
|
+
for offset in range(end - start):
|
|
140
|
+
result_index_by_base_index[start + offset] = result_start + offset
|
|
141
|
+
return edits, result_index_by_base_index
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def independent_result(index: bytes, head: bytes, working: bytes) -> tuple[bytes, tuple[tuple[ResetSource, int], ...]]:
|
|
145
|
+
"""Replay disjoint changes only when neither shared intervals nor repeated anchors can relocate B."""
|
|
146
|
+
index_lines, head_lines, working_lines = byte_lines(index), byte_lines(head), byte_lines(working)
|
|
147
|
+
if head != index and working != index:
|
|
148
|
+
seen_line_set: set[bytes] = set()
|
|
149
|
+
for line in index_lines:
|
|
150
|
+
if line in seen_line_set:
|
|
151
|
+
raise ValueError("Repeated context makes the staged and unstaged correspondence ambiguous")
|
|
152
|
+
seen_line_set.add(line)
|
|
153
|
+
undo_edits, _head_index_by_base_index = compare_lines(index_lines, head_lines)
|
|
154
|
+
working_edits, working_index_by_base_index = compare_lines(index_lines, working_lines)
|
|
155
|
+
if undo_edits and working_edits:
|
|
156
|
+
for undo in undo_edits:
|
|
157
|
+
for edit in working_edits:
|
|
158
|
+
overlap = max(undo.start, edit.start) < min(undo.end, edit.end)
|
|
159
|
+
insertion = (undo.start == undo.end and edit.start <= undo.start <= edit.end) or (edit.start == edit.end and undo.start <= edit.start <= undo.end)
|
|
160
|
+
if overlap or insertion:
|
|
161
|
+
raise ValueError("Staged and unstaged changes overlap or share an insertion boundary; Reset cannot preserve them independently")
|
|
162
|
+
changes: list[tuple[Edit, ResetSource]] = []
|
|
163
|
+
for edit in undo_edits:
|
|
164
|
+
changes.append((edit, ResetSource.HEAD))
|
|
165
|
+
for edit in working_edits:
|
|
166
|
+
changes.append((edit, ResetSource.WORKING))
|
|
167
|
+
changes.sort(key=lambda item: item[0].start)
|
|
168
|
+
result_lines: list[bytes] = []
|
|
169
|
+
sources: list[tuple[ResetSource, int]] = []
|
|
170
|
+
position = 0
|
|
171
|
+
for edit, source in changes:
|
|
172
|
+
while position < edit.start:
|
|
173
|
+
result_index = working_index_by_base_index[position]
|
|
174
|
+
result_lines.append(working_lines[result_index])
|
|
175
|
+
sources.append((ResetSource.WORKING, result_index))
|
|
176
|
+
position += 1
|
|
177
|
+
source_lines = head_lines if source == ResetSource.HEAD else working_lines
|
|
178
|
+
for result_index in range(edit.result_start, edit.result_end):
|
|
179
|
+
result_lines.append(source_lines[result_index])
|
|
180
|
+
sources.append((source, result_index))
|
|
181
|
+
position = edit.end
|
|
182
|
+
while position < len(index_lines):
|
|
183
|
+
result_index = working_index_by_base_index[position]
|
|
184
|
+
result_lines.append(working_lines[result_index])
|
|
185
|
+
sources.append((ResetSource.WORKING, result_index))
|
|
186
|
+
position += 1
|
|
187
|
+
return b"".join(result_lines), tuple(sources)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def verify_canonical(content: bytes, expected: bytes, path: bytes) -> Generator[GitCommand, bytes]:
|
|
191
|
+
"""Require Git to map the published raw bytes back to the independently computed blob."""
|
|
192
|
+
expected_oid = yield GitCommand(["hash-object", "--no-filters", "--stdin"], stdin=expected)
|
|
193
|
+
actual_oid = yield GitCommand(["hash-object", "--path=" + os.fsdecode(path), "--stdin"], stdin=content,
|
|
194
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
195
|
+
if expected_oid != actual_oid:
|
|
196
|
+
raise ValueError("Git conversion cannot preserve the unstaged bytes in the Reset result")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def merge_content(index: bytes, head: bytes, working: bytes, raw: FileImage, restored: FileImage, path: bytes,
|
|
200
|
+
operation_dir: str, mode: str, destination_path: bytes) -> Generator[GitCommand | FileTask, bytes, bytes]:
|
|
201
|
+
"""Cross-check Git's merge with interval replay and preserve original unmodified raw slices."""
|
|
202
|
+
if index == head:
|
|
203
|
+
yield from verify_canonical(raw.read(), working, destination_path)
|
|
204
|
+
return raw.read()
|
|
205
|
+
if working == index:
|
|
206
|
+
return restored.read()
|
|
207
|
+
if any(b"\0" in content for content in (index, head, working)):
|
|
208
|
+
raise ValueError("Binary staged and unstaged changes cannot be separated safely")
|
|
209
|
+
expected, sources = yield FileTask(lambda: independent_result(index, head, working))
|
|
210
|
+
filenames: list[str] = []
|
|
211
|
+
for basename, content in (("merge-working", working), ("merge-index", index), ("merge-head", head)):
|
|
212
|
+
filename = os.path.join(operation_dir, basename)
|
|
213
|
+
with open(filename, "wb") as stream:
|
|
214
|
+
stream.write(content)
|
|
215
|
+
filenames.append(filename)
|
|
216
|
+
try:
|
|
217
|
+
merged = yield GitCommand(["merge-file", "-p", "--", *filenames], timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS)
|
|
218
|
+
except GitCommandError as error:
|
|
219
|
+
raise ValueError("Git could not merge the staged Reset without conflicts; no working bytes were changed") from error
|
|
220
|
+
if merged != expected:
|
|
221
|
+
raise ValueError("Git's merged result differs from the independently preserved unstaged changes")
|
|
222
|
+
attributes = yield GitCommand(["check-attr", "-z", "filter", "working-tree-encoding", "--stdin"], stdin=path + b"\0")
|
|
223
|
+
attribute_by_name: dict[bytes, bytes] = {}
|
|
224
|
+
fields = iter(attributes.removesuffix(b"\0").split(b"\0"))
|
|
225
|
+
for _path in fields:
|
|
226
|
+
name, value = next(fields), next(fields)
|
|
227
|
+
attribute_by_name[name] = value
|
|
228
|
+
encoding_value = attribute_by_name.get(b"working-tree-encoding", b"unspecified")
|
|
229
|
+
encoding = "" if encoding_value in (b"unspecified", b"unset") else encoding_value.decode("ascii")
|
|
230
|
+
if attribute_by_name.get(b"filter", b"unspecified") not in (b"unspecified", b"unset"):
|
|
231
|
+
working_oid = (yield GitCommand(["hash-object", "-w", "--no-filters", "--stdin"], stdin=working, profile=LOCAL_WRITE_PROFILE)).strip().decode("ascii")
|
|
232
|
+
materialized = yield from materialize_blob(working_oid, path, mode, os.path.join(operation_dir, "materialized-working"))
|
|
233
|
+
head_slices = yield from filtered_line_slices(head, restored.read(), restored.read(), path)
|
|
234
|
+
working_slices = yield from filtered_line_slices(working, raw.read(), materialized.read(), path)
|
|
235
|
+
else:
|
|
236
|
+
head_slices = line_slices(head, restored.read(), encoding)
|
|
237
|
+
working_slices = line_slices(working, raw.read(), encoding)
|
|
238
|
+
raw_lines: list[bytes] = []
|
|
239
|
+
for source, line_index in sources:
|
|
240
|
+
line = head_slices.lines[line_index] if source == ResetSource.HEAD else working_slices.lines[line_index]
|
|
241
|
+
if source == ResetSource.HEAD and head_slices.codec and working_slices.codec and head_slices.codec != working_slices.codec:
|
|
242
|
+
line = line.decode(head_slices.codec).encode(working_slices.codec)
|
|
243
|
+
raw_lines.append(line)
|
|
244
|
+
candidate = working_slices.prefix + b"".join(raw_lines)
|
|
245
|
+
yield from verify_canonical(candidate, expected, destination_path)
|
|
246
|
+
return candidate
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def index_records(output: bytes) -> dict[bytes, bytes]:
|
|
250
|
+
"""Validate NUL index records before comparing selected and unrelated entries."""
|
|
251
|
+
record_by_path: dict[bytes, bytes] = {}
|
|
252
|
+
if output and not output.endswith(b"\0"):
|
|
253
|
+
raise ValueError("Incomplete candidate index entries")
|
|
254
|
+
for record in output.split(b"\0"):
|
|
255
|
+
if not record:
|
|
256
|
+
continue
|
|
257
|
+
metadata, separator, path = record.partition(b"\t")
|
|
258
|
+
if not separator or not metadata.endswith(b" 0") or path in record_by_path:
|
|
259
|
+
raise ValueError("Conflicted or malformed candidate index entry")
|
|
260
|
+
record_by_path[path] = record
|
|
261
|
+
return record_by_path
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def reset_executable_mode(mode: int, git_mode: str) -> int:
|
|
265
|
+
"""Restore Git executability while retaining local read/write and special permission bits."""
|
|
266
|
+
if bool(mode & stat.S_IXUSR) == (git_mode == "100755"):
|
|
267
|
+
return mode
|
|
268
|
+
executable_mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
269
|
+
mode &= ~executable_mask
|
|
270
|
+
if git_mode == "100755":
|
|
271
|
+
read_mask = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
|
|
272
|
+
read_to_execute_shift = 2
|
|
273
|
+
mode |= (mode & read_mask) >> read_to_execute_shift
|
|
274
|
+
return mode
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def capture_rename_origin(context: RepositoryContext, path: bytes) -> tuple[FileImage, int]:
|
|
278
|
+
"""Capture a rename's original path with its link count, so Reset can tell one entry seen twice from a second file.
|
|
279
|
+
|
|
280
|
+
The count is read from the same file the image describes. On Linux and macOS a link added or removed while reading
|
|
281
|
+
also moves the file's change time, so a moved identity refuses instead of trusting a stale count. On Windows that
|
|
282
|
+
time is the creation time and does not move, so the count read here is the only check; that is enough, because
|
|
283
|
+
replacing the directory entry never rewrites the bytes of another link.
|
|
284
|
+
"""
|
|
285
|
+
image = capture_image(context, path, allow_missing_parents=True)
|
|
286
|
+
if not image.exists:
|
|
287
|
+
return image, 0
|
|
288
|
+
metadata = os.lstat(worktree_filename(context, path))
|
|
289
|
+
if stat_identity(metadata) != image.identity:
|
|
290
|
+
raise ValueError("Working file changed while reading")
|
|
291
|
+
return image, metadata.st_nlink
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def directory_lists_name(context: RepositoryContext, path: bytes) -> bool:
|
|
295
|
+
"""Tell whether the working folder holds a directory entry spelled exactly like ``path``.
|
|
296
|
+
|
|
297
|
+
A disk that ignores letter case or Unicode form finds a renamed file under its old spelling too, but lists it only
|
|
298
|
+
under the spelling the entry has; a disk that keeps spellings apart lists a second link under its own spelling.
|
|
299
|
+
"""
|
|
300
|
+
parent_dir, basename = os.path.split(worktree_filename(context, path))
|
|
301
|
+
with os.scandir(parent_dir) as iterator:
|
|
302
|
+
for entry in iterator:
|
|
303
|
+
if entry.name == basename:
|
|
304
|
+
return True
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def calculate_file(snapshot: WorktreeSnapshot, entry: LocalEntry, record: bytes | None, file_dir: str, symlinks: bool, *, filemode: bool = True) -> Generator:
|
|
309
|
+
"""Handle absence, renames and file metadata separately from the content-preservation proof.
|
|
310
|
+
|
|
311
|
+
Both settings are read once by the caller for the whole operation; filemode keeps Git's default when omitted.
|
|
312
|
+
"""
|
|
313
|
+
path = entry.path
|
|
314
|
+
if "160000" in (entry.old_mode, entry.new_mode):
|
|
315
|
+
raise ValueError("Reset cannot change submodule working files; use the submodule's own checkout controls")
|
|
316
|
+
before = yield FileTask(lambda: capture_image(snapshot.context, path, allow_missing_parents=True))
|
|
317
|
+
index_mode, index_oid = "000000", ""
|
|
318
|
+
if record is not None:
|
|
319
|
+
raw_mode, raw_oid, _stage = record.partition(b"\t")[0].split(b" ")
|
|
320
|
+
index_mode, index_oid = raw_mode.decode("ascii"), raw_oid.decode("ascii")
|
|
321
|
+
if entry.side == StagingSide.UNSTAGED and not index_oid:
|
|
322
|
+
raise ValueError("This file has no index version; use Delete file explicitly for untracked files")
|
|
323
|
+
target_oid = index_oid if entry.side == StagingSide.UNSTAGED else entry.old_blob_oid
|
|
324
|
+
target_mode = index_mode if entry.side == StagingSide.UNSTAGED else entry.old_mode
|
|
325
|
+
destination_path = entry.old_path if entry.side == StagingSide.STAGED and entry.status == FileStatus.RENAMED else path
|
|
326
|
+
if target_mode not in ("000000", "100644", "100755", "120000") or index_mode not in ("000000", "100644", "100755", "120000"):
|
|
327
|
+
raise ValueError("This Git file mode cannot be reset safely")
|
|
328
|
+
restored = FileImage(False, 0)
|
|
329
|
+
if target_oid and target_mode != "000000":
|
|
330
|
+
restored = yield from materialize_blob(target_oid, destination_path, target_mode, os.path.join(file_dir, "materialized-source"), symlinks)
|
|
331
|
+
if entry.side == StagingSide.UNSTAGED:
|
|
332
|
+
mode = restored.mode
|
|
333
|
+
if before.exists and stat.S_IFMT(before.mode) == stat.S_IFMT(mode):
|
|
334
|
+
mode = before.mode
|
|
335
|
+
if stat.S_ISREG(mode) and index_mode in ("100644", "100755") and filemode:
|
|
336
|
+
mode = reset_executable_mode(mode, index_mode)
|
|
337
|
+
return (FileChange(path, before, FileImage(restored.exists, mode, restored.read())),)
|
|
338
|
+
index = (yield GitCommand(["cat-file", "blob", index_oid])) if index_oid else None
|
|
339
|
+
working = None
|
|
340
|
+
if before.exists:
|
|
341
|
+
if stat.S_ISLNK(before.mode) or index_mode == "120000":
|
|
342
|
+
working = before.read()
|
|
343
|
+
else:
|
|
344
|
+
working_oid = (yield GitCommand(["hash-object", "-w", "--path=" + os.fsdecode(path), "--stdin"], stdin=before.read(),
|
|
345
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)).strip().decode("ascii")
|
|
346
|
+
working = yield GitCommand(["cat-file", "blob", working_oid])
|
|
347
|
+
if index is None:
|
|
348
|
+
if before.exists:
|
|
349
|
+
raise ValueError("A file recreated after staged deletion collides with the version to restore")
|
|
350
|
+
return (FileChange(path, before, restored),)
|
|
351
|
+
if not restored.exists:
|
|
352
|
+
if working is not None and working != index:
|
|
353
|
+
raise ValueError("An added file's unstaged changes cannot be separated from its staged content")
|
|
354
|
+
return (FileChange(path, before, FileImage(False, 0)),)
|
|
355
|
+
head = yield GitCommand(["cat-file", "blob", target_oid])
|
|
356
|
+
if working is None:
|
|
357
|
+
if head != index:
|
|
358
|
+
raise ValueError("Unstaged deletion overlaps the staged content; Reset cannot preserve both")
|
|
359
|
+
after = FileImage(False, 0)
|
|
360
|
+
else:
|
|
361
|
+
type_changed = stat.S_ISLNK(before.mode) != (index_mode == "120000" and symlinks)
|
|
362
|
+
if type_changed or ("120000" in (index_mode, target_mode) and working != index and head != index):
|
|
363
|
+
raise ValueError("The staged and unstaged file types or link targets cannot be separated safely")
|
|
364
|
+
regular_mode_changed = index_mode != target_mode and index_mode in ("100644", "100755") and target_mode in ("100644", "100755")
|
|
365
|
+
if regular_mode_changed and filemode and bool(before.mode & stat.S_IXUSR) != (index_mode == "100755"):
|
|
366
|
+
raise ValueError("Staged and unstaged executable mode changes overlap")
|
|
367
|
+
content = yield from merge_content(index, head, working, before, restored, path, file_dir, target_mode, destination_path)
|
|
368
|
+
mode = before.mode if index_mode == target_mode else restored.mode
|
|
369
|
+
if regular_mode_changed:
|
|
370
|
+
mode = reset_executable_mode(before.mode, target_mode)
|
|
371
|
+
after = FileImage(True, mode, content)
|
|
372
|
+
if destination_path == path:
|
|
373
|
+
return (FileChange(path, before, after),)
|
|
374
|
+
old_image, old_link_count = yield FileTask(lambda: capture_rename_origin(snapshot.context, destination_path))
|
|
375
|
+
same_identity = old_image.exists and before.exists and old_image.identity == before.identity
|
|
376
|
+
if same_identity and old_link_count != SINGLE_LINK_COUNT:
|
|
377
|
+
# When nothing is listed under the original spelling, the extra links live under unrelated names: the refusal
|
|
378
|
+
# stays, but it names those links rather than an original path that nothing occupies.
|
|
379
|
+
origin_listed = yield FileTask(lambda: directory_lists_name(snapshot.context, destination_path))
|
|
380
|
+
if not origin_listed:
|
|
381
|
+
raise ValueError(OTHER_HARD_LINKS_MESSAGE)
|
|
382
|
+
same_file = same_identity and old_link_count == SINGLE_LINK_COUNT
|
|
383
|
+
if old_image.exists and not same_file:
|
|
384
|
+
raise ValueError(RENAME_ORIGIN_OCCUPIED_MESSAGE)
|
|
385
|
+
if same_file:
|
|
386
|
+
# Both names lead to the same file and that file has a single link, so they are one directory entry that a
|
|
387
|
+
# filesystem ignoring letter case or Unicode form finds under both spellings (a case-only rename on macOS or
|
|
388
|
+
# Windows). A private candidate replaces that entry under its original name. A file with two links is a real
|
|
389
|
+
# second file under the other name and keeps the refusal above.
|
|
390
|
+
return (FileChange(destination_path, old_image, after, path),)
|
|
391
|
+
return (FileChange(path, before, FileImage(False, 0)), FileChange(destination_path, old_image, after))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def calculate_reset(snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation_dir: str) -> Generator:
|
|
395
|
+
"""Compute all selected images and a private index, leaving live Git and files untouched.
|
|
396
|
+
|
|
397
|
+
The captured index entries stand for the first listing of the private copy, since that copy is proven byte for
|
|
398
|
+
byte identical to the captured index; only a capture without entries lists the copy again. The final listing
|
|
399
|
+
always runs, because it is the proof that nothing outside the selection changed.
|
|
400
|
+
"""
|
|
401
|
+
if not snapshot.fresh or snapshot.context.is_bare or not entries:
|
|
402
|
+
raise ValueError("Choose files from a complete current working tree read")
|
|
403
|
+
side = next(iter(entries)).side
|
|
404
|
+
if side not in (StagingSide.STAGED, StagingSide.UNSTAGED) or any(entry not in snapshot.entries or entry.side != side or entry.skip_worktree for entry in entries):
|
|
405
|
+
raise ValueError("Choose current files on the same side; skip-worktree files cannot be reset")
|
|
406
|
+
if any(entry.conflict_stages for entry in snapshot.entries):
|
|
407
|
+
raise ValueError("Resolve all conflicts before Reset")
|
|
408
|
+
candidate_filename = os.path.join(operation_dir, "candidate-index")
|
|
409
|
+
environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
|
|
410
|
+
if snapshot.index_content is None:
|
|
411
|
+
yield GitCommand(["read-tree", "--empty"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
412
|
+
else:
|
|
413
|
+
shutil.copy2(snapshot.index_filename, candidate_filename)
|
|
414
|
+
if read_index(candidate_filename) != snapshot.index_content:
|
|
415
|
+
raise ValueError("The index changed while preparing Reset")
|
|
416
|
+
if may_carry_split_index(snapshot.index_content):
|
|
417
|
+
yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
418
|
+
before_entries = snapshot.index_entries
|
|
419
|
+
if before_entries is None:
|
|
420
|
+
before_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
|
|
421
|
+
record_by_path = index_records(before_entries)
|
|
422
|
+
changes: list[FileChange] = []
|
|
423
|
+
policy = yield from read_reset_file_policy()
|
|
424
|
+
for number, entry in enumerate(entries):
|
|
425
|
+
path = entry.path
|
|
426
|
+
file_dir = os.path.join(operation_dir, str(number))
|
|
427
|
+
os.mkdir(file_dir, PRIVATE_DIRECTORY_MODE)
|
|
428
|
+
changes.extend((yield from calculate_file(snapshot, entry, record_by_path.get(path), file_dir, policy.symlinks, filemode=policy.filemode)))
|
|
429
|
+
changed_path_set: set[bytes] = set()
|
|
430
|
+
for change in changes:
|
|
431
|
+
if change.path in changed_path_set:
|
|
432
|
+
raise ValueError("Selected structural changes share a destination; choose an unambiguous batch")
|
|
433
|
+
changed_path_set.add(change.path)
|
|
434
|
+
paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
|
|
435
|
+
if side == StagingSide.STAGED:
|
|
436
|
+
if snapshot.head_oid:
|
|
437
|
+
yield GitCommand(["restore", "--staged", "--source=" + snapshot.head_oid, "--pathspec-from-file=-", "--pathspec-file-nul"],
|
|
438
|
+
stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
439
|
+
else:
|
|
440
|
+
yield GitCommand(["update-index", "--force-remove", "-z", "--stdin"], stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
441
|
+
after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
|
|
442
|
+
after_record_by_path = index_records(after_entries)
|
|
443
|
+
path_set = set(paths)
|
|
444
|
+
for path in set(record_by_path) | set(after_record_by_path):
|
|
445
|
+
if path not in path_set and record_by_path.get(path) != after_record_by_path.get(path):
|
|
446
|
+
raise ValueError("Reset candidate changed an unrelated index entry")
|
|
447
|
+
return tuple(changes), before_entries, after_entries, read_index(candidate_filename) if side == StagingSide.STAGED else snapshot.index_content
|