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,182 @@
|
|
|
1
|
+
"""Parse unified Git patches independently of their Qt presentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from gitpane.config import COMMIT_LIMIT_BYTES, DIFF_FRAGMENT_LIMIT_CHARS, FILE_LINE_LIMIT_CHARS, FILE_PREVIEW_LIMIT_BYTES
|
|
13
|
+
from gitpane.history.repository_value import BlobContent, BlobContentKind, FileStatus
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from gitpane.history.repository_value import RevisionEntry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode_blob_display(oid: str, raw_bytes: bytes, size_bytes: int | None = None) -> BlobContent:
|
|
20
|
+
"""Classify a bounded preview without confusing decoded text with exact blob bytes."""
|
|
21
|
+
size = len(raw_bytes) if size_bytes is None else size_bytes
|
|
22
|
+
preview = raw_bytes[:FILE_PREVIEW_LIMIT_BYTES]
|
|
23
|
+
complete = len(preview) == size
|
|
24
|
+
kind, diagnostic, text = BlobContentKind.TEXT, "", ""
|
|
25
|
+
encoding = "utf-8"
|
|
26
|
+
has_bom = False
|
|
27
|
+
# UTF-32 must precede UTF-16 because their little-endian byte markers overlap.
|
|
28
|
+
for marker, candidate in ((codecs.BOM_UTF32_LE, "utf-32"), (codecs.BOM_UTF32_BE, "utf-32"), (codecs.BOM_UTF16_LE, "utf-16"),
|
|
29
|
+
(codecs.BOM_UTF16_BE, "utf-16"), (codecs.BOM_UTF8, "utf-8-sig")):
|
|
30
|
+
if preview.startswith(marker):
|
|
31
|
+
encoding = candidate
|
|
32
|
+
has_bom = True
|
|
33
|
+
break
|
|
34
|
+
if not size:
|
|
35
|
+
kind, diagnostic = BlobContentKind.EMPTY, "Empty file"
|
|
36
|
+
elif b"\0" in preview and not has_bom:
|
|
37
|
+
kind, diagnostic = BlobContentKind.BINARY, f"Binary file — {size} bytes (blob)"
|
|
38
|
+
else:
|
|
39
|
+
try:
|
|
40
|
+
decoder = codecs.getincrementaldecoder(encoding)(errors="strict")
|
|
41
|
+
text = decoder.decode(preview, final=complete)
|
|
42
|
+
except UnicodeError:
|
|
43
|
+
text = preview.decode(encoding, "replace")
|
|
44
|
+
kind, diagnostic = BlobContentKind.ENCODING, "Unknown or invalid text encoding — replacement preview"
|
|
45
|
+
display_lines = []
|
|
46
|
+
for line in text.splitlines(keepends=True):
|
|
47
|
+
if len(line.rstrip("\r\n")) > FILE_LINE_LIMIT_CHARS:
|
|
48
|
+
line = line[:FILE_LINE_LIMIT_CHARS] + ("\n" if line.endswith(("\r", "\n")) else "")
|
|
49
|
+
complete = False
|
|
50
|
+
display_lines.append(line)
|
|
51
|
+
if not complete:
|
|
52
|
+
diagnostic = f"Preview limited — {size} bytes" + (f"; {diagnostic}" if diagnostic else "")
|
|
53
|
+
return BlobContent(oid, preview, "".join(display_lines), kind, complete, size, diagnostic)
|
|
54
|
+
|
|
55
|
+
# First old and new line numbers of a hunk header in a text diff.
|
|
56
|
+
HUNK_LINE_NUMBER_PATTERN = re.compile(r"^@@ -(?P<old>\d+)(?:,\d+)? \+(?P<new>\d+)(?:,\d+)? @@")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def comparison_metadata(entry: RevisionEntry) -> bytes:
|
|
60
|
+
"""Describe renames and mode-only changes even when identical blobs have no hunk."""
|
|
61
|
+
lines = []
|
|
62
|
+
if entry.status == FileStatus.RENAMED:
|
|
63
|
+
lines.extend([b"rename from " + (entry.old_path or b""), b"rename to " + (entry.new_path or b"")])
|
|
64
|
+
if entry.old_blob_oid and entry.new_blob_oid and entry.old_mode != entry.new_mode:
|
|
65
|
+
lines.extend([f"old mode {entry.old_mode}".encode(), f"new mode {entry.new_mode}".encode()])
|
|
66
|
+
return b"\n".join(lines) + b"\n" if lines else b""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unchanged_context(content: bytes) -> bytes:
|
|
70
|
+
"""Show identical blob content as numbered context below rename or mode metadata."""
|
|
71
|
+
if not content:
|
|
72
|
+
return b"Empty file\n"
|
|
73
|
+
lines = content.split(b"\n")
|
|
74
|
+
final_linefeed = content.endswith(b"\n")
|
|
75
|
+
if final_linefeed:
|
|
76
|
+
lines.pop()
|
|
77
|
+
line_count = len(lines)
|
|
78
|
+
patch_lines = [f"@@ -1,{line_count} +1,{line_count} @@".encode()]
|
|
79
|
+
for line in lines:
|
|
80
|
+
patch_lines.append(b" " + line)
|
|
81
|
+
if not final_linefeed:
|
|
82
|
+
patch_lines.append(b"\")
|
|
83
|
+
return b"\n".join(patch_lines) + b"\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DiffLineKind(Enum):
|
|
87
|
+
"""Role of one displayed line of a diff, shared by the diff reader and the line staging."""
|
|
88
|
+
|
|
89
|
+
HEADER = "header"
|
|
90
|
+
"""A line outside any hunk: the diff and index headers, file names, mode or rename lines."""
|
|
91
|
+
HUNK = "hunk"
|
|
92
|
+
"""A hunk header, ``@@ -old +new @@``, which sets the line numbers of the lines after it."""
|
|
93
|
+
CONTEXT = "context"
|
|
94
|
+
"""An unchanged line, present on both sides."""
|
|
95
|
+
REMOVED = "removed"
|
|
96
|
+
"""A line only the old side has."""
|
|
97
|
+
ADDED = "added"
|
|
98
|
+
"""A line only the new side has."""
|
|
99
|
+
MARKER = "marker"
|
|
100
|
+
"""Git's ``\`` note about the line before it."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class DiffLine:
|
|
105
|
+
"""Keep visual numbering and changed fragments outside the original patch text."""
|
|
106
|
+
|
|
107
|
+
old_number: int | None
|
|
108
|
+
new_number: int | None
|
|
109
|
+
kind: DiffLineKind
|
|
110
|
+
text: str
|
|
111
|
+
changed_spans: list[tuple[int, int]] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class DiffDocument:
|
|
116
|
+
"""Retain the exact displayed lines and their independent comparison metadata."""
|
|
117
|
+
|
|
118
|
+
lines: list[DiffLine] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_unified_diff(patch: str) -> DiffDocument:
|
|
122
|
+
"""Parse bounded patch text without interpreting file paths or rewriting its content."""
|
|
123
|
+
if len(patch.encode("utf-8")) > COMMIT_LIMIT_BYTES:
|
|
124
|
+
raise ValueError("Diff exceeds the display size limit")
|
|
125
|
+
document = DiffDocument()
|
|
126
|
+
old_number = new_number = 0
|
|
127
|
+
in_hunk = False
|
|
128
|
+
removed_lines: list[DiffLine] = []
|
|
129
|
+
added_lines: list[DiffLine] = []
|
|
130
|
+
|
|
131
|
+
def accent_pair() -> None:
|
|
132
|
+
"""Compare nearby replacement lines under a fixed per-line work limit."""
|
|
133
|
+
prefix_length = 1
|
|
134
|
+
for removed, added in zip(removed_lines, added_lines, strict=False):
|
|
135
|
+
if max(len(removed.text), len(added.text)) > DIFF_FRAGMENT_LIMIT_CHARS:
|
|
136
|
+
continue
|
|
137
|
+
matcher = SequenceMatcher(None, removed.text[prefix_length:], added.text[prefix_length:], autojunk=True)
|
|
138
|
+
for kind, old_start, old_end, new_start, new_end in matcher.get_opcodes():
|
|
139
|
+
if kind == "equal":
|
|
140
|
+
continue
|
|
141
|
+
if old_start != old_end:
|
|
142
|
+
removed.changed_spans.append((old_start + prefix_length, old_end + prefix_length))
|
|
143
|
+
if new_start != new_end:
|
|
144
|
+
added.changed_spans.append((new_start + prefix_length, new_end + prefix_length))
|
|
145
|
+
removed_lines.clear()
|
|
146
|
+
added_lines.clear()
|
|
147
|
+
|
|
148
|
+
for text in patch.removesuffix("\n").split("\n") if patch else ():
|
|
149
|
+
match = HUNK_LINE_NUMBER_PATTERN.match(text)
|
|
150
|
+
if match is not None:
|
|
151
|
+
accent_pair()
|
|
152
|
+
old_number, new_number = int(match.group("old")), int(match.group("new"))
|
|
153
|
+
in_hunk = True
|
|
154
|
+
line = DiffLine(None, None, DiffLineKind.HUNK, text)
|
|
155
|
+
elif text.startswith("\\") and in_hunk:
|
|
156
|
+
line = DiffLine(None, None, DiffLineKind.MARKER, text)
|
|
157
|
+
elif in_hunk and text.startswith("-"):
|
|
158
|
+
line = DiffLine(old_number, None, DiffLineKind.REMOVED, text)
|
|
159
|
+
old_number += 1
|
|
160
|
+
removed_lines.append(line)
|
|
161
|
+
elif in_hunk and text.startswith("+"):
|
|
162
|
+
line = DiffLine(None, new_number, DiffLineKind.ADDED, text)
|
|
163
|
+
new_number += 1
|
|
164
|
+
added_lines.append(line)
|
|
165
|
+
elif in_hunk and (text.startswith(" ") or not text):
|
|
166
|
+
accent_pair()
|
|
167
|
+
line = DiffLine(old_number, new_number, DiffLineKind.CONTEXT, text)
|
|
168
|
+
old_number += 1
|
|
169
|
+
new_number += 1
|
|
170
|
+
else:
|
|
171
|
+
accent_pair()
|
|
172
|
+
in_hunk = False
|
|
173
|
+
line = DiffLine(None, None, DiffLineKind.HEADER, text)
|
|
174
|
+
document.lines.append(line)
|
|
175
|
+
accent_pair()
|
|
176
|
+
return document
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def utf16_length(text: str) -> int:
|
|
180
|
+
"""Translate Python character offsets to the UTF-16 units used by Qt cursors."""
|
|
181
|
+
bytes_per_unit = 2
|
|
182
|
+
return len(text.encode("utf-16-le")) // bytes_per_unit
|
|
File without changes
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Calculate commit replacements for a literal history removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from gitpane.config import COMMIT_SUBJECT_LIMIT_CHARS
|
|
9
|
+
from gitpane.history_rewrite.protocol import check_cancelled, matches_target
|
|
10
|
+
from gitpane.history_rewrite.value import BranchRewrite, HistoryAnalysis, ImportCommit, RewriteRefusal, RewriteRefusalKind, RewriteRow, RewriteStatus
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
from gitpane.history_rewrite.value import ExportCommit, RawCommit, RewriteCapture, RewriteTarget
|
|
17
|
+
|
|
18
|
+
IDENTITY_DATE_FIELD_COUNT = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ordered_history(records: list[ExportCommit], raw_by_oid: dict[str, RawCommit], cancelled: Callable[[], bool] | None = None) -> list[ExportCommit]:
|
|
22
|
+
"""Order the complete graph parents first, resolving every tie by full object ID."""
|
|
23
|
+
record_by_oid = {}
|
|
24
|
+
oid_by_mark = {}
|
|
25
|
+
children_by_oid: dict[str, list[str]] = {}
|
|
26
|
+
pending_by_oid = {}
|
|
27
|
+
for record in records:
|
|
28
|
+
check_cancelled(cancelled)
|
|
29
|
+
record_by_oid[record.oid] = record
|
|
30
|
+
oid_by_mark[record.mark] = record.oid
|
|
31
|
+
children_by_oid[record.oid] = []
|
|
32
|
+
for record in records:
|
|
33
|
+
check_cancelled(cancelled)
|
|
34
|
+
raw = raw_by_oid.get(record.oid)
|
|
35
|
+
if raw is None:
|
|
36
|
+
raise ValueError(f"Missing raw commit {record.oid}; restore the local object before retrying")
|
|
37
|
+
export_parents = []
|
|
38
|
+
for parent_ref in record.parent_refs:
|
|
39
|
+
export_parents.append(oid_by_mark.get(parent_ref, parent_ref))
|
|
40
|
+
if tuple(export_parents) != raw.parent_oids:
|
|
41
|
+
raise ValueError(f"Export ancestry does not match raw commit {raw.oid}; source unchanged")
|
|
42
|
+
parent_set = set(raw.parent_oids)
|
|
43
|
+
pending_by_oid[raw.oid] = len(parent_set)
|
|
44
|
+
for parent_oid in parent_set:
|
|
45
|
+
if parent_oid not in children_by_oid:
|
|
46
|
+
raise ValueError(f"Missing parent {parent_oid} of {raw.oid}; restore the local history before retrying")
|
|
47
|
+
children_by_oid[parent_oid].append(raw.oid)
|
|
48
|
+
ready_oids = []
|
|
49
|
+
for oid, pending in pending_by_oid.items():
|
|
50
|
+
if pending == 0:
|
|
51
|
+
heapq.heappush(ready_oids, oid)
|
|
52
|
+
ordered_records = []
|
|
53
|
+
while ready_oids:
|
|
54
|
+
check_cancelled(cancelled)
|
|
55
|
+
oid = heapq.heappop(ready_oids)
|
|
56
|
+
ordered_records.append(record_by_oid[oid])
|
|
57
|
+
for child_oid in children_by_oid[oid]:
|
|
58
|
+
pending_by_oid[child_oid] -= 1
|
|
59
|
+
if pending_by_oid[child_oid] == 0:
|
|
60
|
+
heapq.heappush(ready_oids, child_oid)
|
|
61
|
+
if len(ordered_records) != len(records):
|
|
62
|
+
raise ValueError("Cyclic or duplicate history records; restore the local history before retrying")
|
|
63
|
+
return ordered_records
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def affected_branch_closure(capture: RewriteCapture, target: RewriteTarget, records: list[ExportCommit], raw_by_oid: dict[str, RawCommit],
|
|
67
|
+
cancelled: Callable[[], bool] | None = None) -> set[str]:
|
|
68
|
+
"""Include active ancestors plus every local descendant affected by any newly included change."""
|
|
69
|
+
head_oid = capture.local_ref_by_name.get(capture.head_ref)
|
|
70
|
+
if head_oid is None:
|
|
71
|
+
raise ValueError("A named current branch is required; check out a local branch and retry")
|
|
72
|
+
children_by_oid: dict[str, list[str]] = {}
|
|
73
|
+
touched_oid_set = set()
|
|
74
|
+
for record in records:
|
|
75
|
+
check_cancelled(cancelled)
|
|
76
|
+
children_by_oid[record.oid] = []
|
|
77
|
+
if any(matches_target(change.path, target) for change in record.changes):
|
|
78
|
+
touched_oid_set.add(record.oid)
|
|
79
|
+
for name, oid in capture.local_ref_by_name.items():
|
|
80
|
+
check_cancelled(cancelled)
|
|
81
|
+
if oid not in children_by_oid:
|
|
82
|
+
raise ValueError(f"Missing branch tip {oid} for {name}; refresh the complete local history before retrying")
|
|
83
|
+
for record in records:
|
|
84
|
+
check_cancelled(cancelled)
|
|
85
|
+
for parent_oid in set(raw_by_oid[record.oid].parent_oids):
|
|
86
|
+
children_by_oid[parent_oid].append(record.oid)
|
|
87
|
+
included_oid_set: set[str] = set()
|
|
88
|
+
changed_oid_set: set[str] = set()
|
|
89
|
+
pending_include_oids = [head_oid]
|
|
90
|
+
pending_changed_oids: list[str] = []
|
|
91
|
+
# Every exported descendant leads to a local tip. Including its parents immediately avoids
|
|
92
|
+
# repeatedly walking a whole branch when a merge brings another affected ancestry into scope.
|
|
93
|
+
while pending_include_oids or pending_changed_oids:
|
|
94
|
+
while pending_include_oids:
|
|
95
|
+
check_cancelled(cancelled)
|
|
96
|
+
oid = pending_include_oids.pop()
|
|
97
|
+
if oid in included_oid_set:
|
|
98
|
+
continue
|
|
99
|
+
included_oid_set.add(oid)
|
|
100
|
+
pending_include_oids.extend(raw_by_oid[oid].parent_oids)
|
|
101
|
+
if oid in touched_oid_set:
|
|
102
|
+
pending_changed_oids.append(oid)
|
|
103
|
+
while pending_changed_oids:
|
|
104
|
+
check_cancelled(cancelled)
|
|
105
|
+
oid = pending_changed_oids.pop()
|
|
106
|
+
if oid in changed_oid_set:
|
|
107
|
+
continue
|
|
108
|
+
changed_oid_set.add(oid)
|
|
109
|
+
pending_include_oids.append(oid)
|
|
110
|
+
pending_changed_oids.extend(children_by_oid[oid])
|
|
111
|
+
return included_oid_set
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def remap_history(target: RewriteTarget, records: list[ExportCommit], raw_by_oid: dict[str, RawCommit],
|
|
115
|
+
cancelled: Callable[[], bool] | None = None) -> tuple[dict[str, str | None], tuple[ImportCommit, ...]]:
|
|
116
|
+
"""Rebuild a parent-ordered union once, retaining untouched identities and every merge parent."""
|
|
117
|
+
replacement_by_oid: dict[str, str | None] = {}
|
|
118
|
+
commits: list[ImportCommit] = []
|
|
119
|
+
for record in records:
|
|
120
|
+
check_cancelled(cancelled)
|
|
121
|
+
raw = raw_by_oid.get(record.oid)
|
|
122
|
+
if raw is None:
|
|
123
|
+
raise ValueError(f"Missing raw commit {record.oid}; restore the local object before retrying")
|
|
124
|
+
merge = len(raw.parent_oids) > 1
|
|
125
|
+
parent_refs = []
|
|
126
|
+
for parent_oid in raw.parent_oids:
|
|
127
|
+
if parent_oid not in replacement_by_oid:
|
|
128
|
+
raise ValueError(f"Missing parent {parent_oid} of {raw.oid}; restore the local history before retrying")
|
|
129
|
+
replacement = replacement_by_oid[parent_oid]
|
|
130
|
+
if merge and replacement is None:
|
|
131
|
+
raise RewriteRefusal(RewriteRefusalKind.INVALID_MERGE_PARENTS,
|
|
132
|
+
f"Merge {raw.oid} has no surviving parent for {parent_oid} after removing {target.path!r}; source unchanged, select a narrower target")
|
|
133
|
+
if replacement is not None:
|
|
134
|
+
parent_refs.append(replacement)
|
|
135
|
+
changes = []
|
|
136
|
+
touched = False
|
|
137
|
+
for change in record.changes:
|
|
138
|
+
if matches_target(change.path, target):
|
|
139
|
+
touched = True
|
|
140
|
+
else:
|
|
141
|
+
changes.append(change)
|
|
142
|
+
deleted = not merge and bool(record.changes) and not changes
|
|
143
|
+
if deleted:
|
|
144
|
+
replacement_by_oid[raw.oid] = next(iter(parent_refs), None)
|
|
145
|
+
elif not touched and tuple(parent_refs) == raw.parent_oids:
|
|
146
|
+
replacement_by_oid[raw.oid] = raw.oid
|
|
147
|
+
else:
|
|
148
|
+
replacement_by_oid[raw.oid] = record.mark
|
|
149
|
+
commits.append(ImportCommit(raw, record.mark, tuple(parent_refs), tuple(changes)))
|
|
150
|
+
return replacement_by_oid, tuple(commits)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def contribution_rows(target: RewriteTarget, records: list[ExportCommit], raw_by_oid: dict[str, RawCommit], raw_source: BinaryIO, replacement_by_oid: dict[str, str | None],
|
|
154
|
+
cancelled: Callable[[], bool] | None = None) -> tuple[RewriteRow, ...]:
|
|
155
|
+
"""Show only changed first-parent contributions, independently of the full rewrite boundary."""
|
|
156
|
+
rows = []
|
|
157
|
+
for record in reversed(records):
|
|
158
|
+
check_cancelled(cancelled)
|
|
159
|
+
if any(matches_target(change.path, target) for change in record.changes):
|
|
160
|
+
raw = raw_by_oid[record.oid]
|
|
161
|
+
first_parent_oid = next(iter(raw.parent_oids), None)
|
|
162
|
+
deleted = replacement_by_oid[raw.oid] == replacement_by_oid.get(first_parent_oid)
|
|
163
|
+
raw_source.seek(raw.message_offset)
|
|
164
|
+
message = raw_source.read(min(raw.message_length, COMMIT_SUBJECT_LIMIT_CHARS))
|
|
165
|
+
identity, timestamp, timezone = raw.author.rsplit(b" ", IDENTITY_DATE_FIELD_COUNT)
|
|
166
|
+
author = identity.decode("utf-8", "surrogateescape")
|
|
167
|
+
date = b" ".join((timestamp, timezone)).decode("ascii", "replace")
|
|
168
|
+
subject, _separator, _remainder = message.partition(b"\n")
|
|
169
|
+
rows.append(RewriteRow(raw.oid, RewriteStatus.DELETED if deleted else RewriteStatus.MODIFIED,
|
|
170
|
+
subject.decode("utf-8", "surrogateescape"), author, date, raw.message_offset, raw.message_length))
|
|
171
|
+
return tuple(rows)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def analyze_history(capture: RewriteCapture, target: RewriteTarget, records: list[ExportCommit], raw_by_oid: dict[str, RawCommit], raw_source: BinaryIO,
|
|
175
|
+
cancelled: Callable[[], bool] | None = None) -> HistoryAnalysis:
|
|
176
|
+
"""Filter the transitive local branch union while keeping unrelated histories outside the proposal."""
|
|
177
|
+
ordered_records = ordered_history(records, raw_by_oid, cancelled)
|
|
178
|
+
included_oid_set = affected_branch_closure(capture, target, ordered_records, raw_by_oid, cancelled)
|
|
179
|
+
selected_records = []
|
|
180
|
+
for record in ordered_records:
|
|
181
|
+
check_cancelled(cancelled)
|
|
182
|
+
if record.oid in included_oid_set:
|
|
183
|
+
selected_records.append(record)
|
|
184
|
+
replacement_by_oid, commits = remap_history(target, selected_records, raw_by_oid, cancelled)
|
|
185
|
+
rows = contribution_rows(target, selected_records, raw_by_oid, raw_source, replacement_by_oid, cancelled)
|
|
186
|
+
deleted_oid_set = set()
|
|
187
|
+
for row in rows:
|
|
188
|
+
if row.status == RewriteStatus.DELETED:
|
|
189
|
+
deleted_oid_set.add(row.oid)
|
|
190
|
+
signed_count = 0
|
|
191
|
+
for oid, replacement in replacement_by_oid.items():
|
|
192
|
+
check_cancelled(cancelled)
|
|
193
|
+
if raw_by_oid[oid].signed and replacement != oid:
|
|
194
|
+
signed_count += 1
|
|
195
|
+
branches = []
|
|
196
|
+
remote_refs_by_oid: dict[str, list[str]] = {}
|
|
197
|
+
for remote_name, remote_oid in capture.remote_ref_by_name.items():
|
|
198
|
+
remote_refs_by_oid.setdefault(remote_oid, []).append(remote_name)
|
|
199
|
+
for name in sorted(capture.local_ref_by_name, key=lambda name: (name != capture.head_ref, name)):
|
|
200
|
+
check_cancelled(cancelled)
|
|
201
|
+
old_oid = capture.local_ref_by_name[name]
|
|
202
|
+
if old_oid not in replacement_by_oid:
|
|
203
|
+
continue
|
|
204
|
+
new_oid = replacement_by_oid[old_oid]
|
|
205
|
+
if new_oid is None:
|
|
206
|
+
raise RewriteRefusal(RewriteRefusalKind.NO_SURVIVING_TIP,
|
|
207
|
+
f"Branch {name} at {old_oid} has no surviving commit after removing {target.path!r}; source unchanged, select a narrower target")
|
|
208
|
+
if new_oid != old_oid:
|
|
209
|
+
branches.append(BranchRewrite(name, old_oid, new_oid, capture.upstream_by_name.get(name), tuple(sorted(remote_refs_by_oid.get(old_oid, ()))), old_oid in deleted_oid_set))
|
|
210
|
+
return HistoryAnalysis(replacement_by_oid, commits, rows, tuple(branches), signed_count)
|