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,1180 @@
|
|
|
1
|
+
"""Discover declared gitlinks without treating an empty child as its parent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from gitpane.account.credential_manager import CredentialManagerRefusal
|
|
13
|
+
from gitpane.config import (
|
|
14
|
+
GIT_CLONE_TIMEOUT_MS,
|
|
15
|
+
GIT_TRANSPORT_TIMEOUT_MS,
|
|
16
|
+
SUBMODULE_SUMMARY_COMMIT_LIMIT,
|
|
17
|
+
SUBMODULE_SUMMARY_DIFFERENCE_LIMIT,
|
|
18
|
+
SUBMODULE_SUMMARY_OUTPUT_LIMIT_BYTES,
|
|
19
|
+
SharedText,
|
|
20
|
+
)
|
|
21
|
+
from gitpane.history.history_parse import parse_tree_entries
|
|
22
|
+
from gitpane.history.repository_value import FileStatus, RepositoryContext, validate_oid
|
|
23
|
+
from gitpane.operation.console_line import REPOSITORY_HEADER_LABEL, RETRY_SUBMODULES_LABEL, ConsoleHeader
|
|
24
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
25
|
+
from gitpane.operation.operation_status import OperationStatus, operation_status
|
|
26
|
+
from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
|
|
27
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult
|
|
28
|
+
from gitpane.process.git_output_text import git_error_text
|
|
29
|
+
from gitpane.process.process_runner import NETWORK_PROFILE, ProcessRequest, ProcessStatus
|
|
30
|
+
from gitpane.submodule.checkout_refusal import checkout_refusals, refused_checkout_submodule_paths, refused_submodule_dirs
|
|
31
|
+
from gitpane.submodule.checkout_refusal_notice import checkout_refusal_text
|
|
32
|
+
from gitpane.submodule.cloned_without_checkout import is_cloned_without_checkout
|
|
33
|
+
from gitpane.system.path_identity import canonical_path
|
|
34
|
+
from gitpane.transport.git_refusal import CREDENTIALS_MISSING_PATTERN, file_transport_refused, git_output_lines, repository_not_found
|
|
35
|
+
from gitpane.transport.refusal_notice import FILE_TRANSPORT_REFUSED_TEXT
|
|
36
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
37
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, SubmoduleState, parse_status
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Callable, Generator
|
|
41
|
+
|
|
42
|
+
from gitpane.account.authentication import AuthenticationService
|
|
43
|
+
from gitpane.account.credential_manager import CredentialManagerDetection
|
|
44
|
+
from gitpane.account.ssh import SshService
|
|
45
|
+
from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
|
|
46
|
+
from gitpane.operation.repository_operation import RepositoryOperationService
|
|
47
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
48
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SUBMODULE_INIT_ARGUMENTS = ("submodule", "update", "--init", "--recursive", "--checkout", "--")
|
|
52
|
+
# The same update for a submodule Git cloned but never checked out. Git skips the checkout of a submodule whose HEAD
|
|
53
|
+
# already names the recorded commit, which is what a clone leaves whenever that commit is the tip of the default
|
|
54
|
+
# branch; --force makes it check the files out anyway. It only ever runs on a folder that holds nothing but its .git
|
|
55
|
+
# file and a Git folder without an index, checked again right before Git starts, so there is nothing it could overwrite.
|
|
56
|
+
SUBMODULE_CLONED_CHECKOUT_ARGUMENTS = ("submodule", "update", "--init", "--recursive", "--checkout", "--force", "--")
|
|
57
|
+
# The line that ends an initialization console, with the word of its status (``done``); the result record of the --log
|
|
58
|
+
# file keeps the same line with the bare status (``success``).
|
|
59
|
+
INITIALIZE_END_TEMPLATE = "Initialize submodule: {status} — {repository_dir}"
|
|
60
|
+
SUBMODULE_OCCUPIED_MESSAGE = ("Cannot initialize the submodule: its folder already contains files. "
|
|
61
|
+
"Please remove these files or initialize the submodule manually.")
|
|
62
|
+
# Causes of a failed, stopped or incomplete initialization. Each ends on the action that tries again, which depends on
|
|
63
|
+
# where it started: the repository window opens the submodule again, the clone form offers its Retry submodules button.
|
|
64
|
+
# The ending is chosen once per initialization, so every cause it can give names the same action.
|
|
65
|
+
INITIALIZE_INTERRUPTED_START = "Submodule initialization was interrupted. Review the details before "
|
|
66
|
+
INITIALIZE_FAILED_START = "Could not initialize the submodule. Existing files were preserved. Review the details, fix the reported problem, then "
|
|
67
|
+
INITIALIZE_INTERRUPTED_KEPT_START = "Submodule initialization was interrupted. Existing files were preserved. "
|
|
68
|
+
INITIALIZE_INCOMPLETE_START = "Some submodules could not be initialized. Existing files were preserved. "
|
|
69
|
+
OPEN_AGAIN_INTERRUPTED_ENDING = "opening it again."
|
|
70
|
+
OPEN_AGAIN_FAILED_ENDING = "open the submodule again."
|
|
71
|
+
OPEN_AGAIN_RETRY_SENTENCE = "Open the submodule again to retry."
|
|
72
|
+
RETRY_INTERRUPTED_ENDING = "clicking " + RETRY_SUBMODULES_LABEL + " again."
|
|
73
|
+
RETRY_FAILED_ENDING = "click " + RETRY_SUBMODULES_LABEL + " again."
|
|
74
|
+
RETRY_AGAIN_SENTENCE = "Click " + RETRY_SUBMODULES_LABEL + " again."
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def initialization_command(git_filename: str, parent_dir: str, path: str, *, powershell: bool) -> str:
|
|
78
|
+
"""Quote a manual initialization command for one child and its parent without running it.
|
|
79
|
+
|
|
80
|
+
The command is shown so the user can run it after clearing an occupied submodule folder. It is the same
|
|
81
|
+
initialization as opening the submodule: like it, it adds no protocol.file.allow, so Git's own setting decides
|
|
82
|
+
whether a submodule whose URL is a local folder may be cloned.
|
|
83
|
+
"""
|
|
84
|
+
arguments = [git_filename, "--literal-pathspecs", "-C", parent_dir, *SUBMODULE_INIT_ARGUMENTS, path]
|
|
85
|
+
if not powershell:
|
|
86
|
+
return shlex.join(arguments)
|
|
87
|
+
quoted_paths = []
|
|
88
|
+
for argument in (git_filename, parent_dir, path):
|
|
89
|
+
# PowerShell treats typographic apostrophes as quotes too; double each accepted delimiter.
|
|
90
|
+
for quote in ("'", "\u2018", "\u2019"):
|
|
91
|
+
argument = argument.replace(quote, quote + quote)
|
|
92
|
+
quoted_paths.append("'" + argument + "'")
|
|
93
|
+
quoted_git_filename, quoted_parent_dir, quoted_path = quoted_paths
|
|
94
|
+
return " ".join(["&", quoted_git_filename, "--literal-pathspecs", "-C", quoted_parent_dir, *SUBMODULE_INIT_ARGUMENTS, quoted_path])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SubmoduleReferenceState:
|
|
99
|
+
"""Keep parent HEAD, parent index and child checkout as independent observed sources."""
|
|
100
|
+
|
|
101
|
+
parent_dir: str
|
|
102
|
+
path: bytes
|
|
103
|
+
repository_dir: str
|
|
104
|
+
parent_head_oid: str | None
|
|
105
|
+
committed_oid: str | None
|
|
106
|
+
indexed_oid: str | None
|
|
107
|
+
checkout_oid: str | None
|
|
108
|
+
committed_availability: str
|
|
109
|
+
indexed_availability: str
|
|
110
|
+
checkout_availability: str
|
|
111
|
+
submodule: SubmoduleState
|
|
112
|
+
cause: str = ""
|
|
113
|
+
internal_changes: tuple[SubmoduleDifference, ...] | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class SubmoduleCommit:
|
|
118
|
+
"""Preserve a known hash even when its metadata cannot be read locally."""
|
|
119
|
+
|
|
120
|
+
oid: str | None
|
|
121
|
+
timestamp: int | None = None
|
|
122
|
+
subject: str | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class SubmoduleDifference:
|
|
127
|
+
"""Retain literal path bytes, including both sides of a rename."""
|
|
128
|
+
|
|
129
|
+
status: str
|
|
130
|
+
path: bytes
|
|
131
|
+
old_path: bytes | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_internal_status(output: bytes) -> tuple[SubmoduleDifference, ...]:
|
|
135
|
+
"""Preserve both native status columns and literal rename paths from porcelain v1 NUL records."""
|
|
136
|
+
if output and not output.endswith(b"\0"):
|
|
137
|
+
raise ValueError("Incomplete submodule status output")
|
|
138
|
+
changes = []
|
|
139
|
+
records = iter(output.split(b"\0"))
|
|
140
|
+
status_width = len("XY")
|
|
141
|
+
for record in records:
|
|
142
|
+
if not record:
|
|
143
|
+
continue
|
|
144
|
+
status = record[:status_width].decode("ascii")
|
|
145
|
+
separator = record[status_width:status_width + len(b" ")]
|
|
146
|
+
path = record[status_width + len(b" "):]
|
|
147
|
+
if separator != b" " or not path or any(character not in " MTADRCU?!" for character in status):
|
|
148
|
+
raise ValueError("Malformed submodule status record")
|
|
149
|
+
old_path = None
|
|
150
|
+
if "R" in status or "C" in status:
|
|
151
|
+
old_path = next(records, None)
|
|
152
|
+
if not old_path:
|
|
153
|
+
raise ValueError("Missing submodule rename source")
|
|
154
|
+
changes.append(SubmoduleDifference(status, path, old_path))
|
|
155
|
+
return tuple(sorted(changes, key=lambda change: (change.path, change.old_path or b"", change.status)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class SubmoduleSummary:
|
|
160
|
+
"""Publish one validated comparison with unknown facts distinct from empty lists."""
|
|
161
|
+
|
|
162
|
+
reference: SubmoduleReferenceState
|
|
163
|
+
side: StagingSide
|
|
164
|
+
source: SubmoduleCommit
|
|
165
|
+
target: SubmoduleCommit
|
|
166
|
+
relation: str = "Unavailable"
|
|
167
|
+
added_count: int | None = None
|
|
168
|
+
removed_count: int | None = None
|
|
169
|
+
added_commits: tuple[SubmoduleCommit, ...] = ()
|
|
170
|
+
removed_commits: tuple[SubmoduleCommit, ...] = ()
|
|
171
|
+
differences: tuple[SubmoduleDifference, ...] | None = None
|
|
172
|
+
differences_truncated: bool = False
|
|
173
|
+
cause: str = ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def summary_command(directory: str, arguments: list[str], *, accepted_codes: tuple[int, ...] = (0,)) -> GitCommand:
|
|
177
|
+
"""Apply the same output bound to every local comparison read."""
|
|
178
|
+
return GitCommand(["-C", directory, *arguments], accepted_codes=accepted_codes, stdout_limit_bytes=SUBMODULE_SUMMARY_OUTPUT_LIMIT_BYTES)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class SubmoduleEntry:
|
|
183
|
+
"""Describe a repository or declared child independently of its tree label."""
|
|
184
|
+
|
|
185
|
+
repository_dir: str
|
|
186
|
+
parent_dir: str | None
|
|
187
|
+
path: str
|
|
188
|
+
section: str
|
|
189
|
+
branch: str | None
|
|
190
|
+
head_oid: str | None
|
|
191
|
+
availability: str
|
|
192
|
+
cause: str = ""
|
|
193
|
+
submodule: SubmoduleState = field(default_factory=SubmoduleState)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class SubmoduleSnapshot:
|
|
198
|
+
"""Publish one complete repository family with explicit child availability."""
|
|
199
|
+
|
|
200
|
+
root_dir: str
|
|
201
|
+
entries: tuple[SubmoduleEntry, ...]
|
|
202
|
+
cause: str = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class SubmoduleInitIntent:
|
|
207
|
+
"""Capture the parent index reference and the declared target before native writes."""
|
|
208
|
+
|
|
209
|
+
operation: OperationIntent
|
|
210
|
+
path: bytes
|
|
211
|
+
expected_oid: str
|
|
212
|
+
repository_dir: str
|
|
213
|
+
signature: tuple
|
|
214
|
+
configuration_filenames: tuple[str, ...] = ()
|
|
215
|
+
inspected_dirs: tuple[str, ...] = ()
|
|
216
|
+
# True for a submodule Git cloned but never checked out: its HEAD may differ from the recorded commit while there is
|
|
217
|
+
# no file to keep, and Git is asked to check it out anyway.
|
|
218
|
+
cloned_without_checkout: bool = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class SubmoduleInitRun:
|
|
223
|
+
"""Retain native, validation and opening results independently across a retry."""
|
|
224
|
+
|
|
225
|
+
operation_id: str
|
|
226
|
+
entry: SubmoduleEntry
|
|
227
|
+
trace: OperationTrace
|
|
228
|
+
session_id: str = ""
|
|
229
|
+
intent: SubmoduleInitIntent | None = None
|
|
230
|
+
status: str = "preparing"
|
|
231
|
+
validation_status: str = "not_started"
|
|
232
|
+
open_status: str = "not_started"
|
|
233
|
+
cancelled: bool = False
|
|
234
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
235
|
+
snapshot: SubmoduleSnapshot | None = None
|
|
236
|
+
# None until Git was launched for this initialization.
|
|
237
|
+
native_status: ProcessStatus | None = None
|
|
238
|
+
return_code: int | None = None
|
|
239
|
+
# Why the credential manager was refused when that refusal stopped this initialization, so the window explaining
|
|
240
|
+
# how to install it can open; None otherwise.
|
|
241
|
+
credential_manager_refusal: CredentialManagerDetection | None = None
|
|
242
|
+
# True once the sentence of each submodule whose checkout Git refused was written, before the submodules Git left
|
|
243
|
+
# cloned after it were checked out, so the end of the initialization does not write it again.
|
|
244
|
+
refusal_explained: bool = False
|
|
245
|
+
# The error output of the initialization's submodule update process, or of the nested one that failed, decoded once
|
|
246
|
+
# for every refusal check.
|
|
247
|
+
git_error_text: str = ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class SubmoduleAuthenticationTarget:
|
|
252
|
+
"""Bind a requested account to a missing child and the configuration that named it."""
|
|
253
|
+
|
|
254
|
+
entry: SubmoduleEntry
|
|
255
|
+
endpoint: str
|
|
256
|
+
parent: RepositoryContext
|
|
257
|
+
signature: tuple
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def initialization_signature(context: RepositoryContext, child_dir: str, configuration_filenames: tuple[str, ...] = (), inspected_dirs: tuple[str, ...] = ()) -> tuple:
|
|
261
|
+
"""Detect replaced path components and changed parent configuration while queued."""
|
|
262
|
+
states = []
|
|
263
|
+
for inspected_dir in inspected_dirs:
|
|
264
|
+
declared_directory(context.repository_dir, os.path.relpath(inspected_dir, context.repository_dir))
|
|
265
|
+
directory = inspected_dir
|
|
266
|
+
while canonical_path(directory) != canonical_path(context.repository_dir):
|
|
267
|
+
state = os.stat(directory) if os.path.exists(directory) else None
|
|
268
|
+
states.append((directory, None if state is None else (state.st_dev, state.st_ino)))
|
|
269
|
+
directory = os.path.dirname(directory)
|
|
270
|
+
directory = child_dir
|
|
271
|
+
root_dir = canonical_path(context.repository_dir)
|
|
272
|
+
while True:
|
|
273
|
+
if os.path.islink(directory) or os.path.isjunction(directory):
|
|
274
|
+
raise ValueError("Linked submodule paths are not supported")
|
|
275
|
+
state = os.stat(directory) if os.path.exists(directory) else None
|
|
276
|
+
states.append((directory, None if state is None else (state.st_dev, state.st_ino)))
|
|
277
|
+
if canonical_path(directory) == root_dir:
|
|
278
|
+
break
|
|
279
|
+
parent_dir = os.path.dirname(directory)
|
|
280
|
+
if parent_dir == directory:
|
|
281
|
+
raise ValueError("Submodule path leaves its parent repository")
|
|
282
|
+
directory = parent_dir
|
|
283
|
+
for filename in (context.git_dir, context.common_git_dir, os.path.join(context.repository_dir, ".git"), os.path.join(context.repository_dir, ".gitmodules"),
|
|
284
|
+
os.path.join(context.git_dir, "index"), os.path.join(context.common_git_dir, "config"), *configuration_filenames):
|
|
285
|
+
if os.path.islink(filename) or os.path.isjunction(filename):
|
|
286
|
+
raise ValueError("Linked repository metadata is not supported")
|
|
287
|
+
if os.path.isfile(filename):
|
|
288
|
+
with open(filename, "rb") as stream:
|
|
289
|
+
states.append((filename, stream.read()))
|
|
290
|
+
elif os.path.isdir(filename):
|
|
291
|
+
state = os.stat(filename)
|
|
292
|
+
states.append((filename, state.st_dev, state.st_ino))
|
|
293
|
+
else:
|
|
294
|
+
states.append((filename, None))
|
|
295
|
+
if os.path.isdir(child_dir) and not os.path.lexists(os.path.join(child_dir, ".git")):
|
|
296
|
+
with os.scandir(child_dir) as children:
|
|
297
|
+
if next(children, None) is not None:
|
|
298
|
+
raise ValueError(SUBMODULE_OCCUPIED_MESSAGE)
|
|
299
|
+
return tuple(states)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def declared_directory(parent_dir: str, path: str) -> str:
|
|
303
|
+
"""Reject escaped or linked child paths before reading another repository."""
|
|
304
|
+
parts = path.replace("\\", "/").split("/")
|
|
305
|
+
if not path or os.path.isabs(path) or ":" in path or any(part in ("", ".", "..") for part in parts):
|
|
306
|
+
raise ValueError("Invalid submodule path")
|
|
307
|
+
root_dir = canonical_path(parent_dir)
|
|
308
|
+
directory = parent_dir
|
|
309
|
+
for part in parts:
|
|
310
|
+
directory = os.path.join(directory, part)
|
|
311
|
+
if os.path.islink(directory) or os.path.isjunction(directory):
|
|
312
|
+
raise ValueError("Linked submodule paths are not supported")
|
|
313
|
+
if os.path.commonpath((root_dir, canonical_path(directory))) != root_dir:
|
|
314
|
+
raise ValueError("Submodule path leaves its parent repository")
|
|
315
|
+
return directory
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class OperationCredentialHelper:
|
|
320
|
+
"""Keep the credential helper one submodule operation found, so every child it initializes uses it without searching again.
|
|
321
|
+
|
|
322
|
+
``commands`` stays None until the first child that composes an account searches; after a refusal that lets the
|
|
323
|
+
operation go on, it holds the preview, so no later child searches either.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
commands: tuple[str, ...] | None = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SubmoduleInitializationError(ValueError):
|
|
330
|
+
"""A submodule initialization that failed after GitPane already prepared its diagnostic.
|
|
331
|
+
|
|
332
|
+
The diagnostic travels with the error so the console shows at most one line of it and the ``--log`` file keeps its
|
|
333
|
+
full block; the message is the short cause. The error output of the Git process that failed travels with it too,
|
|
334
|
+
decoded once, so a refusal read from it names the same folders as everywhere else. It stays a ValueError, so every
|
|
335
|
+
handler of a failed initialization still catches it.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self, diagnostic: PreparedDiagnostic, git_error_text: str) -> None:
|
|
339
|
+
"""Keep the prepared diagnostic and Git's decoded error output, and give the short cause as the error message."""
|
|
340
|
+
self.diagnostic = diagnostic
|
|
341
|
+
self.git_error_text = git_error_text
|
|
342
|
+
super().__init__(diagnostic.cause)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class SubmoduleService:
|
|
346
|
+
"""Read native Git declarations through the shared bounded process service."""
|
|
347
|
+
|
|
348
|
+
def __init__(self, worktree: WorktreeService, repository_operation: RepositoryOperationService | None = None,
|
|
349
|
+
authentication: AuthenticationService | None = None, ssh: SshService | None = None, log: DiagnosticLog | None = None) -> None:
|
|
350
|
+
"""Inject the existing process scheduler for responsive discovery."""
|
|
351
|
+
self.worktree = worktree
|
|
352
|
+
self.repository_operation = repository_operation
|
|
353
|
+
self.authentication = authentication
|
|
354
|
+
self.ssh = ssh
|
|
355
|
+
self.log = log
|
|
356
|
+
self.run_by_id: dict[str, SubmoduleInitRun] = {}
|
|
357
|
+
|
|
358
|
+
def has_incomplete_initialization(self, repository_dir: str) -> bool:
|
|
359
|
+
"""Use the latest actual attempt for a child while retaining its historical diagnostics."""
|
|
360
|
+
directory = canonical_path(repository_dir)
|
|
361
|
+
for run in reversed(self.run_by_id.values()):
|
|
362
|
+
if canonical_path(run.entry.repository_dir) != directory:
|
|
363
|
+
continue
|
|
364
|
+
# A refused preparation did not change the checkout and cannot replace
|
|
365
|
+
# the last native attempt's result, including a successful recovery.
|
|
366
|
+
if run.native_status is None and run.status not in ("preparing", "running"):
|
|
367
|
+
continue
|
|
368
|
+
return run.validation_status != OperationStatus.SUCCESS.value
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def initialize(self, context: RepositoryContext, git_filename: str, entry: SubmoduleEntry, callback: Callable[[SubmoduleInitRun], None],
|
|
372
|
+
*, session_id: str, generation: int, is_current: Callable[[], bool]) -> SubmoduleInitRun:
|
|
373
|
+
"""Initialize only a freshly verified declared child and retain ownership through reconciliation."""
|
|
374
|
+
operation_id = uuid.uuid4().hex
|
|
375
|
+
# The header names the parent that declares the submodule, the window's repository for a root entry. The
|
|
376
|
+
# initialization itself runs there and reaches the submodule through -C. The preparation reads run in the
|
|
377
|
+
# window's repository, so for a nested submodule their lines carry the "(in …)" suffix of another folder.
|
|
378
|
+
trace = OperationTrace(operation_id, self.worktree.runner, self.log, header=ConsoleHeader(REPOSITORY_HEADER_LABEL, entry.parent_dir or context.repository_dir))
|
|
379
|
+
run = SubmoduleInitRun(operation_id, entry, trace, session_id)
|
|
380
|
+
self.run_by_id[operation_id] = run
|
|
381
|
+
|
|
382
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
383
|
+
"""Publish a filtered terminal result after all reads and native descendants finish."""
|
|
384
|
+
if error is not None:
|
|
385
|
+
run.status = OperationStatus.CANCELLED.value if run.cancelled else OperationStatus.FAILED.value
|
|
386
|
+
if run.validation_status == "running":
|
|
387
|
+
run.validation_status = OperationStatus.FAILED.value
|
|
388
|
+
# A Git process that failed, the initialization's own or a nested checkout's, keeps its prepared
|
|
389
|
+
# diagnostic, so the --log block names its command and its process; any other error keeps its cause.
|
|
390
|
+
if isinstance(error, (GitCommandError, SubmoduleInitializationError)):
|
|
391
|
+
run.diagnostic = error.diagnostic
|
|
392
|
+
else:
|
|
393
|
+
run.diagnostic = Diagnostic("Initialize submodule", entry.repository_dir, git_filename, [], str(error)).prepare()
|
|
394
|
+
if isinstance(error, SubmoduleInitializationError):
|
|
395
|
+
run.git_error_text = error.git_error_text
|
|
396
|
+
if isinstance(error, CredentialManagerRefusal) and not run.cancelled:
|
|
397
|
+
run.credential_manager_refusal = error.detection
|
|
398
|
+
publish_after_trace(publish)
|
|
399
|
+
|
|
400
|
+
def publish() -> None:
|
|
401
|
+
"""Write the result in the console and the --log file, release the repositories, then hand the run back.
|
|
402
|
+
|
|
403
|
+
When Git refused a local submodule through its protocol.file.allow setting, or refused to check out a path of
|
|
404
|
+
a submodule, the cause is written once in plain words, after Git's own lines and before the end line.
|
|
405
|
+
"""
|
|
406
|
+
# The run's state holds a finished operation's word by now; it is read as that end once, here.
|
|
407
|
+
end_status = OperationStatus(run.status)
|
|
408
|
+
if run.diagnostic is not None and file_transport_refused(run.git_error_text):
|
|
409
|
+
run.trace.append_failure_cause(operation_id, FILE_TRANSPORT_REFUSED_TEXT)
|
|
410
|
+
if run.diagnostic is not None and not run.refusal_explained:
|
|
411
|
+
# A cause is one console line, so each submodule whose checkout Git refused gets its own.
|
|
412
|
+
for refusal in checkout_refusals(run.git_error_text):
|
|
413
|
+
run.trace.append_failure_cause(operation_id, checkout_refusal_text((refusal,), sys.platform))
|
|
414
|
+
run.trace.append_end_line(operation_id, "result", INITIALIZE_END_TEMPLATE.format(status=run.status, repository_dir=entry.repository_dir),
|
|
415
|
+
INITIALIZE_END_TEMPLATE.format(status=end_status.end_word, repository_dir=entry.repository_dir), end_status,
|
|
416
|
+
failure_reported=run.diagnostic is not None)
|
|
417
|
+
if run.diagnostic is not None:
|
|
418
|
+
# The credential manager was refused before Git started, so the line says the initialization did not
|
|
419
|
+
# start, as every other refused operation does; the run's own status stays failed.
|
|
420
|
+
run.trace.append_diagnostic(operation_id, "error", run.diagnostic, OperationStatus.REFUSED if run.credential_manager_refusal is not None else end_status)
|
|
421
|
+
if operation_id in self.repository_operation.active_by_id:
|
|
422
|
+
self.repository_operation.reconcile(OperationOutcome(operation_id, end_status, (OperationStepResult("Initialize submodule", end_status, after=run.snapshot,
|
|
423
|
+
diagnostic=run.diagnostic, process_status=run.native_status,
|
|
424
|
+
return_code=run.return_code),), run.diagnostic))
|
|
425
|
+
callback(run)
|
|
426
|
+
|
|
427
|
+
def prepared(value: object, error: Exception | None) -> None:
|
|
428
|
+
"""Reserve before transport preparation and verify again immediately before launch.
|
|
429
|
+
|
|
430
|
+
A refused reservation takes the repository owner's reason, which names another operation only when a write
|
|
431
|
+
holds the parent or a submodule folder, and otherwise says why the application no longer allows the write.
|
|
432
|
+
"""
|
|
433
|
+
if error is not None:
|
|
434
|
+
complete(None, error)
|
|
435
|
+
return
|
|
436
|
+
parent, expected_oid, known_contexts, configuration_filenames, inspected_dirs = value
|
|
437
|
+
owner = OperationIntent.capture(operation_id, parent, git_filename, session_id=session_id, generation=generation, operation="Initialize submodule",
|
|
438
|
+
reserved_repository_dirs=(canonical_path(entry.repository_dir),), reserved_contexts=known_contexts, action=OperationAction.SUBMODULE_INIT)
|
|
439
|
+
if not self.repository_operation.reserve(owner):
|
|
440
|
+
complete(None, ValueError(self.repository_operation.refusal_cause(owner)))
|
|
441
|
+
return
|
|
442
|
+
try:
|
|
443
|
+
signature = initialization_signature(parent, entry.repository_dir, configuration_filenames, inspected_dirs)
|
|
444
|
+
run.intent = SubmoduleInitIntent(owner, os.fsencode(entry.path), expected_oid, entry.repository_dir, signature, configuration_filenames, inspected_dirs)
|
|
445
|
+
except (OSError, ValueError) as failure:
|
|
446
|
+
complete(None, failure)
|
|
447
|
+
return
|
|
448
|
+
self.worktree.run(parent, git_filename, self._initialize_then_cloned_children(run, is_current), complete, operation_id=operation_id)
|
|
449
|
+
|
|
450
|
+
self.worktree.run(context, git_filename, self._prepare_initialization(entry), prepared)
|
|
451
|
+
return run
|
|
452
|
+
|
|
453
|
+
def _initialize_then_cloned_children(self, run: SubmoduleInitRun, is_current: Callable[[], bool]) -> Generator:
|
|
454
|
+
"""Initialize the submodule, then check out the nested submodules Git cloned but left without files after a refused checkout.
|
|
455
|
+
|
|
456
|
+
Git clones every nested submodule before checking them out, and stops at the first checkout it refuses on a file
|
|
457
|
+
name: the nested submodules after that one are cloned but never checked out. They are checked out here, one at a
|
|
458
|
+
time, inside the initialized submodule only; those whose checkout Git refused on a file name, even one cut by a
|
|
459
|
+
line break, are left exactly as Git left them. The sentence of each refusal whose path Git could cite is written
|
|
460
|
+
first, right after Git's own lines. A checkout that failed for another reason starts nothing here: the
|
|
461
|
+
initialization stays failed and opening the submodule again takes it up.
|
|
462
|
+
"""
|
|
463
|
+
credential_helper = OperationCredentialHelper()
|
|
464
|
+
yield from self._initialize(run, is_current, credential_helper=credential_helper)
|
|
465
|
+
if run.cancelled or run.diagnostic is None or not is_current():
|
|
466
|
+
return
|
|
467
|
+
refused_paths = refused_checkout_submodule_paths(run.git_error_text)
|
|
468
|
+
if not refused_paths:
|
|
469
|
+
return
|
|
470
|
+
for refusal in checkout_refusals(run.git_error_text):
|
|
471
|
+
run.trace.append_failure_cause(run.operation_id, checkout_refusal_text((refusal,), sys.platform))
|
|
472
|
+
run.refusal_explained = True
|
|
473
|
+
owner = run.intent.operation
|
|
474
|
+
refused_dir_set = set(refused_submodule_dirs(refused_paths, owner.context.repository_dir))
|
|
475
|
+
run.snapshot = yield from self.resume_missing(owner, run.trace, is_current, credential_helper=credential_helper, refused_dir_set=refused_dir_set,
|
|
476
|
+
initialize_missing=False, scope_dir=run.entry.repository_dir, retry_wording=False)
|
|
477
|
+
|
|
478
|
+
def cancel(self, operation_id: str) -> None:
|
|
479
|
+
"""Stop supervised native work without releasing ownership before reconciliation."""
|
|
480
|
+
run = self.run_by_id.get(operation_id)
|
|
481
|
+
if run is None or run.validation_status in (OperationStatus.SUCCESS.value, OperationStatus.PARTIAL.value) or run.status not in ("preparing", "running", OperationStatus.SUCCESS.value):
|
|
482
|
+
return
|
|
483
|
+
run.cancelled = True
|
|
484
|
+
self.worktree.runner.cancel(operation_id)
|
|
485
|
+
|
|
486
|
+
def _context(self, directory: str) -> Generator:
|
|
487
|
+
"""Read actual Git identities instead of inferring them from directory proximity.
|
|
488
|
+
|
|
489
|
+
One process answers the three locations of an ordinary repository. Output that does not split into three
|
|
490
|
+
absolute locations starting with this exact root is read again one location at a time, so a line break
|
|
491
|
+
inside a path can never move a boundary between two answers.
|
|
492
|
+
"""
|
|
493
|
+
location_arguments = ("--show-toplevel", "--absolute-git-dir", "--git-common-dir")
|
|
494
|
+
root_position = 0
|
|
495
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--path-format=absolute", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"])
|
|
496
|
+
values = os.fsdecode(output.removesuffix(b"\n")).split("\n")
|
|
497
|
+
grouped = (len(values) == len(location_arguments) and all(os.path.isabs(value) for value in values)
|
|
498
|
+
and canonical_path(values[root_position]) == canonical_path(directory))
|
|
499
|
+
if not grouped:
|
|
500
|
+
values = []
|
|
501
|
+
for argument in location_arguments:
|
|
502
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--path-format=absolute", argument])
|
|
503
|
+
values.append(os.fsdecode(output).rstrip("\r\n"))
|
|
504
|
+
root_dir, git_dir, common_dir = values
|
|
505
|
+
if canonical_path(root_dir) != canonical_path(directory):
|
|
506
|
+
raise ValueError("Repository does not resolve to its declared worktree")
|
|
507
|
+
return RepositoryContext(directory, git_dir, common_dir, False)
|
|
508
|
+
|
|
509
|
+
def _prepare_initialization(self, entry: SubmoduleEntry, *, cloned_without_checkout: bool = False) -> Generator:
|
|
510
|
+
"""Reject occupied paths, conflicting gitlinks and existing divergent checkouts before update.
|
|
511
|
+
|
|
512
|
+
With ``cloned_without_checkout``, the submodule itself is one Git cloned but never checked out: its HEAD is still
|
|
513
|
+
the clone's default branch, which may differ from the recorded commit, and that is not a checkout to keep, since
|
|
514
|
+
its folder holds no file. Its nested submodules, if any, are still checked like any other.
|
|
515
|
+
"""
|
|
516
|
+
if entry.parent_dir is None:
|
|
517
|
+
raise ValueError("No parent declares this submodule")
|
|
518
|
+
child_dir = declared_directory(entry.parent_dir, entry.path)
|
|
519
|
+
parent = yield from self._context(entry.parent_dir)
|
|
520
|
+
section_by_path, stage_by_path = yield from self._read_declarations(entry.parent_dir)
|
|
521
|
+
stages = stage_by_path.get(entry.path, [])
|
|
522
|
+
if section_by_path.get(entry.path) != entry.section or len(stages) != 1:
|
|
523
|
+
raise ValueError("The submodule declaration or index changed. Refresh the repository and retry.")
|
|
524
|
+
mode, expected_oid, stage = next(iter(stages))
|
|
525
|
+
if mode != "160000" or stage != "0":
|
|
526
|
+
raise ValueError("The submodule index has unresolved stages or no gitlink.")
|
|
527
|
+
initialization_signature(parent, child_dir)
|
|
528
|
+
known_contexts = []
|
|
529
|
+
inspected_dirs = []
|
|
530
|
+
if os.path.lexists(os.path.join(child_dir, ".git")):
|
|
531
|
+
family = yield from self._read(child_dir)
|
|
532
|
+
if family.cause:
|
|
533
|
+
raise ValueError(family.cause)
|
|
534
|
+
for child in family.entries:
|
|
535
|
+
if not child.repository_dir or not self._inside(child_dir, child.repository_dir):
|
|
536
|
+
continue
|
|
537
|
+
inspected_dirs.append(child.repository_dir)
|
|
538
|
+
if child.availability == "not initialized":
|
|
539
|
+
continue
|
|
540
|
+
if child.availability != "initialized":
|
|
541
|
+
raise ValueError(child.cause or "The submodule path cannot be initialized safely.")
|
|
542
|
+
child_context = yield from self._context(child.repository_dir)
|
|
543
|
+
known_contexts.append(child_context)
|
|
544
|
+
if cloned_without_checkout and canonical_path(child.repository_dir) == canonical_path(child_dir):
|
|
545
|
+
continue
|
|
546
|
+
reference = yield from self._read_reference_state(child.parent_dir, os.fsencode(child.path))
|
|
547
|
+
if reference.checkout_oid != reference.indexed_oid:
|
|
548
|
+
raise ValueError("An existing submodule checkout differs from its parent index. Existing work was preserved.")
|
|
549
|
+
configuration_filename_set: set[str] = set()
|
|
550
|
+
for context in (parent, *known_contexts):
|
|
551
|
+
output = yield GitCommand(["-C", context.repository_dir, "config", "--includes", "--show-origin", "--name-only", "--null", "--list"])
|
|
552
|
+
fields = iter(output.split(b"\0"))
|
|
553
|
+
for origin in fields:
|
|
554
|
+
if not origin:
|
|
555
|
+
continue
|
|
556
|
+
next(fields, None)
|
|
557
|
+
if origin.startswith(b"file:"):
|
|
558
|
+
filename = os.fsdecode(origin.removeprefix(b"file:"))
|
|
559
|
+
if not os.path.isabs(filename):
|
|
560
|
+
filename = os.path.join(context.repository_dir, filename)
|
|
561
|
+
configuration_filename_set.add(os.path.abspath(filename))
|
|
562
|
+
configuration_filename_set.add(os.path.join(context.repository_dir, ".gitmodules"))
|
|
563
|
+
configuration_filename_set.add(os.path.join(context.git_dir, "index"))
|
|
564
|
+
configuration_filename_set.add(os.path.join(context.repository_dir, ".git"))
|
|
565
|
+
configuration_filename_set.add(os.path.join(context.git_dir, "HEAD"))
|
|
566
|
+
configuration_filename_set.add(os.path.join(context.common_git_dir, "packed-refs"))
|
|
567
|
+
head_reference = yield GitCommand(["-C", context.repository_dir, "symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
568
|
+
if head_reference.strip():
|
|
569
|
+
configuration_filename_set.add(declared_directory(context.common_git_dir, os.fsdecode(head_reference).strip()))
|
|
570
|
+
output = yield GitCommand(["-C", context.repository_dir, "config", "--includes", "--show-origin", "--null", "--get-regexp", r"^include(if\..*)?\.path$"], accepted_codes=(0, 1))
|
|
571
|
+
fields = iter(output.split(b"\0"))
|
|
572
|
+
for origin in fields:
|
|
573
|
+
if not origin:
|
|
574
|
+
continue
|
|
575
|
+
record = next(fields)
|
|
576
|
+
_key, value = record.split(b"\n", 1)
|
|
577
|
+
filename = os.path.expanduser(os.fsdecode(value))
|
|
578
|
+
if not os.path.isabs(filename) and origin.startswith(b"file:"):
|
|
579
|
+
source_filename = os.fsdecode(origin.removeprefix(b"file:"))
|
|
580
|
+
filename = os.path.join(context.repository_dir, os.path.dirname(source_filename), filename)
|
|
581
|
+
if os.path.isabs(filename):
|
|
582
|
+
configuration_filename_set.add(os.path.abspath(filename))
|
|
583
|
+
return parent, validate_oid(expected_oid), tuple(known_contexts), tuple(sorted(configuration_filename_set)), tuple(inspected_dirs)
|
|
584
|
+
|
|
585
|
+
def _inside(self, root_dir: str, directory: str) -> bool:
|
|
586
|
+
"""Compare path boundaries without accepting similarly prefixed sibling folders."""
|
|
587
|
+
return os.path.commonpath((canonical_path(root_dir), canonical_path(directory))) == canonical_path(root_dir)
|
|
588
|
+
|
|
589
|
+
def authentication_target(self, context: RepositoryContext, diagnostic: str) -> Generator:
|
|
590
|
+
"""Identify an HTTPS child from native declarations and the reported authentication refusal.
|
|
591
|
+
|
|
592
|
+
Besides plain refusals, Git's words for credentials nobody gave count, and so does a repository the server says
|
|
593
|
+
does not exist: GitHub answers that way for a private repository the account used cannot see, so another
|
|
594
|
+
account is offered. A submodule URL that is really wrong is offered an account too; its retry fails the same way.
|
|
595
|
+
"""
|
|
596
|
+
lowered = diagnostic.lower()
|
|
597
|
+
refused = any(word in lowered for word in ("authentication", "credential", "401", "403"))
|
|
598
|
+
if not refused and CREDENTIALS_MISSING_PATTERN.search(diagnostic) is None and not repository_not_found(git_output_lines(diagnostic)):
|
|
599
|
+
return None
|
|
600
|
+
snapshot = yield from self._read(context.repository_dir)
|
|
601
|
+
targets = []
|
|
602
|
+
for entry in snapshot.entries:
|
|
603
|
+
if entry.availability != "not initialized" or entry.parent_dir is None:
|
|
604
|
+
continue
|
|
605
|
+
parent = yield from self._context(entry.parent_dir)
|
|
606
|
+
endpoint_raw = yield GitCommand(["-C", entry.parent_dir, "config", "--get", "submodule." + entry.section + ".url"], accepted_codes=(0, 1))
|
|
607
|
+
endpoint = endpoint_raw.decode("utf-8", "replace").strip()
|
|
608
|
+
if not endpoint.startswith("https://"):
|
|
609
|
+
continue
|
|
610
|
+
resolved_raw = yield GitCommand(["-C", entry.parent_dir, "ls-remote", "--get-url", "--", endpoint])
|
|
611
|
+
endpoint = resolved_raw.decode("utf-8", "replace").strip()
|
|
612
|
+
target = SubmoduleAuthenticationTarget(entry, endpoint, parent, initialization_signature(parent, entry.repository_dir))
|
|
613
|
+
if endpoint in diagnostic:
|
|
614
|
+
return target
|
|
615
|
+
targets.append(target)
|
|
616
|
+
return next(iter(targets)) if len(targets) == 1 else None
|
|
617
|
+
|
|
618
|
+
def resume_missing(self, owner: OperationIntent, trace: OperationTrace, is_current: Callable[[], bool], destinations: tuple = (), *,
|
|
619
|
+
credential_helper: OperationCredentialHelper | None = None, refused_dir_set: set[str] | None = None, initialize_missing: bool = True,
|
|
620
|
+
scope_dir: str | None = None, retry_wording: bool = True) -> Generator:
|
|
621
|
+
"""Reread the family, then initialize its missing children one at a time under an already reserved owner; return the last read.
|
|
622
|
+
|
|
623
|
+
Every child shares ``credential_helper``: the credential manager is searched at most once for the whole operation.
|
|
624
|
+
|
|
625
|
+
With ``refused_dir_set``, the children Git cloned but never checked out, because it stopped on a refused checkout
|
|
626
|
+
before them, are checked out too, except the folders in that set: Git refused their checkout on a file name, and
|
|
627
|
+
would refuse it again. A child of that kind whose checkout Git refuses in turn on a file name, even one cut by a
|
|
628
|
+
line break, gets its sentence when Git could cite the refused path, one console line per refused submodule, its
|
|
629
|
+
folder joins the set and the next child follows. Any other failure, such as a locked index or a name too long for
|
|
630
|
+
the file system, stops here with the diagnostic of its Git process, and the folder stays out of the set so that a
|
|
631
|
+
later retry checks it out again. A child that only looks like one (a file in its folder, an index) is never written.
|
|
632
|
+
``initialize_missing`` set to False leaves the children never cloned alone, and ``scope_dir`` limits the work to
|
|
633
|
+
the children inside that folder.
|
|
634
|
+
``retry_wording`` picks which action the causes of a failure name: Retry submodules, or opening the submodule again.
|
|
635
|
+
"""
|
|
636
|
+
if credential_helper is None:
|
|
637
|
+
credential_helper = OperationCredentialHelper()
|
|
638
|
+
attempted_dir_set: set[str] = set()
|
|
639
|
+
while True:
|
|
640
|
+
if not is_current():
|
|
641
|
+
raise ValueError("Submodule retry was cancelled. Existing files were preserved.")
|
|
642
|
+
snapshot = yield from self._read(owner.context.repository_dir)
|
|
643
|
+
if snapshot.cause:
|
|
644
|
+
raise ValueError(snapshot.cause)
|
|
645
|
+
missing = None
|
|
646
|
+
cloned_without_checkout = False
|
|
647
|
+
for entry in snapshot.entries:
|
|
648
|
+
if scope_dir is not None and (not entry.repository_dir or not self._inside(scope_dir, entry.repository_dir)):
|
|
649
|
+
continue
|
|
650
|
+
if entry.availability == "not initialized":
|
|
651
|
+
if not initialize_missing:
|
|
652
|
+
continue
|
|
653
|
+
missing = entry
|
|
654
|
+
break
|
|
655
|
+
if entry.availability != "initialized":
|
|
656
|
+
raise ValueError(entry.cause or "Submodule state could not be read.")
|
|
657
|
+
if refused_dir_set is None or entry.parent_dir is None:
|
|
658
|
+
continue
|
|
659
|
+
directory = canonical_path(entry.repository_dir)
|
|
660
|
+
if directory in refused_dir_set or directory in attempted_dir_set or not is_cloned_without_checkout(entry.repository_dir):
|
|
661
|
+
continue
|
|
662
|
+
attempted_dir_set.add(directory)
|
|
663
|
+
missing = entry
|
|
664
|
+
cloned_without_checkout = True
|
|
665
|
+
break
|
|
666
|
+
if missing is None:
|
|
667
|
+
return snapshot
|
|
668
|
+
parent, expected_oid, _known_contexts, configuration_filenames, inspected_dirs = yield from self._prepare_initialization(
|
|
669
|
+
missing, cloned_without_checkout=cloned_without_checkout)
|
|
670
|
+
operation = OperationIntent.capture(owner.operation_id, parent, owner.git_filename, session_id=owner.session_id, generation=owner.generation,
|
|
671
|
+
capability=owner.capability, operation=owner.operation, action=OperationAction.SUBMODULE_INIT)
|
|
672
|
+
signature = initialization_signature(parent, missing.repository_dir, configuration_filenames, inspected_dirs)
|
|
673
|
+
run = SubmoduleInitRun(owner.operation_id, missing, trace, owner.session_id)
|
|
674
|
+
run.intent = SubmoduleInitIntent(operation, os.fsencode(missing.path), expected_oid, missing.repository_dir, signature, configuration_filenames, inspected_dirs,
|
|
675
|
+
cloned_without_checkout)
|
|
676
|
+
yield from self._initialize(run, is_current, owner, destinations, credential_helper=credential_helper, retry_wording=retry_wording)
|
|
677
|
+
if run.status == OperationStatus.SUCCESS.value and run.validation_status == OperationStatus.SUCCESS.value:
|
|
678
|
+
continue
|
|
679
|
+
error_text = run.git_error_text if run.diagnostic is not None and not run.cancelled else ""
|
|
680
|
+
refused_paths = refused_checkout_submodule_paths(error_text)
|
|
681
|
+
if refused_dir_set is not None:
|
|
682
|
+
refused_dir_set.update(refused_submodule_dirs(refused_paths, parent.repository_dir))
|
|
683
|
+
if cloned_without_checkout and refused_paths:
|
|
684
|
+
# Git's lines are already in the console: the reads that ended the initialization ran after its process.
|
|
685
|
+
for refusal in checkout_refusals(error_text):
|
|
686
|
+
trace.append_failure_cause(owner.operation_id, checkout_refusal_text((refusal,), sys.platform))
|
|
687
|
+
continue
|
|
688
|
+
if run.diagnostic is not None:
|
|
689
|
+
raise SubmoduleInitializationError(run.diagnostic, run.git_error_text)
|
|
690
|
+
raise ValueError(INITIALIZE_INCOMPLETE_START + (RETRY_AGAIN_SENTENCE if retry_wording else OPEN_AGAIN_RETRY_SENTENCE))
|
|
691
|
+
|
|
692
|
+
def _initialize(self, run: SubmoduleInitRun, is_current: Callable[[], bool], external_owner: OperationIntent | None = None, selected_destinations: tuple = (), *,
|
|
693
|
+
credential_helper: OperationCredentialHelper | None = None, retry_wording: bool | None = None) -> Generator:
|
|
694
|
+
"""Use Git's recursive checkout and reconcile even failed or interrupted partial results.
|
|
695
|
+
|
|
696
|
+
The reservation is first extended to the parent and the submodules already there; a refused extension says why,
|
|
697
|
+
naming another operation only when a write really holds one of them. When a destination has an account and the
|
|
698
|
+
operation's ``credential_helper`` is still empty, the credential manager is searched here, in the background,
|
|
699
|
+
and kept there for the following children. ``retry_wording`` says whether the causes name Retry submodules; by
|
|
700
|
+
default they do when another owner holds the work, as a retry from the clone form does.
|
|
701
|
+
|
|
702
|
+
A submodule Git cloned but never checked out is checked out with ``--force``, since Git would skip one whose HEAD
|
|
703
|
+
already names the recorded commit; right before Git starts, its folder must still hold nothing but its ``.git``
|
|
704
|
+
file, and its Git folder no index.
|
|
705
|
+
"""
|
|
706
|
+
if credential_helper is None:
|
|
707
|
+
credential_helper = OperationCredentialHelper()
|
|
708
|
+
if retry_wording is None:
|
|
709
|
+
retry_wording = external_owner is not None
|
|
710
|
+
# A retry started from the clone form names its own button: the submodule cannot be opened from there.
|
|
711
|
+
if retry_wording:
|
|
712
|
+
interrupted_ending, failed_ending, retry_sentence = RETRY_INTERRUPTED_ENDING, RETRY_FAILED_ENDING, RETRY_AGAIN_SENTENCE
|
|
713
|
+
else:
|
|
714
|
+
interrupted_ending, failed_ending, retry_sentence = OPEN_AGAIN_INTERRUPTED_ENDING, OPEN_AGAIN_FAILED_ENDING, OPEN_AGAIN_RETRY_SENTENCE
|
|
715
|
+
intent = run.intent
|
|
716
|
+
parent = intent.operation.context
|
|
717
|
+
parent_again, expected_oid, known_contexts, configuration_filenames, inspected_dirs = yield from self._prepare_initialization(
|
|
718
|
+
run.entry, cloned_without_checkout=intent.cloned_without_checkout)
|
|
719
|
+
if parent_again != parent or expected_oid != intent.expected_oid or configuration_filenames != intent.configuration_filenames or inspected_dirs != intent.inspected_dirs:
|
|
720
|
+
raise ValueError("The parent repository or indexed submodule changed. Refresh and retry.")
|
|
721
|
+
owner = external_owner or intent.operation
|
|
722
|
+
active_owner = self.repository_operation.active_by_id[owner.operation_id]
|
|
723
|
+
extended_owner = replace(owner, reserved_contexts=(*active_owner.reserved_contexts, parent, *known_contexts))
|
|
724
|
+
if not self.repository_operation.extend(extended_owner):
|
|
725
|
+
# The submodules reached now can be refused by their own state, a paused rebase for example, as well as by a write holding them.
|
|
726
|
+
raise ValueError(self.repository_operation.refusal_cause(extended_owner))
|
|
727
|
+
repository_dirs = (parent.repository_dir, *(context.repository_dir for context in known_contexts))
|
|
728
|
+
inherited_keys = yield from self.authentication.recursive_inherited_keys(repository_dirs)
|
|
729
|
+
destinations = list(self.authentication.recursive_context())
|
|
730
|
+
from gitpane.account.authentication import PREVIEW_HELPER_COMMANDS, RecursiveTransportContext, endpoint_origin, helper_after_refusal
|
|
731
|
+
|
|
732
|
+
parent_uses_account = False
|
|
733
|
+
for context in (parent, *known_contexts):
|
|
734
|
+
endpoint_raw = yield GitCommand(["-C", context.repository_dir, "remote", "get-url", "origin"], accepted_codes=(0, 2))
|
|
735
|
+
endpoint = endpoint_raw.decode("utf-8", "replace").strip()
|
|
736
|
+
if endpoint.startswith("https://"):
|
|
737
|
+
selection = self.authentication.account_for_endpoint(endpoint, context)
|
|
738
|
+
destinations.append(RecursiveTransportContext(endpoint, endpoint_origin(endpoint), selection, context))
|
|
739
|
+
if context is parent and selection is not None:
|
|
740
|
+
parent_uses_account = True
|
|
741
|
+
destinations.extend(selected_destinations)
|
|
742
|
+
# An account chosen for this operation itself, such as the account the user picked for a refused submodule
|
|
743
|
+
# before Retry submodules, commits it as much as the parent's own account, so a refused credential manager
|
|
744
|
+
# stops it on its sentence instead of letting Git run without any helper. Only the operation's own choices
|
|
745
|
+
# count: the service defaults in ``destinations`` are kept for other submodules and never block it.
|
|
746
|
+
operation_uses_account = parent_uses_account
|
|
747
|
+
for selected_destination in selected_destinations:
|
|
748
|
+
if selected_destination.selection is not None:
|
|
749
|
+
operation_uses_account = True
|
|
750
|
+
if credential_helper.commands is None and any(destination.selection is not None for destination in destinations):
|
|
751
|
+
git_filename = intent.operation.git_filename
|
|
752
|
+
try:
|
|
753
|
+
helper = yield FileTask(lambda: self.authentication.credential_helper(git_filename))
|
|
754
|
+
credential_helper.commands = helper.commands
|
|
755
|
+
except ValueError as refusal:
|
|
756
|
+
credential_helper.commands = helper_after_refusal(refusal, operation_uses_account=operation_uses_account, log=self.log)
|
|
757
|
+
helper_commands = credential_helper.commands if credential_helper.commands is not None else PREVIEW_HELPER_COMMANDS
|
|
758
|
+
arguments = self.authentication.recursive_configuration(tuple(destinations), inherited_keys, helper_commands=helper_commands)
|
|
759
|
+
environment_by_name = {}
|
|
760
|
+
if self.ssh.configuration().ssh_filename:
|
|
761
|
+
ssh_arguments, environment_by_name = self.ssh.command_configuration()
|
|
762
|
+
arguments.extend(ssh_arguments)
|
|
763
|
+
|
|
764
|
+
def before_start() -> None:
|
|
765
|
+
"""Recheck queued filesystem identity and authority immediately before native writes."""
|
|
766
|
+
current_owner = self.repository_operation.active_by_id.get(owner.operation_id)
|
|
767
|
+
if current_owner is None:
|
|
768
|
+
raise ValueError("Submodule initialization no longer owns its repositories")
|
|
769
|
+
self.repository_operation.validate(current_owner)
|
|
770
|
+
if run.cancelled or not is_current():
|
|
771
|
+
raise ValueError(INITIALIZE_INTERRUPTED_KEPT_START + retry_sentence)
|
|
772
|
+
if initialization_signature(parent, intent.repository_dir, configuration_filenames, inspected_dirs) != intent.signature:
|
|
773
|
+
raise ValueError("The parent configuration, index or submodule path changed. Refresh and retry.")
|
|
774
|
+
# The forced checkout may only ever write into a folder that still holds no file of anyone.
|
|
775
|
+
if intent.cloned_without_checkout and not is_cloned_without_checkout(intent.repository_dir):
|
|
776
|
+
raise ValueError(SharedText.CLONED_SUBMODULE_CHANGED.value.format(submodule=run.entry.path))
|
|
777
|
+
self.authentication.recursive_configuration(tuple(destinations), inherited_keys, helper_commands=helper_commands)
|
|
778
|
+
|
|
779
|
+
# The launcher runs before_start when the process really starts, after any wait in its queue; checking here
|
|
780
|
+
# as well would only compute the same signature once more, before the moment that matters.
|
|
781
|
+
# No protocol.file.allow is added: Git's own setting decides whether a submodule whose URL is a local folder or a
|
|
782
|
+
# file:// URL may be cloned, and a refusal is explained when the initialization ends.
|
|
783
|
+
update_arguments = SUBMODULE_CLONED_CHECKOUT_ARGUMENTS if intent.cloned_without_checkout else SUBMODULE_INIT_ARGUMENTS
|
|
784
|
+
request = ProcessRequest(run.operation_id, intent.operation.git_filename,
|
|
785
|
+
["--literal-pathspecs", "-C", parent.repository_dir, *arguments, *update_arguments,
|
|
786
|
+
os.fsdecode(intent.path)],
|
|
787
|
+
parent.repository_dir, GIT_CLONE_TIMEOUT_MS, session_id=intent.operation.session_id, idle_timeout_ms=GIT_TRANSPORT_TIMEOUT_MS,
|
|
788
|
+
environment_by_name=environment_by_name, operation="Initialize submodule", profile=NETWORK_PROFILE, operation_id=run.operation_id, before_start=before_start)
|
|
789
|
+
request = self.ssh.prompt(request, context=owner.context, generation=owner.generation, is_current=lambda: not run.cancelled and is_current())
|
|
790
|
+
run.status = "running"
|
|
791
|
+
# The existing scheduler resumes this sequence only after native descendants drain.
|
|
792
|
+
result = yield request
|
|
793
|
+
run.native_status = result.status
|
|
794
|
+
run.return_code = result.return_code
|
|
795
|
+
run.git_error_text = git_error_text(result.stderr)
|
|
796
|
+
run.status = OperationStatus.CANCELLED.value if run.cancelled else operation_status(result.status).value
|
|
797
|
+
if result.status != ProcessStatus.SUCCESS:
|
|
798
|
+
# A refusal of the launch check carries its own reason (a changed index, configuration or owner); it is the
|
|
799
|
+
# useful explanation, since Git never ran and wrote nothing to its error output.
|
|
800
|
+
refusal = result.cause if result.status == ProcessStatus.START_FAILED else ""
|
|
801
|
+
cause = INITIALIZE_INTERRUPTED_START + interrupted_ending if run.cancelled else refusal or INITIALIZE_FAILED_START + failed_ending
|
|
802
|
+
run.diagnostic = Diagnostic("Initialize submodule", intent.repository_dir, request.program_filename, request.arguments, cause,
|
|
803
|
+
result.stderr, result.return_code, process_id=request.request_id).prepare()
|
|
804
|
+
run.validation_status = "running"
|
|
805
|
+
declared_directory(parent.repository_dir, os.fsdecode(intent.path))
|
|
806
|
+
if (yield from self._context(parent.repository_dir)) != parent:
|
|
807
|
+
raise ValueError("The parent repository identity changed during initialization.")
|
|
808
|
+
run.snapshot = yield from self._read(parent.repository_dir)
|
|
809
|
+
if result.status != ProcessStatus.SUCCESS or run.cancelled:
|
|
810
|
+
run.validation_status = OperationStatus.PARTIAL.value
|
|
811
|
+
return
|
|
812
|
+
section_by_path, stage_by_path = yield from self._read_declarations(parent.repository_dir)
|
|
813
|
+
if section_by_path.get(os.fsdecode(intent.path)) != run.entry.section or stage_by_path.get(os.fsdecode(intent.path)) != [("160000", intent.expected_oid, "0")]:
|
|
814
|
+
raise ValueError("The parent declaration or indexed submodule changed during initialization.")
|
|
815
|
+
child_context = yield from self._context(intent.repository_dir)
|
|
816
|
+
output = yield GitCommand(["-C", child_context.repository_dir, "rev-parse", "HEAD"])
|
|
817
|
+
if output.decode().strip() != intent.expected_oid:
|
|
818
|
+
raise ValueError("The initialized submodule does not match the captured parent index.")
|
|
819
|
+
output = yield GitCommand(["-C", parent.repository_dir, "submodule", "status", "--recursive", "--", os.fsdecode(intent.path)])
|
|
820
|
+
if not output or any(not line.startswith(b" ") for line in output.splitlines()):
|
|
821
|
+
raise ValueError(INITIALIZE_INCOMPLETE_START + retry_sentence)
|
|
822
|
+
if run.snapshot.cause or any(child.availability != "initialized" for child in run.snapshot.entries if child.repository_dir and self._inside(intent.repository_dir, child.repository_dir)):
|
|
823
|
+
raise ValueError("Submodule validation is incomplete. Existing files were preserved.")
|
|
824
|
+
run.validation_status = OperationStatus.SUCCESS.value
|
|
825
|
+
if run.cancelled or not is_current():
|
|
826
|
+
run.status = OperationStatus.CANCELLED.value
|
|
827
|
+
|
|
828
|
+
def read(self, context: RepositoryContext, git_filename: str, callback: Callable[[SubmoduleSnapshot | None, Exception | None], None], root_dir: str | None = None) -> None:
|
|
829
|
+
"""Discover an entire initialized family without changing any checkout."""
|
|
830
|
+
self.worktree.run(context, git_filename, self._read(root_dir or context.repository_dir), callback)
|
|
831
|
+
|
|
832
|
+
def read_reference_state(self, context: RepositoryContext, git_filename: str, path: bytes, callback: Callable[[SubmoduleReferenceState | None, Exception | None], None],
|
|
833
|
+
*, parent_dir: str | None = None) -> None:
|
|
834
|
+
"""Read the three sources from either the parent view or a child with a verified parent."""
|
|
835
|
+
self.worktree.run(context, git_filename, self._validated_reference(parent_dir or context.repository_dir, path), callback)
|
|
836
|
+
|
|
837
|
+
def _validated_reference(self, parent_dir: str, path: bytes) -> Generator:
|
|
838
|
+
"""Reject a reference capture when its sources change during observation."""
|
|
839
|
+
reference = yield from self._read_reference_state(parent_dir, path)
|
|
840
|
+
if reference != (yield from self._read_reference_state(parent_dir, path)):
|
|
841
|
+
raise ValueError("The submodule changed since it was read. Refresh the repository and review it before staging again.")
|
|
842
|
+
return reference
|
|
843
|
+
|
|
844
|
+
def _read_reference_state(self, parent_dir: str, path: bytes) -> Generator:
|
|
845
|
+
"""Read exact gitlinks and child identity independently, preserving unavailable facts."""
|
|
846
|
+
child_dir = declared_directory(parent_dir, os.fsdecode(path))
|
|
847
|
+
causes: list[str] = []
|
|
848
|
+
parent_head_oid = committed_oid = indexed_oid = checkout_oid = None
|
|
849
|
+
committed_availability = indexed_availability = checkout_availability = "Unavailable"
|
|
850
|
+
state = SubmoduleState()
|
|
851
|
+
internal_changes = None
|
|
852
|
+
try:
|
|
853
|
+
raw_head = yield summary_command(parent_dir, ["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
854
|
+
parent_head_oid = validate_oid(raw_head.decode().strip()) if raw_head.strip() else None
|
|
855
|
+
committed_availability = "Parent has no commit" if parent_head_oid is None else "No committed gitlink"
|
|
856
|
+
if parent_head_oid is not None:
|
|
857
|
+
output = yield summary_command(parent_dir, ["ls-tree", "-z", parent_head_oid, "--", os.fsdecode(path)])
|
|
858
|
+
for entry in parse_tree_entries(output):
|
|
859
|
+
if entry.path == path and entry.mode == "160000":
|
|
860
|
+
committed_oid, committed_availability = entry.oid, "Available"
|
|
861
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
862
|
+
committed_availability = "Unavailable"
|
|
863
|
+
causes.append(str(error))
|
|
864
|
+
try:
|
|
865
|
+
output = yield summary_command(parent_dir, ["ls-files", "--stage", "-z", "--", os.fsdecode(path)])
|
|
866
|
+
indexed_availability = "No indexed gitlink"
|
|
867
|
+
for record in output.split(b"\0"):
|
|
868
|
+
if not record:
|
|
869
|
+
continue
|
|
870
|
+
metadata, raw_path = record.split(b"\t", 1)
|
|
871
|
+
mode, oid, stage = metadata.decode().split()
|
|
872
|
+
if raw_path != path:
|
|
873
|
+
continue
|
|
874
|
+
if stage != "0":
|
|
875
|
+
raise ValueError("The submodule index has unresolved stages")
|
|
876
|
+
if mode == "160000":
|
|
877
|
+
indexed_oid, indexed_availability = validate_oid(oid), "Available"
|
|
878
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
879
|
+
indexed_oid, indexed_availability = None, "Unavailable"
|
|
880
|
+
causes.append(str(error))
|
|
881
|
+
try:
|
|
882
|
+
output = yield summary_command(child_dir, ["rev-parse", "--show-toplevel"])
|
|
883
|
+
if canonical_path(os.fsdecode(output).rstrip("\r\n")) != canonical_path(child_dir):
|
|
884
|
+
raise ValueError("Submodule does not resolve to its declared worktree")
|
|
885
|
+
output = yield summary_command(child_dir, ["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
886
|
+
checkout_oid = validate_oid(output.decode().strip()) if output.strip() else None
|
|
887
|
+
checkout_availability = "Available" if checkout_oid else "Child has no commit"
|
|
888
|
+
output = yield summary_command(child_dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"])
|
|
889
|
+
internal_changes = parse_internal_status(output)
|
|
890
|
+
tracked_changed = untracked_changed = False
|
|
891
|
+
for change in internal_changes:
|
|
892
|
+
if change.status == "??":
|
|
893
|
+
untracked_changed = True
|
|
894
|
+
else:
|
|
895
|
+
tracked_changed = True
|
|
896
|
+
state = SubmoduleState(None if checkout_oid is None or indexed_oid is None else checkout_oid != indexed_oid, tracked_changed, untracked_changed)
|
|
897
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
898
|
+
causes.append(str(error))
|
|
899
|
+
return SubmoduleReferenceState(parent_dir, path, child_dir, parent_head_oid, committed_oid, indexed_oid, checkout_oid,
|
|
900
|
+
committed_availability, indexed_availability, checkout_availability, state, "\n".join(causes), internal_changes)
|
|
901
|
+
|
|
902
|
+
def compare(self, context: RepositoryContext, git_filename: str, entry: LocalEntry, callback: Callable[[SubmoduleSummary | None, Exception | None], None]) -> None:
|
|
903
|
+
"""Compare the selected side in the verified child and reject changed sources."""
|
|
904
|
+
self.worktree.run(context, git_filename, self._compare(context.repository_dir, entry.path, entry.side), callback)
|
|
905
|
+
|
|
906
|
+
def _commit(self, directory: str, oid: str | None) -> Generator:
|
|
907
|
+
"""Read the real subject and author date without losing an unavailable hash."""
|
|
908
|
+
if oid is None:
|
|
909
|
+
return SubmoduleCommit(None)
|
|
910
|
+
output = yield summary_command(directory, ["show", "--no-patch", "--no-notes", "--format=%at%x00%s", oid, "--"])
|
|
911
|
+
timestamp, subject = output.removesuffix(b"\n").split(b"\0", 1)
|
|
912
|
+
return SubmoduleCommit(oid, int(timestamp), subject.decode("utf-8", "replace"))
|
|
913
|
+
|
|
914
|
+
def _ancestor(self, directory: str, source_oid: str, target_oid: str) -> Generator:
|
|
915
|
+
"""Distinguish a negative ancestry answer from a failed Git read."""
|
|
916
|
+
try:
|
|
917
|
+
yield summary_command(directory, ["merge-base", "--is-ancestor", source_oid, target_oid])
|
|
918
|
+
except GitCommandError as error:
|
|
919
|
+
if error.return_code == 1 and error.process_status == ProcessStatus.FAILED:
|
|
920
|
+
return False
|
|
921
|
+
raise
|
|
922
|
+
return True
|
|
923
|
+
|
|
924
|
+
def _commit_count(self, directory: str, source_oid: str, target_oid: str) -> Generator:
|
|
925
|
+
"""Keep the established total independent of bounded metadata retrieval."""
|
|
926
|
+
output = yield summary_command(directory, ["rev-list", "--count", source_oid + ".." + target_oid, "--"])
|
|
927
|
+
return int(output.strip())
|
|
928
|
+
|
|
929
|
+
def _commits(self, directory: str, source_oid: str, target_oid: str) -> Generator:
|
|
930
|
+
"""List a bounded prefix without treating its length as the total history."""
|
|
931
|
+
revision = source_oid + ".." + target_oid
|
|
932
|
+
output = yield summary_command(directory, ["log", "--no-notes", "--format=%H%x00%at%x00%s%x00", "--max-count=" + str(SUBMODULE_SUMMARY_COMMIT_LIMIT), revision, "--"])
|
|
933
|
+
commits: list[SubmoduleCommit] = []
|
|
934
|
+
fields = output.split(b"\0")
|
|
935
|
+
field_count = 3
|
|
936
|
+
for offset in range(0, len(fields) - 1, field_count):
|
|
937
|
+
oid, timestamp, subject = fields[offset:offset + field_count]
|
|
938
|
+
commits.append(SubmoduleCommit(validate_oid(oid.strip().decode()), int(timestamp), subject.decode("utf-8", "replace")))
|
|
939
|
+
return tuple(commits)
|
|
940
|
+
|
|
941
|
+
def _relation(self, directory: str, source_oid: str, target_oid: str) -> Generator:
|
|
942
|
+
"""Only negative ancestry in a complete repository can establish unrelated history."""
|
|
943
|
+
if source_oid == target_oid:
|
|
944
|
+
return "Unchanged", False
|
|
945
|
+
output = yield summary_command(directory, ["rev-parse", "--is-shallow-repository"])
|
|
946
|
+
shallow = output.strip() == b"true"
|
|
947
|
+
if (yield from self._ancestor(directory, source_oid, target_oid)):
|
|
948
|
+
return "Fast Forward", shallow
|
|
949
|
+
if (yield from self._ancestor(directory, target_oid, source_oid)):
|
|
950
|
+
return "Rewind", shallow
|
|
951
|
+
common = yield summary_command(directory, ["merge-base", source_oid, target_oid], accepted_codes=(0, 1))
|
|
952
|
+
if shallow:
|
|
953
|
+
return "Unavailable", True
|
|
954
|
+
return ("Diverged" if common.strip() else "Unrelated histories"), False
|
|
955
|
+
|
|
956
|
+
def _differences(self, directory: str, source_oid: str, target_oid: str) -> Generator:
|
|
957
|
+
"""Preserve NUL-delimited filenames and explicit rename pairs."""
|
|
958
|
+
output = yield summary_command(directory, ["diff", "--name-status", "-z", "--find-renames", "--no-ext-diff", "--no-textconv", "--no-color", source_oid, target_oid, "--"])
|
|
959
|
+
fields = iter(output.split(b"\0"))
|
|
960
|
+
differences: list[SubmoduleDifference] = []
|
|
961
|
+
truncated = False
|
|
962
|
+
for raw_status in fields:
|
|
963
|
+
if not raw_status:
|
|
964
|
+
break
|
|
965
|
+
status = raw_status.decode("ascii")
|
|
966
|
+
path = next(fields)
|
|
967
|
+
old_path = None
|
|
968
|
+
if status.startswith(("R", "C")):
|
|
969
|
+
old_path, path = path, next(fields)
|
|
970
|
+
if len(differences) < SUBMODULE_SUMMARY_DIFFERENCE_LIMIT:
|
|
971
|
+
differences.append(SubmoduleDifference(status, path, old_path))
|
|
972
|
+
else:
|
|
973
|
+
truncated = True
|
|
974
|
+
return tuple(differences), truncated
|
|
975
|
+
|
|
976
|
+
def _compare(self, parent_dir: str, path: bytes, side: StagingSide) -> Generator:
|
|
977
|
+
"""Build facts between immutable commits and recheck all mutable sources at the end."""
|
|
978
|
+
if side not in (StagingSide.STAGED, StagingSide.UNSTAGED):
|
|
979
|
+
raise ValueError("Unknown comparison side")
|
|
980
|
+
reference = yield from self._read_reference_state(parent_dir, path)
|
|
981
|
+
source_oid, target_oid = (reference.committed_oid, reference.indexed_oid) if side == StagingSide.STAGED else (reference.indexed_oid, reference.checkout_oid)
|
|
982
|
+
directory = reference.repository_dir
|
|
983
|
+
causes = [reference.cause] if reference.cause else []
|
|
984
|
+
relation = "Unavailable"
|
|
985
|
+
counts: list[int | None] = [None, None]
|
|
986
|
+
commit_lists: list[tuple[SubmoduleCommit, ...]] = [(), ()]
|
|
987
|
+
endpoint_commits: list[SubmoduleCommit] = []
|
|
988
|
+
differences, truncated = None, False
|
|
989
|
+
child_available = reference.checkout_availability != "Unavailable"
|
|
990
|
+
for oid in (source_oid, target_oid):
|
|
991
|
+
commit = SubmoduleCommit(oid)
|
|
992
|
+
if child_available:
|
|
993
|
+
try:
|
|
994
|
+
commit = yield from self._commit(directory, oid)
|
|
995
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
996
|
+
causes.append(str(error))
|
|
997
|
+
endpoint_commits.append(commit)
|
|
998
|
+
if child_available and source_oid and target_oid:
|
|
999
|
+
shallow = True
|
|
1000
|
+
try:
|
|
1001
|
+
relation, shallow = yield from self._relation(directory, source_oid, target_oid)
|
|
1002
|
+
if shallow:
|
|
1003
|
+
causes.append("History is shallow; complete added and removed commit counts are unavailable.")
|
|
1004
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1005
|
+
causes.append(str(error))
|
|
1006
|
+
if not shallow:
|
|
1007
|
+
for position, (old_oid, new_oid) in enumerate(((source_oid, target_oid), (target_oid, source_oid))):
|
|
1008
|
+
try:
|
|
1009
|
+
counts[position] = yield from self._commit_count(directory, old_oid, new_oid)
|
|
1010
|
+
commit_lists[position] = yield from self._commits(directory, old_oid, new_oid)
|
|
1011
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1012
|
+
causes.append(str(error))
|
|
1013
|
+
try:
|
|
1014
|
+
differences, truncated = yield from self._differences(directory, source_oid, target_oid)
|
|
1015
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1016
|
+
causes.append(str(error))
|
|
1017
|
+
if reference != (yield from self._read_reference_state(parent_dir, path)):
|
|
1018
|
+
raise ValueError("The submodule changed since it was read. Refresh the repository and review it before staging again.")
|
|
1019
|
+
source, target = endpoint_commits
|
|
1020
|
+
added_count, removed_count = counts
|
|
1021
|
+
added_commits, removed_commits = commit_lists
|
|
1022
|
+
return SubmoduleSummary(reference, side, source, target, relation, added_count, removed_count, added_commits, removed_commits, differences, truncated, "\n".join(causes))
|
|
1023
|
+
|
|
1024
|
+
def _read_declarations(self, directory: str) -> Generator:
|
|
1025
|
+
"""Join declarations with all index stages while rejecting ambiguous paths."""
|
|
1026
|
+
modules_filename = os.path.join(directory, ".gitmodules")
|
|
1027
|
+
if not os.path.lexists(modules_filename):
|
|
1028
|
+
return {}, {}
|
|
1029
|
+
if os.path.islink(modules_filename) or not os.path.isfile(modules_filename):
|
|
1030
|
+
raise ValueError("Invalid .gitmodules file")
|
|
1031
|
+
declarations = yield GitCommand(["-C", directory, "config", "--null", "--file", modules_filename, "--get-regexp", r"^submodule\..*\.path$"], accepted_codes=(0, 1))
|
|
1032
|
+
index = yield GitCommand(["-C", directory, "ls-files", "--stage", "-z"])
|
|
1033
|
+
stage_by_path: dict[str, list[tuple[str, str, str]]] = {}
|
|
1034
|
+
section_by_path: dict[str, str] = {}
|
|
1035
|
+
for record in index.split(b"\0"):
|
|
1036
|
+
if record:
|
|
1037
|
+
metadata, raw_path = record.split(b"\t", 1)
|
|
1038
|
+
mode, oid, stage = metadata.decode().split()
|
|
1039
|
+
stage_by_path.setdefault(os.fsdecode(raw_path), []).append((mode, oid, stage))
|
|
1040
|
+
for declaration in declarations.split(b"\0"):
|
|
1041
|
+
if declaration:
|
|
1042
|
+
key, raw_path = declaration.split(b"\n", 1)
|
|
1043
|
+
path = os.fsdecode(raw_path)
|
|
1044
|
+
if path in section_by_path:
|
|
1045
|
+
raise ValueError("Duplicate submodule path declaration")
|
|
1046
|
+
section_by_path[path] = key.decode("utf-8", "replace").removeprefix("submodule.").removesuffix(".path")
|
|
1047
|
+
return section_by_path, stage_by_path
|
|
1048
|
+
|
|
1049
|
+
def _root_and_parent(self, directory: str, verified_directory_set: set[str]) -> Generator:
|
|
1050
|
+
"""Prove a repository's own root and name its native parent, in one process when the output is unambiguous.
|
|
1051
|
+
|
|
1052
|
+
A directory whose root Git confirms is added to the caller's set of proven roots. Output that does not split
|
|
1053
|
+
into one or two absolute paths starting with this exact root is read again one question at a time, so a line
|
|
1054
|
+
break inside a path can never turn part of a path into a parent.
|
|
1055
|
+
"""
|
|
1056
|
+
root_position, parent_position = 0, 1
|
|
1057
|
+
lone_field_count, parent_field_count = 1, 2
|
|
1058
|
+
output = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel", "--show-superproject-working-tree"])
|
|
1059
|
+
fields = os.fsdecode(output.removesuffix(b"\n")).split("\n")
|
|
1060
|
+
if (len(fields) in (lone_field_count, parent_field_count) and all(os.path.isabs(field) for field in fields)
|
|
1061
|
+
and canonical_path(fields[root_position]) == canonical_path(directory)):
|
|
1062
|
+
verified_directory_set.add(canonical_path(directory))
|
|
1063
|
+
return fields[parent_position] if len(fields) == parent_field_count else ""
|
|
1064
|
+
root_raw = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel"])
|
|
1065
|
+
if canonical_path(os.fsdecode(root_raw).rstrip("\r\n")) == canonical_path(directory):
|
|
1066
|
+
verified_directory_set.add(canonical_path(directory))
|
|
1067
|
+
parent_raw = yield GitCommand(["-C", directory, "rev-parse", "--show-superproject-working-tree"])
|
|
1068
|
+
return os.fsdecode(parent_raw).rstrip("\r\n")
|
|
1069
|
+
|
|
1070
|
+
def _find_root(self, directory: str, verified_directory_set: set[str]) -> Generator:
|
|
1071
|
+
"""Ascend only through native parents that declare this exact gitlink.
|
|
1072
|
+
|
|
1073
|
+
Every repository crossed on the way proves its own root in the same process that names its parent; those
|
|
1074
|
+
proven roots are recorded in the set the caller owns for the duration of one read.
|
|
1075
|
+
"""
|
|
1076
|
+
visited_dir_set: set[str] = set()
|
|
1077
|
+
while canonical_path(directory) not in visited_dir_set:
|
|
1078
|
+
visited_dir_set.add(canonical_path(directory))
|
|
1079
|
+
parent_dir = yield from self._root_and_parent(directory, verified_directory_set)
|
|
1080
|
+
if not parent_dir or canonical_path(parent_dir) in visited_dir_set:
|
|
1081
|
+
break
|
|
1082
|
+
try:
|
|
1083
|
+
section_by_path, stage_by_path = yield from self._read_declarations(parent_dir)
|
|
1084
|
+
matched = False
|
|
1085
|
+
for path in section_by_path:
|
|
1086
|
+
if canonical_path(declared_directory(parent_dir, path)) != canonical_path(directory):
|
|
1087
|
+
continue
|
|
1088
|
+
if any(mode == "160000" for mode, _oid, _stage in stage_by_path.get(path, [])):
|
|
1089
|
+
matched = True
|
|
1090
|
+
break
|
|
1091
|
+
if not matched:
|
|
1092
|
+
break
|
|
1093
|
+
except (ValueError, RuntimeError, OSError):
|
|
1094
|
+
break
|
|
1095
|
+
directory = parent_dir
|
|
1096
|
+
return directory
|
|
1097
|
+
|
|
1098
|
+
def _read(self, root_dir: str) -> Generator:
|
|
1099
|
+
"""Keep recursive reads inside verified declared worktrees.
|
|
1100
|
+
|
|
1101
|
+
Each repository proves its exact root once per read: while ascending to the family root, or when it is taken
|
|
1102
|
+
from the queue after its declared path was validated again. The set of proven roots is local to this read, so
|
|
1103
|
+
no later read and no check made before a write can ever rely on it.
|
|
1104
|
+
"""
|
|
1105
|
+
verified_directory_set: set[str] = set()
|
|
1106
|
+
root_dir = yield from self._find_root(root_dir, verified_directory_set)
|
|
1107
|
+
entries: list[SubmoduleEntry] = []
|
|
1108
|
+
causes: list[str] = []
|
|
1109
|
+
pending_dirs = [(root_dir, None, "", "")]
|
|
1110
|
+
visited_dir_set: set[str] = set()
|
|
1111
|
+
while pending_dirs:
|
|
1112
|
+
directory, parent_dir, path, section = pending_dirs.pop(0)
|
|
1113
|
+
canonical_dir = canonical_path(directory)
|
|
1114
|
+
if canonical_dir in visited_dir_set:
|
|
1115
|
+
continue
|
|
1116
|
+
visited_dir_set.add(canonical_dir)
|
|
1117
|
+
try:
|
|
1118
|
+
if parent_dir is not None:
|
|
1119
|
+
declared_directory(parent_dir, path)
|
|
1120
|
+
if canonical_dir not in verified_directory_set:
|
|
1121
|
+
root_raw = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel"])
|
|
1122
|
+
if canonical_path(os.fsdecode(root_raw).rstrip("\r\n")) != canonical_dir:
|
|
1123
|
+
raise ValueError("Submodule does not resolve to its declared worktree")
|
|
1124
|
+
verified_directory_set.add(canonical_dir)
|
|
1125
|
+
branch_raw = yield GitCommand(["-C", directory, "symbolic-ref", "--quiet", "--short", "HEAD"], accepted_codes=(0, 1))
|
|
1126
|
+
head_raw = yield GitCommand(["-C", directory, "rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
1127
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1128
|
+
entries.append(SubmoduleEntry(directory, parent_dir, path, section, None, None, "unknown", str(error)))
|
|
1129
|
+
continue
|
|
1130
|
+
cause = ""
|
|
1131
|
+
internal_state = SubmoduleState()
|
|
1132
|
+
try:
|
|
1133
|
+
output = yield GitCommand(["-C", directory, "status", "--porcelain=v2", "-z", "--untracked-files=all", "--ignore-submodules=none"])
|
|
1134
|
+
local_entries = parse_status(output)
|
|
1135
|
+
tracked_changed = False
|
|
1136
|
+
untracked_changed = False
|
|
1137
|
+
for entry in local_entries:
|
|
1138
|
+
if entry.old_mode == "000000" and entry.new_mode == "000000" and entry.status == FileStatus.ADDED:
|
|
1139
|
+
untracked_changed = True
|
|
1140
|
+
else:
|
|
1141
|
+
tracked_changed = True
|
|
1142
|
+
internal_state = SubmoduleState(None, tracked_changed, untracked_changed)
|
|
1143
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1144
|
+
cause = str(error)
|
|
1145
|
+
entries.append(SubmoduleEntry(directory, parent_dir, path, section, branch_raw.decode("utf-8", "replace").strip() or None,
|
|
1146
|
+
head_raw.decode().strip() or None, "initialized", cause, internal_state))
|
|
1147
|
+
try:
|
|
1148
|
+
section_by_path, stage_by_path = yield from self._read_declarations(directory)
|
|
1149
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
1150
|
+
causes.append(str(error))
|
|
1151
|
+
continue
|
|
1152
|
+
for child_path, child_section in sorted(section_by_path.items()):
|
|
1153
|
+
try:
|
|
1154
|
+
child_dir = declared_directory(directory, child_path)
|
|
1155
|
+
except ValueError as error:
|
|
1156
|
+
entries.append(SubmoduleEntry("", directory, child_path, child_section, None, None, "invalid", str(error)))
|
|
1157
|
+
continue
|
|
1158
|
+
stages = stage_by_path.get(child_path, [])
|
|
1159
|
+
if any(stage != "0" for _mode, _oid, stage in stages):
|
|
1160
|
+
entries.append(SubmoduleEntry(child_dir, directory, child_path, child_section, None, None, "conflict", "The submodule index has unresolved stages"))
|
|
1161
|
+
continue
|
|
1162
|
+
if not stages or not any(mode == "160000" and stage == "0" for mode, _oid, stage in stages):
|
|
1163
|
+
entries.append(SubmoduleEntry(child_dir, directory, child_path, child_section, None, None, "unknown", "No stage-zero gitlink"))
|
|
1164
|
+
continue
|
|
1165
|
+
if not os.path.exists(child_dir):
|
|
1166
|
+
entries.append(SubmoduleEntry(child_dir, directory, child_path, child_section, None, None, "not initialized"))
|
|
1167
|
+
continue
|
|
1168
|
+
if not os.path.isdir(child_dir):
|
|
1169
|
+
entries.append(SubmoduleEntry(child_dir, directory, child_path, child_section, None, None, "occupied", "A file occupies the declared path"))
|
|
1170
|
+
continue
|
|
1171
|
+
if not os.path.lexists(os.path.join(child_dir, ".git")):
|
|
1172
|
+
with os.scandir(child_dir) as children:
|
|
1173
|
+
occupied = next(children, None) is not None
|
|
1174
|
+
availability = "occupied" if occupied else "not initialized"
|
|
1175
|
+
entries.append(SubmoduleEntry(child_dir, directory, child_path, child_section, None, None, availability,
|
|
1176
|
+
SUBMODULE_OCCUPIED_MESSAGE if occupied else ""))
|
|
1177
|
+
continue
|
|
1178
|
+
# The child proves its exact root when it is taken from the queue, which reports a failure as the same unknown entry.
|
|
1179
|
+
pending_dirs.append((child_dir, directory, child_path, child_section))
|
|
1180
|
+
return SubmoduleSnapshot(root_dir, tuple(entries), "\n".join(causes))
|