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,584 @@
|
|
|
1
|
+
"""Run Git command sequences on the shared process runner, the worktree reads among them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, Qt, QTimer, Signal, Slot
|
|
12
|
+
|
|
13
|
+
from gitpane.config import (
|
|
14
|
+
FILE_PREVIEW_LIMIT_BYTES,
|
|
15
|
+
LOCAL_PREFIX,
|
|
16
|
+
WORKTREE_FILE_WORKER_COUNT,
|
|
17
|
+
ProcessPriority,
|
|
18
|
+
)
|
|
19
|
+
from gitpane.history.history_parse import CatFileParser
|
|
20
|
+
from gitpane.history.repository_value import validate_oid
|
|
21
|
+
from gitpane.process.process_runner import READ_PROFILE, ProcessRequest, ProcessResult, ProcessRunner, ProcessStatus
|
|
22
|
+
from gitpane.sequencer.merge_state import read_merge_banner_state
|
|
23
|
+
from gitpane.system.path_identity import canonical_path
|
|
24
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
25
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch, command_profile, resumed_priority
|
|
26
|
+
from gitpane.worktree.worktree_snapshot import (
|
|
27
|
+
STATUS_BRANCH_HEAD_PREFIX,
|
|
28
|
+
STATUS_BRANCH_OID_PREFIX,
|
|
29
|
+
STATUS_DETACHED_HEAD,
|
|
30
|
+
STATUS_HEADER_PREFIX,
|
|
31
|
+
STATUS_INITIAL_OID,
|
|
32
|
+
STATUS_RENAMED_PREFIX,
|
|
33
|
+
STATUS_UNKNOWN_HEAD,
|
|
34
|
+
STATUS_UNMERGED_PREFIX,
|
|
35
|
+
StagingSide,
|
|
36
|
+
WorktreeKey,
|
|
37
|
+
WorktreeProbe,
|
|
38
|
+
WorktreeSnapshot,
|
|
39
|
+
apply_conflict_attributes,
|
|
40
|
+
fingerprint,
|
|
41
|
+
fingerprint_files,
|
|
42
|
+
parse_index_listing,
|
|
43
|
+
parse_status,
|
|
44
|
+
read_index,
|
|
45
|
+
reusable_index_listing,
|
|
46
|
+
validate_conflict_index,
|
|
47
|
+
verify_local_state,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Callable, Generator
|
|
52
|
+
from concurrent.futures import Future
|
|
53
|
+
|
|
54
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
55
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# The one status every worktree read and every probe runs, so a probe and the snapshot it is compared with always
|
|
59
|
+
# come from the same command.
|
|
60
|
+
STATUS_ARGUMENTS: tuple[str, ...] = ("-c", "status.renameLimit=0", "status", "--porcelain=v2", "--branch", "--no-ahead-behind", "-z", "--untracked-files=all",
|
|
61
|
+
"--renames", "--ignore-submodules=none")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolves_to_directory(root_output: bytes, directory: str) -> bool:
|
|
65
|
+
"""Tell whether one Git-reported root, without its line terminator, is exactly the expected directory."""
|
|
66
|
+
root = os.fsdecode(root_output)
|
|
67
|
+
return os.path.isabs(root) and canonical_path(root) == canonical_path(directory)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_gitlink_head(context: RepositoryContext, path: bytes) -> Generator:
|
|
71
|
+
"""Prove the exact child root before trusting its HEAD, since Git otherwise finds the parent repository.
|
|
72
|
+
|
|
73
|
+
One process answers both the root and HEAD of an ordinary child. When its output cannot be split with
|
|
74
|
+
certainty, the two facts are read again separately so a line break inside a path never moves a boundary.
|
|
75
|
+
"""
|
|
76
|
+
directory = worktree_filename(context, path)
|
|
77
|
+
if not os.path.isdir(directory) or not os.path.lexists(os.path.join(directory, ".git")):
|
|
78
|
+
return "uninitialized"
|
|
79
|
+
if os.path.islink(directory) or os.path.isjunction(directory):
|
|
80
|
+
raise ValueError("Linked submodule paths are not supported")
|
|
81
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel", "--verify", "HEAD"])
|
|
82
|
+
fields = output.removesuffix(b"\n").split(b"\n")
|
|
83
|
+
grouped_field_count = 2
|
|
84
|
+
if len(fields) == grouped_field_count:
|
|
85
|
+
root_output, head_output = fields
|
|
86
|
+
if resolves_to_directory(root_output, directory):
|
|
87
|
+
try:
|
|
88
|
+
return validate_oid(head_output.decode("ascii", "replace"))
|
|
89
|
+
except ValueError:
|
|
90
|
+
# An unreadable HEAD field is not a result; the separate reads below decide.
|
|
91
|
+
pass
|
|
92
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel"])
|
|
93
|
+
if not resolves_to_directory(output.removesuffix(b"\n"), directory):
|
|
94
|
+
raise ValueError("Submodule does not resolve to its declared worktree; refresh the changes")
|
|
95
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--verify", "HEAD"])
|
|
96
|
+
return validate_oid(output.removesuffix(b"\n").decode("ascii", "replace"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_worktree_probe(output: bytes) -> Generator[GitCommand, bytes, WorktreeProbe]:
|
|
100
|
+
"""Build the probe from one status output; only a HEAD Git cannot name costs a second process.
|
|
101
|
+
|
|
102
|
+
Only the branch identity and head headers are read: upstream and ahead or behind headers change with a fetch
|
|
103
|
+
and say nothing about the worktree.
|
|
104
|
+
"""
|
|
105
|
+
branch_oid: bytes | None = None
|
|
106
|
+
branch_head: bytes | None = None
|
|
107
|
+
header_length = 0
|
|
108
|
+
for record in output.split(b"\0"):
|
|
109
|
+
if not record.startswith(STATUS_HEADER_PREFIX):
|
|
110
|
+
break
|
|
111
|
+
header_length += len(record) + len(b"\0")
|
|
112
|
+
if record.startswith(STATUS_BRANCH_OID_PREFIX):
|
|
113
|
+
branch_oid = record.removeprefix(STATUS_BRANCH_OID_PREFIX)
|
|
114
|
+
elif record.startswith(STATUS_BRANCH_HEAD_PREFIX):
|
|
115
|
+
branch_head = record.removeprefix(STATUS_BRANCH_HEAD_PREFIX)
|
|
116
|
+
if branch_oid is None or branch_head is None:
|
|
117
|
+
raise ValueError("Incomplete Git status branch output")
|
|
118
|
+
entry_output = output[header_length:]
|
|
119
|
+
head_oid = None if branch_oid == STATUS_INITIAL_OID else branch_oid.decode("ascii")
|
|
120
|
+
head_reference: str | None
|
|
121
|
+
if branch_head == STATUS_DETACHED_HEAD:
|
|
122
|
+
head_reference = None
|
|
123
|
+
elif branch_head.upper() == STATUS_UNKNOWN_HEAD:
|
|
124
|
+
# Git writes this value when HEAD points outside the local branches; a branch name built from it would name a
|
|
125
|
+
# reference that does not exist, so Git is asked for the reference itself. The C library prints the missing
|
|
126
|
+
# name: "(NULL)" with Git for Windows, "(null)" with glibc. Asking Git is right even for a branch really named
|
|
127
|
+
# so, since the answer is the reference itself.
|
|
128
|
+
symbolic_output = yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
129
|
+
reference = symbolic_output.removesuffix(b"\n")
|
|
130
|
+
head_reference = os.fsdecode(reference) if reference else None
|
|
131
|
+
else:
|
|
132
|
+
head_reference = LOCAL_PREFIX + os.fsdecode(branch_head)
|
|
133
|
+
unmerged_records: list[bytes] = []
|
|
134
|
+
original_path_follows = False
|
|
135
|
+
for record in entry_output.split(b"\0"):
|
|
136
|
+
if original_path_follows:
|
|
137
|
+
# The record after a rename is the original path of that rename, not a record of its own.
|
|
138
|
+
original_path_follows = False
|
|
139
|
+
continue
|
|
140
|
+
if record.startswith(STATUS_RENAMED_PREFIX):
|
|
141
|
+
original_path_follows = True
|
|
142
|
+
continue
|
|
143
|
+
if record.startswith(STATUS_UNMERGED_PREFIX):
|
|
144
|
+
unmerged_records.append(record)
|
|
145
|
+
return WorktreeProbe(head_oid, head_reference, tuple(unmerged_records), fingerprint(entry_output))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def inspect_conflict_blobs(entries: tuple[LocalEntry, ...]) -> Generator:
|
|
149
|
+
"""Classify immutable side contents in one bounded stream without keeping file bodies."""
|
|
150
|
+
oid_set: set[str] = set()
|
|
151
|
+
for entry in entries:
|
|
152
|
+
for side in entry.conflict_sides:
|
|
153
|
+
if side.mode in ("100644", "100755"):
|
|
154
|
+
oid_set.add(side.oid)
|
|
155
|
+
if not oid_set:
|
|
156
|
+
return entries
|
|
157
|
+
binary_oid_set: set[str] = set()
|
|
158
|
+
oids = sorted(oid_set)
|
|
159
|
+
retained_body_limit = 0
|
|
160
|
+
parser = CatFileParser(oids, object_limit_bytes=retained_body_limit, expected_object_type="blob")
|
|
161
|
+
|
|
162
|
+
def inspect(body: bytes) -> None:
|
|
163
|
+
"""Remember a binary marker for the currently framed immutable object only."""
|
|
164
|
+
if b"\0" in body and parser.oid is not None:
|
|
165
|
+
binary_oid_set.add(parser.oid)
|
|
166
|
+
|
|
167
|
+
def consume(chunk: bytes) -> None:
|
|
168
|
+
"""Feed the existing strict object framing while discarding completed bodies."""
|
|
169
|
+
parser.feed(chunk)
|
|
170
|
+
|
|
171
|
+
parser.body_callback = inspect
|
|
172
|
+
yield GitCommand(["cat-file", "--batch"], stdin="".join(oid + "\n" for oid in oids).encode("ascii"), consume=consume, stdout_limit_bytes=None)
|
|
173
|
+
parser.finish()
|
|
174
|
+
inspected_entries = []
|
|
175
|
+
for entry in entries:
|
|
176
|
+
if entry.conflict_sides:
|
|
177
|
+
entry = replace(entry, conflict_binary=any(side.oid in binary_oid_set for side in entry.conflict_sides))
|
|
178
|
+
inspected_entries.append(entry)
|
|
179
|
+
return tuple(inspected_entries)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class WorktreeService(QObject):
|
|
183
|
+
"""Share the process runner while keeping each read sequence and result isolated."""
|
|
184
|
+
|
|
185
|
+
snapshot_read = Signal(object)
|
|
186
|
+
idle = Signal()
|
|
187
|
+
file_completed = Signal(str, object)
|
|
188
|
+
|
|
189
|
+
def __init__(self, runner: ProcessRunner, parent: QObject | None = None) -> None:
|
|
190
|
+
"""Use the existing runner so all Git work obeys application process limits."""
|
|
191
|
+
super().__init__(parent)
|
|
192
|
+
self.runner = runner
|
|
193
|
+
self.continuation_by_id: dict[str, tuple[Generator, Callable, RepositoryContext, str, GitCommand | ProcessRequest]] = {}
|
|
194
|
+
self.output_by_id: dict[str, bytearray] = {}
|
|
195
|
+
self.stream_error_by_id: dict[str, Exception] = {}
|
|
196
|
+
self.operation_id_by_sequence: dict[Generator, str] = {}
|
|
197
|
+
# Kept apart from the operation identity: the level orders the waiting queue only, and nothing else reads it.
|
|
198
|
+
self.priority_by_sequence: dict[Generator, ProcessPriority] = {}
|
|
199
|
+
self.file_executor = ThreadPoolExecutor(max_workers=WORKTREE_FILE_WORKER_COUNT, thread_name_prefix="gitpane-worktree")
|
|
200
|
+
self.file_task_by_id: dict[str, tuple[Generator, Callable, RepositoryContext, str]] = {}
|
|
201
|
+
self.file_completed.connect(self._file_finished, Qt.ConnectionType.QueuedConnection)
|
|
202
|
+
runner.stdout_chunk.connect(self._collect)
|
|
203
|
+
runner.finished.connect(self._finished)
|
|
204
|
+
|
|
205
|
+
def run(self, context: RepositoryContext, git_filename: str, sequence: Generator, callback: Callable[[object, Exception | None], None], *, operation_id: str = "",
|
|
206
|
+
priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
207
|
+
"""Advance a command generator only after each real process has terminated.
|
|
208
|
+
|
|
209
|
+
A sequence has no profile: each of its commands chooses its own. Its priority is where it stands in the waiting
|
|
210
|
+
queue, given by whoever started it: a user action, the rest of an operation, or background work.
|
|
211
|
+
"""
|
|
212
|
+
self.operation_id_by_sequence[sequence] = operation_id
|
|
213
|
+
self.priority_by_sequence[sequence] = priority
|
|
214
|
+
self._advance(context, git_filename, sequence, callback)
|
|
215
|
+
|
|
216
|
+
def _advance(self, context: RepositoryContext, git_filename: str, sequence: Generator, callback: Callable, output: object = None, error: Exception | None = None) -> None:
|
|
217
|
+
"""Resume sequential Git work, returning either its value or its actual error.
|
|
218
|
+
|
|
219
|
+
An error given to the sequence, whether the sequence handles it or lets it through, leaves without any frame,
|
|
220
|
+
so whoever keeps it keeps neither the sequence, nor this service's plumbing, nor the frames that delivered the
|
|
221
|
+
process result.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
command = sequence.throw(error) if error is not None else sequence.send(output)
|
|
225
|
+
except StopIteration as stopped:
|
|
226
|
+
self.operation_id_by_sequence.pop(sequence, None)
|
|
227
|
+
self.priority_by_sequence.pop(sequence, None)
|
|
228
|
+
callback(stopped.value, None)
|
|
229
|
+
if not self.continuation_by_id and not self.file_task_by_id:
|
|
230
|
+
self.idle.emit()
|
|
231
|
+
return
|
|
232
|
+
except (OSError, ValueError, RuntimeError) as failure:
|
|
233
|
+
# Workaround for a reference cycle, see the ``finally`` below: an escaping error leaves without its traceback.
|
|
234
|
+
failure.__traceback__ = None
|
|
235
|
+
self.operation_id_by_sequence.pop(sequence, None)
|
|
236
|
+
self.priority_by_sequence.pop(sequence, None)
|
|
237
|
+
callback(None, failure)
|
|
238
|
+
if not self.continuation_by_id and not self.file_task_by_id:
|
|
239
|
+
self.idle.emit()
|
|
240
|
+
return
|
|
241
|
+
finally:
|
|
242
|
+
# Workaround for a reference cycle: an error thrown into the sequence keeps, in its traceback, the frame of
|
|
243
|
+
# the sequence that saw it, whether the sequence handled it or let it through, and a generator frame that
|
|
244
|
+
# outlives its run keeps the frame that resumed it (``f_back``), this one. This frame holds the error
|
|
245
|
+
# (``error``) and the callback, which may store it, as a read batch does; so until the cyclic garbage
|
|
246
|
+
# collector runs, the error keeps every frame that called this one, up to whichever window, dialog or test
|
|
247
|
+
# ran the event loop. Dropping only this frame from the traceback did not break that cycle, the sequence's
|
|
248
|
+
# frame still pointing back here. Nothing reads these tracebacks: the cause is the error's text.
|
|
249
|
+
if error is not None:
|
|
250
|
+
error.__traceback__ = None
|
|
251
|
+
priority = self.priority_by_sequence[sequence]
|
|
252
|
+
if not isinstance(command, ReadBatch):
|
|
253
|
+
# The step now asked for is this level's work; whatever the sequence asks after it is its follow-up. A file
|
|
254
|
+
# task counts as a step too: the action is under way even though it started no process.
|
|
255
|
+
self.priority_by_sequence[sequence] = resumed_priority(priority)
|
|
256
|
+
if isinstance(command, FileTask):
|
|
257
|
+
request_id = uuid.uuid4().hex
|
|
258
|
+
self.file_task_by_id[request_id] = (sequence, callback, context, git_filename)
|
|
259
|
+
try:
|
|
260
|
+
future = self.file_executor.submit(command.callback)
|
|
261
|
+
except RuntimeError as failure:
|
|
262
|
+
self.file_task_by_id.pop(request_id, None)
|
|
263
|
+
QTimer.singleShot(0, lambda failure=failure: self._advance(context, git_filename, sequence, callback, error=failure))
|
|
264
|
+
return
|
|
265
|
+
# Queued delivery resumes on the Qt thread immediately; polling adds one
|
|
266
|
+
# full timer interval per file and stalls verification of large repositories.
|
|
267
|
+
future.add_done_callback(lambda completed: self.file_completed.emit(request_id, completed))
|
|
268
|
+
return
|
|
269
|
+
if isinstance(command, ReadBatch):
|
|
270
|
+
self._run_batch(context, git_filename, sequence, callback, command, priority)
|
|
271
|
+
return
|
|
272
|
+
if isinstance(command, ProcessRequest):
|
|
273
|
+
# Only the place in the queue follows the sequence; everything the generator decided about the launch stays.
|
|
274
|
+
command = replace(command, priority=priority)
|
|
275
|
+
self.continuation_by_id[command.request_id] = (sequence, callback, context, git_filename, command)
|
|
276
|
+
self.output_by_id[command.request_id] = bytearray()
|
|
277
|
+
try:
|
|
278
|
+
self.runner.start(command)
|
|
279
|
+
except (ValueError, RuntimeError) as failure:
|
|
280
|
+
self.continuation_by_id.pop(command.request_id, None)
|
|
281
|
+
self.output_by_id.pop(command.request_id, None)
|
|
282
|
+
QTimer.singleShot(0, lambda failure=failure: self._advance(context, git_filename, sequence, callback, error=failure))
|
|
283
|
+
return
|
|
284
|
+
request_id = uuid.uuid4().hex
|
|
285
|
+
self.continuation_by_id[request_id] = (sequence, callback, context, git_filename, command)
|
|
286
|
+
self.output_by_id[request_id] = bytearray()
|
|
287
|
+
operation_id = self.operation_id_by_sequence[sequence]
|
|
288
|
+
profile = command_profile(command)
|
|
289
|
+
request = ProcessRequest(request_id, git_filename, ["--literal-pathspecs", *command.arguments], context.repository_dir, command.timeout_ms,
|
|
290
|
+
session_id="worktree:" + WorktreeKey.from_context(context).git_dir, stdin=command.stdin, environment_by_name=command.environment_by_name,
|
|
291
|
+
stdout_limit_bytes=command.stdout_limit_bytes, idle_timeout_ms=command.idle_timeout_ms, profile=profile, operation_id=operation_id,
|
|
292
|
+
before_start=command.before_start, editor_context=command.editor_context, suppress_editor=command.suppress_editor, priority=priority,
|
|
293
|
+
accepted_return_codes=command.accepted_codes, stdin_filename=command.stdin_filename,
|
|
294
|
+
opens_files_deleted_at_end=command.opens_files_deleted_at_end)
|
|
295
|
+
try:
|
|
296
|
+
if command.editor_context is not None and os.path.normcase(command.editor_context.git_dir) != WorktreeKey.from_context(context).git_dir:
|
|
297
|
+
raise ValueError("The editor context belongs to another worktree.")
|
|
298
|
+
self.runner.start(request)
|
|
299
|
+
except (ValueError, RuntimeError) as failure:
|
|
300
|
+
self.continuation_by_id.pop(request_id, None)
|
|
301
|
+
self.output_by_id.pop(request_id, None)
|
|
302
|
+
QTimer.singleShot(0, lambda failure=failure: self._advance(context, git_filename, sequence, callback, error=failure))
|
|
303
|
+
|
|
304
|
+
def _run_batch(self, context: RepositoryContext, git_filename: str, sequence: Generator, callback: Callable, batch: ReadBatch, priority: ProcessPriority) -> None:
|
|
305
|
+
"""Wait for every bounded read before resuming, even when a command fails or finishes out of order.
|
|
306
|
+
|
|
307
|
+
Every member waits in the queue at the level the batch had when it was reached, and the generator takes its
|
|
308
|
+
follow-up level only once the whole batch has ended.
|
|
309
|
+
"""
|
|
310
|
+
if not batch.commands:
|
|
311
|
+
self.priority_by_sequence[sequence] = resumed_priority(priority)
|
|
312
|
+
self._advance(context, git_filename, sequence, callback, ())
|
|
313
|
+
return
|
|
314
|
+
result_by_position: dict[int, tuple[object, Exception | None]] = {}
|
|
315
|
+
|
|
316
|
+
def complete(position: int, value: object, error: Exception | None) -> None:
|
|
317
|
+
"""Keep result identity independent of process scheduling and report the first declared error."""
|
|
318
|
+
result_by_position[position] = (value, error)
|
|
319
|
+
if len(result_by_position) != len(batch.commands):
|
|
320
|
+
return
|
|
321
|
+
outputs = []
|
|
322
|
+
failure = None
|
|
323
|
+
for result_position in range(len(batch.commands)):
|
|
324
|
+
output, result_error = result_by_position[result_position]
|
|
325
|
+
outputs.append(output)
|
|
326
|
+
if failure is None:
|
|
327
|
+
failure = result_error
|
|
328
|
+
self.priority_by_sequence[sequence] = resumed_priority(priority)
|
|
329
|
+
self._advance(context, git_filename, sequence, callback, tuple(outputs), failure)
|
|
330
|
+
|
|
331
|
+
def read(command: GitCommand | FileTask) -> Generator:
|
|
332
|
+
"""Collect a single batch member through the normal bounded process path."""
|
|
333
|
+
return (yield command)
|
|
334
|
+
|
|
335
|
+
operation_id = self.operation_id_by_sequence[sequence]
|
|
336
|
+
for position, command in enumerate(batch.commands):
|
|
337
|
+
self.run(context, git_filename, read(command), lambda value, error, position=position: complete(position, value, error), operation_id=operation_id,
|
|
338
|
+
priority=priority)
|
|
339
|
+
|
|
340
|
+
@Slot(str, object)
|
|
341
|
+
def _file_finished(self, request_id: str, future: Future) -> None:
|
|
342
|
+
"""Resume a completed filesystem task on the owning Qt thread and retain shutdown accounting."""
|
|
343
|
+
continuation = self.file_task_by_id.pop(request_id, None)
|
|
344
|
+
if continuation is None:
|
|
345
|
+
return
|
|
346
|
+
sequence, callback, context, git_filename = continuation
|
|
347
|
+
error = future.exception()
|
|
348
|
+
self._advance(context, git_filename, sequence, callback, None if error else future.result(), error)
|
|
349
|
+
|
|
350
|
+
def _collect(self, request: ProcessRequest, chunk: bytes) -> None:
|
|
351
|
+
"""Route owned streams directly to their consumer without accumulating file contents."""
|
|
352
|
+
continuation = self.continuation_by_id.get(request.request_id)
|
|
353
|
+
if continuation is None or request.request_id in self.stream_error_by_id:
|
|
354
|
+
return
|
|
355
|
+
_sequence, _callback, _context, _git_filename, command = continuation
|
|
356
|
+
if isinstance(command, ProcessRequest):
|
|
357
|
+
return
|
|
358
|
+
if command.consume is None:
|
|
359
|
+
self.output_by_id[request.request_id].extend(chunk)
|
|
360
|
+
return
|
|
361
|
+
try:
|
|
362
|
+
command.consume(chunk)
|
|
363
|
+
except (OSError, ValueError) as error:
|
|
364
|
+
self.stream_error_by_id[request.request_id] = error
|
|
365
|
+
self.runner.cancel(request.request_id)
|
|
366
|
+
|
|
367
|
+
def _finished(self, result: ProcessResult) -> None:
|
|
368
|
+
"""Resume only after process termination, preserving stream-consumer errors."""
|
|
369
|
+
continuation = self.continuation_by_id.pop(result.request.request_id, None)
|
|
370
|
+
if continuation is None:
|
|
371
|
+
return
|
|
372
|
+
sequence, callback, context, git_filename, command = continuation
|
|
373
|
+
output = bytes(self.output_by_id.pop(result.request.request_id))
|
|
374
|
+
if isinstance(command, ProcessRequest):
|
|
375
|
+
self._advance(context, git_filename, sequence, callback, result)
|
|
376
|
+
return
|
|
377
|
+
accepted = result.return_code in command.accepted_codes and result.status in (ProcessStatus.SUCCESS, ProcessStatus.FAILED)
|
|
378
|
+
error = self.stream_error_by_id.pop(result.request.request_id, None)
|
|
379
|
+
self._advance(context, git_filename, sequence, callback, output, error or (None if accepted else GitCommandError(result)))
|
|
380
|
+
|
|
381
|
+
def capture(self, context: RepositoryContext, git_filename: str, generation: int = 0) -> Generator[GitCommand, bytes, WorktreeSnapshot]:
|
|
382
|
+
"""Read the worktree validated at both ends, as the operations check it before they write.
|
|
383
|
+
|
|
384
|
+
Status, configuration and attributes are read again at the end and must be unchanged. Nothing is ever taken
|
|
385
|
+
over from an earlier read: a check before a write proves the state it relies on by itself.
|
|
386
|
+
"""
|
|
387
|
+
return (yield from self._capture(context, git_filename, generation, previous=None, closing_reread=True))
|
|
388
|
+
|
|
389
|
+
def capture_display(self, context: RepositoryContext, git_filename: str, generation: int = 0,
|
|
390
|
+
previous: WorktreeSnapshot | None = None) -> Generator[GitCommand, bytes, WorktreeSnapshot]:
|
|
391
|
+
"""Read what the views show, starting each Git read once.
|
|
392
|
+
|
|
393
|
+
The index listing of the previous snapshot is taken over when the index bytes have not moved since it was read.
|
|
394
|
+
"""
|
|
395
|
+
return (yield from self._capture(context, git_filename, generation, previous=previous, closing_reread=False))
|
|
396
|
+
|
|
397
|
+
def _capture(self, context: RepositoryContext, git_filename: str, generation: int, *, previous: WorktreeSnapshot | None,
|
|
398
|
+
closing_reread: bool) -> Generator[GitCommand, bytes, WorktreeSnapshot]:
|
|
399
|
+
"""Read one coherent snapshot; the closing Git reads run only when the caller will rely on the result to write."""
|
|
400
|
+
if context.is_bare or context.worktree_dir is None:
|
|
401
|
+
raise ValueError("This bare repository has no working tree to prepare")
|
|
402
|
+
location_output = yield GitCommand(["rev-parse", "--path-format=absolute", "--git-dir", "--git-path", "index", "--git-path", "hooks"])
|
|
403
|
+
location_fields = location_output.removesuffix(b"\n").split(b"\n")
|
|
404
|
+
expected_location_count = 3
|
|
405
|
+
if len(location_fields) != expected_location_count:
|
|
406
|
+
# Newlines are valid in POSIX directory names; separate results preserve their boundaries.
|
|
407
|
+
location_outputs = yield ReadBatch((GitCommand(["rev-parse", "--absolute-git-dir"]),
|
|
408
|
+
GitCommand(["rev-parse", "--path-format=absolute", "--git-path", "index"]),
|
|
409
|
+
GitCommand(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])))
|
|
410
|
+
location_fields = []
|
|
411
|
+
for value in location_outputs:
|
|
412
|
+
location_fields.append(value.removesuffix(b"\n"))
|
|
413
|
+
git_directory, index_output, hooks_output = location_fields
|
|
414
|
+
resolved_git_dir = canonical_path(os.fsdecode(git_directory.rstrip(b"\r\n")))
|
|
415
|
+
if resolved_git_dir != WorktreeKey.from_context(context).git_dir:
|
|
416
|
+
raise ValueError("The repository identity changed; reopen the repository")
|
|
417
|
+
index_filename = os.fsdecode(index_output.rstrip(b"\r\n"))
|
|
418
|
+
before_index = yield FileTask(lambda: read_index(index_filename))
|
|
419
|
+
# The status arguments come from a constant, so the source does not show them as a read: the command says it.
|
|
420
|
+
read_commands: tuple[GitCommand, ...] = (GitCommand(["config", "--null", "--list", "--show-origin"]), GitCommand(list(STATUS_ARGUMENTS), profile=READ_PROFILE))
|
|
421
|
+
# The listing is a pure function of the index bytes just read, so equal bytes give the same listing. The index
|
|
422
|
+
# is read again at the end of this read, which keeps the listing and the bytes it belongs to consistent.
|
|
423
|
+
listing = reusable_index_listing(previous, context, git_filename, index_filename, before_index)
|
|
424
|
+
if listing is None:
|
|
425
|
+
read_commands += (GitCommand(["ls-files", "--stage", "-v", "-z"]),)
|
|
426
|
+
read_outputs = yield ReadBatch(read_commands)
|
|
427
|
+
conversion, output, *listing_outputs = read_outputs
|
|
428
|
+
if listing is None:
|
|
429
|
+
listing = parse_index_listing(next(iter(listing_outputs)))
|
|
430
|
+
probe = yield from read_worktree_probe(output)
|
|
431
|
+
head_oid = probe.head_oid
|
|
432
|
+
entries = parse_status(output)
|
|
433
|
+
skip_path_set = set(listing.skip_worktree_paths)
|
|
434
|
+
index_entries = listing.index_entries
|
|
435
|
+
validate_conflict_index(entries, index_entries)
|
|
436
|
+
entries = yield from inspect_conflict_blobs(entries)
|
|
437
|
+
local_entries: list[LocalEntry] = []
|
|
438
|
+
for entry in entries:
|
|
439
|
+
local_entries.append(replace(entry, skip_worktree=any(path in skip_path_set for path in entry.paths)))
|
|
440
|
+
entries = tuple(local_entries)
|
|
441
|
+
# Git lists changed entries before unmerged ones, so resolving a conflict moves its path in the status order.
|
|
442
|
+
# Sorting the paths keeps the fingerprints and the attribute output of two reads of the same paths in the same
|
|
443
|
+
# order, which lets a later read be compared with an earlier one byte for byte.
|
|
444
|
+
paths = tuple(sorted(dict.fromkeys(path for entry in entries for path in entry.paths)))
|
|
445
|
+
fingerprint_by_path = yield FileTask(lambda: fingerprint_files(context, paths))
|
|
446
|
+
submodule_path_set: set[bytes] = set()
|
|
447
|
+
for entry in entries:
|
|
448
|
+
for path in entry.paths:
|
|
449
|
+
if entry.is_gitlink and path not in submodule_path_set:
|
|
450
|
+
submodule_path_set.add(path)
|
|
451
|
+
submodule_head = yield from read_gitlink_head(context, path)
|
|
452
|
+
fingerprint_by_path[path] += ":" + submodule_head
|
|
453
|
+
path_input = b"\0".join(fingerprint_by_path) + (b"\0" if fingerprint_by_path else b"")
|
|
454
|
+
attributes = (yield GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path_input)) if paths else b""
|
|
455
|
+
if any(entry.conflict_sides for entry in entries):
|
|
456
|
+
entries = apply_conflict_attributes(entries, attributes)
|
|
457
|
+
# A read for the views accepts that a file appearing while it runs only shows at the next read: every write
|
|
458
|
+
# checks the repository again on its own side. The index and working-file checks below stay in both modes
|
|
459
|
+
# because they start no process.
|
|
460
|
+
if closing_reread:
|
|
461
|
+
final_commands = (
|
|
462
|
+
GitCommand(list(STATUS_ARGUMENTS), profile=READ_PROFILE),
|
|
463
|
+
GitCommand(["config", "--null", "--list", "--show-origin"]),
|
|
464
|
+
)
|
|
465
|
+
if paths:
|
|
466
|
+
final_commands += (GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path_input),)
|
|
467
|
+
final_outputs = yield ReadBatch(final_commands)
|
|
468
|
+
after_output, after_conversion, *attribute_outputs = final_outputs
|
|
469
|
+
after_attributes = next(iter(attribute_outputs), b"")
|
|
470
|
+
if output != after_output or conversion != after_conversion or attributes != after_attributes:
|
|
471
|
+
raise ValueError("Repository changed during reading; refresh the changes")
|
|
472
|
+
after_index = yield FileTask(lambda: read_index(index_filename))
|
|
473
|
+
if before_index != after_index:
|
|
474
|
+
raise ValueError("Repository changed during reading; refresh the changes")
|
|
475
|
+
actual_by_path = yield FileTask(lambda: fingerprint_files(context, paths))
|
|
476
|
+
for path, expected in fingerprint_by_path.items():
|
|
477
|
+
actual = actual_by_path[path]
|
|
478
|
+
if path in submodule_path_set:
|
|
479
|
+
submodule_head = yield from read_gitlink_head(context, path)
|
|
480
|
+
actual += ":" + submodule_head
|
|
481
|
+
if actual != expected:
|
|
482
|
+
raise ValueError("Working file changed during reading; refresh the changes")
|
|
483
|
+
return WorktreeSnapshot(WorktreeKey.from_context(context), context, generation, git_filename, head_oid, index_filename, before_index, entries, fingerprint_by_path,
|
|
484
|
+
conversion + attributes, index_entries=index_entries, hooks_dir=os.fsdecode(hooks_output), hidden_index_paths=listing.hidden_index_paths,
|
|
485
|
+
skip_worktree_paths=listing.skip_worktree_paths, probe=probe)
|
|
486
|
+
|
|
487
|
+
def read(self, context: RepositoryContext, git_filename: str, generation: int, callback: Callable, previous: WorktreeSnapshot | None = None, *,
|
|
488
|
+
priority: ProcessPriority = ProcessPriority.USER) -> None:
|
|
489
|
+
"""Read what the views show through the shared asynchronous runner, handing over the snapshot it replaces.
|
|
490
|
+
|
|
491
|
+
The merge state the repository window's banner shows is read from Git's files right after the capture, in the
|
|
492
|
+
same sequence, so whoever publishes the read knows the merge before anything is shown.
|
|
493
|
+
"""
|
|
494
|
+
self.run(context, git_filename, self._read_display(context, git_filename, generation, previous), callback, priority=priority)
|
|
495
|
+
|
|
496
|
+
def _read_display(self, context: RepositoryContext, git_filename: str, generation: int,
|
|
497
|
+
previous: WorktreeSnapshot | None) -> Generator[GitCommand | FileTask, object, WorktreeSnapshot]:
|
|
498
|
+
"""Capture what the views show, then read the merge state in a file task, without a Git process."""
|
|
499
|
+
snapshot = yield from self.capture_display(context, git_filename, generation, previous)
|
|
500
|
+
git_dir = context.git_dir
|
|
501
|
+
merge_banner_state = yield FileTask(lambda: read_merge_banner_state(git_dir))
|
|
502
|
+
return replace(snapshot, merge_banner_state=merge_banner_state)
|
|
503
|
+
|
|
504
|
+
def probe(self, snapshot: WorktreeSnapshot) -> Generator[GitCommand | FileTask, object, WorktreeProbe]:
|
|
505
|
+
"""Read again only what says whether the displayed snapshot still holds: local bytes without a process, then one status.
|
|
506
|
+
|
|
507
|
+
Any local difference is an error, so the caller reads everything again. Nothing is kept and the snapshot is not changed.
|
|
508
|
+
"""
|
|
509
|
+
if not snapshot.fresh or snapshot.context.is_bare or snapshot.context.worktree_dir is None:
|
|
510
|
+
raise ValueError("A complete current working tree read is required")
|
|
511
|
+
# A file already modified and modified again keeps the same status line, and another tool can rewrite the index
|
|
512
|
+
# without changing anything visible. Only the bytes tell, and they are read without starting a process.
|
|
513
|
+
yield FileTask(lambda: verify_local_state(snapshot, tuple(snapshot.fingerprint_by_path)))
|
|
514
|
+
output = yield GitCommand(list(STATUS_ARGUMENTS), profile=READ_PROFILE)
|
|
515
|
+
return (yield from read_worktree_probe(output))
|
|
516
|
+
|
|
517
|
+
def verify_conversion(self, snapshot: WorktreeSnapshot) -> Generator:
|
|
518
|
+
"""Compare configuration and attributes on the captured paths even after some become clean."""
|
|
519
|
+
path_input = b"\0".join(snapshot.fingerprint_by_path) + (b"\0" if snapshot.fingerprint_by_path else b"")
|
|
520
|
+
conversion, attributes = yield ReadBatch((
|
|
521
|
+
GitCommand(["config", "--null", "--list", "--show-origin"]),
|
|
522
|
+
GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path_input),
|
|
523
|
+
))
|
|
524
|
+
if conversion + attributes != snapshot.conversion:
|
|
525
|
+
raise ValueError("Repository configuration or attributes changed. Rescan before retrying.")
|
|
526
|
+
|
|
527
|
+
def verify(self, snapshot: WorktreeSnapshot, paths: tuple[bytes, ...]) -> Generator:
|
|
528
|
+
"""Recheck write preconditions directly without rebuilding every visible list and rename comparison."""
|
|
529
|
+
if not snapshot.fresh or snapshot.context.is_bare or snapshot.context.worktree_dir is None:
|
|
530
|
+
raise ValueError("A complete current working tree read is required")
|
|
531
|
+
context = snapshot.context
|
|
532
|
+
before_index = yield FileTask(lambda: read_index(snapshot.index_filename))
|
|
533
|
+
before_by_path = yield FileTask(lambda: fingerprint_files(context, paths))
|
|
534
|
+
path_input = b"\0".join(snapshot.fingerprint_by_path) + (b"\0" if snapshot.fingerprint_by_path else b"")
|
|
535
|
+
location_output, conversion, attributes = yield ReadBatch((
|
|
536
|
+
GitCommand(["rev-parse", "--path-format=absolute", "--git-dir", "--git-path", "index", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1)),
|
|
537
|
+
GitCommand(["config", "--null", "--list", "--show-origin"]),
|
|
538
|
+
GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path_input),
|
|
539
|
+
))
|
|
540
|
+
location_fields = location_output.removesuffix(b"\n").split(b"\n")
|
|
541
|
+
unborn_location_count, committed_location_count = 2, 3
|
|
542
|
+
if len(location_fields) not in (unborn_location_count, committed_location_count):
|
|
543
|
+
location_outputs = yield ReadBatch((GitCommand(["rev-parse", "--absolute-git-dir"]),
|
|
544
|
+
GitCommand(["rev-parse", "--path-format=absolute", "--git-path", "index"]),
|
|
545
|
+
GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))))
|
|
546
|
+
location_fields = []
|
|
547
|
+
for value in location_outputs:
|
|
548
|
+
location_fields.append(value.removesuffix(b"\n"))
|
|
549
|
+
git_directory, index_output, *head_outputs = location_fields
|
|
550
|
+
head_output = next(iter(head_outputs), b"")
|
|
551
|
+
resolved_git_dir = canonical_path(os.fsdecode(git_directory.rstrip(b"\r\n")))
|
|
552
|
+
index_filename = os.fsdecode(index_output.rstrip(b"\r\n"))
|
|
553
|
+
if resolved_git_dir != snapshot.key.git_dir or snapshot.key != WorktreeKey.from_context(context):
|
|
554
|
+
raise ValueError("The repository identity changed; reopen the repository")
|
|
555
|
+
if canonical_path(index_filename) != canonical_path(snapshot.index_filename):
|
|
556
|
+
raise ValueError("The selected index does not belong to this worktree")
|
|
557
|
+
after_index = yield FileTask(lambda: read_index(index_filename))
|
|
558
|
+
if before_index != snapshot.index_content or after_index != snapshot.index_content or (head_output.strip().decode("ascii") or None) != snapshot.head_oid or conversion + attributes != snapshot.conversion:
|
|
559
|
+
raise ValueError("Repository changed since selection; refresh the changes")
|
|
560
|
+
after_by_path = yield FileTask(lambda: fingerprint_files(context, paths))
|
|
561
|
+
for path in paths:
|
|
562
|
+
actual = after_by_path[path]
|
|
563
|
+
if before_by_path[path] != actual:
|
|
564
|
+
raise ValueError("Working file changed during verification; refresh the changes")
|
|
565
|
+
expected = snapshot.fingerprint_by_path.get(path)
|
|
566
|
+
if expected is not None and expected.startswith(actual + ":"):
|
|
567
|
+
submodule_head = yield from read_gitlink_head(context, path)
|
|
568
|
+
actual += ":" + submodule_head
|
|
569
|
+
if actual != expected:
|
|
570
|
+
raise ValueError("Working file changed since selection; refresh the changes")
|
|
571
|
+
|
|
572
|
+
def diff(self, snapshot: WorktreeSnapshot, entry: LocalEntry) -> Generator[GitCommand, bytes, bytes]:
|
|
573
|
+
"""Read the actual selected comparison, bounding only its presentation."""
|
|
574
|
+
arguments = ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames"]
|
|
575
|
+
if entry.side == StagingSide.STAGED:
|
|
576
|
+
arguments.append("--cached")
|
|
577
|
+
if entry.old_path is None and entry.side == StagingSide.UNSTAGED:
|
|
578
|
+
arguments.extend(["--no-index", "--", os.devnull, os.fsdecode(entry.path)])
|
|
579
|
+
else:
|
|
580
|
+
arguments.append("--")
|
|
581
|
+
arguments.extend(map(os.fsdecode, entry.paths))
|
|
582
|
+
# The argument list is built at run time, so the source cannot show it is a read: the command says it.
|
|
583
|
+
output = yield GitCommand(arguments, accepted_codes=(0, 1), stdout_limit_bytes=FILE_PREVIEW_LIMIT_BYTES, profile=READ_PROFILE)
|
|
584
|
+
return output
|