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,288 @@
|
|
|
1
|
+
"""Authorize an exact repository state for history removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX
|
|
10
|
+
from gitpane.history.repository_value import validate_oid
|
|
11
|
+
from gitpane.history_rewrite.graph import affected_branch_closure, ordered_history
|
|
12
|
+
from gitpane.history_rewrite.protocol import check_cancelled, matches_target, read_line
|
|
13
|
+
from gitpane.history_rewrite.value import ChangeKind, RewriteProtectedKind, RewriteRefusal, RewriteRefusalKind
|
|
14
|
+
from gitpane.operation.repository_operation import MERGE_MARKER_BASENAMES, REBASE_MARKER_BASENAMES, REPLAY_MARKER_BASENAMES
|
|
15
|
+
from gitpane.process.process_runner import READ_PROFILE
|
|
16
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
17
|
+
from gitpane.reference.reference_snapshot import REFERENCE_FORMAT, parse_reference_read
|
|
18
|
+
from gitpane.system.path_identity import canonical_path
|
|
19
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
20
|
+
from gitpane.worktree.worktree_file import stat_identity
|
|
21
|
+
from gitpane.worktree.worktree_snapshot import read_index
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Generator
|
|
25
|
+
|
|
26
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
27
|
+
from gitpane.history_rewrite.value import ExportCommit, HistoryAnalysis, RawCommit, RewriteCapture, RewriteTarget
|
|
28
|
+
|
|
29
|
+
UPSTREAM_CONFIG_PATTERN = r"^(branch\..*\.(remote|merge)|remote\..*\.fetch)$"
|
|
30
|
+
# Git answers the active Git directory, then the common directory that linked worktrees share, one per line.
|
|
31
|
+
GIT_STORAGE_ARGUMENTS = ("rev-parse", "--absolute-git-dir", "--path-format=absolute", "--git-common-dir")
|
|
32
|
+
GIT_ANSWER_LINE_FEED = b"\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git_storage_matches(context: RepositoryContext, output: bytes) -> bool:
|
|
36
|
+
"""Tell whether Git's answer to GIT_STORAGE_ARGUMENTS names exactly the Git directory and common directory of the context.
|
|
37
|
+
|
|
38
|
+
The folders are compared by identity, never by spelling: Git for Windows writes forward slashes where Python writes
|
|
39
|
+
backslashes, so a text comparison would refuse the same folder every time. A folder name may hold a line feed, so
|
|
40
|
+
each answer takes as many lines as the context's own path has; an answer of any other shape is refused.
|
|
41
|
+
"""
|
|
42
|
+
if not output.endswith(GIT_ANSWER_LINE_FEED):
|
|
43
|
+
return False
|
|
44
|
+
lines = output.removesuffix(GIT_ANSWER_LINE_FEED).split(GIT_ANSWER_LINE_FEED)
|
|
45
|
+
git_dir_line_count = len(os.fsencode(context.git_dir).split(GIT_ANSWER_LINE_FEED))
|
|
46
|
+
common_git_dir_line_count = len(os.fsencode(context.common_git_dir).split(GIT_ANSWER_LINE_FEED))
|
|
47
|
+
if len(lines) != git_dir_line_count + common_git_dir_line_count:
|
|
48
|
+
return False
|
|
49
|
+
git_dir = os.fsdecode(GIT_ANSWER_LINE_FEED.join(lines[:git_dir_line_count]))
|
|
50
|
+
common_git_dir = os.fsdecode(GIT_ANSWER_LINE_FEED.join(lines[git_dir_line_count:]))
|
|
51
|
+
return canonical_path(git_dir) == canonical_path(context.git_dir) and canonical_path(common_git_dir) == canonical_path(context.common_git_dir)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _worktree_entry(record: bytes) -> tuple[str, str | None]:
|
|
55
|
+
"""Interpret one complete native worktree entry without relying on field order."""
|
|
56
|
+
worktree_dir = None
|
|
57
|
+
branch = None
|
|
58
|
+
has_head = False
|
|
59
|
+
for field in record.split(b"\0"):
|
|
60
|
+
key, _separator, value = field.partition(b" ")
|
|
61
|
+
if key == b"worktree":
|
|
62
|
+
if worktree_dir is not None or not value:
|
|
63
|
+
raise ValueError("Incomplete worktree listing; refresh the repository")
|
|
64
|
+
worktree_dir = os.fsdecode(value)
|
|
65
|
+
elif key == b"branch":
|
|
66
|
+
if branch is not None or not value:
|
|
67
|
+
raise ValueError("Incomplete worktree branch; refresh the repository")
|
|
68
|
+
branch = os.fsdecode(value)
|
|
69
|
+
elif key == b"HEAD":
|
|
70
|
+
validate_oid(value.decode("ascii"))
|
|
71
|
+
has_head = True
|
|
72
|
+
elif key == b"bare":
|
|
73
|
+
has_head = True
|
|
74
|
+
if worktree_dir is None or not has_head:
|
|
75
|
+
raise ValueError("Incomplete worktree listing; refresh the repository")
|
|
76
|
+
return worktree_dir, branch
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_worktree_listing(output: bytes) -> tuple[bytes, ...]:
|
|
80
|
+
"""Normalize complete native worktree records so command ordering grants no authority."""
|
|
81
|
+
if not output or not output.endswith(b"\0\0"):
|
|
82
|
+
raise ValueError("Incomplete worktree listing; refresh the repository")
|
|
83
|
+
records = []
|
|
84
|
+
for record in output.removesuffix(b"\0\0").split(b"\0\0"):
|
|
85
|
+
_worktree_entry(record)
|
|
86
|
+
records.append(b"\0".join(sorted(record.split(b"\0"))))
|
|
87
|
+
return tuple(sorted(records))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def other_worktree_dir_by_branch(context: RepositoryContext, worktrees: tuple[bytes, ...]) -> dict[str, str]:
|
|
91
|
+
"""Identify checked-out branches outside the active worktree, including absorbed submodules.
|
|
92
|
+
|
|
93
|
+
Folders are compared by canonical_path, the identity the Git folder check of this guard uses, so the active worktree
|
|
94
|
+
listed by Git in another letter case or Unicode form is still known as the active one.
|
|
95
|
+
"""
|
|
96
|
+
active_dir_set = {canonical_path(context.repository_dir)}
|
|
97
|
+
# Git reports an absorbed submodule's main worktree using its administration directory.
|
|
98
|
+
# It denotes this checkout only when the active Git directory is also the common one.
|
|
99
|
+
if canonical_path(context.git_dir) == canonical_path(context.common_git_dir):
|
|
100
|
+
active_dir_set.add(canonical_path(context.git_dir))
|
|
101
|
+
worktree_dir_by_branch = {}
|
|
102
|
+
for record in worktrees:
|
|
103
|
+
worktree_dir, branch = _worktree_entry(record)
|
|
104
|
+
if branch and canonical_path(worktree_dir) not in active_dir_set:
|
|
105
|
+
# The folder is only shown to the user: spell it the system's way, since Git for Windows writes forward slashes.
|
|
106
|
+
worktree_dir_by_branch[branch] = os.path.normpath(worktree_dir)
|
|
107
|
+
return worktree_dir_by_branch
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_local_state(capture: RewriteCapture) -> RewriteCapture:
|
|
111
|
+
"""Capture exact index bytes plus native operation markers without refreshing the index."""
|
|
112
|
+
index_filename = os.path.join(capture.context.git_dir, "index")
|
|
113
|
+
index_identity = read_index(index_filename)
|
|
114
|
+
index_stat = stat_identity(os.lstat(index_filename)) if index_identity is not None else None
|
|
115
|
+
native_states = []
|
|
116
|
+
for basename in REBASE_MARKER_BASENAMES + REPLAY_MARKER_BASENAMES + MERGE_MARKER_BASENAMES:
|
|
117
|
+
if os.path.lexists(os.path.join(capture.context.git_dir, basename)):
|
|
118
|
+
native_states.append(basename)
|
|
119
|
+
return replace(capture, index_identity=index_identity, index_stat=index_stat, native_state=tuple(sorted(native_states)))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def capture_rewrite_state(capture: RewriteCapture) -> Generator[GitCommand | FileTask, object, RewriteCapture]:
|
|
123
|
+
"""Read local authority in a fixed number of native commands, with no network access."""
|
|
124
|
+
if capture.context.is_bare:
|
|
125
|
+
raise RewriteRefusal(RewriteRefusalKind.INCOMPLETE_HISTORY, "A working repository is required; open its worktree before building a preview")
|
|
126
|
+
authority = yield FileTask(lambda: reference_signature(capture.context))
|
|
127
|
+
locations = yield GitCommand(list(GIT_STORAGE_ARGUMENTS), profile=READ_PROFILE)
|
|
128
|
+
if not git_storage_matches(capture.context, locations):
|
|
129
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "The repository Git storage changed; reopen the repository and build a new preview")
|
|
130
|
+
initial = yield FileTask(lambda: _read_local_state(capture))
|
|
131
|
+
output = yield GitCommand(["for-each-ref", "--format=" + REFERENCE_FORMAT, LOCAL_PREFIX, REMOTE_PREFIX])
|
|
132
|
+
reference = parse_reference_read(output)
|
|
133
|
+
for entry in reference.references:
|
|
134
|
+
if entry.full_name.startswith(LOCAL_PREFIX) and entry.symbolic_target is not None:
|
|
135
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, f"Local branch {entry.full_name} is symbolic. Use ordinary local branches before building a new preview.")
|
|
136
|
+
symbolic_head = (yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))).rstrip(b"\n")
|
|
137
|
+
head_ref = os.fsdecode(symbolic_head) if symbolic_head else None
|
|
138
|
+
if reference.head_ref != head_ref and reference.head_ref is not None:
|
|
139
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "HEAD changed during the read; build a new preview")
|
|
140
|
+
local_ref_by_name = {}
|
|
141
|
+
remote_ref_by_name = {}
|
|
142
|
+
for name, oid in reference.object_oid_by_name.items():
|
|
143
|
+
if name.startswith(LOCAL_PREFIX):
|
|
144
|
+
local_ref_by_name[name] = oid
|
|
145
|
+
elif name.startswith(REMOTE_PREFIX):
|
|
146
|
+
remote_ref_by_name[name] = oid
|
|
147
|
+
upstream_config = yield GitCommand(["config", "--null", "--get-regexp", UPSTREAM_CONFIG_PATTERN], accepted_codes=(0, 1))
|
|
148
|
+
worktree_output = yield GitCommand(["worktree", "list", "--porcelain", "-z"])
|
|
149
|
+
worktrees = parse_worktree_listing(worktree_output)
|
|
150
|
+
current = yield FileTask(lambda: _read_local_state(capture))
|
|
151
|
+
if (initial.index_identity, initial.index_stat, initial.native_state) != (current.index_identity, current.index_stat, current.native_state):
|
|
152
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "The index or native operation changed during the read; build a new preview")
|
|
153
|
+
shared_path = (yield GitCommand(["rev-parse", "--path-format=absolute", "--shared-index-path"])).removesuffix(b"\n")
|
|
154
|
+
shared_index_filename = os.fsdecode(shared_path)
|
|
155
|
+
shared_identity = (yield FileTask(lambda: read_index(shared_index_filename))) if shared_index_filename else None
|
|
156
|
+
if (yield FileTask(lambda: reference_signature(capture.context))) != authority:
|
|
157
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "References, configuration or worktrees changed during the read; build a new preview")
|
|
158
|
+
return replace(current, head_ref=head_ref, local_ref_by_name=local_ref_by_name, remote_ref_by_name=remote_ref_by_name,
|
|
159
|
+
upstream_by_name=reference.upstream_by_name, upstream_config=upstream_config, worktrees=worktrees, complete=True,
|
|
160
|
+
shared_index_filename=shared_index_filename, shared_index_identity=shared_identity)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def publication_authority(capture: RewriteCapture) -> tuple:
|
|
164
|
+
"""Bracket the final native reads with local evidence of refs, configuration and native operations."""
|
|
165
|
+
local = _read_local_state(capture)
|
|
166
|
+
shared_identity = read_index(capture.shared_index_filename) if capture.shared_index_filename else None
|
|
167
|
+
return reference_signature(capture.context), local.index_identity, local.index_stat, local.native_state, shared_identity
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_publication_authority(capture: RewriteCapture, expected: tuple) -> None:
|
|
171
|
+
"""Reject local authority changes while the collective reference transaction waits to launch."""
|
|
172
|
+
if publication_authority(capture) != expected:
|
|
173
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "Repository authority changed before publication; build a new preview")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_rewrite_capture(expected: RewriteCapture, observed: RewriteCapture) -> None:
|
|
177
|
+
"""Refuse every authority change, including a newly created ref or an absent index."""
|
|
178
|
+
if not expected.complete or not observed.complete or expected != observed:
|
|
179
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "Preview is out of date. Repository, HEAD, references, index, upstream settings or worktrees changed; build a new preview")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def protected_target_refusal(target: RewriteTarget, path: bytes, mode: bytes, commit_oid: str | None = None) -> None:
|
|
183
|
+
"""Refuse submodule boundaries or metadata with their exact historical witness."""
|
|
184
|
+
_parent, _separator, basename = path.rpartition(b"/")
|
|
185
|
+
protected_kind = RewriteProtectedKind.SUBMODULE if mode == b"160000" else RewriteProtectedKind.MODULE_CONFIGURATION if basename == b".gitmodules" else None
|
|
186
|
+
if protected_kind is None:
|
|
187
|
+
return
|
|
188
|
+
if not matches_target(path, target) and not (mode == b"160000" and target.path.startswith(path + b"/")):
|
|
189
|
+
return
|
|
190
|
+
witness = f"commit {commit_oid} contains" if commit_oid else "it contains"
|
|
191
|
+
raise RewriteRefusal(RewriteRefusalKind.PROTECTED_TARGET,
|
|
192
|
+
f"Cannot remove {target.path!r}: {witness} {path!r}, which is {protected_kind.value}. Choose a path that does not include a submodule or .gitmodules. No history was changed.",
|
|
193
|
+
path=path, commit_oid=commit_oid)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_history_protection(capture: RewriteCapture, target: RewriteTarget, records: list[ExportCommit], raw_by_oid: dict[str, RawCommit],
|
|
197
|
+
cancelled: Callable[[], bool] | None = None) -> None:
|
|
198
|
+
"""Check protected entries only in the final affected ancestry, including deleted gitlinks."""
|
|
199
|
+
ordered_records = ordered_history(records, raw_by_oid, cancelled)
|
|
200
|
+
included_oid_set = affected_branch_closure(capture, target, ordered_records, raw_by_oid, cancelled)
|
|
201
|
+
for record in ordered_records:
|
|
202
|
+
check_cancelled(cancelled)
|
|
203
|
+
if record.oid not in included_oid_set:
|
|
204
|
+
continue
|
|
205
|
+
for change in record.changes:
|
|
206
|
+
if change.kind == ChangeKind.MODIFY:
|
|
207
|
+
protected_target_refusal(target, change.path, change.mode, record.oid)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_initial_state(capture: RewriteCapture, target: RewriteTarget) -> Generator[GitCommand, bytes]:
|
|
211
|
+
"""Reject unsafe source states while allowing unrelated staged or unstaged changes."""
|
|
212
|
+
if capture.head_ref is None:
|
|
213
|
+
raise RewriteRefusal(RewriteRefusalKind.DETACHED_HEAD, "HEAD is detached. Check out a local branch, then build a new preview.")
|
|
214
|
+
if capture.head_ref not in capture.local_ref_by_name:
|
|
215
|
+
raise RewriteRefusal(RewriteRefusalKind.UNBORN_BRANCH, f"Branch {capture.head_ref} is unborn. Create a commit before building a new preview.", branch=capture.head_ref)
|
|
216
|
+
if capture.native_state:
|
|
217
|
+
operation = ", ".join(capture.native_state)
|
|
218
|
+
raise RewriteRefusal(RewriteRefusalKind.NATIVE_OPERATION, f"This repository is busy with {operation}. Finish or stop that operation, then build a new preview.")
|
|
219
|
+
protected_target_refusal(target, target.path, b"")
|
|
220
|
+
listing = yield GitCommand(["ls-files", "--stage", "-z"])
|
|
221
|
+
for record in listing.removesuffix(b"\0").split(b"\0") if listing else ():
|
|
222
|
+
metadata, separator, path = record.partition(b"\t")
|
|
223
|
+
if not separator:
|
|
224
|
+
raise ValueError("Incomplete index listing; build a new preview")
|
|
225
|
+
mode, _oid, stage = metadata.split(b" ")
|
|
226
|
+
if stage != b"0":
|
|
227
|
+
raise RewriteRefusal(RewriteRefusalKind.UNMERGED_INDEX, f"The index has conflicts at {path!r}. Resolve the conflicts, then build a new preview.", path=path)
|
|
228
|
+
protected_target_refusal(target, path, mode)
|
|
229
|
+
staged = yield GitCommand(["diff", "--cached", "--name-only", "-z", "--no-renames", "--ita-visible-in-index", "--ignore-submodules=none",
|
|
230
|
+
capture.local_ref_by_name[capture.head_ref], "--"])
|
|
231
|
+
for path in staged.removesuffix(b"\0").split(b"\0") if staged else ():
|
|
232
|
+
if matches_target(path, target):
|
|
233
|
+
raise RewriteRefusal(RewriteRefusalKind.STAGED_TARGET,
|
|
234
|
+
f"{path!r} has staged changes. Unstage this path first so its staged version is not lost, then build a new preview. Other staged files can stay staged.", path=path)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def validate_branch_state(capture: RewriteCapture, analysis: HistoryAnalysis, ancestry_filename: str,
|
|
238
|
+
cancelled: Callable[[], bool] | None = None) -> HistoryAnalysis:
|
|
239
|
+
"""Check all moving branches against known upstream ancestry plus other worktrees."""
|
|
240
|
+
parent_oids_by_oid = {}
|
|
241
|
+
with open(ancestry_filename, "rb") as stream:
|
|
242
|
+
while line := read_line(stream, cancelled):
|
|
243
|
+
oid, *parent_oids = line.decode("ascii").split()
|
|
244
|
+
parent_oids_by_oid[validate_oid(oid)] = tuple(validate_oid(parent) for parent in parent_oids)
|
|
245
|
+
|
|
246
|
+
def ancestor_set(tip_oid: str) -> set[str]:
|
|
247
|
+
"""Visit each ancestor once, refusing incomplete local knowledge."""
|
|
248
|
+
visited_oid_set = set()
|
|
249
|
+
pending_oids = [tip_oid]
|
|
250
|
+
while pending_oids:
|
|
251
|
+
check_cancelled(cancelled)
|
|
252
|
+
oid = pending_oids.pop()
|
|
253
|
+
if oid in visited_oid_set:
|
|
254
|
+
continue
|
|
255
|
+
if oid not in parent_oids_by_oid:
|
|
256
|
+
raise RewriteRefusal(RewriteRefusalKind.INCOMPLETE_HISTORY, f"Missing history at {oid}; make the missing objects available, then build a new preview. No fetch was performed.", commit_oid=oid)
|
|
257
|
+
visited_oid_set.add(oid)
|
|
258
|
+
pending_oids.extend(parent_oids_by_oid[oid])
|
|
259
|
+
return visited_oid_set
|
|
260
|
+
|
|
261
|
+
worktree_dir_by_branch = other_worktree_dir_by_branch(capture.context, capture.worktrees)
|
|
262
|
+
changed_oid_set = set()
|
|
263
|
+
for oid, replacement in analysis.replacement_by_oid.items():
|
|
264
|
+
if oid != replacement:
|
|
265
|
+
changed_oid_set.add(oid)
|
|
266
|
+
published_oid_set_by_ref = {}
|
|
267
|
+
for name, oid in capture.remote_ref_by_name.items():
|
|
268
|
+
published_oid_set_by_ref[name] = ancestor_set(oid) & changed_oid_set
|
|
269
|
+
branches = []
|
|
270
|
+
for branch in analysis.branches:
|
|
271
|
+
if branch.name in worktree_dir_by_branch:
|
|
272
|
+
raise RewriteRefusal(RewriteRefusalKind.OTHER_WORKTREE,
|
|
273
|
+
f"{branch.name} is checked out in another worktree: {worktree_dir_by_branch[branch.name]}. Release that branch in the other worktree, then build a new preview. GitPane will not change that worktree.",
|
|
274
|
+
branch=branch.name)
|
|
275
|
+
local_ancestor_set = ancestor_set(branch.old_oid)
|
|
276
|
+
if branch.upstream:
|
|
277
|
+
upstream_oid = capture.remote_ref_by_name.get(branch.upstream, capture.local_ref_by_name.get(branch.upstream))
|
|
278
|
+
if upstream_oid is None:
|
|
279
|
+
raise RewriteRefusal(RewriteRefusalKind.INCOMPLETE_HISTORY, f"The known upstream {branch.upstream} of {branch.name} is unavailable. Make its history available, then build a new preview. No fetch was performed.", branch=branch.name)
|
|
280
|
+
if upstream_oid not in local_ancestor_set:
|
|
281
|
+
raise RewriteRefusal(RewriteRefusalKind.BEHIND_UPSTREAM,
|
|
282
|
+
f"{branch.name} is behind {branch.upstream} according to the last fetch. Update that local branch before removing this path from history, then build a new preview.", branch=branch.name)
|
|
283
|
+
remote_refs = []
|
|
284
|
+
for name, published_oid_set in published_oid_set_by_ref.items():
|
|
285
|
+
if published_oid_set & local_ancestor_set:
|
|
286
|
+
remote_refs.append(name)
|
|
287
|
+
branches.append(replace(branch, remote_refs=tuple(sorted(remote_refs))))
|
|
288
|
+
return replace(analysis, branches=tuple(branches))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Prepare a verified private index without changing the working repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from gitpane.config import EXACT_PATH_CASE_ARGUMENTS, GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME, SUBMODULE_RECURSION_OFF_ARGUMENTS
|
|
12
|
+
from gitpane.history.repository_value import validate_oid
|
|
13
|
+
from gitpane.history_rewrite.protocol import check_cancelled, matches_target
|
|
14
|
+
from gitpane.history_rewrite.value import RewriteIndex, RewriteRefusal, RewriteRefusalKind
|
|
15
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE
|
|
16
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
17
|
+
from gitpane.worktree.worktree_snapshot import read_index
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Generator
|
|
21
|
+
|
|
22
|
+
from gitpane.history_rewrite.value import RewriteCapture, RewriteTarget
|
|
23
|
+
|
|
24
|
+
# Native debug output is deliberately checked rather than guessed: Git documents it as unstable.
|
|
25
|
+
# Unknown formats must refuse publication, because flags cannot otherwise be verified faithfully.
|
|
26
|
+
INDEX_DEBUG_PATTERN = re.compile(rb" ctime: \d+:\d+\n mtime: \d+:\d+\n dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n size: \d+\tflags: (?P<flags>[0-9a-fA-F]+)\n")
|
|
27
|
+
INDEX_ASSUME_UNCHANGED_FLAG = 0x8000
|
|
28
|
+
INDEX_INTENT_TO_ADD_FLAG = 1 << 29
|
|
29
|
+
INDEX_SKIP_WORKTREE_FLAG = 1 << 30
|
|
30
|
+
INDEX_PERSISTENT_FLAG_MASK = INDEX_ASSUME_UNCHANGED_FLAG | INDEX_INTENT_TO_ADD_FLAG | INDEX_SKIP_WORKTREE_FLAG
|
|
31
|
+
INDEX_LIST_ARGUMENTS = ["ls-files", "--stage", "--debug", "-z"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_index_entry(output: bytes, cancelled: Callable[[], bool] | None = None) -> dict[tuple[bytes, bytes], tuple[bytes, str, int]]:
|
|
35
|
+
"""Read literal paths, stages and persistent flags from a complete native listing."""
|
|
36
|
+
entry_by_key = {}
|
|
37
|
+
position = 0
|
|
38
|
+
while position < len(output):
|
|
39
|
+
check_cancelled(cancelled)
|
|
40
|
+
end = output.find(b"\0", position)
|
|
41
|
+
if end < 0:
|
|
42
|
+
raise RewriteRefusal(RewriteRefusalKind.UNSUPPORTED_INDEX, "Incomplete private index listing; no history was changed")
|
|
43
|
+
metadata, separator, path = output[position:end].partition(b"\t")
|
|
44
|
+
debug = INDEX_DEBUG_PATTERN.match(output, end + 1)
|
|
45
|
+
if not separator or not path or debug is None:
|
|
46
|
+
raise RewriteRefusal(RewriteRefusalKind.UNSUPPORTED_INDEX, "Cannot verify this Git index format without losing entry flags; no history was changed")
|
|
47
|
+
mode, oid, stage = metadata.split(b" ")
|
|
48
|
+
key = (path, stage)
|
|
49
|
+
if key in entry_by_key:
|
|
50
|
+
raise RewriteRefusal(RewriteRefusalKind.UNSUPPORTED_INDEX, "Duplicate private index entry; no history was changed")
|
|
51
|
+
entry_by_key[key] = (mode, validate_oid(oid.decode("ascii")), int(debug.group("flags"), 16) & INDEX_PERSISTENT_FLAG_MASK)
|
|
52
|
+
position = debug.end()
|
|
53
|
+
return entry_by_key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify_candidate_index(target: RewriteTarget, original: bytes, candidate: bytes, cancelled: Callable[[], bool] | None = None) -> None:
|
|
57
|
+
"""Require every retained entry to keep its mode, blob, stage and persistent flags."""
|
|
58
|
+
expected_by_key = {}
|
|
59
|
+
for key, entry in parse_index_entry(original, cancelled).items():
|
|
60
|
+
path, _stage = key
|
|
61
|
+
if not matches_target(path, target):
|
|
62
|
+
expected_by_key[key] = entry
|
|
63
|
+
if parse_index_entry(candidate, cancelled) != expected_by_key:
|
|
64
|
+
raise RewriteRefusal(RewriteRefusalKind.UNSUPPORTED_INDEX, "The candidate index changed unrelated entries or flags; no history was changed. Inspect the index before building another preview.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def index_digest(filename: str) -> bytes | None:
|
|
68
|
+
"""Distinguish an absent index from every exact byte representation used for publication."""
|
|
69
|
+
content = read_index(filename)
|
|
70
|
+
return hashlib.sha256(content).digest() if content is not None else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _copy_captured_index(capture: RewriteCapture, operation_dir: str, private_dir: str) -> RewriteIndex:
|
|
74
|
+
"""Materialize only captured index bytes and its shared base inside owned private storage."""
|
|
75
|
+
source_filename = os.path.join(capture.context.git_dir, "index")
|
|
76
|
+
if read_index(source_filename) != capture.index_identity:
|
|
77
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "The index changed before candidate preparation; build a new preview")
|
|
78
|
+
original_filename = os.path.join(operation_dir, "original.index")
|
|
79
|
+
candidate_filename = os.path.join(private_dir, "index")
|
|
80
|
+
os.mkdir(os.path.join(operation_dir, "index-worktree"))
|
|
81
|
+
if capture.shared_index_filename:
|
|
82
|
+
shared_content = read_index(capture.shared_index_filename)
|
|
83
|
+
if shared_content is None or shared_content != capture.shared_index_identity:
|
|
84
|
+
raise RewriteRefusal(RewriteRefusalKind.STALE_CAPTURE, "The shared index changed before candidate preparation; build a new preview")
|
|
85
|
+
with open(os.path.join(private_dir, os.path.basename(capture.shared_index_filename)), "xb") as stream:
|
|
86
|
+
stream.write(shared_content)
|
|
87
|
+
if capture.index_identity is not None:
|
|
88
|
+
for filename in (original_filename, candidate_filename):
|
|
89
|
+
with open(filename, "xb") as stream:
|
|
90
|
+
stream.write(capture.index_identity)
|
|
91
|
+
return RewriteIndex(original_filename, candidate_filename, index_digest(original_filename), index_digest(candidate_filename))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_removal_request(filename: str, target: RewriteTarget, original: bytes, cancelled: Callable[[], bool] | None) -> bool:
|
|
95
|
+
"""Pass only exact indexed target paths to native Git through NUL-framed input."""
|
|
96
|
+
paths = []
|
|
97
|
+
for path, _stage in parse_index_entry(original, cancelled):
|
|
98
|
+
if matches_target(path, target):
|
|
99
|
+
paths.append(path)
|
|
100
|
+
with open(filename, "xb") as stream:
|
|
101
|
+
for path in sorted(paths):
|
|
102
|
+
check_cancelled(cancelled)
|
|
103
|
+
stream.write(path + b"\0")
|
|
104
|
+
return bool(paths)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prepare_candidate_index(capture: RewriteCapture, target: RewriteTarget, operation_dir: str, private_dir: str,
|
|
108
|
+
cancelled: Callable[[], bool] | None = None) -> Generator[GitCommand | FileTask, object, RewriteIndex]:
|
|
109
|
+
"""Remove target entries in private Git storage, preserving every unrelated staged decision."""
|
|
110
|
+
check_cancelled(cancelled)
|
|
111
|
+
index = yield FileTask(lambda: _copy_captured_index(capture, operation_dir, private_dir))
|
|
112
|
+
original = yield GitCommand(INDEX_LIST_ARGUMENTS.copy(), profile=READ_PROFILE)
|
|
113
|
+
request_filename = os.path.join(operation_dir, "remove-index.in")
|
|
114
|
+
remove = yield FileTask(lambda: _write_removal_request(request_filename, target, original, cancelled))
|
|
115
|
+
environment_by_name = {"GIT_INDEX_FILE": index.candidate_filename, GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME: os.path.join(capture.context.common_git_dir, "objects")}
|
|
116
|
+
private_arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, *EXACT_PATH_CASE_ARGUMENTS, "--git-dir=" + private_dir, "--work-tree=" + os.path.join(operation_dir, "index-worktree"),
|
|
117
|
+
"-c", "core.bare=false", "-c", "core.splitIndex=false", "-c", "core.sparseCheckout=false", "-c", "index.sparse=false"]
|
|
118
|
+
if remove:
|
|
119
|
+
yield GitCommand([*private_arguments, "update-index", "--no-split-index", "--force-remove", "-z", "--stdin"], stdin_filename=request_filename,
|
|
120
|
+
environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE, before_start=lambda: check_cancelled(cancelled))
|
|
121
|
+
# The candidate index and the private repository are deleted with the private folder as soon as this read ends.
|
|
122
|
+
candidate = yield GitCommand([*EXACT_PATH_CASE_ARGUMENTS, "-C", private_dir, *INDEX_LIST_ARGUMENTS], environment_by_name=environment_by_name, profile=READ_PROFILE,
|
|
123
|
+
opens_files_deleted_at_end=True)
|
|
124
|
+
yield FileTask(lambda: verify_candidate_index(target, original, candidate, cancelled))
|
|
125
|
+
return replace(index, candidate_identity=(yield FileTask(lambda: index_digest(index.candidate_filename))))
|