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,138 @@
1
+ """Watch over one released helper, stopping it, never what it started, as soon as the process that started it ends.
2
+
3
+ A browser sign-in starts Git Credential Manager in a session of its own, so that stopping the sign-in never closes the
4
+ browser it opens. macOS gives a process no signal when its parent dies, and stopping the worker's group does not reach
5
+ a helper that leads a session of its own: without help, a helper whose worker was stopped would keep its local sign-in
6
+ port open until it ends by itself. This watcher is the helper's parent, and the kernel tells it when its own parent,
7
+ the worker, ends; it then stops the helper alone. The browser the helper opened is never signalled.
8
+
9
+ This file is run on its own: the base interpreter runs it in isolated mode, with neither the application's packages
10
+ nor any site directory, so only the standard library can be imported. The application imports it only to find this
11
+ file. The helper inherits the watcher's standard streams and environment, and the watcher ends with the helper's own
12
+ exit code, so the worker reads the helper's answer as if it had started the helper itself.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import select
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+
23
+ # Workaround: copies of process_group's exit codes. This file runs in isolated mode, where it cannot import the
24
+ # application package, so it keeps its own; a test checks that both stay equal.
25
+ PROCESS_STOP_EXIT_CODE = 125
26
+ PROCESS_START_FAILED_EXIT_CODE = 127
27
+ WATCH_OWNER_ARGUMENT_INDEX = 1
28
+ WATCH_COMMAND_ARGUMENT_OFFSET = 2
29
+ # Workaround: copy of the shell's signal exit base in config.py, kept here for the same isolated-mode reason; a test
30
+ # checks that both stay equal. The watcher reports its helper ended by signal n with this base plus n.
31
+ SIGNAL_EXIT_CODE_BASE = 128
32
+ # At most the owner's end and the helper's end are pending at once.
33
+ WATCH_EVENT_BATCH = 2
34
+
35
+
36
+ class HelperState:
37
+ """Hold the helper once started, and whether a stop was asked before or while it starts.
38
+
39
+ The stop request comes as a signal, which may arrive before the helper exists: it is then remembered, so that
40
+ nothing is started, or the helper just started is stopped at once.
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ """Start with no helper and no stop asked."""
45
+ self.helper: subprocess.Popen | None = None
46
+ self.stop_requested = False
47
+
48
+ def request_stop(self, signal_number: int, frame: object) -> None:
49
+ """Answer the stop signal: stop the helper alone if it runs, and remember the request."""
50
+ self.stop_requested = True
51
+ self.stop_helper()
52
+
53
+ def stop_helper(self) -> None:
54
+ """Stop the helper, never its group: the browser it opened is a member of that group.
55
+
56
+ The helper is this watcher's child and is reaped only after this, so its process ID cannot name another process.
57
+ """
58
+ if self.helper is not None and self.helper.returncode is None:
59
+ self.helper.kill()
60
+
61
+
62
+ def read_owner_id(arguments: list[str]) -> int:
63
+ """Read the process ID of the worker that started this watcher, refusing anything but a positive number and a command after it."""
64
+ if len(arguments) <= WATCH_COMMAND_ARGUMENT_OFFSET:
65
+ raise ValueError("The released helper watcher needs an owner process ID and a command")
66
+ owner_id = int(arguments[WATCH_OWNER_ARGUMENT_INDEX])
67
+ if owner_id <= 0:
68
+ raise ValueError("The released helper watcher owner process ID must be positive")
69
+ return owner_id
70
+
71
+
72
+ def exit_watch(process_id: int) -> select.kevent:
73
+ """Describe a one-shot subscription to the end of ``process_id``."""
74
+ return select.kevent(process_id, select.KQ_FILTER_PROC, select.KQ_EV_ADD | select.KQ_EV_ONESHOT, select.KQ_NOTE_EXIT)
75
+
76
+
77
+ def helper_exit_code(return_code: int) -> int:
78
+ """Turn the helper's status into this watcher's exit code: its own code, or the shell's form for a signal."""
79
+ if return_code >= 0:
80
+ return return_code
81
+ return SIGNAL_EXIT_CODE_BASE - return_code
82
+
83
+
84
+ def watch(owner_id: int, command: list[str]) -> int:
85
+ """Start the helper only while its owner lives, stop it when the owner ends or on SIGTERM, and return its exit code.
86
+
87
+ The owner's end is subscribed to before anything starts, so the helper never runs unguarded. A watcher whose owner
88
+ is already gone, or is no longer its parent, starts nothing.
89
+ """
90
+ queue = select.kqueue()
91
+ try:
92
+ try:
93
+ queue.control([exit_watch(owner_id)], 0)
94
+ except ProcessLookupError:
95
+ return PROCESS_STOP_EXIT_CODE
96
+ if os.getppid() != owner_id:
97
+ return PROCESS_STOP_EXIT_CODE
98
+ state = HelperState()
99
+ signal.signal(signal.SIGTERM, state.request_stop)
100
+ if state.stop_requested:
101
+ return PROCESS_STOP_EXIT_CODE
102
+ try:
103
+ state.helper = subprocess.Popen(command)
104
+ except OSError as error:
105
+ print("Cannot start the credential helper: " + str(error), file=sys.stderr, flush=True)
106
+ return PROCESS_START_FAILED_EXIT_CODE
107
+ if state.stop_requested:
108
+ state.stop_helper()
109
+ try:
110
+ queue.control([exit_watch(state.helper.pid)], 0)
111
+ except ProcessLookupError:
112
+ # A stop asked while the helper started may already have reaped it, so the kernel no longer knows its ID;
113
+ # the helper has ended and waiting only reads the code recorded then.
114
+ return helper_exit_code(state.helper.wait())
115
+ helper_ended = False
116
+ while not helper_ended:
117
+ for event in queue.control(None, WATCH_EVENT_BATCH):
118
+ if event.ident == state.helper.pid:
119
+ helper_ended = True
120
+ elif event.ident == owner_id:
121
+ state.stop_helper()
122
+ return helper_exit_code(state.helper.wait())
123
+ finally:
124
+ queue.close()
125
+
126
+
127
+ def main() -> int:
128
+ """Read the owner and the command, then watch the helper; a refused argument starts nothing."""
129
+ try:
130
+ owner_id = read_owner_id(sys.argv)
131
+ except ValueError as error:
132
+ print(str(error), file=sys.stderr, flush=True)
133
+ return PROCESS_START_FAILED_EXIT_CODE
134
+ return watch(owner_id, sys.argv[WATCH_COMMAND_ARGUMENT_OFFSET:])
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,422 @@
1
+ """Group the recent folders by what their answer waits on, from the mount table of Linux or macOS.
2
+
3
+ Asking whether a folder exists waits on the file system that holds it: a sleeping disk, a frozen FUSE mount or a dead
4
+ network share. Folders of one mount, or of one disk or server, are asked in order on one thread, so a slow one only
5
+ delays its own folders. Reading the table itself touches no mounted disk: it is a list the system keeps in memory.
6
+
7
+ This module only reads and compares text; it knows nothing about threads or windows.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import errno
13
+ import os
14
+ import posixpath
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import unicodedata
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ from gitpane.config import DECOMPOSED_UNICODE_FORM, LINUX_PLATFORM, MACOS_PLATFORM, MOUNT_TABLE_TIMEOUT_SECONDS
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ # Linux keeps the table of this process's mounts here, one mount per line: source, mount point, type, options.
28
+ PROC_MOUNTS_FILENAME = "/proc/self/mounts"
29
+ # The kernel writes a space, tab, newline, backslash and, in the source, a hash as a backslash and three octal digits,
30
+ # so that the fields stay separated by spaces.
31
+ MOUNT_FIELD_ESCAPE_PATTERN = re.compile(rb"\\([0-7]{3})")
32
+ # The escaped digits are octal.
33
+ OCTAL_BASE = 8
34
+ # Positions of the fields a line of /proc/mounts starts with.
35
+ SOURCE_FIELD, MOUNT_DIR_FIELD, FS_TYPE_FIELD = 0, 1, 2
36
+ # A line shorter than this is not a mount: it is skipped rather than failing the whole table.
37
+ MOUNT_LINE_MIN_FIELD_COUNT = 3
38
+ # Start of a source that is a disk or a partition: one disk mounted at several places is one group.
39
+ DEVICE_SOURCE_PREFIX = "/dev/"
40
+ # Starts of the group keys: the prefix keeps a server name from ever meeting a device or a mount point with the same text.
41
+ SERVER_GROUP_PREFIX = "server:"
42
+ DEVICE_GROUP_PREFIX = "device:"
43
+ MOUNT_GROUP_PREFIX = "mount:"
44
+ # NFS writes its source as host:/export, or [address]:/export for an IPv6 address.
45
+ NFS_FS_TYPE_SET = frozenset({"nfs", "nfs4"})
46
+ # SMB writes its source as //[user@]host/share: macOS writes the user, Linux does not.
47
+ SMB_FS_TYPE_SET = frozenset({"cifs", "smb3", "smbfs"})
48
+ # sshfs writes its source as [user@]host:[path].
49
+ SSHFS_FS_TYPE_SET = frozenset({"fuse.sshfs"})
50
+ # Every network file system; those whose source names no server GitPane can read are grouped by mount point.
51
+ NETWORK_FS_TYPE_SET = NFS_FS_TYPE_SET | SMB_FS_TYPE_SET | frozenset({"afpfs", "webdav", "9p"})
52
+ # FUSE mounts answer through a program that may hang; on Linux their type is also written fuse.<program>.
53
+ FUSE_FS_TYPE_SET = frozenset({"fuse", "fuseblk", "macfuse", "osxfuse"})
54
+ # fusectl starts with "fuse" but is only the control folder of FUSE, not a FUSE mount: the dot keeps it out.
55
+ FUSE_FS_TYPE_PREFIX = "fuse."
56
+ # Reading inside an automount point triggers the mount, so it may wait on the network.
57
+ AUTOMOUNT_FS_TYPE_SET = frozenset({"autofs"})
58
+ # Separators of a network source: //host/share, user@host, host:path and [IPv6 address].
59
+ NETWORK_SHARE_SOURCE_PREFIX = "//"
60
+ NETWORK_SHARE_PATH_SEPARATOR = "/"
61
+ USER_SEPARATOR = "@"
62
+ HOST_PATH_SEPARATOR = ":"
63
+ BRACKETED_HOST_START = "["
64
+ BRACKETED_HOST_END = "]"
65
+ # POSIX lets a path keep exactly two leading slashes, so normpath keeps them; Linux and macOS read them as one.
66
+ DOUBLE_SLASH_ROOT = posixpath.sep * 2
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class MountEntry:
71
+ """One mounted file system: what is mounted, where, and its type."""
72
+
73
+ source: str
74
+ mount_dir: str
75
+ fs_type: str
76
+
77
+
78
+ def decode_mount_field(field: bytes) -> str:
79
+ """Undo the kernel's octal escapes, then decode as Python decodes every path, so the result compares with recent paths.
80
+
81
+ Names that are not UTF-8 keep the same form as in Python's own paths.
82
+ """
83
+ return os.fsdecode(MOUNT_FIELD_ESCAPE_PATTERN.sub(lambda match: bytes([int(match.group(1), OCTAL_BASE)]), field))
84
+
85
+
86
+ def parse_proc_mounts(data: bytes) -> list[MountEntry]:
87
+ """Read the lines of /proc/mounts; a malformed line is skipped rather than failing the whole table."""
88
+ entries: list[MountEntry] = []
89
+ for line in data.splitlines():
90
+ fields = line.split()
91
+ if len(fields) < MOUNT_LINE_MIN_FIELD_COUNT:
92
+ continue
93
+ entries.append(MountEntry(decode_mount_field(fields[SOURCE_FIELD]), decode_mount_field(fields[MOUNT_DIR_FIELD]), decode_mount_field(fields[FS_TYPE_FIELD])))
94
+ return entries
95
+
96
+
97
+ def read_file_bytes(filename: str) -> bytes:
98
+ """Read a whole file as bytes."""
99
+ with open(filename, "rb") as file:
100
+ return file.read()
101
+
102
+
103
+ def read_proc_mounts(read_file: Callable[[str], bytes] = read_file_bytes) -> list[MountEntry]:
104
+ """Read this process's mount table on Linux; an unreadable table is empty, never an error, so the checks keep one group."""
105
+ try:
106
+ data = read_file(PROC_MOUNTS_FILENAME)
107
+ except OSError:
108
+ return []
109
+ return parse_proc_mounts(data)
110
+
111
+
112
+ def is_unwalked_mount(entry: MountEntry) -> bool:
113
+ """Tell whether even reading a link on this mount may wait on the network or on a program: network, FUSE and automount.
114
+
115
+ Following the links of a recent's path reads nothing on such a mount; only the question itself is asked there.
116
+ """
117
+ if entry.fs_type in NETWORK_FS_TYPE_SET or entry.fs_type in FUSE_FS_TYPE_SET or entry.fs_type in AUTOMOUNT_FS_TYPE_SET:
118
+ return True
119
+ return entry.fs_type.startswith(FUSE_FS_TYPE_PREFIX)
120
+
121
+
122
+ def smb_server_name(source: str) -> str:
123
+ """Return the host of a //[user@]host/share source, or an empty text when the source does not have that form."""
124
+ if not source.startswith(NETWORK_SHARE_SOURCE_PREFIX):
125
+ return ""
126
+ user_and_host, _separator, _share = source.removeprefix(NETWORK_SHARE_SOURCE_PREFIX).partition(NETWORK_SHARE_PATH_SEPARATOR)
127
+ _user, _separator, host = user_and_host.rpartition(USER_SEPARATOR)
128
+ return host
129
+
130
+
131
+ def host_path_server_name(source: str) -> str:
132
+ """Return the host of a [user@]host:path source, the host being bracketed for an IPv6 address; empty when there is none.
133
+
134
+ The user is only looked for before the host's colon, since the path after it may hold an at sign of its own.
135
+ """
136
+ host_end = source.find(HOST_PATH_SEPARATOR)
137
+ user_end = source.rfind(USER_SEPARATOR, 0, host_end if host_end != -1 else len(source))
138
+ host_and_path = source[user_end + len(USER_SEPARATOR):] if user_end != -1 else source
139
+ if host_and_path.startswith(BRACKETED_HOST_START):
140
+ host, separator, _path = host_and_path.removeprefix(BRACKETED_HOST_START).partition(BRACKETED_HOST_END)
141
+ return host if separator else ""
142
+ host, separator, _path = host_and_path.partition(HOST_PATH_SEPARATOR)
143
+ return host if separator else ""
144
+
145
+
146
+ def server_name(entry: MountEntry) -> str | None:
147
+ """Return the network server a mount waits on, without user and compared without case; None when its source names none.
148
+
149
+ Only the host is kept, never the user or the path, so neither ends up in a group key or in a log line.
150
+ """
151
+ if entry.fs_type in SMB_FS_TYPE_SET:
152
+ host = smb_server_name(entry.source)
153
+ elif entry.fs_type in NFS_FS_TYPE_SET or entry.fs_type in SSHFS_FS_TYPE_SET:
154
+ host = host_path_server_name(entry.source)
155
+ else:
156
+ return None
157
+ return host.casefold() or None
158
+
159
+
160
+ def case_sensitive_path_key(path: str) -> str:
161
+ """Compare paths as written, as Linux does: ``/mnt/NAS`` and ``/mnt/nas`` are two folders."""
162
+ return path
163
+
164
+
165
+ def case_insensitive_path_key(path: str) -> str:
166
+ """Compare paths without case and without Unicode form, as the disks of macOS do by default.
167
+
168
+ ``/volumes/nas/dépôt`` is ``/Volumes/NAS/DÉPÔT`` whether its accents are composed or decomposed. The text is
169
+ decomposed again after the case fold, since folding a letter can give back a composed one.
170
+ """
171
+ return unicodedata.normalize(DECOMPOSED_UNICODE_FORM, unicodedata.normalize(DECOMPOSED_UNICODE_FORM, path).casefold())
172
+
173
+
174
+ def platform_path_compare_key(platform: str) -> Callable[[str], str]:
175
+ """Return how this platform compares a recent's path with the mount points: without case on macOS, as written elsewhere."""
176
+ if platform == MACOS_PLATFORM:
177
+ return case_insensitive_path_key
178
+ return case_sensitive_path_key
179
+
180
+
181
+ # Chosen once at import: the platform never changes while GitPane runs.
182
+ PLATFORM_PATH_COMPARE_KEY = platform_path_compare_key(sys.platform)
183
+
184
+
185
+ def without_double_slash_root(path: str) -> str:
186
+ """Write a path that starts with exactly two slashes with one, as the system reads it, so it compares with the mount points."""
187
+ if path.startswith(DOUBLE_SLASH_ROOT) and not path.startswith(DOUBLE_SLASH_ROOT + posixpath.sep):
188
+ return path[len(posixpath.sep):]
189
+ return path
190
+
191
+
192
+ def comparable_path(path: str, path_compare_key: Callable[[str], str]) -> str:
193
+ """Return the form of a path or a mount point that is compared: one leading slash, and without case where the platform ignores it."""
194
+ return path_compare_key(without_double_slash_root(path))
195
+
196
+
197
+ def path_parts(path: str) -> list[str]:
198
+ """Return the names of a path's folders, in order, without the empty ones that slashes leave."""
199
+ parts: list[str] = []
200
+ for part in path.split(posixpath.sep):
201
+ if part:
202
+ parts.append(part)
203
+ return parts
204
+
205
+
206
+ # Mount points on Linux and macOS are POSIX paths: they are compared with posixpath so these functions and their tests
207
+ # behave the same on Windows, where this module never runs for real.
208
+ def mount_contains(mount_dir: str, path: str, path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> bool:
209
+ """Tell whether the path is the mount point itself or below it: ``/mnt/nas2`` is not inside ``/mnt/nas``.
210
+
211
+ Both are brought to the same form first, so a path written ``//mnt/nas``, or on macOS ``/volumes/nas``, is on the mount.
212
+ """
213
+ compared_mount_dir = comparable_path(mount_dir, path_compare_key)
214
+ compared_path = comparable_path(path, path_compare_key)
215
+ if compared_path == compared_mount_dir:
216
+ return True
217
+ return compared_path.startswith(compared_mount_dir.rstrip(posixpath.sep) + posixpath.sep)
218
+
219
+
220
+ def entry_preference_key(entry: MountEntry) -> tuple[bool, str, str]:
221
+ """Order two entries mounted at the same point by their content alone, never by their place in the table.
222
+
223
+ A mount that is not walked comes first, since that is the one a question may wait on; then the type and the source.
224
+ """
225
+ return (not is_unwalked_mount(entry), entry.fs_type, entry.source)
226
+
227
+
228
+ def deepest_mount_entry(path: str, entries: list[MountEntry], path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> MountEntry | None:
229
+ """Return the mount that holds the path, the one with the longest mount point; None when no mount holds it.
230
+
231
+ The path is absolute and normalized. Of two entries at the same point, such as an automount point and the network
232
+ share it mounted, the one not walked is preferred, then the smaller type and source: the order of the system's table
233
+ is not guaranteed, so it never decides. Either choice keeps the folder in a group of its own mount.
234
+ """
235
+ best_entry: MountEntry | None = None
236
+ for entry in entries:
237
+ if not mount_contains(entry.mount_dir, path, path_compare_key):
238
+ continue
239
+ if best_entry is None or len(entry.mount_dir) > len(best_entry.mount_dir):
240
+ best_entry = entry
241
+ continue
242
+ if len(entry.mount_dir) == len(best_entry.mount_dir) and entry_preference_key(entry) < entry_preference_key(best_entry):
243
+ best_entry = entry
244
+ return best_entry
245
+
246
+
247
+ def mount_group_key(path: str, entries: list[MountEntry], path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> str | None:
248
+ """Return what the folder's answer waits on: its network server, its disk, or its mount point; None when no mount holds it.
249
+
250
+ Every mount of one server is one group whatever its protocol, since a server that is off blocks them all alike.
251
+ """
252
+ entry = deepest_mount_entry(path, entries, path_compare_key)
253
+ if entry is None:
254
+ return None
255
+ return entry_group_key(entry)
256
+
257
+
258
+ def entry_group_key(entry: MountEntry) -> str:
259
+ """Return the group of the folders of one mount: its network server, its disk, or its mount point."""
260
+ server = server_name(entry)
261
+ if server is not None:
262
+ return SERVER_GROUP_PREFIX + server
263
+ if entry.source.startswith(DEVICE_SOURCE_PREFIX):
264
+ return DEVICE_GROUP_PREFIX + entry.source
265
+ return MOUNT_GROUP_PREFIX + entry.mount_dir
266
+
267
+
268
+ # Linux itself follows at most this many links while resolving one path; past it, the question is asked as the path is.
269
+ MAX_FOLLOWED_LINK_COUNT = 40
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class FolderWalk:
274
+ """Where following the links of a recent's path led.
275
+
276
+ ``walked_dir`` is the path with the links read so far replaced by their targets; ``group`` is the group whose thread
277
+ goes on with the folder: its own group to ask the question, another one to hand it over.
278
+ """
279
+
280
+ walked_dir: str
281
+ group: str
282
+ followed_link_count: int
283
+
284
+
285
+ def path_parts_below(path: str, mount_dir: str) -> list[str]:
286
+ """Return the names of the path's folders below a mount point that holds it, in order; empty for the mount point itself.
287
+
288
+ Counted in folders, not in characters: the path may write the mount point in another case, and folding the case
289
+ can change a name's length (``straße`` and ``STRASSE``).
290
+ """
291
+ return path_parts(path)[len(path_parts(mount_dir)):]
292
+
293
+
294
+ def follow_folder_links(walked_dir: str, home_group: str, followed_link_count: int, entries: list[MountEntry], read_link: Callable[[str], str],
295
+ path_compare_key: Callable[[str], str] = case_sensitive_path_key) -> FolderWalk:
296
+ """Follow the links of a recent's path, one folder at a time, as long as it stays on the mounts of its own group.
297
+
298
+ It runs on the thread of the group the path is on: reading a link only waits on the disk that holds it, so a slow disk
299
+ only delays its own folders. The path is compared with the mount table before every read; as soon as it enters a
300
+ mount of another group, the walk stops and names that group, which goes on with the folder: a link on the system
301
+ disk that points to a dead share never holds the system disk's thread. Nothing is ever read on a network, FUSE or
302
+ automount mount: its thread only asks the question. The walk starts at the deepest mount point of the path, since
303
+ the system lists mount points as real paths.
304
+
305
+ The question and its answer stay about the recent as saved, which the caller keeps: the walk only decides which
306
+ thread asks it. So the walk stops and the question is asked in the current group whenever the path cannot be read
307
+ further: a folder that does not exist, a refused access, a name the system rejects, or more links than the system
308
+ itself would follow, the count going on across groups. The question then tells what is there. A relative target is
309
+ joined to the link's folder and normalized: the folders above it are real paths already, so ``..`` is exact.
310
+
311
+ The path is compared with the mount points as the platform compares names, the same way the folders were sorted
312
+ into their groups: a path written in another case on macOS, or with two leading slashes, is still on its mount.
313
+ """
314
+ # Folders already found not to be links: a path that comes back after a link is not read twice.
315
+ plain_dir_set: set[str] = set()
316
+ while True:
317
+ entry = deepest_mount_entry(walked_dir, entries, path_compare_key)
318
+ if entry is None:
319
+ return FolderWalk(walked_dir, home_group, followed_link_count)
320
+ group = entry_group_key(entry)
321
+ if group != home_group:
322
+ return FolderWalk(walked_dir, group, followed_link_count)
323
+ if is_unwalked_mount(entry):
324
+ return FolderWalk(walked_dir, home_group, followed_link_count)
325
+ # Every folder read below is a start of the walked path, so the deepest mount of the whole path holds them all:
326
+ # checking it once, above, is checking each of them before its read.
327
+ next_dir: str | None = None
328
+ candidate_dir = entry.mount_dir
329
+ parts = path_parts_below(walked_dir, entry.mount_dir)
330
+ for part_index, part in enumerate(parts):
331
+ candidate_dir = posixpath.join(candidate_dir, part)
332
+ if candidate_dir in plain_dir_set:
333
+ continue
334
+ if followed_link_count >= MAX_FOLLOWED_LINK_COUNT:
335
+ return FolderWalk(walked_dir, home_group, followed_link_count)
336
+ try:
337
+ target = read_link(candidate_dir)
338
+ except OSError as error:
339
+ if error.errno != errno.EINVAL:
340
+ return FolderWalk(walked_dir, home_group, followed_link_count)
341
+ plain_dir_set.add(candidate_dir)
342
+ continue
343
+ except ValueError:
344
+ return FolderWalk(walked_dir, home_group, followed_link_count)
345
+ next_dir = without_double_slash_root(posixpath.normpath(posixpath.join(posixpath.dirname(candidate_dir), target, *parts[part_index + 1:])))
346
+ followed_link_count += 1
347
+ break
348
+ if next_dir is None:
349
+ return FolderWalk(walked_dir, home_group, followed_link_count)
350
+ walked_dir = next_dir
351
+
352
+
353
+ # The mount command of macOS, by absolute path: it is never looked for in the PATH, where another program could take its name.
354
+ MOUNT_COMMAND_FILENAME = "/sbin/mount"
355
+ # macOS writes each mount as "<source> on <mount point> (<type>, <option>, ...)", without escaping spaces. The mount
356
+ # point is absolute, so the source ends at the first " on /", which keeps a source such as "Music on NAS" whole; the
357
+ # mount point ends at the last " (".
358
+ MACOS_MOUNT_ON_SEPARATOR = " on /"
359
+ MACOS_MOUNT_OPTION_START = " ("
360
+ MACOS_MOUNT_OPTION_END = ")"
361
+ MACOS_MOUNT_OPTION_SEPARATOR = ","
362
+
363
+
364
+ def parse_macos_mount_output(text: str) -> list[MountEntry]:
365
+ """Read the output of the mount command of macOS; a line that does not have the mount form is skipped.
366
+
367
+ A line without an absolute mount point is skipped too, rather than read as a mount that would hold no recent.
368
+ """
369
+ entries: list[MountEntry] = []
370
+ for line in text.splitlines():
371
+ source, on_separator, point_and_options = line.partition(MACOS_MOUNT_ON_SEPARATOR)
372
+ if not on_separator:
373
+ continue
374
+ # The separator took the mount point's leading slash.
375
+ point_and_options = posixpath.sep + point_and_options
376
+ mount_dir, option_separator, options = point_and_options.rpartition(MACOS_MOUNT_OPTION_START)
377
+ if not option_separator or not options.endswith(MACOS_MOUNT_OPTION_END):
378
+ continue
379
+ fs_type, _separator, _other_options = options.removesuffix(MACOS_MOUNT_OPTION_END).partition(MACOS_MOUNT_OPTION_SEPARATOR)
380
+ fs_type = fs_type.strip()
381
+ if not source or not mount_dir or not fs_type:
382
+ continue
383
+ entries.append(MountEntry(source, mount_dir, fs_type))
384
+ return entries
385
+
386
+
387
+ def read_macos_mount_table(open_process: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> list[MountEntry]:
388
+ """Read the mount table of macOS from the mount command; any failure gives an empty table, so the checks keep one group.
389
+
390
+ Only called on the sorting thread of the recent folder checks: the command asks every mount point and may wait.
391
+ """
392
+ try:
393
+ process = open_process([MOUNT_COMMAND_FILENAME], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
394
+ except (OSError, ValueError):
395
+ return []
396
+ try:
397
+ stdout, _stderr = process.communicate(timeout=MOUNT_TABLE_TIMEOUT_SECONDS)
398
+ except subprocess.TimeoutExpired:
399
+ # Workaround: subprocess.run waits for the child after killing it, and a child stuck in the kernel on a dead share
400
+ # may never end, which would hold the sorting thread for good. The child is killed and left for subprocess to
401
+ # reap later, without waiting for it here.
402
+ process.kill()
403
+ return []
404
+ if process.returncode != 0:
405
+ return []
406
+ return parse_macos_mount_output(os.fsdecode(stdout))
407
+
408
+
409
+ def platform_mount_table_reader(platform: str) -> Callable[[], list[MountEntry]] | None:
410
+ """Return the function that reads the mount table on this platform, or None where the folders are grouped otherwise.
411
+
412
+ Windows groups by drive letter and network server, and keeps doing so; an unknown system keeps a single group.
413
+ """
414
+ if platform == LINUX_PLATFORM:
415
+ return read_proc_mounts
416
+ if platform == MACOS_PLATFORM:
417
+ return read_macos_mount_table
418
+ return None
419
+
420
+
421
+ # Chosen once at import: the platform never changes while GitPane runs.
422
+ PLATFORM_MOUNT_TABLE_READER = platform_mount_table_reader(sys.platform)