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,630 @@
|
|
|
1
|
+
"""Hold the coherent state of a worktree, parsed from Git's status, checked against local file fingerprints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Self
|
|
12
|
+
|
|
13
|
+
from gitpane.config import PROCESS_CHUNK_BYTES, READ_SIZE_SENTINEL_BYTES, WORKTREE_FILE_WORKER_COUNT, WORKTREE_INDEX_LIMIT_BYTES
|
|
14
|
+
from gitpane.history.repository_value import FileStatus, file_status
|
|
15
|
+
from gitpane.system.path_identity import canonical_path
|
|
16
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
17
|
+
from gitpane.worktree.command_sequence import FileTask, ReadBatch
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Generator
|
|
21
|
+
from typing import BinaryIO
|
|
22
|
+
|
|
23
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
24
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
25
|
+
from gitpane.sequencer.merge_state import MergeBannerState
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class WorktreeKey:
|
|
30
|
+
"""Keep linked worktrees independent by using their own canonical Git directory."""
|
|
31
|
+
|
|
32
|
+
git_dir: str
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_context(cls, context: RepositoryContext) -> WorktreeKey:
|
|
36
|
+
"""Normalize filesystem aliases without merging the common object directory."""
|
|
37
|
+
return cls(canonical_path(context.git_dir))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
CONFLICT_ROLE_BY_STAGE = {1: "BASE", 2: "LOCAL", 3: "REMOTE"}
|
|
41
|
+
CONFLICT_ROLE_SET_BY_STATUS = {
|
|
42
|
+
b"DD": frozenset(("BASE",)), b"AU": frozenset(("LOCAL",)), b"UA": frozenset(("REMOTE",)),
|
|
43
|
+
b"UD": frozenset(("BASE", "LOCAL")), b"DU": frozenset(("BASE", "REMOTE")),
|
|
44
|
+
b"AA": frozenset(("LOCAL", "REMOTE")), b"UU": frozenset(CONFLICT_ROLE_BY_STAGE.values()),
|
|
45
|
+
}
|
|
46
|
+
INDEX_MODE_SET = frozenset(("100644", "100755", "120000", "160000", "040000"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
STATUS_HEADER_PREFIX = b"# "
|
|
50
|
+
STATUS_BRANCH_OID_PREFIX = b"# branch.oid "
|
|
51
|
+
STATUS_BRANCH_HEAD_PREFIX = b"# branch.head "
|
|
52
|
+
STATUS_UNMERGED_PREFIX = b"u "
|
|
53
|
+
# An unmerged status record holds ten spaces before its path, which may itself contain spaces.
|
|
54
|
+
STATUS_UNMERGED_SEPARATOR_COUNT = 10
|
|
55
|
+
STATUS_RENAMED_PREFIX = b"2 "
|
|
56
|
+
STATUS_ORDINARY_PREFIX = b"1 "
|
|
57
|
+
STATUS_UNTRACKED_PREFIX = b"? "
|
|
58
|
+
# Letter of the XY column of a porcelain v2 record for the side (index or work tree) that did not change.
|
|
59
|
+
STATUS_UNCHANGED_LETTER = "."
|
|
60
|
+
STATUS_INITIAL_OID = b"(initial)"
|
|
61
|
+
STATUS_DETACHED_HEAD = b"(detached)"
|
|
62
|
+
STATUS_UNKNOWN_HEAD = b"(NULL)"
|
|
63
|
+
SUBMODULE_DIRTY_DESCRIPTION = "These changes are inside the submodule and are not included in the parent commit."
|
|
64
|
+
SUBMODULE_NO_COMMIT_CHANGE = "There is no commit change to stage. Commit internal changes inside the submodule first."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SubmoduleState:
|
|
69
|
+
"""Distinguish gitlink movement from tracked and untracked child changes."""
|
|
70
|
+
|
|
71
|
+
commit_changed: bool | None = None
|
|
72
|
+
tracked_changed: bool | None = None
|
|
73
|
+
untracked_changed: bool | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def dirty(self) -> bool | None:
|
|
77
|
+
"""Keep incomplete status unknown without hiding a known internal change."""
|
|
78
|
+
if self.tracked_changed or self.untracked_changed:
|
|
79
|
+
return True
|
|
80
|
+
if self.tracked_changed is None or self.untracked_changed is None:
|
|
81
|
+
return None
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def parse(cls, value: bytes) -> SubmoduleState:
|
|
86
|
+
"""Decode native porcelain flags while preserving unsupported values as unknown."""
|
|
87
|
+
if value == b"N...":
|
|
88
|
+
return cls(False, False, False)
|
|
89
|
+
if len(value) != len(b"S...") or not value.startswith(b"S"):
|
|
90
|
+
return cls()
|
|
91
|
+
flags = []
|
|
92
|
+
for actual, changed in zip(value.removeprefix(b"S"), b"CMU", strict=True):
|
|
93
|
+
flags.append(True if actual == changed else False if actual == ord(".") else None)
|
|
94
|
+
return cls(*flags)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ConflictSide:
|
|
99
|
+
"""Identify one version actually present in the unmerged index."""
|
|
100
|
+
|
|
101
|
+
stage: int
|
|
102
|
+
mode: str
|
|
103
|
+
oid: str
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
"""Reject invalid object identities before any consumer can use a side."""
|
|
107
|
+
if (self.stage not in CONFLICT_ROLE_BY_STAGE or self.mode not in INDEX_MODE_SET or self.mode == "040000"
|
|
108
|
+
or not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", self.oid) or not self.oid.strip("0")):
|
|
109
|
+
raise ValueError("Malformed unmerged index side")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StagingSide(Enum):
|
|
113
|
+
"""The side of the staging window a changed file is listed on: staged in the index, or unstaged in the working tree.
|
|
114
|
+
|
|
115
|
+
The value is the word written under "side" in a reset's recovery record; ``label`` is the word inside a sentence
|
|
116
|
+
and ``title`` the word that starts a title.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
STAGED = ("staged", "staged", "Staged")
|
|
120
|
+
UNSTAGED = ("unstaged", "unstaged", "Unstaged")
|
|
121
|
+
|
|
122
|
+
def __new__(cls, value: str, label: str, title: str) -> Self:
|
|
123
|
+
"""Keep the recorded word as the value, with the sentence and title words beside it."""
|
|
124
|
+
member = object.__new__(cls)
|
|
125
|
+
member._value_ = value
|
|
126
|
+
member.label = label
|
|
127
|
+
member.title = title
|
|
128
|
+
return member
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class LocalEntry:
|
|
133
|
+
"""Retain original Git paths and object identities independently of labels."""
|
|
134
|
+
|
|
135
|
+
side: StagingSide
|
|
136
|
+
old_path: bytes | None
|
|
137
|
+
new_path: bytes | None
|
|
138
|
+
status: FileStatus
|
|
139
|
+
old_mode: str = "000000"
|
|
140
|
+
new_mode: str = "000000"
|
|
141
|
+
old_blob_oid: str | None = None
|
|
142
|
+
new_blob_oid: str | None = None
|
|
143
|
+
conflict_stages: tuple[int, ...] = ()
|
|
144
|
+
skip_worktree: bool = False
|
|
145
|
+
conflict_sides: tuple[ConflictSide, ...] = ()
|
|
146
|
+
conflict_binary: bool | None = None
|
|
147
|
+
submodule: SubmoduleState = SubmoduleState()
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_gitlink(self) -> bool:
|
|
151
|
+
"""Recognize child repositories on either side of additions and deletions."""
|
|
152
|
+
return "160000" in (self.old_mode, self.new_mode)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def stageable(self) -> bool:
|
|
156
|
+
"""Allow real reference changes without staging internal-only child edits."""
|
|
157
|
+
return not self.is_gitlink or self.status in (FileStatus.ADDED, FileStatus.DELETED) or self.old_mode != self.new_mode or self.submodule.commit_changed is True
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def path(self) -> bytes:
|
|
161
|
+
"""Choose the current identity, retaining a deleted path when necessary."""
|
|
162
|
+
return self.new_path if self.new_path is not None else self.old_path or b""
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def paths(self) -> tuple[bytes, ...]:
|
|
166
|
+
"""Include both ends of a rename for a complete index operation."""
|
|
167
|
+
return tuple(dict.fromkeys(path for path in (self.old_path, self.new_path) if path is not None))
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def label(self) -> str:
|
|
171
|
+
"""Decode paths for display and distinguish parent gitlinks from internal child files."""
|
|
172
|
+
label = self.path.decode("utf-8", "replace")
|
|
173
|
+
if self.status == FileStatus.RENAMED and self.old_path is not None:
|
|
174
|
+
label += " (" + self.old_path.decode("utf-8", "replace") + ")"
|
|
175
|
+
if self.is_gitlink:
|
|
176
|
+
if self.side == StagingSide.STAGED or self.stageable:
|
|
177
|
+
label += " — Submodule commit"
|
|
178
|
+
if self.side == StagingSide.UNSTAGED and self.submodule.dirty:
|
|
179
|
+
label += " + internal changes"
|
|
180
|
+
elif self.submodule.dirty:
|
|
181
|
+
label += " — Internal changes only"
|
|
182
|
+
return label
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class WorktreeProbe:
|
|
187
|
+
"""The facts of a single status that say whether the displayed state still holds.
|
|
188
|
+
|
|
189
|
+
A hint for the views, never a proof a write can rely on.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
head_oid: str | None
|
|
193
|
+
branch_head: str | None
|
|
194
|
+
unmerged_records: tuple[bytes, ...]
|
|
195
|
+
entry_fingerprint: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def unmerged_record_path(record: bytes) -> bytes:
|
|
199
|
+
"""Answer the path of one unmerged status record, exactly as Git wrote it."""
|
|
200
|
+
if not record.startswith(STATUS_UNMERGED_PREFIX):
|
|
201
|
+
raise ValueError("Malformed unmerged status record")
|
|
202
|
+
fields = record.split(b" ", STATUS_UNMERGED_SEPARATOR_COUNT)
|
|
203
|
+
# Each separator ends one field, and the path is the field after the last one.
|
|
204
|
+
if len(fields) != STATUS_UNMERGED_SEPARATOR_COUNT + 1:
|
|
205
|
+
raise ValueError("Malformed unmerged status record")
|
|
206
|
+
path = fields[STATUS_UNMERGED_SEPARATOR_COUNT]
|
|
207
|
+
if not path:
|
|
208
|
+
raise ValueError("Malformed unmerged status record")
|
|
209
|
+
return path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class PassiveObservationSource:
|
|
214
|
+
"""What the automatic refresh already has in hand when the banner follows a pause: the status facts and the pause paths.
|
|
215
|
+
|
|
216
|
+
It lets that display skip reading them again through Git; it is never a proof a write can rely on.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
head_oid: str | None
|
|
220
|
+
branch_head: str | None
|
|
221
|
+
unmerged_paths: tuple[bytes, ...]
|
|
222
|
+
filename_by_git_path: dict[str, str]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def passive_observation_source(worktree_probe: WorktreeProbe, filename_by_git_path: dict[str, str]) -> PassiveObservationSource:
|
|
226
|
+
"""Gather the facts of one status and the pause paths of its session for the observation that follows the pause."""
|
|
227
|
+
path_set = set()
|
|
228
|
+
for record in worktree_probe.unmerged_records:
|
|
229
|
+
path_set.add(unmerged_record_path(record))
|
|
230
|
+
return PassiveObservationSource(worktree_probe.head_oid, worktree_probe.branch_head, tuple(sorted(path_set)), dict(filename_by_git_path))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class WorktreeColorState(Enum):
|
|
234
|
+
"""What the Commit icon says about the worktree: which sides hold changes, or unknown before any fresh read."""
|
|
235
|
+
|
|
236
|
+
UNKNOWN = "unknown"
|
|
237
|
+
MIXED = "mixed"
|
|
238
|
+
STAGED = "staged"
|
|
239
|
+
UNSTAGED = "unstaged"
|
|
240
|
+
CLEAN = "clean"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class WorktreeSnapshot:
|
|
245
|
+
"""Capture a complete read, including exact index absence and conversion settings."""
|
|
246
|
+
|
|
247
|
+
key: WorktreeKey
|
|
248
|
+
context: RepositoryContext
|
|
249
|
+
generation: int
|
|
250
|
+
git_filename: str
|
|
251
|
+
head_oid: str | None
|
|
252
|
+
index_filename: str
|
|
253
|
+
index_content: bytes | None
|
|
254
|
+
entries: tuple[LocalEntry, ...]
|
|
255
|
+
fingerprint_by_path: dict[bytes, str]
|
|
256
|
+
conversion: bytes
|
|
257
|
+
fresh: bool = True
|
|
258
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
259
|
+
index_entries: bytes | None = None
|
|
260
|
+
hooks_dir: str | None = None
|
|
261
|
+
hidden_index_paths: tuple[bytes, ...] = ()
|
|
262
|
+
skip_worktree_paths: tuple[bytes, ...] = ()
|
|
263
|
+
probe: WorktreeProbe | None = None
|
|
264
|
+
# The merge state read from Git's files right after this capture, for the repository window's banner; None when the
|
|
265
|
+
# capture did not read it, as the captures made for a write do not. It is left out of the comparison of two
|
|
266
|
+
# snapshots: the session that shows them tracks the merge state on its own, and a changed merge alone does not
|
|
267
|
+
# change the files shown.
|
|
268
|
+
merge_banner_state: MergeBannerState | None = field(default=None, compare=False)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def index_fingerprint(self) -> str:
|
|
272
|
+
"""Distinguish an absent index from every existing byte representation."""
|
|
273
|
+
return fingerprint(self.index_content)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def count(self) -> int:
|
|
277
|
+
"""Count grouped staged renames once and deduplicate a path on both sides."""
|
|
278
|
+
return len({entry.path for entry in self.entries})
|
|
279
|
+
|
|
280
|
+
def color_state(self) -> WorktreeColorState:
|
|
281
|
+
"""Distinguish staged and unstaged changes without letting a message alter repository status."""
|
|
282
|
+
if not self.fresh:
|
|
283
|
+
return WorktreeColorState.UNKNOWN
|
|
284
|
+
side_set = {entry.side for entry in self.entries}
|
|
285
|
+
if StagingSide.STAGED in side_set and StagingSide.UNSTAGED in side_set:
|
|
286
|
+
return WorktreeColorState.MIXED
|
|
287
|
+
if StagingSide.STAGED in side_set:
|
|
288
|
+
return WorktreeColorState.STAGED
|
|
289
|
+
return WorktreeColorState.UNSTAGED if side_set else WorktreeColorState.CLEAN
|
|
290
|
+
|
|
291
|
+
def requires_file_confirmation(self, paths: tuple[bytes, ...]) -> bool:
|
|
292
|
+
"""Confirm destructive file actions when a target is an addition on either side."""
|
|
293
|
+
path_set = set(paths)
|
|
294
|
+
return any(entry.status == FileStatus.ADDED and entry.path in path_set for entry in self.entries)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def local_history_path(snapshot: WorktreeSnapshot, entry: LocalEntry) -> bytes | None:
|
|
298
|
+
"""Find a possible HEAD name without pretending an index-only addition has history."""
|
|
299
|
+
if snapshot.head_oid is None or entry not in snapshot.entries or entry.side != StagingSide.UNSTAGED:
|
|
300
|
+
return None
|
|
301
|
+
for staged in snapshot.entries:
|
|
302
|
+
if staged.side != StagingSide.STAGED or staged.path != entry.path:
|
|
303
|
+
continue
|
|
304
|
+
if staged.status == FileStatus.RENAMED:
|
|
305
|
+
return staged.old_path
|
|
306
|
+
if staged.status == FileStatus.ADDED:
|
|
307
|
+
return None
|
|
308
|
+
if staged.status == FileStatus.DELETED:
|
|
309
|
+
return staged.old_path
|
|
310
|
+
return entry.old_path
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fingerprint(content: bytes | None) -> str:
|
|
314
|
+
"""Hash exact bytes while keeping absence explicit."""
|
|
315
|
+
return "absent" if content is None else hashlib.sha256(content).hexdigest()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def read_validated_size(stream: BinaryIO, validated_size: int) -> bytes:
|
|
319
|
+
"""Read a small file whose size was just validated on its open descriptor, asking for that size and one byte more.
|
|
320
|
+
|
|
321
|
+
A read sized on the largest file accepted makes the interpreter reserve that whole buffer even for a few bytes.
|
|
322
|
+
The extra byte is how growth shows: the caller refuses every length other than the validated size, so a file that
|
|
323
|
+
grew or shrank meanwhile never passes for complete content.
|
|
324
|
+
"""
|
|
325
|
+
return stream.read(validated_size + READ_SIZE_SENTINEL_BYTES)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def read_index(index_filename: str) -> bytes | None:
|
|
329
|
+
"""Read a bounded index without confusing a missing file with an empty one."""
|
|
330
|
+
try:
|
|
331
|
+
if not stat.S_ISREG(os.lstat(index_filename).st_mode):
|
|
332
|
+
raise ValueError("The Git index is not a regular file")
|
|
333
|
+
# A read sized to the safety limit allocates the maximum even for a small index.
|
|
334
|
+
# Chunked reads retain the same limit without paying that cost on every check.
|
|
335
|
+
content = bytearray()
|
|
336
|
+
with open(index_filename, "rb") as stream:
|
|
337
|
+
while chunk := stream.read(PROCESS_CHUNK_BYTES):
|
|
338
|
+
content.extend(chunk)
|
|
339
|
+
if len(content) > WORKTREE_INDEX_LIMIT_BYTES:
|
|
340
|
+
raise ValueError("The Git index exceeds the supported read limit")
|
|
341
|
+
return bytes(content)
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _validate_fingerprint_parent(context: RepositoryContext, filename: str, metadata_by_dir: dict[str, os.stat_result | None]) -> None:
|
|
347
|
+
"""Check each ancestor once per batch and retain its identity for the closing check."""
|
|
348
|
+
parent_dir = os.path.dirname(filename)
|
|
349
|
+
while os.path.normcase(parent_dir) != os.path.normcase(os.path.abspath(context.worktree_dir)):
|
|
350
|
+
if parent_dir in metadata_by_dir:
|
|
351
|
+
return
|
|
352
|
+
try:
|
|
353
|
+
metadata = os.lstat(parent_dir)
|
|
354
|
+
except FileNotFoundError:
|
|
355
|
+
metadata = None
|
|
356
|
+
if metadata is not None and (stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", None) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -1)):
|
|
357
|
+
raise ValueError("A selected path traverses a linked directory")
|
|
358
|
+
metadata_by_dir[parent_dir] = metadata
|
|
359
|
+
ancestor_dir = os.path.dirname(parent_dir)
|
|
360
|
+
if ancestor_dir == parent_dir:
|
|
361
|
+
raise ValueError("A selected path is outside the working tree")
|
|
362
|
+
parent_dir = ancestor_dir
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _fingerprint_filename(filename: str) -> str:
|
|
366
|
+
"""Hash one validated file's bytes and mode without following a final symlink."""
|
|
367
|
+
try:
|
|
368
|
+
metadata = os.lstat(filename)
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
return "absent"
|
|
371
|
+
mode = str(stat.S_IFMT(metadata.st_mode)) + ":" + str(stat.S_IMODE(metadata.st_mode))
|
|
372
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
373
|
+
return mode + ":" + fingerprint(os.fsencode(os.readlink(filename)))
|
|
374
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
375
|
+
return mode
|
|
376
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
377
|
+
raise ValueError("Unsupported working file type")
|
|
378
|
+
digest = hashlib.sha256()
|
|
379
|
+
with open(filename, "rb") as stream:
|
|
380
|
+
while chunk := stream.read(PROCESS_CHUNK_BYTES):
|
|
381
|
+
digest.update(chunk)
|
|
382
|
+
return mode + ":" + digest.hexdigest()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def file_fingerprint(context: RepositoryContext, path: bytes) -> str:
|
|
386
|
+
"""Hash working bytes and mode without following symlinks or walking submodules."""
|
|
387
|
+
filename = worktree_filename(context, path)
|
|
388
|
+
_validate_fingerprint_parent(context, filename, {})
|
|
389
|
+
return _fingerprint_filename(filename)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def fingerprint_files(context: RepositoryContext, paths: tuple[bytes, ...]) -> dict[bytes, str]:
|
|
393
|
+
"""Hash exact bytes in one worker and validate shared ancestor directories once at each end."""
|
|
394
|
+
fingerprint_by_path: dict[bytes, str] = {}
|
|
395
|
+
metadata_by_dir: dict[str, os.stat_result | None] = {}
|
|
396
|
+
for path in paths:
|
|
397
|
+
if path and path not in fingerprint_by_path:
|
|
398
|
+
filename = worktree_filename(context, path)
|
|
399
|
+
_validate_fingerprint_parent(context, filename, metadata_by_dir)
|
|
400
|
+
fingerprint_by_path[path] = _fingerprint_filename(filename)
|
|
401
|
+
for directory, expected in metadata_by_dir.items():
|
|
402
|
+
try:
|
|
403
|
+
actual = os.lstat(directory)
|
|
404
|
+
except FileNotFoundError:
|
|
405
|
+
actual = None
|
|
406
|
+
if actual is None and expected is None:
|
|
407
|
+
continue
|
|
408
|
+
if actual is None or expected is None or (actual.st_dev, actual.st_ino, actual.st_mode, getattr(actual, "st_reparse_tag", None)) != (
|
|
409
|
+
expected.st_dev, expected.st_ino, expected.st_mode, getattr(expected, "st_reparse_tag", None)
|
|
410
|
+
):
|
|
411
|
+
raise ValueError("A working directory changed during reading; refresh the changes")
|
|
412
|
+
return fingerprint_by_path
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def read_fingerprints(context: RepositoryContext, paths: tuple[bytes, ...]) -> Generator:
|
|
416
|
+
"""Distribute exact byte reads across the existing bounded filesystem workers."""
|
|
417
|
+
unique_paths = tuple(dict.fromkeys(path for path in paths if path))
|
|
418
|
+
groups = []
|
|
419
|
+
for position in range(min(WORKTREE_FILE_WORKER_COUNT, len(unique_paths))):
|
|
420
|
+
group = unique_paths[position::WORKTREE_FILE_WORKER_COUNT]
|
|
421
|
+
groups.append(FileTask(lambda group=group: fingerprint_files(context, group)))
|
|
422
|
+
results = yield ReadBatch(tuple(groups))
|
|
423
|
+
fingerprint_by_path: dict[bytes, str] = {}
|
|
424
|
+
for result in results:
|
|
425
|
+
fingerprint_by_path.update(result)
|
|
426
|
+
return fingerprint_by_path
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def verify_local_state(snapshot: WorktreeSnapshot, paths: tuple[bytes, ...]) -> None:
|
|
430
|
+
"""Refuse stale local bytes before private preparation; Git metadata is checked before publication."""
|
|
431
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
432
|
+
raise ValueError("The index changed since selection; refresh the changes")
|
|
433
|
+
for path, actual in fingerprint_files(snapshot.context, paths).items():
|
|
434
|
+
expected = snapshot.fingerprint_by_path.get(path)
|
|
435
|
+
# Gitlink HEAD is checked by Git before publication; the directory itself is checked here.
|
|
436
|
+
if actual != expected and (expected is None or not expected.startswith(actual + ":")):
|
|
437
|
+
raise ValueError("Working file changed since selection; refresh the changes")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def parse_status(output: bytes) -> tuple[LocalEntry, ...]:
|
|
441
|
+
"""Parse NUL records without splitting or normalizing the path bytes."""
|
|
442
|
+
if output and not output.endswith(b"\0"):
|
|
443
|
+
raise ValueError("Incomplete Git status output")
|
|
444
|
+
entries: list[LocalEntry] = []
|
|
445
|
+
renamed_separator_count, ordinary_separator_count = 9, 8
|
|
446
|
+
records = iter(output.split(b"\0"))
|
|
447
|
+
for record in records:
|
|
448
|
+
if not record or record.startswith(STATUS_HEADER_PREFIX):
|
|
449
|
+
continue
|
|
450
|
+
if record.startswith(STATUS_UNTRACKED_PREFIX):
|
|
451
|
+
entries.append(LocalEntry(StagingSide.UNSTAGED, None, record[len(STATUS_UNTRACKED_PREFIX):], FileStatus.ADDED))
|
|
452
|
+
continue
|
|
453
|
+
if record.startswith(STATUS_UNMERGED_PREFIX):
|
|
454
|
+
fields = record.split(b" ", STATUS_UNMERGED_SEPARATOR_COUNT)
|
|
455
|
+
_kind, _status, _submodule, base_mode, local_mode, remote_mode, work_mode, base_oid, local_oid, remote_oid, path = fields
|
|
456
|
+
if not path or work_mode.decode("ascii") not in INDEX_MODE_SET | {"000000"}:
|
|
457
|
+
raise ValueError("Malformed unmerged status path or mode")
|
|
458
|
+
sides = []
|
|
459
|
+
stages = []
|
|
460
|
+
role_set: set[str] = set()
|
|
461
|
+
for stage, mode, oid in zip(CONFLICT_ROLE_BY_STAGE, (base_mode, local_mode, remote_mode), (base_oid, local_oid, remote_oid), strict=True):
|
|
462
|
+
if mode == b"000000":
|
|
463
|
+
if not re.fullmatch(rb"(?:0{40}|0{64})", oid):
|
|
464
|
+
raise ValueError("Absent conflict side has a nonzero object")
|
|
465
|
+
continue
|
|
466
|
+
sides.append(ConflictSide(stage, mode.decode("ascii"), oid.decode("ascii")))
|
|
467
|
+
stages.append(stage)
|
|
468
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[stage])
|
|
469
|
+
if (not sides or not len(base_oid) == len(local_oid) == len(remote_oid)
|
|
470
|
+
or role_set != CONFLICT_ROLE_SET_BY_STATUS.get(_status)):
|
|
471
|
+
raise ValueError("Malformed unmerged status sides")
|
|
472
|
+
entries.append(LocalEntry(StagingSide.UNSTAGED, path, path, FileStatus.UNMERGED, base_mode.decode(), work_mode.decode(), base_oid.decode(),
|
|
473
|
+
conflict_stages=tuple(stages), conflict_sides=tuple(sides), submodule=SubmoduleState.parse(_submodule)))
|
|
474
|
+
continue
|
|
475
|
+
if not record.startswith((STATUS_ORDINARY_PREFIX, STATUS_RENAMED_PREFIX)):
|
|
476
|
+
raise ValueError("Unsupported Git status record")
|
|
477
|
+
old_path = None
|
|
478
|
+
if record.startswith(STATUS_RENAMED_PREFIX):
|
|
479
|
+
_kind, status, _submodule, head_mode, index_mode, work_mode, head_oid, index_oid, _score, path = record.split(b" ", renamed_separator_count)
|
|
480
|
+
old_path = next(records, None)
|
|
481
|
+
if not old_path:
|
|
482
|
+
raise ValueError("Missing renamed status path")
|
|
483
|
+
else:
|
|
484
|
+
_kind, status, _submodule, head_mode, index_mode, work_mode, head_oid, index_oid, path = record.split(b" ", ordinary_separator_count)
|
|
485
|
+
staged_letter, unstaged_letter = status.decode("ascii")
|
|
486
|
+
if staged_letter != STATUS_UNCHANGED_LETTER:
|
|
487
|
+
staged_status = file_status(staged_letter)
|
|
488
|
+
entries.append(LocalEntry(StagingSide.STAGED, (old_path or path) if staged_status != FileStatus.ADDED else None, path if staged_status != FileStatus.DELETED else None, staged_status,
|
|
489
|
+
head_mode.decode(), index_mode.decode(), head_oid.decode(), index_oid.decode(), submodule=SubmoduleState.parse(_submodule)))
|
|
490
|
+
if unstaged_letter != STATUS_UNCHANGED_LETTER:
|
|
491
|
+
unstaged_status = file_status(unstaged_letter)
|
|
492
|
+
entries.append(LocalEntry(StagingSide.UNSTAGED, path, path if unstaged_status != FileStatus.DELETED else None, unstaged_status,
|
|
493
|
+
index_mode.decode(), work_mode.decode(), index_oid.decode(), submodule=SubmoduleState.parse(_submodule)))
|
|
494
|
+
return tuple(entries)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@dataclass(frozen=True)
|
|
498
|
+
class IndexListing:
|
|
499
|
+
"""What `ls-files --stage -v -z` says about an index: a pure function of the index bytes it listed."""
|
|
500
|
+
|
|
501
|
+
index_entries: bytes
|
|
502
|
+
hidden_index_paths: tuple[bytes, ...]
|
|
503
|
+
skip_worktree_paths: tuple[bytes, ...]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def parse_index_listing(flag_output: bytes) -> IndexListing:
|
|
507
|
+
"""Split a flagged index listing into sorted entry records and the paths Git hides from the working tree."""
|
|
508
|
+
if flag_output and not flag_output.endswith(b"\0"):
|
|
509
|
+
raise ValueError("Incomplete Git index flag output")
|
|
510
|
+
index_metadata_field_count = 3
|
|
511
|
+
skip_paths: list[bytes] = []
|
|
512
|
+
hidden_paths: list[bytes] = []
|
|
513
|
+
index_records: list[bytes] = []
|
|
514
|
+
for record in flag_output.split(b"\0"):
|
|
515
|
+
if not record:
|
|
516
|
+
continue
|
|
517
|
+
flag, separator, index_record = record.partition(b" ")
|
|
518
|
+
metadata, path_separator, path = index_record.partition(b"\t")
|
|
519
|
+
if not separator or not path_separator or len(metadata.split(b" ")) != index_metadata_field_count or not path:
|
|
520
|
+
raise ValueError("Malformed Git index entry output")
|
|
521
|
+
index_records.append(index_record + b"\0")
|
|
522
|
+
if flag.upper() == b"S":
|
|
523
|
+
skip_paths.append(path)
|
|
524
|
+
if flag.upper() == b"S" or flag.islower():
|
|
525
|
+
hidden_paths.append(path)
|
|
526
|
+
return IndexListing(b"".join(sorted(index_records)), tuple(hidden_paths), tuple(skip_paths))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def reusable_index_listing(previous: WorktreeSnapshot | None, context: RepositoryContext, git_filename: str, index_filename: str,
|
|
530
|
+
index_content: bytes | None) -> IndexListing | None:
|
|
531
|
+
"""Return the index listing of a previous read of the same worktree, index file and Git, when its index bytes are the ones just read."""
|
|
532
|
+
if previous is None or previous.index_entries is None:
|
|
533
|
+
return None
|
|
534
|
+
if previous.key != WorktreeKey.from_context(context) or previous.git_filename != git_filename:
|
|
535
|
+
return None
|
|
536
|
+
if canonical_path(previous.index_filename) != canonical_path(index_filename):
|
|
537
|
+
return None
|
|
538
|
+
if previous.index_content != index_content:
|
|
539
|
+
return None
|
|
540
|
+
return IndexListing(previous.index_entries, previous.hidden_index_paths, previous.skip_worktree_paths)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def conflict_side_by_path(index_entries: bytes) -> dict[bytes, tuple[ConflictSide, ...]]:
|
|
544
|
+
"""Validate captured index records and group only their nonzero stages."""
|
|
545
|
+
if index_entries and not index_entries.endswith(b"\0"):
|
|
546
|
+
raise ValueError("Incomplete Git index entries")
|
|
547
|
+
side_by_path: dict[bytes, list[ConflictSide]] = {}
|
|
548
|
+
stage_by_path: dict[bytes, set[int]] = {}
|
|
549
|
+
for record in index_entries.split(b"\0"):
|
|
550
|
+
if not record:
|
|
551
|
+
continue
|
|
552
|
+
metadata, separator, path = record.partition(b"\t")
|
|
553
|
+
mode, oid, stage_value = metadata.split(b" ")
|
|
554
|
+
if (not separator or not path or stage_value not in (b"0", b"1", b"2", b"3") or mode.decode("ascii") not in INDEX_MODE_SET
|
|
555
|
+
or not re.fullmatch(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})", oid) or not oid.strip(b"0")):
|
|
556
|
+
raise ValueError("Malformed Git index entry")
|
|
557
|
+
stage = int(stage_value)
|
|
558
|
+
stage_set = stage_by_path.setdefault(path, set())
|
|
559
|
+
if stage in stage_set or (stage_set and (stage == 0 or 0 in stage_set)):
|
|
560
|
+
raise ValueError("Duplicate or conflicting Git index stage")
|
|
561
|
+
stage_set.add(stage)
|
|
562
|
+
if stage:
|
|
563
|
+
side_by_path.setdefault(path, []).append(ConflictSide(stage, mode.decode("ascii"), oid.decode("ascii")))
|
|
564
|
+
result_by_path = {}
|
|
565
|
+
for path, sides in side_by_path.items():
|
|
566
|
+
result_by_path[path] = tuple(sorted(sides, key=lambda side: side.stage))
|
|
567
|
+
return result_by_path
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def validate_conflict_index(entries: tuple[LocalEntry, ...], index_entries: bytes) -> None:
|
|
571
|
+
"""Refuse disagreement between status and index from the same capture."""
|
|
572
|
+
side_by_path = conflict_side_by_path(index_entries)
|
|
573
|
+
seen_path_set: set[bytes] = set()
|
|
574
|
+
for entry in entries:
|
|
575
|
+
if entry.status != FileStatus.UNMERGED:
|
|
576
|
+
if entry.path in side_by_path:
|
|
577
|
+
raise ValueError("A path is both resolved and unmerged in status")
|
|
578
|
+
continue
|
|
579
|
+
if (entry.path in seen_path_set or side_by_path.get(entry.path) != entry.conflict_sides
|
|
580
|
+
or tuple(side.stage for side in entry.conflict_sides) != entry.conflict_stages):
|
|
581
|
+
raise ValueError("Unmerged status and index disagree; refresh the changes")
|
|
582
|
+
seen_path_set.add(entry.path)
|
|
583
|
+
if seen_path_set != set(side_by_path):
|
|
584
|
+
raise ValueError("Unmerged index paths are missing from status; refresh the changes")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def apply_conflict_attributes(entries: tuple[LocalEntry, ...], attributes: bytes) -> tuple[LocalEntry, ...]:
|
|
588
|
+
"""Retain native binary merge/diff attributes even when the blob contains only text."""
|
|
589
|
+
if attributes and not attributes.endswith(b"\0"):
|
|
590
|
+
raise ValueError("Incomplete conflict attributes")
|
|
591
|
+
fields = attributes.removesuffix(b"\0").split(b"\0") if attributes else []
|
|
592
|
+
attribute_field_count = 3
|
|
593
|
+
if len(fields) % attribute_field_count:
|
|
594
|
+
raise ValueError("Malformed conflict attributes")
|
|
595
|
+
binary_path_set: set[bytes] = set()
|
|
596
|
+
iterator = iter(fields)
|
|
597
|
+
for path, attribute, value in zip(iterator, iterator, iterator, strict=True):
|
|
598
|
+
if ((attribute in (b"diff", b"merge") and value == b"unset") or (attribute == b"merge" and value == b"binary")
|
|
599
|
+
or (attribute == b"binary" and value == b"set")):
|
|
600
|
+
binary_path_set.add(path)
|
|
601
|
+
result_entries = []
|
|
602
|
+
for entry in entries:
|
|
603
|
+
result_entries.append(replace(entry, conflict_binary=True) if entry.conflict_sides and entry.path in binary_path_set else entry)
|
|
604
|
+
return tuple(result_entries)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def parse_staged(output: bytes) -> tuple[LocalEntry, ...]:
|
|
608
|
+
"""Read rename-aware raw comparisons using Git's exact object modes and paths."""
|
|
609
|
+
if output and not output.endswith(b"\0"):
|
|
610
|
+
raise ValueError("Incomplete staged comparison output")
|
|
611
|
+
records = iter(output.split(b"\0"))
|
|
612
|
+
entries: list[LocalEntry] = []
|
|
613
|
+
for record in records:
|
|
614
|
+
if not record:
|
|
615
|
+
continue
|
|
616
|
+
if not record.startswith(b":"):
|
|
617
|
+
raise ValueError("Malformed staged comparison")
|
|
618
|
+
old_mode, new_mode, old_oid, new_oid, raw_status = record[len(b":"):].split(b" ")
|
|
619
|
+
status = file_status(raw_status.decode("ascii"))
|
|
620
|
+
path = next(records, None)
|
|
621
|
+
if not path:
|
|
622
|
+
raise ValueError("Missing staged path")
|
|
623
|
+
old_path = None if status == FileStatus.ADDED else path
|
|
624
|
+
new_path = None if status == FileStatus.DELETED else path
|
|
625
|
+
if status in (FileStatus.RENAMED, FileStatus.COPIED):
|
|
626
|
+
new_path = next(records, None)
|
|
627
|
+
if not new_path:
|
|
628
|
+
raise ValueError("Missing renamed path")
|
|
629
|
+
entries.append(LocalEntry(StagingSide.STAGED, old_path, new_path, status, old_mode.decode(), new_mode.decode(), old_oid.decode(), new_oid.decode()))
|
|
630
|
+
return tuple(entries)
|