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.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. 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))