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,506 @@
|
|
|
1
|
+
"""Keep unresolved paths tied to complete working-tree observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import TYPE_CHECKING, BinaryIO
|
|
11
|
+
|
|
12
|
+
from gitpane.history.repository_value import FileStatus
|
|
13
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
|
|
14
|
+
from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE, ConflictSide, LocalEntry, WorktreeKey, validate_conflict_index
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable, Generator
|
|
18
|
+
|
|
19
|
+
from gitpane.operation.repository_operation import OperationIntent
|
|
20
|
+
from gitpane.sequencer.mergetool import MergetoolRequest, TemporaryMergeStore
|
|
21
|
+
from gitpane.worktree.mutation import MutationService
|
|
22
|
+
from gitpane.worktree.worktree_file import FileImage
|
|
23
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
24
|
+
|
|
25
|
+
# The same refusals are given by every route that accepts a conflict result, so their wording lives in one place.
|
|
26
|
+
SUBMODULE_RESOLUTION_REFUSAL = "Resolve the submodule commit externally; this entry is not a file to merge."
|
|
27
|
+
UNSUPPORTED_RESULT_REFUSAL = "The working result is not a regular file or symbolic link"
|
|
28
|
+
# Said when a conflict action (merge tool, choosing a side, marking solved) is asked with no conflict selected; the merge
|
|
29
|
+
# tool service gives it too, for a caller that skipped this check.
|
|
30
|
+
NO_CONFLICT_SELECTED_TEXT = "Select a conflict before choosing its resolution."
|
|
31
|
+
# Git writes its conflict markers seven characters long unless the conflict-marker-size attribute says otherwise. That
|
|
32
|
+
# attribute is not read here, so a path configured with longer markers is not recognised as still conflicted.
|
|
33
|
+
CONFLICT_MARKER_LENGTH = 7
|
|
34
|
+
CONFLICT_MARKER_CHARACTERS = (b"<", b"|", b"=", b">")
|
|
35
|
+
# A marker is followed by its label after a space, or ends its line; any other character makes it ordinary text.
|
|
36
|
+
CONFLICT_MARKER_FOLLOWER_SET = frozenset((b" ", b"\t"))
|
|
37
|
+
# Git treats content holding a NUL byte as binary and never writes text markers into it.
|
|
38
|
+
BINARY_CONTENT_BYTE = b"\0"
|
|
39
|
+
FIRST_LINE_NUMBER = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def conflict_count_text(count: int) -> str:
|
|
43
|
+
"""Describe the same unresolved count in the repository banner and conflict list."""
|
|
44
|
+
return f"{count} unresolved merge conflict" + ("s" if count != 1 else "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def conflict_path_label(path: bytes) -> str:
|
|
48
|
+
"""Expose control characters without changing the original path identity."""
|
|
49
|
+
parts = []
|
|
50
|
+
quoted_content_start, quoted_content_end = 1, -1
|
|
51
|
+
for character in path.decode("utf-8", "surrogateescape"):
|
|
52
|
+
if character == "\\":
|
|
53
|
+
parts.append("\\\\")
|
|
54
|
+
elif not character.isprintable():
|
|
55
|
+
parts.append(ascii(character)[quoted_content_start:quoted_content_end])
|
|
56
|
+
else:
|
|
57
|
+
parts.append(character)
|
|
58
|
+
return "".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ConflictMarkerLine:
|
|
63
|
+
"""Name one line of a working result that is still a conflict marker, numbered from 1 as an editor shows it."""
|
|
64
|
+
|
|
65
|
+
line_number: int
|
|
66
|
+
marker: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def conflict_marker_of_line(line: bytes) -> str:
|
|
70
|
+
"""Return the marker that opens a line, or an empty text when the line is ordinary text.
|
|
71
|
+
|
|
72
|
+
Seven `<`, `|`, `=` or `>` count only when followed by a space, a tab or the end of the line: a longer run such as
|
|
73
|
+
a Markdown underline of eight `=` is text, like a marker glued to its label.
|
|
74
|
+
"""
|
|
75
|
+
for character in CONFLICT_MARKER_CHARACTERS:
|
|
76
|
+
marker = character * CONFLICT_MARKER_LENGTH
|
|
77
|
+
if not line.startswith(marker):
|
|
78
|
+
continue
|
|
79
|
+
follower = line[CONFLICT_MARKER_LENGTH:CONFLICT_MARKER_LENGTH + 1]
|
|
80
|
+
if not follower or follower in CONFLICT_MARKER_FOLLOWER_SET:
|
|
81
|
+
return marker.decode("ascii")
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def conflict_marker_lines(content: bytes) -> tuple[ConflictMarkerLine, ...]:
|
|
86
|
+
"""Find the conflict markers Git left in a working result, so a file still holding them is not staged as resolved without a warning.
|
|
87
|
+
|
|
88
|
+
Staging those lines by mistake commits broken code. Lines end with LF or CRLF. Binary content, recognised as Git
|
|
89
|
+
does by a NUL byte, is never scanned and gives no marker.
|
|
90
|
+
"""
|
|
91
|
+
if BINARY_CONTENT_BYTE in content:
|
|
92
|
+
return ()
|
|
93
|
+
marker_lines = []
|
|
94
|
+
for line_number, line in enumerate(content.split(b"\n"), start=FIRST_LINE_NUMBER):
|
|
95
|
+
marker = conflict_marker_of_line(line.removesuffix(b"\r"))
|
|
96
|
+
if marker:
|
|
97
|
+
marker_lines.append(ConflictMarkerLine(line_number, marker))
|
|
98
|
+
return tuple(marker_lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def has_deleted_side(sides: tuple[ConflictSide, ...]) -> bool:
|
|
102
|
+
"""Identify a file with a base and an absent merge side, excluding submodule entries."""
|
|
103
|
+
role_set = set()
|
|
104
|
+
for side in sides:
|
|
105
|
+
if side.mode == "160000":
|
|
106
|
+
return False
|
|
107
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
|
|
108
|
+
return "BASE" in role_set and not {"LOCAL", "REMOTE"}.issubset(role_set)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class ConflictEntry:
|
|
113
|
+
"""Describe an unresolved path and its immutable Git object versions."""
|
|
114
|
+
|
|
115
|
+
path: bytes
|
|
116
|
+
sides: tuple[ConflictSide, ...]
|
|
117
|
+
kind: str = "Text conflict"
|
|
118
|
+
explanation: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ConflictSelection:
|
|
123
|
+
"""Bind exact selected paths to a worktree and capture generation."""
|
|
124
|
+
|
|
125
|
+
key: WorktreeKey
|
|
126
|
+
generation: int
|
|
127
|
+
paths: tuple[bytes, ...]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class ConflictResolutionIntent:
|
|
132
|
+
"""Bind a confirmed working result to its exact unresolved index and operation owner."""
|
|
133
|
+
|
|
134
|
+
snapshot: WorktreeSnapshot
|
|
135
|
+
path: bytes
|
|
136
|
+
sides: tuple[ConflictSide, ...]
|
|
137
|
+
image: FileImage
|
|
138
|
+
operation: OperationIntent
|
|
139
|
+
delete: bool = False
|
|
140
|
+
# Conflict markers found in the accepted bytes; the confirmation warns about them before anything is staged.
|
|
141
|
+
marker_lines: tuple[ConflictMarkerLine, ...] = ()
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def capture(cls, snapshot: WorktreeSnapshot, path: bytes, operation: OperationIntent, *, delete: bool = False) -> ConflictResolutionIntent:
|
|
145
|
+
"""Capture the working result before confirmation without changing its bytes or index."""
|
|
146
|
+
from gitpane.worktree.worktree_file import capture_image
|
|
147
|
+
from gitpane.worktree.worktree_snapshot import verify_local_state
|
|
148
|
+
|
|
149
|
+
image = capture_image(snapshot.context, path, allow_missing_parents=True)
|
|
150
|
+
intent = cls.accept(snapshot, path, image, operation, delete=delete)
|
|
151
|
+
verify_local_state(snapshot, (path,))
|
|
152
|
+
return intent
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def accept(cls, snapshot: WorktreeSnapshot, path: bytes, image: FileImage, operation: OperationIntent, *, delete: bool = False) -> ConflictResolutionIntent:
|
|
156
|
+
"""Bind an already captured working result to its unresolved index stages, reading the file again only to prove it unchanged.
|
|
157
|
+
|
|
158
|
+
A caller that accepted exact bytes earlier builds its intent here, so a second capture can never silently adopt
|
|
159
|
+
bytes written in between.
|
|
160
|
+
"""
|
|
161
|
+
from gitpane.worktree.worktree_file import validate_image
|
|
162
|
+
|
|
163
|
+
entries = conflicts_from_snapshot(snapshot)
|
|
164
|
+
entry = next((entry for entry in entries if entry.path == path), None)
|
|
165
|
+
if entry is None:
|
|
166
|
+
raise ValueError("The selected path is no longer unresolved; rescan merge conflicts")
|
|
167
|
+
if any(side.mode == "160000" for side in entry.sides):
|
|
168
|
+
raise ValueError(SUBMODULE_RESOLUTION_REFUSAL)
|
|
169
|
+
if delete and image.exists and not has_deleted_side(entry.sides):
|
|
170
|
+
raise ValueError("Delete file requires a conflict with a deleted side")
|
|
171
|
+
if image.exists and not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode)):
|
|
172
|
+
raise ValueError(UNSUPPORTED_RESULT_REFUSAL)
|
|
173
|
+
if not delete and (not image.exists or not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode))):
|
|
174
|
+
raise ValueError("The working result is missing or unsupported; a deletion requires explicit confirmation")
|
|
175
|
+
validate_image(snapshot.context, path, image)
|
|
176
|
+
return cls(snapshot, path, entry.sides, image, operation, delete)
|
|
177
|
+
|
|
178
|
+
def with_marker_lines(self) -> ConflictResolutionIntent:
|
|
179
|
+
"""Attach the conflict markers still present in the accepted bytes, read from the captured image and never from the file again.
|
|
180
|
+
|
|
181
|
+
A deletion and a symbolic link have no file content to scan. Runs on a file worker, since the image may have to
|
|
182
|
+
be read from its private copy.
|
|
183
|
+
"""
|
|
184
|
+
if self.delete or not self.image.exists or not stat.S_ISREG(self.image.mode):
|
|
185
|
+
return self
|
|
186
|
+
return replace(self, marker_lines=conflict_marker_lines(self.image.read()))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def capture_conflict_result(snapshot: WorktreeSnapshot, paths: tuple[bytes, ...], *,
|
|
190
|
+
expected_image_by_path: dict[bytes, FileImage] | None = None) -> tuple[WorktreeSnapshot, dict[bytes, FileImage]]:
|
|
191
|
+
"""Accept the working bytes of selected unresolved paths as they are now, and answer the snapshot that expects them.
|
|
192
|
+
|
|
193
|
+
Only the fingerprints of the accepted paths change. HEAD, index bytes and entries, conflict sides, conversion
|
|
194
|
+
settings, generation and every other field stay the ones the snapshot was read with, so a later Git verification
|
|
195
|
+
still compares the repository against that earlier read: this is a local acceptance of chosen bytes, never a new
|
|
196
|
+
read by Git. With expected images, each path must still be exactly the published file, type, parents and bytes
|
|
197
|
+
included. Runs on a file worker.
|
|
198
|
+
"""
|
|
199
|
+
from gitpane.worktree.worktree_file import capture_image, validate_image
|
|
200
|
+
from gitpane.worktree.worktree_snapshot import file_fingerprint, read_index
|
|
201
|
+
|
|
202
|
+
if not snapshot.fresh or snapshot.context.is_bare or snapshot.key != WorktreeKey.from_context(snapshot.context):
|
|
203
|
+
raise ValueError("A complete current conflict read is required; rescan merge conflicts")
|
|
204
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
205
|
+
raise ValueError("The index changed since selection; rescan merge conflicts")
|
|
206
|
+
entry_by_path: dict[bytes, ConflictEntry] = {}
|
|
207
|
+
for entry in conflicts_from_snapshot(snapshot):
|
|
208
|
+
entry_by_path[entry.path] = entry
|
|
209
|
+
fingerprint_by_path = dict(snapshot.fingerprint_by_path)
|
|
210
|
+
image_by_path: dict[bytes, FileImage] = {}
|
|
211
|
+
for path in paths:
|
|
212
|
+
entry = entry_by_path.get(path)
|
|
213
|
+
if entry is None:
|
|
214
|
+
raise ValueError("The selected path is no longer unresolved; rescan merge conflicts")
|
|
215
|
+
if any(side.mode == "160000" for side in entry.sides):
|
|
216
|
+
raise ValueError(SUBMODULE_RESOLUTION_REFUSAL)
|
|
217
|
+
image = capture_image(snapshot.context, path, allow_missing_parents=True)
|
|
218
|
+
if image.exists and not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode)):
|
|
219
|
+
raise ValueError(UNSUPPORTED_RESULT_REFUSAL)
|
|
220
|
+
if expected_image_by_path is not None:
|
|
221
|
+
expected = expected_image_by_path.get(path)
|
|
222
|
+
if expected is None or (image.exists, image.identity, image.parents, image.digest()) != (expected.exists, expected.identity, expected.parents, expected.digest()):
|
|
223
|
+
raise ValueError("The published working result changed; rescan merge conflicts")
|
|
224
|
+
accepted_fingerprint = file_fingerprint(snapshot.context, path)
|
|
225
|
+
# The fingerprint is read after the image; proving the image unchanged afterwards binds both to the same file.
|
|
226
|
+
validate_image(snapshot.context, path, image)
|
|
227
|
+
fingerprint_by_path[path] = accepted_fingerprint
|
|
228
|
+
image_by_path[path] = image
|
|
229
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
230
|
+
raise ValueError("The index changed since selection; rescan merge conflicts")
|
|
231
|
+
return replace(snapshot, fingerprint_by_path=fingerprint_by_path), image_by_path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Version bodies are written straight into their private files as Git streams them; the parser keeps none of them.
|
|
235
|
+
RETAINED_VERSION_BODY_LIMIT_BYTES = 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def open_version_files(filenames_by_oid: dict[str, list[str]]) -> dict[str, list[BinaryIO]]:
|
|
239
|
+
"""Create every private version file exclusively; if one cannot be created, close and remove the ones already made."""
|
|
240
|
+
streams_by_oid: dict[str, list[BinaryIO]] = {}
|
|
241
|
+
try:
|
|
242
|
+
for oid, filenames in filenames_by_oid.items():
|
|
243
|
+
for filename in filenames:
|
|
244
|
+
# The writer spans asynchronous Git delivery; close_version_files owns its closing.
|
|
245
|
+
streams_by_oid.setdefault(oid, []).append(open(filename, "xb")) # noqa: SIM115
|
|
246
|
+
except OSError:
|
|
247
|
+
close_version_files(streams_by_oid)
|
|
248
|
+
remove_version_files(filenames_by_oid)
|
|
249
|
+
raise
|
|
250
|
+
return streams_by_oid
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def close_version_files(streams_by_oid: dict[str, list[BinaryIO]]) -> None:
|
|
254
|
+
"""Close every version file, even when one of them fails to close, then report the first failure."""
|
|
255
|
+
first_error: OSError | None = None
|
|
256
|
+
for streams in streams_by_oid.values():
|
|
257
|
+
for stream in streams:
|
|
258
|
+
try:
|
|
259
|
+
stream.close()
|
|
260
|
+
except OSError as error:
|
|
261
|
+
if first_error is None:
|
|
262
|
+
first_error = error
|
|
263
|
+
if first_error is not None:
|
|
264
|
+
raise first_error
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def remove_version_files(filenames_by_oid: dict[str, list[str]]) -> None:
|
|
268
|
+
"""Remove the private version files this preparation created, so a failed read leaves no partial tool input."""
|
|
269
|
+
for filenames in filenames_by_oid.values():
|
|
270
|
+
for filename in filenames:
|
|
271
|
+
if os.path.isfile(filename) and not os.path.islink(filename):
|
|
272
|
+
os.unlink(filename)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def prepare_conflict_deletion(intent: ConflictResolutionIntent, operation_dir: str) -> Generator:
|
|
276
|
+
"""Remove only the selected index stages in a private copy before publishing a file deletion."""
|
|
277
|
+
from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS
|
|
278
|
+
from gitpane.worktree.command_sequence import GitCommand
|
|
279
|
+
from gitpane.worktree.staged_reset import FileChange, may_carry_split_index
|
|
280
|
+
from gitpane.worktree.worktree_file import PRIVATE_FILE_MODE, FileImage
|
|
281
|
+
from gitpane.worktree.worktree_snapshot import read_index
|
|
282
|
+
|
|
283
|
+
snapshot = intent.snapshot
|
|
284
|
+
candidate_filename = os.path.join(operation_dir, "candidate-index")
|
|
285
|
+
shutil.copy2(snapshot.index_filename, candidate_filename)
|
|
286
|
+
os.chmod(candidate_filename, PRIVATE_FILE_MODE)
|
|
287
|
+
if read_index(candidate_filename) != snapshot.index_content:
|
|
288
|
+
raise ValueError("The index changed while preparing the deletion")
|
|
289
|
+
environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
|
|
290
|
+
# Only an index that may be split is flattened, in the private copy: an ordinary index saves a supervised write.
|
|
291
|
+
if may_carry_split_index(snapshot.index_content):
|
|
292
|
+
yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
293
|
+
yield GitCommand(["update-index", "--force-remove", "-z", "--stdin"], stdin=intent.path + b"\0", environment_by_name=environment_by_name,
|
|
294
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
295
|
+
after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
|
|
296
|
+
expected_records = []
|
|
297
|
+
for record in snapshot.index_entries.split(b"\0"):
|
|
298
|
+
_metadata, _separator, path = record.partition(b"\t")
|
|
299
|
+
if path != intent.path:
|
|
300
|
+
expected_records.append(record)
|
|
301
|
+
if sorted(after_entries.split(b"\0")) != sorted(expected_records):
|
|
302
|
+
raise ValueError("The deletion candidate changed other index entries")
|
|
303
|
+
candidate = read_index(candidate_filename)
|
|
304
|
+
if candidate is None:
|
|
305
|
+
raise ValueError("Git did not produce the deletion candidate index")
|
|
306
|
+
change = FileChange(intent.path, intent.image, FileImage(False, 0, parents=intent.image.parents))
|
|
307
|
+
return (change,), snapshot.index_entries, after_entries, candidate
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class ConflictState:
|
|
312
|
+
"""Retain last known conflicts while distinguishing failed or stale reads."""
|
|
313
|
+
|
|
314
|
+
key: WorktreeKey
|
|
315
|
+
generation: int = 0
|
|
316
|
+
entries: tuple[ConflictEntry, ...] = ()
|
|
317
|
+
fresh: bool = False
|
|
318
|
+
diagnostic: str = "Conflict state is unknown; refresh the repository"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def conflicts_from_snapshot(snapshot: WorktreeSnapshot) -> tuple[ConflictEntry, ...]:
|
|
322
|
+
"""Build conflicts from the status and index captured together, without new reads."""
|
|
323
|
+
if not snapshot.fresh or snapshot.index_entries is None:
|
|
324
|
+
raise ValueError("A complete current index read is required")
|
|
325
|
+
validate_conflict_index(snapshot.entries, snapshot.index_entries)
|
|
326
|
+
entries = []
|
|
327
|
+
for entry in snapshot.entries:
|
|
328
|
+
if entry.status == FileStatus.UNMERGED:
|
|
329
|
+
kind, explanation = classify_conflict(entry)
|
|
330
|
+
entries.append(ConflictEntry(entry.path, entry.conflict_sides, kind, explanation))
|
|
331
|
+
return tuple(sorted(entries, key=lambda entry: entry.path))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def snapshot_has_conflicts(snapshot: WorktreeSnapshot) -> bool:
|
|
335
|
+
"""Tell whether a fresh worktree read still lists a conflict; a read that cannot say counts as still conflicted."""
|
|
336
|
+
try:
|
|
337
|
+
return bool(conflicts_from_snapshot(snapshot))
|
|
338
|
+
except ValueError:
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def classify_conflict(entry: LocalEntry) -> tuple[str, str]:
|
|
343
|
+
"""Describe actual modes and absent roles without guessing associations between paths."""
|
|
344
|
+
role_set = set()
|
|
345
|
+
mode_set = set()
|
|
346
|
+
for side in entry.conflict_sides:
|
|
347
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
|
|
348
|
+
mode_set.add(side.mode)
|
|
349
|
+
if "160000" in mode_set:
|
|
350
|
+
return "Submodule conflict", SUBMODULE_RESOLUTION_REFUSAL
|
|
351
|
+
if "120000" in mode_set:
|
|
352
|
+
return "Symbolic link", "The side objects contain link targets. Resolve the link explicitly without following it as a text file."
|
|
353
|
+
if role_set == {"BASE"}:
|
|
354
|
+
return "Both sides absent", "A rename conflict can leave this original path unresolved. Resolve each listed path independently; no destination is inferred."
|
|
355
|
+
if role_set == {"LOCAL"}:
|
|
356
|
+
return "Added locally (no base)", "Only LOCAL is present. This may be a rename destination; resolve this exact path independently."
|
|
357
|
+
if role_set == {"REMOTE"}:
|
|
358
|
+
return "Added remotely (no base)", "Only REMOTE is present. This may be a rename destination; resolve this exact path independently."
|
|
359
|
+
if "LOCAL" not in role_set:
|
|
360
|
+
return "Deleted locally", "LOCAL is absent; no empty file stands in for the deleted side."
|
|
361
|
+
if "REMOTE" not in role_set:
|
|
362
|
+
return "Deleted remotely", "REMOTE is absent; no empty file stands in for the deleted side."
|
|
363
|
+
if entry.conflict_binary:
|
|
364
|
+
return "Binary conflict", "An available version contains binary data or Git attributes require binary handling. Choose the result explicitly."
|
|
365
|
+
if "BASE" not in role_set:
|
|
366
|
+
return "Add/add (no base)", "Both added versions exist, but BASE is absent."
|
|
367
|
+
if entry.conflict_binary is None:
|
|
368
|
+
return "Unmerged file", "The index versions are known, but their contents have not been inspected."
|
|
369
|
+
return "Text conflict", "BASE, LOCAL and REMOTE refer to the exact versions stored in the index."
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class ConflictService:
|
|
373
|
+
"""Reconcile conflict observations without treating read failures as resolution."""
|
|
374
|
+
|
|
375
|
+
def __init__(self) -> None:
|
|
376
|
+
"""Keep last accepted observations separate for each linked worktree."""
|
|
377
|
+
self.state_by_key: dict[WorktreeKey, ConflictState] = {}
|
|
378
|
+
|
|
379
|
+
def observe(self, key: WorktreeKey, snapshot: WorktreeSnapshot | None, diagnostic: str = "") -> ConflictState:
|
|
380
|
+
"""Publish coherent sides or preserve the previous list with an unknown state."""
|
|
381
|
+
previous = self.state_by_key.get(key, ConflictState(key))
|
|
382
|
+
try:
|
|
383
|
+
if snapshot is None or snapshot.key != key:
|
|
384
|
+
raise ValueError(diagnostic or "Conflict state is unknown; refresh the repository")
|
|
385
|
+
entries = conflicts_from_snapshot(snapshot)
|
|
386
|
+
state = ConflictState(key, snapshot.generation, entries, True, "")
|
|
387
|
+
except ValueError as error:
|
|
388
|
+
state = ConflictState(key, previous.generation, previous.entries, False, diagnostic or str(error))
|
|
389
|
+
self.state_by_key[key] = state
|
|
390
|
+
return state
|
|
391
|
+
|
|
392
|
+
def prepare_merge(self, request: MergetoolRequest, store: TemporaryMergeStore, mutation: MutationService) -> Generator:
|
|
393
|
+
"""Extract only captured index objects into immutable private input files, all versions in one Git read.
|
|
394
|
+
|
|
395
|
+
Each object Git streams is routed by its own identity to every role that names it, so the order of the
|
|
396
|
+
answers never matters and a version shared by two roles is read once. The strict framing must account for
|
|
397
|
+
every requested object before any file becomes a tool input; on any failure every role file is closed and
|
|
398
|
+
removed.
|
|
399
|
+
"""
|
|
400
|
+
from gitpane.history.history_parse import CatFileParser
|
|
401
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
402
|
+
from gitpane.worktree.worktree_file import capture_image
|
|
403
|
+
|
|
404
|
+
snapshot = request.snapshot
|
|
405
|
+
yield from mutation.worktree_service.verify(snapshot, (request.path,))
|
|
406
|
+
image = yield FileTask(lambda: capture_image(snapshot.context, request.path))
|
|
407
|
+
if not image.exists or not stat.S_ISREG(image.mode):
|
|
408
|
+
raise ValueError("Use manual conflict resolution for an absent or non-regular working file.")
|
|
409
|
+
yield FileTask(lambda: store.create(request))
|
|
410
|
+
filenames_by_oid: dict[str, list[str]] = {}
|
|
411
|
+
for side in request.sides:
|
|
412
|
+
filenames_by_oid.setdefault(side.oid, []).append(store.filename(request, CONFLICT_ROLE_BY_STAGE[side.stage]))
|
|
413
|
+
streams_by_oid = yield FileTask(lambda: open_version_files(filenames_by_oid))
|
|
414
|
+
oids = sorted(filenames_by_oid)
|
|
415
|
+
parser = CatFileParser(oids, object_limit_bytes=RETAINED_VERSION_BODY_LIMIT_BYTES, expected_object_type="blob")
|
|
416
|
+
|
|
417
|
+
def route(body: bytes) -> None:
|
|
418
|
+
"""Copy the bytes of the object being framed into every role file that names it."""
|
|
419
|
+
for stream in streams_by_oid[parser.oid]:
|
|
420
|
+
stream.write(body)
|
|
421
|
+
|
|
422
|
+
def consume(chunk: bytes) -> None:
|
|
423
|
+
"""Feed Git's stream to the strict framing, which hands each body fragment to the router."""
|
|
424
|
+
parser.feed(chunk)
|
|
425
|
+
|
|
426
|
+
parser.body_callback = route
|
|
427
|
+
extracted = False
|
|
428
|
+
try:
|
|
429
|
+
yield GitCommand(["cat-file", "--batch"], stdin="".join(oid + "\n" for oid in oids).encode("ascii"), consume=consume, stdout_limit_bytes=None)
|
|
430
|
+
parser.finish()
|
|
431
|
+
extracted = True
|
|
432
|
+
finally:
|
|
433
|
+
yield FileTask(lambda: close_version_files(streams_by_oid))
|
|
434
|
+
if not extracted:
|
|
435
|
+
yield FileTask(lambda: remove_version_files(filenames_by_oid))
|
|
436
|
+
|
|
437
|
+
def freeze() -> None:
|
|
438
|
+
"""Make every complete version read-only and remember its identity for later checks."""
|
|
439
|
+
for filenames in filenames_by_oid.values():
|
|
440
|
+
for filename in filenames:
|
|
441
|
+
store.freeze_input(request, filename)
|
|
442
|
+
|
|
443
|
+
yield FileTask(freeze)
|
|
444
|
+
yield from mutation.worktree_service.verify(snapshot, (request.path,))
|
|
445
|
+
return image
|
|
446
|
+
|
|
447
|
+
def publish_merge_result(self, request: MergetoolRequest, before: FileImage, output: FileImage, store: TemporaryMergeStore,
|
|
448
|
+
mutation: MutationService, is_current: Callable[[], bool]) -> Generator:
|
|
449
|
+
"""Revalidate a saved result, publish its bytes, then stage under its existing owner.
|
|
450
|
+
|
|
451
|
+
The working file replacement keeps its own Git verification. The image it produced is then accepted locally,
|
|
452
|
+
its exact identity and bytes, and the staging verifies the index, HEAD and conversion afresh under its lock:
|
|
453
|
+
a complete capture between the two writes would prove nothing either of them does not prove already.
|
|
454
|
+
"""
|
|
455
|
+
from gitpane.worktree.command_sequence import FileTask
|
|
456
|
+
from gitpane.worktree.worktree_file import FileImage, capture_image, prepare_publication_image, private_file_dir, publish_image_mode, validate_destination, validate_image
|
|
457
|
+
from gitpane.worktree.worktree_snapshot import verify_local_state
|
|
458
|
+
|
|
459
|
+
snapshot, path = request.snapshot, request.path
|
|
460
|
+
yield from mutation.worktree_service.verify(snapshot, (path,))
|
|
461
|
+
|
|
462
|
+
def publish() -> FileImage:
|
|
463
|
+
"""Keep the private saved output intact even when publication or later staging fails."""
|
|
464
|
+
if not is_current():
|
|
465
|
+
raise ValueError("The original repository session is no longer current.")
|
|
466
|
+
store.validate_output(request, output)
|
|
467
|
+
validate_image(snapshot.context, path, before)
|
|
468
|
+
verify_local_state(snapshot, (path,))
|
|
469
|
+
if os.path.lexists(snapshot.index_filename + ".lock"):
|
|
470
|
+
raise ValueError("The Git index is locked by another operation; the saved result has been retained.")
|
|
471
|
+
output_dir, _identity = store.directory_by_request[request.operation.operation_id]
|
|
472
|
+
directory = private_file_dir(snapshot.context, path, output_dir)
|
|
473
|
+
filename = os.path.join(directory, "publish-" + uuid.uuid4().hex)
|
|
474
|
+
try:
|
|
475
|
+
after = prepare_publication_image(filename, FileImage(True, before.mode, output.read()), before)
|
|
476
|
+
store.validate_output(request, output)
|
|
477
|
+
validate_image(snapshot.context, path, before)
|
|
478
|
+
verify_local_state(snapshot, (path,))
|
|
479
|
+
if os.path.lexists(snapshot.index_filename + ".lock"):
|
|
480
|
+
raise ValueError("The Git index is locked by another operation; the saved result has been retained.")
|
|
481
|
+
if not is_current():
|
|
482
|
+
raise ValueError("The original repository session is no longer current.")
|
|
483
|
+
destination_filename, _parents = validate_destination(snapshot.context, path)
|
|
484
|
+
publish_image_mode(filename, after)
|
|
485
|
+
os.replace(filename, destination_filename)
|
|
486
|
+
published = capture_image(snapshot.context, path)
|
|
487
|
+
if published.digest() != after.digest():
|
|
488
|
+
raise ValueError("Working result changed after publication; the saved result has been retained.")
|
|
489
|
+
return published
|
|
490
|
+
finally:
|
|
491
|
+
if os.path.isfile(filename) and not os.path.islink(filename):
|
|
492
|
+
os.unlink(filename)
|
|
493
|
+
if directory != output_dir:
|
|
494
|
+
os.rmdir(directory)
|
|
495
|
+
|
|
496
|
+
published = yield FileTask(publish)
|
|
497
|
+
|
|
498
|
+
def accept() -> ConflictResolutionIntent:
|
|
499
|
+
"""Bind the staging to the exact published image, without adopting any HEAD, index or conversion read now."""
|
|
500
|
+
accepted_snapshot, image_by_path = capture_conflict_result(snapshot, (path,), expected_image_by_path={path: published})
|
|
501
|
+
return ConflictResolutionIntent.accept(accepted_snapshot, path, image_by_path[path], request.operation)
|
|
502
|
+
|
|
503
|
+
intent = yield FileTask(accept)
|
|
504
|
+
if intent.image.read() != output.read() or not is_current():
|
|
505
|
+
raise ValueError("Working result or session changed after publication; rescan merge conflicts.")
|
|
506
|
+
return (yield from mutation.resolve_conflict_sequence(intent))
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Keep refused merge outputs discoverable without granting them publication rights."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import QStandardPaths
|
|
16
|
+
|
|
17
|
+
from gitpane.config import MUTATION_RECORD_LIMIT_BYTES
|
|
18
|
+
from gitpane.system.qt_folder import writable_folder
|
|
19
|
+
from gitpane.worktree.worktree_file import FILE_OBJECT_IDENTITY_FIELDS, FILE_STABLE_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, stat_identity
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
23
|
+
|
|
24
|
+
RECOVERY_DIRECTORY_BASENAME = "gitpane-merge-recovery"
|
|
25
|
+
RECOVERY_RECORD_BASENAME = "record.json"
|
|
26
|
+
RECOVERY_OUTPUT_BASENAME = "MERGED"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MergeRecovery:
|
|
31
|
+
"""Describe a retained file for manual recovery, never for automatic publication."""
|
|
32
|
+
|
|
33
|
+
path: bytes
|
|
34
|
+
filename: str
|
|
35
|
+
saved_at: str
|
|
36
|
+
sha256: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_recovery_directory(directory: str) -> None:
|
|
40
|
+
"""Reject redirected ancestors before accessing private retained files."""
|
|
41
|
+
while True:
|
|
42
|
+
metadata = os.lstat(directory)
|
|
43
|
+
if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(directory) or os.path.isjunction(directory):
|
|
44
|
+
raise ValueError("The merge recovery directory was redirected.")
|
|
45
|
+
parent_dir = os.path.dirname(directory)
|
|
46
|
+
if parent_dir == directory:
|
|
47
|
+
return
|
|
48
|
+
directory = parent_dir
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_recovery_file(filename: str, limit: int | None = None) -> bytes:
|
|
52
|
+
"""Read one regular private file only while its open handle and path remain identical."""
|
|
53
|
+
validate_recovery_directory(os.path.dirname(filename))
|
|
54
|
+
return read_checked_leaf(filename, limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_checked_leaf(filename: str, limit: int | None = None) -> bytes:
|
|
58
|
+
"""Read one regular private file whose containing folder a caller has already checked.
|
|
59
|
+
|
|
60
|
+
Callers that read many files of the same folder walk its ancestors once and then use this function, instead of
|
|
61
|
+
paying that walk again for every file. A caller with no such check must use the function above.
|
|
62
|
+
"""
|
|
63
|
+
metadata = os.lstat(filename)
|
|
64
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or os.path.islink(filename) or os.path.isjunction(filename):
|
|
65
|
+
raise ValueError("The merge recovery file is not a regular private file.")
|
|
66
|
+
if limit is not None and metadata.st_size > limit:
|
|
67
|
+
raise ValueError("The merge recovery record is too large.")
|
|
68
|
+
with open(filename, "rb") as stream:
|
|
69
|
+
# Windows handle and path ctime have different meanings; retain the full path check after reading.
|
|
70
|
+
if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
|
|
71
|
+
raise ValueError("The merge recovery file changed while reading.")
|
|
72
|
+
# A read sized to the safety limit makes Python allocate that whole limit (up to 256 MiB for a replay backup) even for a
|
|
73
|
+
# file of a few bytes. One byte more than the checked size costs the real size only and still shows a file that grew.
|
|
74
|
+
content = stream.read(metadata.st_size + 1)
|
|
75
|
+
if stat_identity(os.lstat(filename)) != stat_identity(metadata) or len(content) != metadata.st_size:
|
|
76
|
+
raise ValueError("The merge recovery file changed while reading.")
|
|
77
|
+
return content
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MergeRecoveryStore:
|
|
81
|
+
"""Store private results independently of repository moves and discover them after restart."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, storage_dir: str | None = None) -> None:
|
|
84
|
+
"""Normalize injected and Qt storage paths so ownership checks match tempfile directories."""
|
|
85
|
+
if storage_dir is None:
|
|
86
|
+
storage_dir = os.path.join(writable_folder(QStandardPaths.StandardLocation.AppLocalDataLocation, sys.platform), RECOVERY_DIRECTORY_BASENAME)
|
|
87
|
+
self.storage_dir = os.path.abspath(storage_dir)
|
|
88
|
+
|
|
89
|
+
def root_dir(self, context: RepositoryContext) -> str:
|
|
90
|
+
"""Keep linked worktrees separate using their own Git administrative directory."""
|
|
91
|
+
# Git already resolved this absolute location; re-resolving a replaced ancestor would retarget recovery.
|
|
92
|
+
repository_key = os.path.normcase(os.path.abspath(context.git_dir))
|
|
93
|
+
return os.path.join(self.storage_dir, hashlib.sha256(os.fsencode(repository_key)).hexdigest())
|
|
94
|
+
|
|
95
|
+
def create_directory(self, context: RepositoryContext) -> str:
|
|
96
|
+
"""Allocate durable private storage before launching an editor."""
|
|
97
|
+
root_dir = self.root_dir(context)
|
|
98
|
+
missing_dirs = []
|
|
99
|
+
existing_dir = root_dir
|
|
100
|
+
while not os.path.lexists(existing_dir):
|
|
101
|
+
missing_dirs.append(existing_dir)
|
|
102
|
+
existing_dir = os.path.dirname(existing_dir)
|
|
103
|
+
validate_recovery_directory(existing_dir)
|
|
104
|
+
for directory in reversed(missing_dirs):
|
|
105
|
+
try:
|
|
106
|
+
os.mkdir(directory, PRIVATE_DIRECTORY_MODE)
|
|
107
|
+
except FileExistsError:
|
|
108
|
+
pass
|
|
109
|
+
validate_recovery_directory(directory)
|
|
110
|
+
validate_recovery_directory(root_dir)
|
|
111
|
+
directory = tempfile.mkdtemp(prefix="merge-", dir=root_dir)
|
|
112
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
113
|
+
return directory
|
|
114
|
+
|
|
115
|
+
def retain(self, context: RepositoryContext, directory: str, path: bytes) -> None:
|
|
116
|
+
"""Flush minimal recovery metadata only after a saved result has been retained."""
|
|
117
|
+
if os.path.dirname(directory) != self.root_dir(context):
|
|
118
|
+
raise ValueError("The merge result is outside its repository recovery directory.")
|
|
119
|
+
content = read_recovery_file(os.path.join(directory, RECOVERY_OUTPUT_BASENAME))
|
|
120
|
+
record_by_name = {"path_hex": path.hex(), "saved_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
121
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
122
|
+
"directory_identity": stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]}
|
|
123
|
+
filename = os.path.join(directory, RECOVERY_RECORD_BASENAME)
|
|
124
|
+
descriptor = os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
125
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
126
|
+
json.dump(record_by_name, stream)
|
|
127
|
+
stream.flush()
|
|
128
|
+
os.fsync(stream.fileno())
|
|
129
|
+
|
|
130
|
+
def discover(self, context: RepositoryContext) -> tuple[MergeRecovery, ...]:
|
|
131
|
+
"""Read retained paths and verify bytes without modifying files or authorizing a replay."""
|
|
132
|
+
root_dir = self.root_dir(context)
|
|
133
|
+
if not os.path.lexists(root_dir):
|
|
134
|
+
return ()
|
|
135
|
+
validate_recovery_directory(root_dir)
|
|
136
|
+
results = []
|
|
137
|
+
directories = []
|
|
138
|
+
with os.scandir(root_dir) as entries:
|
|
139
|
+
for entry in entries:
|
|
140
|
+
if entry.is_dir(follow_symlinks=False):
|
|
141
|
+
directories.append(entry.path)
|
|
142
|
+
for directory in sorted(directories):
|
|
143
|
+
validate_recovery_directory(directory)
|
|
144
|
+
record_filename = os.path.join(directory, RECOVERY_RECORD_BASENAME)
|
|
145
|
+
if not os.path.lexists(record_filename):
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
record_by_name = json.loads(read_recovery_file(record_filename, MUTATION_RECORD_LIMIT_BYTES))
|
|
149
|
+
identity = stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]
|
|
150
|
+
if list(identity) != record_by_name["directory_identity"]:
|
|
151
|
+
raise ValueError("A retained merge directory was replaced.")
|
|
152
|
+
filename = os.path.join(directory, RECOVERY_OUTPUT_BASENAME)
|
|
153
|
+
content = read_recovery_file(filename)
|
|
154
|
+
if hashlib.sha256(content).hexdigest() != record_by_name["sha256"]:
|
|
155
|
+
raise ValueError("A retained merge result changed. Inspect the recovery directory manually: " + root_dir)
|
|
156
|
+
path = bytes.fromhex(record_by_name["path_hex"])
|
|
157
|
+
saved_at = record_by_name["saved_at"]
|
|
158
|
+
if not path or not isinstance(saved_at, str):
|
|
159
|
+
raise ValueError("A retained merge record is incomplete.")
|
|
160
|
+
results.append(MergeRecovery(path, filename, saved_at, record_by_name["sha256"]))
|
|
161
|
+
except (KeyError, TypeError, AttributeError) as error:
|
|
162
|
+
raise ValueError("A retained merge record is unreadable.") from error
|
|
163
|
+
return tuple(results)
|