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,435 @@
|
|
|
1
|
+
"""Build selected changes from canonical Git bytes independently of displayed Unicode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from gitpane.config import FILE_PREVIEW_LIMIT_BYTES, GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS, WORKTREE_OUTPUT_LIMIT_BYTES
|
|
14
|
+
from gitpane.history.history_parse import CatFileParser
|
|
15
|
+
from gitpane.history.repository_value import validate_oid
|
|
16
|
+
from gitpane.history.revision_file import DiffLineKind
|
|
17
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
|
|
18
|
+
from gitpane.worktree.command_sequence import GitCommand
|
|
19
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, read_index
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Generator
|
|
23
|
+
|
|
24
|
+
from gitpane.worktree.worktree_file import FileImage
|
|
25
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeKey, WorktreeSnapshot
|
|
26
|
+
|
|
27
|
+
REGULAR_MODE_SET = frozenset(("100644", "100755"))
|
|
28
|
+
ABSENT_MODE = "000000"
|
|
29
|
+
# Hunk header of a patch read as bytes: old start, old count, new start, new count (counts may be absent).
|
|
30
|
+
HUNK_HEADER_BYTES_PATTERN = re.compile(rb"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
31
|
+
LINE_PREFIX_LENGTH = 1
|
|
32
|
+
INDEX_RECORD_TERMINATOR = b"\0"
|
|
33
|
+
INDEX_METADATA_SEPARATOR = b"\t"
|
|
34
|
+
INDEX_METADATA_FIELD_COUNT = 3
|
|
35
|
+
MERGED_STAGE = b"0"
|
|
36
|
+
# Role of a hunk line by the first byte Git writes on it.
|
|
37
|
+
DIFF_LINE_KIND_BY_PREFIX: dict[bytes, DiffLineKind] = {b" ": DiffLineKind.CONTEXT, b"-": DiffLineKind.REMOVED, b"+": DiffLineKind.ADDED}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RawChange:
|
|
42
|
+
"""Retain one exact Git line and its original patch and blob coordinates."""
|
|
43
|
+
|
|
44
|
+
kind: DiffLineKind
|
|
45
|
+
content: bytes
|
|
46
|
+
old_index: int | None
|
|
47
|
+
new_index: int | None
|
|
48
|
+
patch_index: int | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RawLineSelection:
|
|
53
|
+
"""Bind selected operations to one worktree, snapshot and original patch."""
|
|
54
|
+
|
|
55
|
+
key: WorktreeKey
|
|
56
|
+
snapshot_generation: int
|
|
57
|
+
side: StagingSide
|
|
58
|
+
path: bytes
|
|
59
|
+
line_ids: tuple[int, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class RawPatch:
|
|
64
|
+
"""Keep complete before/after images and only byte-verified patch operations."""
|
|
65
|
+
|
|
66
|
+
snapshot: WorktreeSnapshot
|
|
67
|
+
entry: LocalEntry
|
|
68
|
+
before: bytes | None
|
|
69
|
+
after: bytes | None
|
|
70
|
+
before_oid: str
|
|
71
|
+
after_oid: str
|
|
72
|
+
mode: str
|
|
73
|
+
patch: bytes
|
|
74
|
+
changes: tuple[RawChange, ...]
|
|
75
|
+
|
|
76
|
+
def selected(self, selection: RawLineSelection) -> frozenset[int]:
|
|
77
|
+
"""Reject forged, empty, non-Unstaged or stale operation identities."""
|
|
78
|
+
if (selection.key, selection.snapshot_generation, selection.side, selection.path) != (self.snapshot.key, self.snapshot.generation, StagingSide.UNSTAGED, self.entry.path):
|
|
79
|
+
raise ValueError("The line selection is stale; select the changes again")
|
|
80
|
+
valid_id_set = {change.patch_index for change in self.changes if change.kind in (DiffLineKind.ADDED, DiffLineKind.REMOVED)}
|
|
81
|
+
selected_id_set = frozenset(selection.line_ids)
|
|
82
|
+
if not selected_id_set or not selected_id_set.issubset(valid_id_set):
|
|
83
|
+
raise ValueError("Select changed lines in the Unstaged comparison")
|
|
84
|
+
return selected_id_set
|
|
85
|
+
|
|
86
|
+
def stage_content(self, selection: RawLineSelection) -> bytes | None:
|
|
87
|
+
"""Replay exactly the selected additions and deletions into the index image."""
|
|
88
|
+
selected_id_set = self.selected(selection)
|
|
89
|
+
lines: list[bytes] = []
|
|
90
|
+
for change in self.changes:
|
|
91
|
+
chosen = change.patch_index in selected_id_set
|
|
92
|
+
if change.kind == DiffLineKind.CONTEXT or (change.kind == DiffLineKind.REMOVED and not chosen) or (change.kind == DiffLineKind.ADDED and chosen):
|
|
93
|
+
lines.append(change.content)
|
|
94
|
+
content = b"".join(lines)
|
|
95
|
+
if not content and self.after is None and all(change.kind != DiffLineKind.REMOVED or change.patch_index in selected_id_set for change in self.changes):
|
|
96
|
+
return None
|
|
97
|
+
return content
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def byte_lines(content: bytes) -> list[bytes]:
|
|
101
|
+
"""Split only Git LF boundaries while preserving CR, invalid bytes and missing EOF LF."""
|
|
102
|
+
pieces = content.split(b"\n")
|
|
103
|
+
last_index = len(pieces) - 1
|
|
104
|
+
lines: list[bytes] = []
|
|
105
|
+
for index, piece in enumerate(pieces):
|
|
106
|
+
if index != last_index:
|
|
107
|
+
lines.append(piece + b"\n")
|
|
108
|
+
elif piece:
|
|
109
|
+
lines.append(piece)
|
|
110
|
+
return lines
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_changes(patch: bytes, before: bytes, after: bytes) -> tuple[RawChange, ...]:
|
|
114
|
+
"""Check every patch byte against both blobs, including omitted context and EOF markers."""
|
|
115
|
+
old_lines, new_lines = byte_lines(before), byte_lines(after)
|
|
116
|
+
changes: list[RawChange] = []
|
|
117
|
+
old_index = new_index = 0
|
|
118
|
+
in_hunk = False
|
|
119
|
+
for patch_index, line in enumerate(byte_lines(patch)):
|
|
120
|
+
match = HUNK_HEADER_BYTES_PATTERN.match(line)
|
|
121
|
+
if match:
|
|
122
|
+
old_start, old_count, new_start, new_count = match.groups()
|
|
123
|
+
old_target = int(old_start) - (0 if old_count == b"0" else 1)
|
|
124
|
+
new_target = int(new_start) - (0 if new_count == b"0" else 1)
|
|
125
|
+
while old_index < old_target and new_index < new_target:
|
|
126
|
+
changes.append(RawChange(DiffLineKind.CONTEXT, old_lines[old_index], old_index, new_index, None))
|
|
127
|
+
old_index += 1
|
|
128
|
+
new_index += 1
|
|
129
|
+
if old_index != old_target or new_index != new_target:
|
|
130
|
+
raise ValueError("Patch coordinates do not match the captured files")
|
|
131
|
+
in_hunk = True
|
|
132
|
+
continue
|
|
133
|
+
if not in_hunk:
|
|
134
|
+
continue
|
|
135
|
+
if line.startswith(b"\"):
|
|
136
|
+
previous_index = len(changes) - 1
|
|
137
|
+
if previous_index < 0 or not changes[previous_index].content.endswith(b"\n"):
|
|
138
|
+
raise ValueError("Invalid patch newline marker")
|
|
139
|
+
changes[previous_index] = replace(changes[previous_index], content=changes[previous_index].content.removesuffix(b"\n"))
|
|
140
|
+
continue
|
|
141
|
+
prefix = line[:LINE_PREFIX_LENGTH]
|
|
142
|
+
if prefix not in DIFF_LINE_KIND_BY_PREFIX:
|
|
143
|
+
raise ValueError("Unsupported patch record")
|
|
144
|
+
kind = DIFF_LINE_KIND_BY_PREFIX[prefix]
|
|
145
|
+
changes.append(RawChange(kind, line[LINE_PREFIX_LENGTH:], old_index if prefix != b"+" else None, new_index if prefix != b"-" else None, patch_index))
|
|
146
|
+
old_index += prefix != b"+"
|
|
147
|
+
new_index += prefix != b"-"
|
|
148
|
+
while old_index < len(old_lines) and new_index < len(new_lines):
|
|
149
|
+
changes.append(RawChange(DiffLineKind.CONTEXT, old_lines[old_index], old_index, new_index, None))
|
|
150
|
+
old_index += 1
|
|
151
|
+
new_index += 1
|
|
152
|
+
old_result: list[bytes] = []
|
|
153
|
+
new_result: list[bytes] = []
|
|
154
|
+
for change in changes:
|
|
155
|
+
if change.old_index is not None:
|
|
156
|
+
old_result.append(change.content)
|
|
157
|
+
if change.new_index is not None:
|
|
158
|
+
new_result.append(change.content)
|
|
159
|
+
if b"".join(old_result) != before or b"".join(new_result) != after:
|
|
160
|
+
raise ValueError("The patch cannot reproduce the captured bytes")
|
|
161
|
+
return tuple(changes)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_null_oid(oid: str) -> bool:
|
|
165
|
+
"""Recognize the all-zero object ID Git writes for a side that has no object."""
|
|
166
|
+
return bool(oid) and not oid.strip("0")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def starting_side(entry: LocalEntry) -> tuple[str, str | None]:
|
|
170
|
+
"""Return the index mode and blob the verified capture names for the selection, or no blob when the index has none.
|
|
171
|
+
|
|
172
|
+
The capture listed them from the very index whose bytes the private copy is proven equal to, so listing that copy
|
|
173
|
+
again would only repeat what is already known. A mode without an object, or an object without a mode, is refused.
|
|
174
|
+
"""
|
|
175
|
+
oid = entry.old_blob_oid
|
|
176
|
+
if entry.old_mode == ABSENT_MODE:
|
|
177
|
+
if oid is not None and not is_null_oid(oid):
|
|
178
|
+
raise ValueError("The captured index entry names an object without a mode; refresh the changes")
|
|
179
|
+
return ABSENT_MODE, None
|
|
180
|
+
if oid is None or is_null_oid(validate_oid(oid)):
|
|
181
|
+
raise ValueError("The captured index entry has a mode without an object; refresh the changes")
|
|
182
|
+
return entry.old_mode, oid
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_final_entry(listing: bytes, path: bytes) -> tuple[str, str] | None:
|
|
186
|
+
"""Read the one merged record of the selected path after the private add; an empty listing means Git removed it."""
|
|
187
|
+
if not listing:
|
|
188
|
+
return None
|
|
189
|
+
if not listing.endswith(INDEX_RECORD_TERMINATOR):
|
|
190
|
+
raise ValueError("Incomplete Git index entry output")
|
|
191
|
+
records = listing.removesuffix(INDEX_RECORD_TERMINATOR).split(INDEX_RECORD_TERMINATOR)
|
|
192
|
+
if len(records) != 1:
|
|
193
|
+
raise ValueError("The private index lists the selected path more than once")
|
|
194
|
+
metadata, separator, listed_path = records[0].partition(INDEX_METADATA_SEPARATOR)
|
|
195
|
+
fields = metadata.split(b" ")
|
|
196
|
+
if not separator or listed_path != path or len(fields) != INDEX_METADATA_FIELD_COUNT or fields[-1] != MERGED_STAGE:
|
|
197
|
+
raise ValueError("The private index does not hold one merged entry for the selected path")
|
|
198
|
+
mode_field, oid_field, _stage = fields
|
|
199
|
+
mode = mode_field.decode("ascii")
|
|
200
|
+
oid = validate_oid(oid_field.decode("ascii"))
|
|
201
|
+
if mode not in REGULAR_MODE_SET or is_null_oid(oid):
|
|
202
|
+
raise ValueError("Line actions require a regular file")
|
|
203
|
+
return mode, oid
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read_blob_bodies(oids: list[str]) -> Generator[GitCommand, bytes, dict[str, bytes]]:
|
|
207
|
+
"""Read several blobs in one bounded cat-file batch, keeping only complete bodies of exactly the requested objects.
|
|
208
|
+
|
|
209
|
+
The strict framing refuses a missing, repeated, foreign or non-blob answer, and a body over the limit arrives as
|
|
210
|
+
an explicit unavailable marker, which is refused here rather than ever being taken for an empty file.
|
|
211
|
+
"""
|
|
212
|
+
content_by_oid: dict[str, bytes | None] = {}
|
|
213
|
+
parser = CatFileParser(oids, object_limit_bytes=WORKTREE_OUTPUT_LIMIT_BYTES, expected_object_type="blob")
|
|
214
|
+
|
|
215
|
+
def consume_blobs(chunk: bytes) -> None:
|
|
216
|
+
"""Feed Git's stream to the framing and keep each finished body under its object ID."""
|
|
217
|
+
for oid, body in parser.feed(chunk):
|
|
218
|
+
content_by_oid[oid] = body
|
|
219
|
+
|
|
220
|
+
yield GitCommand(["cat-file", "--batch"], stdin="".join(oid + "\n" for oid in oids).encode("ascii"), consume=consume_blobs, stdout_limit_bytes=None)
|
|
221
|
+
parser.finish()
|
|
222
|
+
body_by_oid: dict[str, bytes] = {}
|
|
223
|
+
for oid in oids:
|
|
224
|
+
body = content_by_oid.get(oid)
|
|
225
|
+
if body is None:
|
|
226
|
+
raise ValueError("Line actions are unavailable for files this large")
|
|
227
|
+
body_by_oid[oid] = body
|
|
228
|
+
return body_by_oid
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def read_partial(snapshot: WorktreeSnapshot, entry: LocalEntry) -> Generator[GitCommand, bytes, RawPatch]:
|
|
232
|
+
"""Canonicalize only the selected path in a private index and read Git's actual byte diff.
|
|
233
|
+
|
|
234
|
+
The index side comes from the verified capture, both blobs come from one object batch, and the empty object is
|
|
235
|
+
written only when one side is missing, because only then does the comparison need it.
|
|
236
|
+
"""
|
|
237
|
+
if not snapshot.fresh or entry.side != StagingSide.UNSTAGED or entry not in snapshot.entries or entry.skip_worktree or any(item.conflict_stages for item in snapshot.entries):
|
|
238
|
+
raise ValueError("A complete current Unstaged comparison is required")
|
|
239
|
+
if entry.old_mode not in REGULAR_MODE_SET | {ABSENT_MODE} or entry.new_mode not in REGULAR_MODE_SET | {ABSENT_MODE}:
|
|
240
|
+
raise ValueError("Line actions require a regular text file")
|
|
241
|
+
before_mode, before_oid = starting_side(entry)
|
|
242
|
+
# A file added with --intent-to-add is in the index with an empty body but the capture names no object for it:
|
|
243
|
+
# its index side is present and empty, which is not the same as a file the index does not have.
|
|
244
|
+
intent_to_add = before_oid is None and entry.old_path is not None
|
|
245
|
+
with tempfile.TemporaryDirectory(prefix="gitpane-lines-", dir=snapshot.context.git_dir) as operation_dir:
|
|
246
|
+
candidate_filename = os.path.join(operation_dir, "canonical-index")
|
|
247
|
+
environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
|
|
248
|
+
if snapshot.index_content is not None:
|
|
249
|
+
shutil.copy2(snapshot.index_filename, candidate_filename)
|
|
250
|
+
if read_index(candidate_filename) != snapshot.index_content:
|
|
251
|
+
raise ValueError("The index changed while reading the selected lines")
|
|
252
|
+
else:
|
|
253
|
+
yield GitCommand(["read-tree", "--empty"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
254
|
+
# This command sits on a reading path but still writes objects and runs the user's clean filters, which must
|
|
255
|
+
# have finished before the repository is handed back, so it is launched under supervision like any write.
|
|
256
|
+
yield GitCommand(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], stdin=entry.path + b"\0", environment_by_name=environment_by_name,
|
|
257
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
258
|
+
final_entry = parse_final_entry((yield GitCommand(["ls-files", "--stage", "-z", "--", os.fsdecode(entry.path)], environment_by_name=environment_by_name)), entry.path)
|
|
259
|
+
after_oid = None if final_entry is None else final_entry[1]
|
|
260
|
+
if before_oid is None and final_entry is None:
|
|
261
|
+
raise ValueError("The selected file is neither in the index nor in the working tree")
|
|
262
|
+
mode = before_mode if before_oid is not None else final_entry[0]
|
|
263
|
+
requested_oid_set: set[str] = set()
|
|
264
|
+
for oid in (before_oid, after_oid):
|
|
265
|
+
if oid is not None:
|
|
266
|
+
requested_oid_set.add(oid)
|
|
267
|
+
body_by_oid = yield from read_blob_bodies(sorted(requested_oid_set))
|
|
268
|
+
before = body_by_oid[before_oid] if before_oid is not None else b"" if intent_to_add else None
|
|
269
|
+
after = body_by_oid[after_oid] if after_oid is not None else None
|
|
270
|
+
if b"\0" in (before or b"") or b"\0" in (after or b""):
|
|
271
|
+
raise ValueError("Line actions are unavailable for binary files")
|
|
272
|
+
if before_oid is None or after_oid is None:
|
|
273
|
+
empty_oid = (yield GitCommand(["hash-object", "-w", "--no-filters", "--stdin"], stdin=b"", profile=LOCAL_WRITE_PROFILE)).strip().decode("ascii")
|
|
274
|
+
before_oid = before_oid or (empty_oid if intent_to_add else None)
|
|
275
|
+
before_side_oid, after_side_oid = before_oid or empty_oid, after_oid or empty_oid
|
|
276
|
+
else:
|
|
277
|
+
before_side_oid, after_side_oid = before_oid, after_oid
|
|
278
|
+
patch = yield GitCommand(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--diff-algorithm=myers", "--no-indent-heuristic", "--unified=3",
|
|
279
|
+
before_side_oid, after_side_oid], stdout_limit_bytes=FILE_PREVIEW_LIMIT_BYTES)
|
|
280
|
+
return RawPatch(snapshot, entry, before, after, before_oid or "", after_oid or "", mode, patch, parse_changes(patch, before or b"", after or b""))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def stage_candidate(model: RawPatch, selection: RawLineSelection, environment_by_name: dict[str, str]) -> Generator[GitCommand, bytes, bytes | None]:
|
|
284
|
+
"""Store already canonical selected bytes once, verify the written object, and return the index record to expect.
|
|
285
|
+
|
|
286
|
+
The returned record is written the way ls-files --stage -z lists it: mode, object ID, stage 0, the raw path and a
|
|
287
|
+
final NUL. None means the path must be absent from the candidate. The caller checks it in the complete listing it
|
|
288
|
+
already needs for the neighbors. The object is still read back: a listing naming the announced ID does not prove
|
|
289
|
+
that the written object holds the selected bytes.
|
|
290
|
+
"""
|
|
291
|
+
content = model.stage_content(selection)
|
|
292
|
+
if content is None:
|
|
293
|
+
yield GitCommand(["update-index", "--force-remove", "-z", "--stdin"], stdin=model.entry.path + INDEX_RECORD_TERMINATOR, environment_by_name=environment_by_name,
|
|
294
|
+
profile=LOCAL_WRITE_PROFILE)
|
|
295
|
+
return None
|
|
296
|
+
oid = (yield GitCommand(["hash-object", "-w", "--no-filters", "--stdin"], stdin=content, profile=LOCAL_WRITE_PROFILE)).strip()
|
|
297
|
+
validate_oid(oid.decode("ascii"))
|
|
298
|
+
record = model.mode.encode("ascii") + b" " + oid + INDEX_METADATA_SEPARATOR + model.entry.path + INDEX_RECORD_TERMINATOR
|
|
299
|
+
yield GitCommand(["update-index", "-z", "--index-info"], stdin=record, environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
300
|
+
actual = yield GitCommand(["cat-file", "blob", oid.decode("ascii")])
|
|
301
|
+
if actual != content:
|
|
302
|
+
raise ValueError("The candidate index does not contain the selected bytes")
|
|
303
|
+
return model.mode.encode("ascii") + b" " + oid + b" " + MERGED_STAGE + INDEX_METADATA_SEPARATOR + model.entry.path + INDEX_RECORD_TERMINATOR
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(frozen=True)
|
|
307
|
+
class RawSlices:
|
|
308
|
+
"""Retain the original per-line bytes and any encoding prefix separately."""
|
|
309
|
+
|
|
310
|
+
prefix: bytes
|
|
311
|
+
lines: tuple[bytes, ...]
|
|
312
|
+
codec: str = ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def line_slices(canonical: bytes, raw: bytes, encoding: str = "") -> RawSlices:
|
|
316
|
+
"""Prove an exact LF/CRLF or working-tree-encoding correspondence without rewriting untouched bytes."""
|
|
317
|
+
prefix = b""
|
|
318
|
+
codec = ""
|
|
319
|
+
lines = byte_lines(raw)
|
|
320
|
+
comparisons = lines
|
|
321
|
+
if encoding:
|
|
322
|
+
normalized_encoding = encoding.lower().replace("-", "")
|
|
323
|
+
encoding_by_marker = ((codecs.BOM_UTF32_LE, "utf-32-le"), (codecs.BOM_UTF32_BE, "utf-32-be"),
|
|
324
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"), (codecs.BOM_UTF16_BE, "utf-16-be"), (codecs.BOM_UTF8, "utf-8"))
|
|
325
|
+
codec = encoding
|
|
326
|
+
for marker, candidate in encoding_by_marker:
|
|
327
|
+
if raw.startswith(marker) and normalized_encoding.startswith(candidate.replace("-", "").removesuffix("le").removesuffix("be")):
|
|
328
|
+
prefix = marker
|
|
329
|
+
codec = candidate
|
|
330
|
+
break
|
|
331
|
+
try:
|
|
332
|
+
text = raw[len(prefix):].decode(codec, "strict")
|
|
333
|
+
utf8_lines = byte_lines(text.encode("utf-8"))
|
|
334
|
+
lines = []
|
|
335
|
+
comparisons = []
|
|
336
|
+
for line in utf8_lines:
|
|
337
|
+
lines.append(line.decode("utf-8").encode(codec))
|
|
338
|
+
comparisons.append(line)
|
|
339
|
+
except (LookupError, UnicodeError) as error:
|
|
340
|
+
raise ValueError("Working-tree encoding has no exact line correspondence") from error
|
|
341
|
+
if prefix + b"".join(lines) != raw:
|
|
342
|
+
raise ValueError("Working-tree encoding cannot preserve the original bytes")
|
|
343
|
+
canonical_lines = byte_lines(canonical)
|
|
344
|
+
if len(canonical_lines) != len(comparisons):
|
|
345
|
+
raise ValueError("Git conversion has no exact line correspondence")
|
|
346
|
+
for expected, actual in zip(canonical_lines, comparisons, strict=True):
|
|
347
|
+
normalized = actual.removesuffix(b"\r\n") + b"\n" if actual.endswith(b"\r\n") else actual
|
|
348
|
+
if expected != actual and expected != normalized:
|
|
349
|
+
raise ValueError("Git conversion has no exact line correspondence")
|
|
350
|
+
return RawSlices(prefix, tuple(lines), codec)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def filtered_line_slices(canonical: bytes, raw: bytes, materialized: bytes, path: bytes) -> Generator[GitCommand, bytes, RawSlices]:
|
|
354
|
+
"""Allow reversible line filters only when full round-trip and individual line transforms agree."""
|
|
355
|
+
if raw != materialized:
|
|
356
|
+
raise ValueError("The Git filter is not reversible for these working bytes; partial reset is unavailable")
|
|
357
|
+
canonical_lines, raw_lines = byte_lines(canonical), byte_lines(raw)
|
|
358
|
+
if len(canonical_lines) != len(raw_lines):
|
|
359
|
+
raise ValueError("The Git filter changes line boundaries; partial reset is unavailable")
|
|
360
|
+
for expected, actual in zip(canonical_lines, raw_lines, strict=True):
|
|
361
|
+
expected_oid = yield GitCommand(["hash-object", "--no-filters", "--stdin"], stdin=expected)
|
|
362
|
+
# With a path, Git runs the user's clean filter, an outside program that must have finished before the
|
|
363
|
+
# repository is handed back, so this command is supervised even though it writes nothing itself.
|
|
364
|
+
actual_oid = yield GitCommand(["hash-object", "--path=" + os.fsdecode(path), "--stdin"], stdin=actual,
|
|
365
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
366
|
+
if expected_oid != actual_oid:
|
|
367
|
+
raise ValueError("The Git filter depends on surrounding lines; partial reset is unavailable")
|
|
368
|
+
return RawSlices(b"", tuple(raw_lines))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def reset_content(model: RawPatch, selection: RawLineSelection, working: FileImage, restored: FileImage,
|
|
372
|
+
materialized_working: FileImage | None, encoding: str = "", has_filter: bool = False) -> Generator[GitCommand, bytes, bytes | None]:
|
|
373
|
+
"""Invert selected operations using original working slices and Git-materialized index slices."""
|
|
374
|
+
selected_id_set = model.selected(selection)
|
|
375
|
+
old_content, work_content = model.before or b"", model.after or b""
|
|
376
|
+
if has_filter:
|
|
377
|
+
old_slices = yield from filtered_line_slices(old_content, restored.read(), restored.read(), model.entry.path)
|
|
378
|
+
work_slices = yield from filtered_line_slices(work_content, working.read(), materialized_working.read(), model.entry.path)
|
|
379
|
+
else:
|
|
380
|
+
old_slices = line_slices(old_content, restored.read(), encoding)
|
|
381
|
+
work_slices = line_slices(work_content, working.read(), encoding)
|
|
382
|
+
if old_slices.codec and work_slices.codec and old_slices.codec != work_slices.codec:
|
|
383
|
+
# Git's UTF-16 materialization may choose the other byte order; encode restored lines to the existing working BOM.
|
|
384
|
+
restored_lines: list[bytes] = []
|
|
385
|
+
for line in old_slices.lines:
|
|
386
|
+
restored_lines.append(line.decode(old_slices.codec).encode(work_slices.codec))
|
|
387
|
+
old_slices = RawSlices(work_slices.prefix, tuple(restored_lines), work_slices.codec)
|
|
388
|
+
raw_lines: list[bytes] = []
|
|
389
|
+
canonical_lines: list[bytes] = []
|
|
390
|
+
for change in reset_order(model.changes):
|
|
391
|
+
chosen = change.patch_index in selected_id_set
|
|
392
|
+
if change.kind == DiffLineKind.CONTEXT or (change.kind == DiffLineKind.ADDED and not chosen):
|
|
393
|
+
raw_lines.append(work_slices.lines[change.new_index])
|
|
394
|
+
canonical_lines.append(change.content)
|
|
395
|
+
elif change.kind == DiffLineKind.REMOVED and chosen:
|
|
396
|
+
raw_lines.append(old_slices.lines[change.old_index])
|
|
397
|
+
canonical_lines.append(change.content)
|
|
398
|
+
prefix = work_slices.prefix if working.exists else old_slices.prefix
|
|
399
|
+
candidate = prefix + b"".join(raw_lines)
|
|
400
|
+
expected_oid = yield GitCommand(["hash-object", "--no-filters", "--stdin"], stdin=b"".join(canonical_lines))
|
|
401
|
+
actual_oid = yield GitCommand(["hash-object", "--path=" + os.fsdecode(model.entry.path), "--stdin"], stdin=candidate,
|
|
402
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
403
|
+
if actual_oid != expected_oid:
|
|
404
|
+
raise ValueError("The reset candidate cannot preserve the unselected changes through Git conversion")
|
|
405
|
+
if not canonical_lines and model.before is None:
|
|
406
|
+
return None
|
|
407
|
+
return candidate
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def reset_order(changes: tuple[RawChange, ...]) -> tuple[RawChange, ...]:
|
|
411
|
+
"""Anchor restored replacement lines beside their working counterparts instead of moving neighboring edits."""
|
|
412
|
+
ordered_changes: list[RawChange] = []
|
|
413
|
+
removed_changes: list[RawChange] = []
|
|
414
|
+
added_changes: list[RawChange] = []
|
|
415
|
+
|
|
416
|
+
def flush() -> None:
|
|
417
|
+
"""Interleave one replacement run while retaining both original line orders."""
|
|
418
|
+
for index in range(max(len(removed_changes), len(added_changes))):
|
|
419
|
+
if index < len(removed_changes):
|
|
420
|
+
ordered_changes.append(removed_changes[index])
|
|
421
|
+
if index < len(added_changes):
|
|
422
|
+
ordered_changes.append(added_changes[index])
|
|
423
|
+
removed_changes.clear()
|
|
424
|
+
added_changes.clear()
|
|
425
|
+
|
|
426
|
+
for change in changes:
|
|
427
|
+
if change.kind == DiffLineKind.CONTEXT:
|
|
428
|
+
flush()
|
|
429
|
+
ordered_changes.append(change)
|
|
430
|
+
elif change.kind == DiffLineKind.REMOVED:
|
|
431
|
+
removed_changes.append(change)
|
|
432
|
+
else:
|
|
433
|
+
added_changes.append(change)
|
|
434
|
+
flush()
|
|
435
|
+
return tuple(ordered_changes)
|