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,851 @@
1
+ """Keep a native process group alive, inherited streams included, beyond the exit of its parent.
2
+
3
+ This file is also run on its own, as the supervisor started before every supervised command: the base interpreter
4
+ runs it in isolated mode, with neither the application's packages nor any site directory. At that point only the
5
+ standard library can be imported, so what the parent side alone needs is imported where the parent side uses it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import enum
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from ctypes import wintypes
17
+ from typing import TYPE_CHECKING, Protocol
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1
23
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
24
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
25
+ # A member may start a process outside the job, but only by asking for it explicitly when it starts that process.
26
+ JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
27
+ # Every process a member starts is outside the job, without asking.
28
+ JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK = 0x1000
29
+ JOB_OBJECT_ASSIGN_PROCESS_AND_QUERY = 0x0005
30
+ CREATE_SUSPENDED = 0x00000004
31
+ CREATE_BREAKAWAY_FROM_JOB = 0x01000000
32
+ # Rights needed to put a process in a job (set quota, terminate) and to let it run afterwards (suspend and resume).
33
+ PROCESS_JOB_ADOPTION_ACCESS = 0x0100 | 0x0001 | 0x0800
34
+ # The least right that lets a caller ask which job holds a process.
35
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
36
+ NT_SUCCESS_STATUS = 0
37
+ # Shows a negative status the way Windows documents it, as an unsigned 32-bit value.
38
+ NT_STATUS_MASK = 0xFFFFFFFF
39
+ PROCESS_STOP_EXIT_CODE = 125
40
+ PROCESS_START_FAILED_EXIT_CODE = 127
41
+ LINUX_SET_CHILD_SUBREAPER = 36
42
+ LINUX_SET_PARENT_DEATH_SIGNAL = 1
43
+ SUPERVISOR_ARGUMENT_OFFSET = 1
44
+ # Settings the supervisor reads before its target program: control folder, job name, poll period and GitPane's process ID.
45
+ SUPERVISOR_SETTING_ARGUMENT_COUNT = 4
46
+ SUPERVISOR_PROCESS_COUNT = 1
47
+ IO_COUNTER_FIELD_COUNT = 6
48
+ MINIMUM_TRACKED_PROCESS_ID = 2
49
+ # Workaround: a copy of config.py's value. The supervisor runs this file in isolated mode, where it cannot import the
50
+ # application package, so it keeps its own; a test checks that both stay equal.
51
+ MILLISECONDS_PER_SECOND = 1000
52
+ # Workaround: copies of config.py's platform names, for the same reason as above; a test checks that they stay equal.
53
+ MACOS_PLATFORM = "darwin"
54
+ LINUX_PLATFORM = "linux"
55
+ WINDOWS_PLATFORM = "win32"
56
+ # Systems where the supervisor stops its command itself when GitPane dies, compared as prefixes of sys.platform.
57
+ OWNER_WATCH_PLATFORM_PREFIXES = (MACOS_PLATFORM, LINUX_PLATFORM)
58
+ CONSOLE_INTERPRETER_BASENAME = "python.exe"
59
+ WINDOWLESS_INTERPRETER_BASENAME = "pythonw.exe"
60
+ STOP_BASENAME = "stop"
61
+ GROUP_BASENAME = "group"
62
+ COMPLETE_BASENAME = "complete"
63
+ START_FAILED_BASENAME = "start-failed"
64
+ PROC_DIR = "/proc"
65
+ PROC_STAT_BASENAME = "stat"
66
+ # Fields of a /proc/<pid>/stat line counted from the first one after the command name.
67
+ PROC_STAT_STATE_INDEX = 0
68
+ PROC_STAT_PARENT_INDEX = 1
69
+ PROC_STAT_GROUP_INDEX = 2
70
+ PROC_STAT_SESSION_INDEX = 3
71
+ ZOMBIE_STATE = "Z"
72
+ # Windows signatures of the kernel32 functions this module calls, declared once so ctypes never truncates a handle or a pointer on 64 bits.
73
+ KERNEL_SIGNATURE_BY_NAME = {
74
+ "CreateJobObjectW": ([ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
75
+ "OpenJobObjectW": ([wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR], wintypes.HANDLE),
76
+ "SetInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
77
+ "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
78
+ "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
79
+ "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
80
+ "GetCurrentProcess": ([], wintypes.HANDLE),
81
+ "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
82
+ "IsProcessInJob": ([wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)], wintypes.BOOL),
83
+ "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL),
84
+ }
85
+
86
+
87
+ def _bound_kernel(function_names: tuple[str, ...]) -> ctypes.WinDLL:
88
+ """Load kernel32 and give each requested function its signature from ``KERNEL_SIGNATURE_BY_NAME``.
89
+
90
+ Every caller gets its own instance, so none depends on the declarations another one made. Only the standard library
91
+ is used, since the supervisor runs this file without the application's packages.
92
+ """
93
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
94
+ for function_name in function_names:
95
+ argument_types, result_type = KERNEL_SIGNATURE_BY_NAME[function_name]
96
+ function = getattr(kernel, function_name)
97
+ function.argtypes = argument_types
98
+ function.restype = result_type
99
+ return kernel
100
+
101
+
102
+ class _JobBasicLimit(ctypes.Structure):
103
+ """Match the native limit structure without assuming pointer or integer widths."""
104
+
105
+ _fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong), ("limit_flags", wintypes.DWORD),
106
+ ("minimum_working_set", ctypes.c_size_t), ("maximum_working_set", ctypes.c_size_t), ("active_process_limit", wintypes.DWORD),
107
+ ("affinity", ctypes.c_size_t), ("priority_class", wintypes.DWORD), ("scheduling_class", wintypes.DWORD)]
108
+
109
+
110
+ class _JobExtendedLimit(ctypes.Structure):
111
+ """Include native accounting fields to place the kill-on-close flag correctly."""
112
+
113
+ _fields_ = [("basic_limit", _JobBasicLimit), ("io_counter", ctypes.c_ulonglong * IO_COUNTER_FIELD_COUNT), ("process_memory_limit", ctypes.c_size_t),
114
+ ("job_memory_limit", ctypes.c_size_t), ("peak_process_memory", ctypes.c_size_t), ("peak_job_memory", ctypes.c_size_t)]
115
+
116
+
117
+ class _JobAccounting(ctypes.Structure):
118
+ """Read the OS count that includes descendants after the original process exits."""
119
+
120
+ _fields_ = [("total_user_time", ctypes.c_longlong), ("total_kernel_time", ctypes.c_longlong), ("period_user_time", ctypes.c_longlong),
121
+ ("period_kernel_time", ctypes.c_longlong), ("page_fault_count", wintypes.DWORD), ("total_process_count", wintypes.DWORD),
122
+ ("active_process_count", wintypes.DWORD), ("terminated_process_count", wintypes.DWORD)]
123
+
124
+
125
+ class _WindowsJob:
126
+ """Own a Windows job whose members cannot leave while their helpers are running."""
127
+
128
+ def __init__(self, name: str | None, *, existing: bool = False, limit_flags: int = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) -> None:
129
+ """Bind the pointer-safe APIs before creating or opening the named job; a job without a name is private to its creator.
130
+
131
+ A created job always stops its members when its last handle closes; ``limit_flags`` may add what its members are
132
+ allowed to start outside of it.
133
+ """
134
+ self.kernel = _bound_kernel(("CreateJobObjectW", "OpenJobObjectW", "SetInformationJobObject", "QueryInformationJobObject", "AssignProcessToJobObject",
135
+ "TerminateJobObject", "GetCurrentProcess", "OpenProcess", "IsProcessInJob", "CloseHandle"))
136
+ self.handle = self.kernel.OpenJobObjectW(JOB_OBJECT_ASSIGN_PROCESS_AND_QUERY, False, name) if existing else self.kernel.CreateJobObjectW(None, name)
137
+ if not self.handle:
138
+ raise ctypes.WinError(ctypes.get_last_error())
139
+ if existing:
140
+ return
141
+ limit = _JobExtendedLimit()
142
+ limit.basic_limit.limit_flags = limit_flags | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
143
+ if not self.kernel.SetInformationJobObject(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(limit), ctypes.sizeof(limit)):
144
+ error = ctypes.WinError(ctypes.get_last_error())
145
+ self.close()
146
+ raise error
147
+
148
+ def adopt_suspended(self, process_id: int) -> None:
149
+ """Put a process started suspended in this job, then let it run, so it can start nothing before it is a member."""
150
+ process_handle = self.kernel.OpenProcess(PROCESS_JOB_ADOPTION_ACCESS, False, process_id)
151
+ if not process_handle:
152
+ raise ctypes.WinError(ctypes.get_last_error())
153
+ try:
154
+ if not self.kernel.AssignProcessToJobObject(self.handle, process_handle):
155
+ raise ctypes.WinError(ctypes.get_last_error())
156
+ # Workaround: subprocess does not keep the handle of the main thread of a process it started, so the thread
157
+ # cannot be resumed with the documented ResumeThread. NtResumeProcess resumes every thread of the process at
158
+ # once; it is exported by ntdll and has kept the same behaviour since Windows XP.
159
+ ntdll = ctypes.WinDLL("ntdll")
160
+ ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE]
161
+ ntdll.NtResumeProcess.restype = ctypes.c_long
162
+ status = ntdll.NtResumeProcess(process_handle)
163
+ if status != NT_SUCCESS_STATUS:
164
+ raise OSError(f"The process could not be resumed (status {status & NT_STATUS_MASK:#010x})")
165
+ finally:
166
+ self.kernel.CloseHandle(process_handle)
167
+
168
+ def join_current_process(self) -> None:
169
+ """Join before launching user commands so even their first child inherits membership."""
170
+ if not self.kernel.AssignProcessToJobObject(self.handle, self.kernel.GetCurrentProcess()):
171
+ raise ctypes.WinError(ctypes.get_last_error())
172
+ # Only the GUI retains a handle: its unexpected exit must also kill this job.
173
+ # Queries with a null handle inspect the calling process's immediate job.
174
+ self.close()
175
+
176
+ def active_count(self) -> int:
177
+ """Ask the kernel for live membership, including nested descendant jobs."""
178
+ accounting = _JobAccounting()
179
+ if not self.kernel.QueryInformationJobObject(self.handle, JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, ctypes.byref(accounting), ctypes.sizeof(accounting), None):
180
+ raise ctypes.WinError(ctypes.get_last_error())
181
+ return accounting.active_process_count
182
+
183
+ def stop(self) -> None:
184
+ """Terminate every current member rather than only the observed parent."""
185
+ if not self.kernel.TerminateJobObject(self.handle, PROCESS_STOP_EXIT_CODE):
186
+ raise ctypes.WinError(ctypes.get_last_error())
187
+
188
+ def close(self) -> None:
189
+ """Release the native handle exactly once after membership is drained."""
190
+ if self.handle:
191
+ self.kernel.CloseHandle(self.handle)
192
+ self.handle = None
193
+
194
+
195
+ class ProcessGroup(Protocol):
196
+ """Provide launch isolation and verifiable drainage for replaceable native controllers."""
197
+
198
+ def prepare(self, program_filename: str, arguments: list[str]) -> tuple[str, list[str]]:
199
+ """Return a supervised command without changing its input and output channels."""
200
+ ...
201
+
202
+ def contains_process(self, process_id: int) -> bool:
203
+ """Return whether the OS proves this process belongs to the tracked descendants."""
204
+ ...
205
+
206
+ def stop(self) -> None:
207
+ """Request descendant termination while retaining proof resources."""
208
+ ...
209
+
210
+ def is_empty(self) -> bool:
211
+ """Return true only when no descendant can continue modifying repository data."""
212
+ ...
213
+
214
+ def launch_failed(self) -> bool:
215
+ """Distinguish a failed target launch from the supervisor's own exit."""
216
+ ...
217
+
218
+ def result_return_code(self, fallback: int | None) -> int | None:
219
+ """Preserve the target's signed native status across the supervisor boundary."""
220
+ ...
221
+
222
+ def close(self) -> None:
223
+ """Release resources after a terminal result becomes safe to publish."""
224
+ ...
225
+
226
+
227
+ def watches_owner_exit(platform: str) -> bool:
228
+ """Tell whether the supervisor must watch GitPane itself to stop its command when GitPane dies.
229
+
230
+ On Windows the supervisor is a member of GitPane's job, which the kernel stops as soon as GitPane exits. macOS has
231
+ no such job and nothing adopts orphans there: the supervisor's parent simply becomes launchd. Linux gives the
232
+ supervisor no parent-death signal either. On both, the supervisor reads its parent at start and stops the command's
233
+ group as soon as that parent changes.
234
+ """
235
+ return platform.startswith(OWNER_WATCH_PLATFORM_PREFIXES)
236
+
237
+
238
+ class ReleasedChildGuard(enum.Enum):
239
+ """How a system keeps a released helper from outliving the worker that started it."""
240
+
241
+ # A private job, which the kernel stops when its owner exits or when it is closed.
242
+ JOB = enum.auto()
243
+ # The kernel's parent-death signal, which the helper asks for before its program runs.
244
+ PARENT_DEATH_SIGNAL = enum.auto()
245
+ # A small watcher started as the helper's parent, which the kernel tells when the worker ends.
246
+ WATCHER = enum.auto()
247
+
248
+
249
+ # Compared as prefixes of sys.platform; a system missing here has no known guard and is refused.
250
+ RELEASED_CHILD_GUARD_BY_PLATFORM_PREFIX = {
251
+ WINDOWS_PLATFORM: ReleasedChildGuard.JOB,
252
+ LINUX_PLATFORM: ReleasedChildGuard.PARENT_DEATH_SIGNAL,
253
+ MACOS_PLATFORM: ReleasedChildGuard.WATCHER,
254
+ }
255
+
256
+
257
+ def released_child_guard(platform: str) -> ReleasedChildGuard:
258
+ """Pick the way ``platform`` stops a released helper together with its worker, refusing a system without one.
259
+
260
+ Windows holds the helper in a private job. Linux lets the helper ask the kernel for a signal when its parent dies.
261
+ macOS has neither, and stopping the worker's group does not reach a helper that leads a session of its own, so a
262
+ watcher becomes the helper's parent and stops it when the kernel reports the worker's end.
263
+ """
264
+ for platform_prefix, guard in RELEASED_CHILD_GUARD_BY_PLATFORM_PREFIX.items():
265
+ if platform.startswith(platform_prefix):
266
+ return guard
267
+ raise OSError("No way to stop a released helper with its worker is known on " + platform)
268
+
269
+
270
+ def released_child_watch_filename() -> str:
271
+ """Answer the absolute file of the released helper watcher, as imported from the installation; a module that cannot be found or is not valid Python, or a missing file, is a launch error."""
272
+ try:
273
+ import gitpane.process.released_child_watch
274
+ except (ImportError, SyntaxError) as error:
275
+ raise OSError("The released helper watcher is not available: " + str(error)) from error
276
+
277
+ watch_filename = os.path.abspath(gitpane.process.released_child_watch.__file__)
278
+ if not os.path.isfile(watch_filename):
279
+ raise OSError("The released helper watcher is not available: " + watch_filename)
280
+ return watch_filename
281
+
282
+
283
+ def supervisor_script_filename() -> str:
284
+ """Answer the absolute file of the supervisor script, this module as imported from the installation; a missing file is a launch error."""
285
+ script_filename = os.path.abspath(__file__)
286
+ if not os.path.isfile(script_filename):
287
+ raise OSError("The supervisor script is not available: " + script_filename)
288
+ return script_filename
289
+
290
+
291
+ def console_interpreter_filename(interpreter_filename: str) -> str:
292
+ """Answer the console form of ``interpreter_filename``: the console interpreter of the same folder for the Windows windowless one, any other name unchanged.
293
+
294
+ Windows gives no console to a windowless program, even when asked to start it with a hidden one, and every console
295
+ program such a process starts then opens a visible console window of its own. A Python helper that starts console
296
+ programs (Git, the credential manager) therefore runs on the console interpreter, which receives the hidden console
297
+ and hands it on. Only the exact Windows name is replaced, in any case, so Linux and macOS interpreters never change.
298
+ The answer is a name only: whether that file exists is for the caller to check.
299
+ """
300
+ directory, basename = os.path.split(interpreter_filename)
301
+ if basename.casefold() != WINDOWLESS_INTERPRETER_BASENAME:
302
+ return interpreter_filename
303
+ return os.path.join(directory, CONSOLE_INTERPRETER_BASENAME)
304
+
305
+
306
+ def supervisor_interpreter_filename() -> str:
307
+ """Answer the interpreter that runs the supervisor: the base interpreter of this runtime, in its console form.
308
+
309
+ The interpreter of a virtual environment is a small launcher that starts the base interpreter as one more process;
310
+ starting the base interpreter directly saves that process for every supervised command, and the supervisor needs
311
+ nothing of the environment. The runtime names its base interpreter; when it names none, the running interpreter is
312
+ the explicit fallback. An application started without a console runs the windowless interpreter, which cannot hand
313
+ its standard streams on to Git, so the console interpreter installed beside it is taken instead. A named
314
+ interpreter that is missing is a launch error: another Python is never searched for.
315
+ """
316
+ # The base interpreter is only published as a private attribute of the runtime; nothing public names it.
317
+ candidate = console_interpreter_filename(getattr(sys, "_base_executable", None) or sys.executable)
318
+ if not os.path.isabs(candidate) or not os.path.isfile(candidate):
319
+ raise OSError("The supervisor interpreter is not available: " + candidate)
320
+ return candidate
321
+
322
+
323
+ def supervisor_poll_ms(value: str) -> int:
324
+ """Read the poll period the parent hands to the supervisor, refusing anything but a positive number of milliseconds."""
325
+ poll_ms = int(value)
326
+ if poll_ms <= 0:
327
+ raise ValueError("The supervisor poll period must be positive")
328
+ return poll_ms
329
+
330
+
331
+ def supervisor_owner_id(value: str) -> int:
332
+ """Read the process ID of GitPane, which the parent hands to the supervisor, refusing anything but a positive number."""
333
+ owner_id = int(value)
334
+ if owner_id <= 0:
335
+ raise ValueError("The supervisor owner process ID must be positive")
336
+ return owner_id
337
+
338
+
339
+ class NativeProcessGroup:
340
+ """Coordinate an OS job or session through private, non-secret control markers."""
341
+
342
+ def __init__(self, *, breakaway_allowed: bool = False) -> None:
343
+ """Create private control storage before any process can be launched.
344
+
345
+ ``breakaway_allowed`` lets a member start a process outside the group when it explicitly asks for it; nothing
346
+ leaves the group otherwise. Only a browser sign-in worker needs it, to hold Git Credential Manager in a job of its own.
347
+ """
348
+ import tempfile
349
+ import uuid
350
+
351
+ self.control = tempfile.TemporaryDirectory(prefix="gitpane-process-")
352
+ self.control_dir = self.control.name
353
+ self.job_name = "Local\\gitpane-" + uuid.uuid4().hex if os.name == "nt" else ""
354
+ limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | (JOB_OBJECT_LIMIT_BREAKAWAY_OK if breakaway_allowed else 0)
355
+ try:
356
+ self.job = _WindowsJob(self.job_name, limit_flags=limit_flags) if os.name == "nt" else None
357
+ except OSError:
358
+ self.control.cleanup()
359
+ raise
360
+
361
+ def prepare(self, program_filename: str, arguments: list[str]) -> tuple[str, list[str]]:
362
+ """Start the standard-library bootstrap, which joins the group before it runs the target unchanged.
363
+
364
+ A repository can contain a hostile gitpane package, a sitecustomize module or a PYTHONPATH: isolated mode
365
+ ignores the environment and the current directory, and skipping site loads nothing from any site directory.
366
+ The script is the absolute file of this module, as imported from the installation. The poll period comes from
367
+ the shared configuration, which the bootstrap itself cannot import. The process ID of the process preparing the
368
+ command, GitPane, which starts the supervisor itself, tells the supervisor which parent to watch.
369
+ """
370
+ from gitpane.config import ISOLATED_INTERPRETER_OPTIONS, PROCESS_GROUP_POLL_MS
371
+
372
+ return supervisor_interpreter_filename(), [*ISOLATED_INTERPRETER_OPTIONS, supervisor_script_filename(), self.control_dir, self.job_name, str(PROCESS_GROUP_POLL_MS),
373
+ str(os.getpid()), program_filename, *arguments]
374
+
375
+ def stop(self) -> None:
376
+ """Cancel startup as well as existing members without freeing their reservation."""
377
+ _write_marker(self.control_dir, STOP_BASENAME)
378
+ if self.job is not None:
379
+ self.job.stop()
380
+ return
381
+ group_id = _read_group_id(self.control_dir)
382
+ if group_id is not None:
383
+ _stop_posix_group(group_id)
384
+
385
+ def is_empty(self) -> bool:
386
+ """Keep ownership when missing or inaccessible proof makes the outcome unknown."""
387
+ if self.job is not None:
388
+ return self.job.active_count() == 0
389
+ group_id = _read_group_id(self.control_dir)
390
+ if group_id is not None and _posix_group_exists(group_id):
391
+ return False
392
+ return os.path.isfile(os.path.join(self.control_dir, COMPLETE_BASENAME)) or self.launch_failed()
393
+
394
+ def contains_process(self, process_id: int) -> bool:
395
+ """Verify that an IPC caller belongs to this operation's native descendant group."""
396
+ if self.job is None:
397
+ group_id = _read_group_id(self.control_dir)
398
+ try:
399
+ return group_id is not None and os.getpgid(process_id) == group_id
400
+ except ProcessLookupError:
401
+ return False
402
+ kernel = self.job.kernel
403
+ handle = kernel.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, process_id)
404
+ if not handle:
405
+ return False
406
+ try:
407
+ contained = wintypes.BOOL()
408
+ return bool(kernel.IsProcessInJob(handle, self.job.handle, ctypes.byref(contained)) and contained.value)
409
+ finally:
410
+ kernel.CloseHandle(handle)
411
+
412
+ def launch_failed(self) -> bool:
413
+ """Read only the supervisor's launch status, never a credential-bearing diagnostic."""
414
+ return os.path.isfile(os.path.join(self.control_dir, START_FAILED_BASENAME))
415
+
416
+ def result_return_code(self, fallback: int | None) -> int | None:
417
+ """Restore negative POSIX signal statuses that a supervisor exit cannot encode."""
418
+ try:
419
+ with open(os.path.join(self.control_dir, COMPLETE_BASENAME), encoding="ascii") as stream:
420
+ value = stream.read()
421
+ return int(value) if value else fallback
422
+ except FileNotFoundError:
423
+ return fallback
424
+ except ValueError as error:
425
+ raise OSError("Invalid supervised process status") from error
426
+
427
+ def close(self) -> None:
428
+ """Dispose of the already empty job and its private markers."""
429
+ if self.job is not None:
430
+ self.job.close()
431
+ self.control.cleanup()
432
+
433
+
434
+ def caller_job_forbids_breakaway() -> bool:
435
+ """Tell whether the job holding this process forbids its members to start a process outside it.
436
+
437
+ Windows refuses a launch that asks to leave such a job with the same access-denied error it gives for a program the
438
+ user may not run (file rights, an application control policy, an antivirus), so the job's own limits tell the two
439
+ apart. A process in no job, in a job that lets its members leave, or whose job cannot be read, is taken as not
440
+ forbidden: the launch's own error is then the one shown. Elsewhere than on Windows there is no such job.
441
+ """
442
+ if os.name != "nt":
443
+ return False
444
+ kernel = _bound_kernel(("GetCurrentProcess", "IsProcessInJob", "QueryInformationJobObject"))
445
+ contained = wintypes.BOOL()
446
+ if not kernel.IsProcessInJob(kernel.GetCurrentProcess(), None, ctypes.byref(contained)) or not contained.value:
447
+ return False
448
+ limit = _JobExtendedLimit()
449
+ # A null job handle reads the job that holds the calling process.
450
+ if not kernel.QueryInformationJobObject(None, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(limit), ctypes.sizeof(limit), None):
451
+ return False
452
+ return not limit.basic_limit.limit_flags & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)
453
+
454
+
455
+ def process_is_in_a_job(process_id: int) -> bool:
456
+ """Tell whether any job holds the process, raising when Windows cannot answer.
457
+
458
+ Since Windows 8 jobs nest. A process started with the request to leave its job leaves each job of the chain that
459
+ allows it, one after the other, and silently stays in the first one that forbids it. A helper still in a job right
460
+ after it was started suspended with that request was therefore kept by a job outside GitPane, since GitPane's own
461
+ jobs all let it leave. Elsewhere than on Windows there are no jobs.
462
+ """
463
+ if os.name != "nt":
464
+ return False
465
+ kernel = _bound_kernel(("OpenProcess", "IsProcessInJob", "CloseHandle"))
466
+ process_handle = kernel.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, process_id)
467
+ if not process_handle:
468
+ raise ctypes.WinError(ctypes.get_last_error())
469
+ try:
470
+ contained = wintypes.BOOL()
471
+ # A null job handle asks about any job rather than one given job.
472
+ if not kernel.IsProcessInJob(process_handle, None, ctypes.byref(contained)):
473
+ raise ctypes.WinError(ctypes.get_last_error())
474
+ return bool(contained.value)
475
+ finally:
476
+ kernel.CloseHandle(process_handle)
477
+
478
+
479
+ class JobBreakawayRefusedError(OSError):
480
+ """A job holding GitPane, the one GitPane runs in or one further out, keeps the process, so a released launch cannot go on.
481
+
482
+ Starting the process inside that job, or inside the supervised group instead, would expose what it opens, such as
483
+ the user's browser, to the stopping of that job or group.
484
+ """
485
+
486
+
487
+ class ReleasedChildJob:
488
+ """Run one process that is stopped with this job, while every process it starts is left running on its own.
489
+
490
+ Git Credential Manager opens the user's browser as a child process during a browser sign-in. In a supervised group
491
+ that browser would be a member like any helper: the group would only count as empty once the user closes the
492
+ browser, and stopping the group would close it. On Windows the process therefore leaves the supervised group when it
493
+ starts (that group must allow it) and is held in this private job instead. The kernel stops the job's members when
494
+ ``stop`` is called or when the job's owner exits, so the process can never outlive the worker that started it;
495
+ the processes it starts leave the job at once, so the browser is never stopped by GitPane. When a job outside GitPane
496
+ forbids leaving it, the process stays in that job without any error; it is then stopped before it has run anything
497
+ and the launch is refused, since that job could close the browser too.
498
+
499
+ Elsewhere, the process runs in a session of its own, and the browser it opens stays in that session and in its group
500
+ (``xdg-open`` and ``open`` move it to neither). Only the process itself is ever stopped, never its group, so the
501
+ browser keeps running. On Linux the kernel also stops the process as soon as the worker that started it exits, as
502
+ the Windows job would, and the supervisor does not wait for what is left of that session once its leader has ended.
503
+ macOS has no such signal, so a small watcher leads that session as the process's parent: the kernel tells it when
504
+ the worker ends, and it then stops the process alone. The watcher hands the process its streams and ends with its
505
+ exit code, so the worker reads the watcher as it would read the process.
506
+ """
507
+
508
+ def __init__(self, caller_job_forbids_breakaway: Callable[[], bool] = caller_job_forbids_breakaway,
509
+ process_is_in_a_job: Callable[[int], bool] = process_is_in_a_job, platform: str = sys.platform) -> None:
510
+ """Pick the system's guard, and create the private job before anything is started where the guard is a job.
511
+
512
+ ``caller_job_forbids_breakaway`` reads the limits of the job holding GitPane, and ``process_is_in_a_job`` tells
513
+ whether a job still holds the process just started; the tests replace both, since Windows alone has such jobs.
514
+ ``platform`` is the system whose guard is used.
515
+ """
516
+ self.guard = released_child_guard(platform)
517
+ self.job = _WindowsJob(None, limit_flags=JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) if self.guard is ReleasedChildGuard.JOB else None
518
+ self.process: subprocess.Popen | None = None
519
+ self.caller_job_forbids_breakaway = caller_job_forbids_breakaway
520
+ self.process_is_in_a_job = process_is_in_a_job
521
+
522
+ def start(self, command: list[str], environment_by_name: dict[str, str]) -> subprocess.Popen:
523
+ """Start the process with piped standard streams, already a member of this job before it runs any code.
524
+
525
+ Raises ``JobBreakawayRefusedError`` without starting anything when the caller's own job forbids leaving it, and
526
+ after stopping the process before it has run anything when a job outside GitPane keeps it. Any other refusal,
527
+ such as a helper program the user may not run, is raised as it came. A process stopped that way is waited for
528
+ and its pipes closed before the refusal is raised, so nothing of it is left behind.
529
+
530
+ On macOS the process returned is the watcher that runs the helper: it shares the helper's streams and ends with
531
+ the helper's exit code, and a helper the watcher cannot start shows up as its exit code 127 with the reason on
532
+ standard error.
533
+ """
534
+ from gitpane.config import ISOLATED_INTERPRETER_OPTIONS, PROCESS_TERMINATE_TIMEOUT_MS
535
+
536
+ pipe_by_stream = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
537
+ if self.job is None and self.guard is ReleasedChildGuard.WATCHER:
538
+ watched_command = [supervisor_interpreter_filename(), *ISOLATED_INTERPRETER_OPTIONS, released_child_watch_filename(), str(os.getpid()), *command]
539
+ self.process = subprocess.Popen(watched_command, env=environment_by_name, start_new_session=True, **pipe_by_stream)
540
+ return self.process
541
+ if self.job is None:
542
+ # Workaround: the parent-death signal can only be requested by the new process itself, so it goes through
543
+ # preexec_fn, which runs between fork and exec. That is only unsafe while other threads run, and the
544
+ # sign-in worker that starts the helper runs none (on POSIX, communicate() waits without threads).
545
+ stop_with_owner = _stop_with_owner_function(os.getpid())
546
+ self.process = subprocess.Popen(command, env=environment_by_name, start_new_session=True, preexec_fn=stop_with_owner, **pipe_by_stream) # noqa: PLW1509 -- see above
547
+ return self.process
548
+ try:
549
+ process = subprocess.Popen(command, env=environment_by_name, creationflags=CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB, **pipe_by_stream)
550
+ except PermissionError as error:
551
+ if not self.caller_job_forbids_breakaway():
552
+ raise
553
+ # A job that forbids leaving it holds this worker. Starting the process inside the supervised group instead
554
+ # would put the browser it opens in that group too, and stopping the group would close the user's browser,
555
+ # so the launch is refused rather than retried.
556
+ raise JobBreakawayRefusedError("The calling job does not allow a process to leave it") from error
557
+ self.process = process
558
+ try:
559
+ # The process is still suspended and has run nothing, so it cannot have opened a browser yet. A job outside
560
+ # GitPane that forbids leaving it keeps the process without any error from Windows; only asking which job
561
+ # holds it now shows that, and the process is then stopped before it runs.
562
+ if self.process_is_in_a_job(process.pid):
563
+ raise JobBreakawayRefusedError("A job outside GitPane keeps the process it starts")
564
+ self.job.adopt_suspended(process.pid)
565
+ except OSError:
566
+ process.kill()
567
+ # The refused process must hold neither a running process nor open handles until the garbage collector
568
+ # finds it, nor make Python warn that it is still running. A suspended process that has run nothing ends as
569
+ # soon as Windows stops it; if it still has not ended in time, the refusal is what the user must see.
570
+ try:
571
+ process.wait(timeout=PROCESS_TERMINATE_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
572
+ except subprocess.TimeoutExpired:
573
+ pass
574
+ for stream in (process.stdin, process.stdout, process.stderr):
575
+ if stream is not None:
576
+ stream.close()
577
+ self.process = None
578
+ raise
579
+ return process
580
+
581
+ def stop(self) -> None:
582
+ """Stop the process now, leaving alone the processes it started.
583
+
584
+ Elsewhere than on Windows only the process is signalled, never its group: the browser it opened is a member of
585
+ that group. The process is still unreaped while it runs, so its ID cannot name another process yet. On macOS the
586
+ watcher is asked to stop, and it stops the helper alone, its own unreaped child, before it ends.
587
+ """
588
+ if self.job is not None:
589
+ self.job.stop()
590
+ return
591
+ if self.process is None or self.process.poll() is not None:
592
+ return
593
+ if self.guard is ReleasedChildGuard.WATCHER:
594
+ self.process.terminate()
595
+ return
596
+ self.process.kill()
597
+
598
+ def close(self) -> None:
599
+ """Release the job; a member still running is stopped by the kernel when its last handle closes.
600
+
601
+ Elsewhere there is no job to close, so a process still running is stopped here, the way the job would stop it.
602
+ """
603
+ if self.job is not None:
604
+ self.job.close()
605
+ return
606
+ self.stop()
607
+
608
+
609
+ def _stop_with_owner_function(owner_id: int) -> Callable[[], None]:
610
+ """Return what a new Linux process runs before its program: ask the kernel to stop it when ``owner_id``, its parent, exits.
611
+
612
+ This is the Linux counterpart of the Windows job that stops its member when its owner exits. The C library is
613
+ bound here, in the parent, so the new process only makes the call. A process whose parent already exited before
614
+ the request took effect, or whose request was refused, ends before its program runs rather than run unguarded.
615
+ """
616
+ library = ctypes.CDLL(None, use_errno=True)
617
+ import signal
618
+
619
+ def ask_kernel() -> None:
620
+ """Run in the new process, between its creation and its program."""
621
+ if library.prctl(LINUX_SET_PARENT_DEATH_SIGNAL, signal.SIGKILL, 0, 0, 0) != 0:
622
+ os._exit(PROCESS_START_FAILED_EXIT_CODE)
623
+ if os.getppid() != owner_id:
624
+ os._exit(PROCESS_STOP_EXIT_CODE)
625
+
626
+ return ask_kernel
627
+
628
+
629
+ def _write_marker(control_dir: str, basename: str, value: str = "") -> None:
630
+ """Atomically publish small control records so polling never reads a partial PID."""
631
+ temporary_filename = os.path.join(control_dir, basename + ".tmp-" + str(os.getpid()))
632
+ with open(temporary_filename, "w", encoding="ascii") as stream:
633
+ stream.write(value)
634
+ os.replace(temporary_filename, os.path.join(control_dir, basename))
635
+
636
+
637
+ def _read_group_id(control_dir: str) -> int | None:
638
+ """Read the isolated session identity once the supervisor has started the target."""
639
+ try:
640
+ with open(os.path.join(control_dir, GROUP_BASENAME), encoding="ascii") as stream:
641
+ group_id = int(stream.read())
642
+ except FileNotFoundError:
643
+ return None
644
+ except ValueError as error:
645
+ raise OSError("Invalid supervised process group") from error
646
+ if group_id < MINIMUM_TRACKED_PROCESS_ID:
647
+ raise OSError("Invalid supervised process group")
648
+ return group_id
649
+
650
+
651
+ def _posix_group_exists(group_id: int) -> bool:
652
+ """Tell whether any member of the group, a zombie included, still exists, so termination is never invented.
653
+
654
+ A group whose remaining members are zombies not yet reaped still exists: it disappears once they are reaped, and
655
+ the caller checks again. Any other refusal of the system is raised, since the outcome is then unknown.
656
+ """
657
+ try:
658
+ os.killpg(group_id, 0)
659
+ except ProcessLookupError:
660
+ return False
661
+ except PermissionError:
662
+ # Workaround: macOS answers EPERM for a group whose remaining members are all zombies not yet reaped, where
663
+ # Linux accepts the request; the group still exists until they are reaped.
664
+ return True
665
+ return True
666
+
667
+
668
+ def _stop_posix_group(group_id: int) -> None:
669
+ """Stop the entire isolated group while leaving the supervisor alive to reap it.
670
+
671
+ A group that no longer exists, or whose remaining members are zombies not yet reaped, has nothing left to stop.
672
+ Any other refusal of the system is raised.
673
+ """
674
+ import signal
675
+
676
+ try:
677
+ os.killpg(group_id, signal.SIGKILL)
678
+ except ProcessLookupError:
679
+ pass
680
+ except PermissionError:
681
+ # Workaround: macOS answers EPERM to killpg on a group whose remaining members are all zombies not yet reaped,
682
+ # where Linux succeeds; nothing alive is left to stop, and reaping them is what ends the group.
683
+ pass
684
+
685
+
686
+ def _reap_exited_children() -> bool:
687
+ """Reap every child that has exited, adopted ones included, and report whether a child still runs."""
688
+ while True:
689
+ try:
690
+ process_id, _ = os.waitpid(-1, os.WNOHANG)
691
+ except ChildProcessError:
692
+ return False
693
+ if process_id == 0:
694
+ return True
695
+
696
+
697
+ def _running_child_records() -> list[tuple[int, int, int]] | None:
698
+ """List the supervisor's running children, adopted ones included, as (process ID, group ID, session ID); None when they cannot be read.
699
+
700
+ No system call lists the children of a process: Linux only gives each process's parent, group and session in its
701
+ own /proc entry, so every entry is read. This only runs while a child outlives the target, which is rare.
702
+ """
703
+ supervisor_id = os.getpid()
704
+ child_records = []
705
+ try:
706
+ proc_entries = os.scandir(PROC_DIR)
707
+ except OSError:
708
+ return None
709
+ with proc_entries:
710
+ for proc_entry in proc_entries:
711
+ if not proc_entry.name.isdigit():
712
+ continue
713
+ try:
714
+ with open(os.path.join(proc_entry.path, PROC_STAT_BASENAME), encoding="ascii", errors="replace") as stream:
715
+ stat_text = stream.read()
716
+ except OSError:
717
+ # The process ended since the folder was listed; one of the supervisor's own is always readable.
718
+ continue
719
+ # The command name comes first, between parentheses, and may itself contain spaces or parentheses.
720
+ stat_fields = stat_text.rpartition(")")[2].split()
721
+ if stat_fields[PROC_STAT_STATE_INDEX] == ZOMBIE_STATE or int(stat_fields[PROC_STAT_PARENT_INDEX]) != supervisor_id:
722
+ continue
723
+ child_records.append((int(proc_entry.name), int(stat_fields[PROC_STAT_GROUP_INDEX]), int(stat_fields[PROC_STAT_SESSION_INDEX])))
724
+ return child_records
725
+
726
+
727
+ def _session_leader_runs(session_id: int) -> bool:
728
+ """Tell whether the process that leads a session still exists.
729
+
730
+ While any member of the session remains, Linux keeps its ID reserved, so the ID cannot name another process.
731
+ """
732
+ try:
733
+ os.kill(session_id, 0)
734
+ except ProcessLookupError:
735
+ return False
736
+ except PermissionError:
737
+ return True
738
+ return True
739
+
740
+
741
+ def _reap_orphan(target_id: int, *, stopping: bool) -> bool:
742
+ """Reap adopted Linux descendants and report whether one must still be waited for; when ``stopping``, stop what the target's session still runs.
743
+
744
+ ``target_id`` is the target's process ID, which is also the ID of the group and of the session it leads. A child in
745
+ the target's session is waited for: a member of its group, or a program the target started in a group of its own
746
+ so that it can stop that program with everything it started. When the command is being stopped, each such group is
747
+ stopped as a whole, as the Windows job stops all of its members. A child that leads a session of its own, as Git's
748
+ background maintenance does once detached, is waited for too: it may still change repository data. Only a child
749
+ left behind by another session whose leader has ended is not waited for: that is what a released credential
750
+ helper opened before it ended, the user's browser, which is never stopped either. When the children cannot be
751
+ read, every one is waited for.
752
+
753
+ Once the command is being stopped, nothing outside the target's session is waited for any more. Such a child is
754
+ never stopped (it may be the user's browser, an SSH ControlPersist master or a gpg-agent that daemonized), so
755
+ waiting for it after a stop would keep the supervisor alive until it is killed, even though Git itself has ended.
756
+ """
757
+ if not _reap_exited_children():
758
+ return False
759
+ child_records = _running_child_records()
760
+ if child_records is None:
761
+ return True
762
+ waiting = False
763
+ for process_id, group_id, session_id in child_records:
764
+ if session_id == target_id:
765
+ if stopping and group_id != target_id:
766
+ _stop_posix_group(group_id)
767
+ waiting = True
768
+ continue
769
+ if stopping:
770
+ continue
771
+ if process_id == session_id or _session_leader_runs(session_id):
772
+ waiting = True
773
+ return waiting
774
+
775
+
776
+ def supervise(control_dir: str, job_name: str, program_filename: str, arguments: list[str], *, poll_ms: int, owner_id: int) -> int:
777
+ """Run a target with unchanged streams and stay until all of its helpers exit, checking again every ``poll_ms``.
778
+
779
+ On Windows the supervisor blocks on the target itself, and only then checks the job until nobody but the supervisor
780
+ is left: stopping goes through the job from the application, so nothing needs watching while the target runs.
781
+ Elsewhere the stop marker must be watched while the target runs, so the target is polled. Once it has exited, the
782
+ supervisor stays while a member of the target's group runs, and on Linux while a descendant it adopted may still
783
+ change repository data; what a released credential helper opened, such as the user's browser, is not waited for.
784
+
785
+ On macOS and Linux the supervisor also watches the process that started it, GitPane, whose process ID is
786
+ ``owner_id``: when its parent is no longer GitPane, GitPane has died, and the target's group is stopped exactly as
787
+ for a stop request. A supervisor whose parent is already not GitPane when it starts runs nothing and records its end
788
+ as for a stop requested before the launch. The parent is compared with GitPane itself, not with the first process
789
+ of the system, because a Linux desktop session adopts orphans in a process of its own, and GitPane may itself be
790
+ that first process in a container.
791
+ """
792
+ job = None
793
+ child = None
794
+ watching_owner = watches_owner_exit(sys.platform)
795
+ try:
796
+ if os.name == "nt":
797
+ job = _WindowsJob(job_name, existing=True)
798
+ job.join_current_process()
799
+ elif sys.platform.startswith(LINUX_PLATFORM):
800
+ # Adopt orphan grandchildren so their zombies cannot keep an empty group alive, and so what the target
801
+ # leaves behind can still be waited for or stopped.
802
+ library = ctypes.CDLL(None, use_errno=True)
803
+ if library.prctl(LINUX_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
804
+ raise OSError(ctypes.get_errno(), "Could not track orphan descendants")
805
+ if os.path.isfile(os.path.join(control_dir, STOP_BASENAME)) or (watching_owner and os.getppid() != owner_id):
806
+ _write_marker(control_dir, COMPLETE_BASENAME)
807
+ return PROCESS_STOP_EXIT_CODE
808
+ child = subprocess.Popen([program_filename, *arguments], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, start_new_session=os.name != "nt",
809
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
810
+ _write_marker(control_dir, GROUP_BASENAME, str(child.pid))
811
+ if job is not None:
812
+ return_code = child.wait()
813
+ while job.active_count() != SUPERVISOR_PROCESS_COUNT:
814
+ time.sleep(poll_ms / MILLISECONDS_PER_SECOND)
815
+ _write_marker(control_dir, COMPLETE_BASENAME, str(return_code))
816
+ return return_code
817
+ while True:
818
+ stopping = os.path.isfile(os.path.join(control_dir, STOP_BASENAME)) or (watching_owner and os.getppid() != owner_id)
819
+ if stopping:
820
+ _stop_posix_group(child.pid)
821
+ return_code = child.poll()
822
+ if return_code is not None:
823
+ orphan_alive = _reap_orphan(child.pid, stopping=stopping)
824
+ drained = not _posix_group_exists(child.pid) and not orphan_alive
825
+ if drained:
826
+ _write_marker(control_dir, COMPLETE_BASENAME, str(return_code))
827
+ return return_code
828
+ time.sleep(poll_ms / MILLISECONDS_PER_SECOND)
829
+ except OSError as error:
830
+ if child is None:
831
+ _write_marker(control_dir, START_FAILED_BASENAME)
832
+ # Keep errors on the existing stderr path, where credential diagnostics are filtered.
833
+ print(str(error), file=sys.stderr, flush=True)
834
+ return PROCESS_START_FAILED_EXIT_CODE
835
+ finally:
836
+ if job is not None:
837
+ job.close()
838
+
839
+
840
+ if __name__ == "__main__":
841
+ supervisor_arguments = sys.argv[SUPERVISOR_ARGUMENT_OFFSET:]
842
+ control_dir, job_name, poll_ms_value, owner_id_value = supervisor_arguments[:SUPERVISOR_SETTING_ARGUMENT_COUNT]
843
+ program_filename, *arguments = supervisor_arguments[SUPERVISOR_SETTING_ARGUMENT_COUNT:]
844
+ try:
845
+ poll_ms = supervisor_poll_ms(poll_ms_value)
846
+ supervisor_owner = supervisor_owner_id(owner_id_value)
847
+ except ValueError as error:
848
+ _write_marker(control_dir, START_FAILED_BASENAME)
849
+ print(str(error), file=sys.stderr, flush=True)
850
+ raise SystemExit(PROCESS_START_FAILED_EXIT_CODE) from None
851
+ raise SystemExit(supervise(control_dir, job_name, program_filename, arguments, poll_ms=poll_ms, owner_id=supervisor_owner))