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,1033 @@
1
+ """Bound every child process the application runs: its lifetime, its byte streams, its share of a window's concurrency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import unicodedata
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field, replace
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QElapsedTimer, QObject, QProcess, QProcessEnvironment, QTimer, Signal
15
+
16
+ import gitpane.process.exact_arguments
17
+ from gitpane.config import (
18
+ COMMIT_LIMIT_BYTES,
19
+ DECOMPOSED_UNICODE_FORM,
20
+ GCM_ENVIRONMENT_PREFIX,
21
+ GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME,
22
+ GIT_FSMONITOR_SETTING,
23
+ GIT_INERT_EDITOR,
24
+ GIT_LOCAL_ENVIRONMENT_NAMES,
25
+ GIT_LOCAL_ENVIRONMENT_PREFIXES,
26
+ GIT_MUTATION_ENVIRONMENT_NAME_SET,
27
+ GLOBAL_PROCESS_LIMIT,
28
+ GLOBAL_QUEUE_LIMIT,
29
+ ISOLATED_INTERPRETER_OPTIONS,
30
+ MACOS_PLATFORM,
31
+ NETWORK_ENVIRONMENT_VALUE_SET_BY_NAME,
32
+ PARSING_BUDGET_MS,
33
+ PROCESS_CHUNK_BYTES,
34
+ PROCESS_GROUP_POLL_MS,
35
+ PROCESS_IDLE_TIMEOUT_MS,
36
+ PROCESS_TERMINATE_TIMEOUT_MS,
37
+ SESSION_PROCESS_LIMIT,
38
+ SESSION_QUEUE_LIMIT,
39
+ SSH_ENVIRONMENT_NAMES,
40
+ STDERR_LIMIT_BYTES,
41
+ TRACE_ENVIRONMENT_PREFIXES,
42
+ TRACE_STORAGE_LIMIT_BYTES,
43
+ WINDOWS_PLATFORM,
44
+ ProcessPriority,
45
+ )
46
+ from gitpane.operation.diagnostic import MASKED_VALUE, SENSITIVE_NAME_PATTERN, SensitiveTextFilter, SensitiveTextStream, mask_sensitive_arguments
47
+ from gitpane.process.exact_arguments import EXACT_ARGUMENTS_ENVIRONMENT_NAME, EXACT_ARGUMENTS_START_FAILED_EXIT_CODE, EXACT_ARGUMENTS_START_FAILURE_PREFIX
48
+ from gitpane.process.process_group import NativeProcessGroup, ProcessGroup, supervisor_interpreter_filename
49
+ from gitpane.tool.editor_bridge import EDITOR_ENVIRONMENT_NAME_SET
50
+
51
+ if TYPE_CHECKING:
52
+ from gitpane.tool.editor import EditorExecutionContext
53
+
54
+ # The profile of a plain read: a process that changes nothing and needs no supervised launcher.
55
+ READ_PROFILE = "read"
56
+ # The profile of a process that talks to the network: isolated environment, error output kept for the console, supervised launcher.
57
+ NETWORK_PROFILE = "network"
58
+ # The profile of a supervised process that writes locally or may run a hook, a filter or the editor.
59
+ LOCAL_WRITE_PROFILE = "local-write"
60
+ # The profile of a process that exchanges a secret: its output is never published.
61
+ CREDENTIAL_PROFILE = "credential"
62
+ TRACKED_PROCESS_PROFILE_SET = frozenset({LOCAL_WRITE_PROFILE, NETWORK_PROFILE, CREDENTIAL_PROFILE})
63
+ # Built from the supervised profiles instead of listed again: the three names that decide supervision
64
+ # are written once, and the only profile that adds nothing to supervise is the plain read.
65
+ ACCEPTED_PROCESS_PROFILE_SET = TRACKED_PROCESS_PROFILE_SET | frozenset({READ_PROFILE})
66
+
67
+
68
+ class ProcessStatus(Enum):
69
+ """How a process request ended, or that it is still running; the value is the word the ``--log`` file writes."""
70
+
71
+ RUNNING = "running"
72
+ """The request was launched and has not ended yet; no published result carries it."""
73
+ SUCCESS = "success"
74
+ """The command ran to its end with exit code 0 and no cause of failure."""
75
+ FAILED = "failed"
76
+ """The command ran to its end with another exit code, or with a cause of failure."""
77
+ TIMEOUT = "timeout"
78
+ """The command was stopped because it ran past its time limit."""
79
+ CANCELLED = "cancelled"
80
+ """The user or the closing runner stopped the request, queued or running."""
81
+ QUEUE_FULL = "queue_full"
82
+ """The request was refused because too many were already waiting; it never ran."""
83
+ START_FAILED = "start_failed"
84
+ """The program could not be launched, refused by its own check or by the system."""
85
+ IDLE_TIMEOUT = "idle_timeout"
86
+ """The command was stopped because it wrote nothing for too long."""
87
+ VOLUME = "volume"
88
+ """The command was stopped because it wrote more than its output limit."""
89
+
90
+
91
+ # Cause of a failure that wrote no error output; written here and recognised by the console, which does not repeat it.
92
+ EXIT_CODE_CAUSE_TEMPLATE = "Process exited with code {return_code}"
93
+ # The systems whose Qt rewrites every argument QProcess passes into its decomposed Unicode form.
94
+ DECOMPOSED_ARGUMENT_PLATFORM_SET = frozenset({MACOS_PLATFORM})
95
+ # The systems where stopping a read can leave running a program it started, with the files it opened still open. On
96
+ # Windows, Git for Windows' cmd\git.exe is a wrapper that starts the real git.exe: stopping the wrapper leaves git.exe
97
+ # alive until its next write to the closed output fails, and Windows refuses to delete a file it still has open.
98
+ STOPPED_READ_LEAVES_CHILD_PLATFORM_SET = frozenset({WINDOWS_PLATFORM})
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class ProcessRequest:
103
+ """Capture immutable launch identity and limits before a command enters the queue.
104
+
105
+ The priority only decides which waiting request takes the next free slot; it says nothing about what the command
106
+ may do, so it never takes part in the choice of a launch profile or of supervision.
107
+
108
+ ``accepted_return_codes`` are the exit codes the caller does not hold for an error, such as 1 for a reference
109
+ lookup that may find nothing. Only the operation console reads them; the launch and the result status ignore them.
110
+ """
111
+
112
+ request_id: str
113
+ program_filename: str
114
+ arguments: list[str]
115
+ working_dir: str
116
+ timeout_ms: int
117
+ session_id: str = "application"
118
+ stdout_limit_bytes: int | None = COMMIT_LIMIT_BYTES
119
+ stderr_limit_bytes: int = STDERR_LIMIT_BYTES
120
+ idle_timeout_ms: int = PROCESS_IDLE_TIMEOUT_MS
121
+ stdin: bytes | None = None
122
+ environment_by_name: dict[str, str] = field(default_factory=dict)
123
+ operation: str = "Read"
124
+ wait_for_exit_on_stop: bool = False
125
+ profile: str = READ_PROFILE
126
+ operation_id: str = ""
127
+ before_start: Callable[[], None] | None = None
128
+ editor_context: EditorExecutionContext | None = field(default=None, repr=False)
129
+ suppress_editor: bool = False
130
+ priority: ProcessPriority = ProcessPriority.USER
131
+ # The group a supervised request runs in, when it needs another one than its runner's default; None keeps the default.
132
+ process_group_factory: Callable[[], ProcessGroup] | None = field(default=None, repr=False)
133
+ accepted_return_codes: tuple[int, ...] = (0,)
134
+ # The caller owns this file until the terminal result; Qt opens it only when the request leaves the queue.
135
+ stdin_filename: str | None = None
136
+ # The caller deletes the files this command opens as soon as it ends. Where a stopped read can leave a program it
137
+ # started running, such a read runs under the supervisor, so its end is only reported once nothing it started is left.
138
+ opens_files_deleted_at_end: bool = False
139
+
140
+ def __post_init__(self) -> None:
141
+ """Reject competing input sources before a process can enter the queue."""
142
+ if self.stdin is not None and self.stdin_filename is not None:
143
+ raise ValueError("stdin and stdin_filename are mutually exclusive")
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class ProcessResult:
148
+ """Report one terminal outcome with complete bounded stderr and launch context.
149
+
150
+ The two timings split the whole life of the request and lose nothing between them: the queue wait runs from the
151
+ moment the request was received to the moment it left the queue, or to its outcome when it never left; the
152
+ duration runs from that departure to the outcome, and is zero for a request that never started.
153
+ """
154
+
155
+ request: ProcessRequest
156
+ status: ProcessStatus
157
+ return_code: int | None
158
+ cause: str
159
+ stderr: bytes
160
+ queue_wait_ms: int = 0
161
+ duration_ms: int = 0
162
+
163
+
164
+ GIT_GLOBAL_FLAG_SET = frozenset({"--literal-pathspecs", "--no-literal-pathspecs"})
165
+ GIT_GLOBAL_VALUE_OPTION_SET = frozenset({"-c", "-C", "--git-dir", "--work-tree"})
166
+
167
+
168
+ def needs_exact_arguments(platform: str, program_filename: str, arguments: list[str]) -> bool:
169
+ """Tell whether a command must reach its program through the exact-arguments relay on ``platform``.
170
+
171
+ Only a system whose Qt decomposes arguments needs it, and there only for a command whose program or one argument
172
+ changes when decomposed: an all-ASCII command, the usual case, is started directly and costs nothing more.
173
+ """
174
+ if platform not in DECOMPOSED_ARGUMENT_PLATFORM_SET:
175
+ return False
176
+ if not unicodedata.is_normalized(DECOMPOSED_UNICODE_FORM, program_filename):
177
+ return True
178
+ for argument in arguments:
179
+ if not unicodedata.is_normalized(DECOMPOSED_UNICODE_FORM, argument):
180
+ return True
181
+ return False
182
+
183
+
184
+ def exact_arguments_relay_filename() -> str:
185
+ """Answer the absolute file of the exact-arguments relay, as imported from the installation; a missing file is a launch error."""
186
+ relay_filename = os.path.abspath(gitpane.process.exact_arguments.__file__)
187
+ if not os.path.isfile(relay_filename):
188
+ raise OSError("The exact-arguments relay is not available: " + relay_filename)
189
+ return relay_filename
190
+
191
+
192
+ def relay_start_failure_cause(relayed: bool, return_code: int | None, stderr: bytes) -> str | None:
193
+ """Answer the relay's refusal line when a relayed command never started, or None when the command itself ran.
194
+
195
+ QProcess starts the relay without trouble, so a program the relay cannot start shows up as an ordinary exit. Only
196
+ the relay's exit code together with its refusal line means the program did not start: a command that ran may
197
+ answer 127 on its own, and its answer must stay a return code.
198
+ """
199
+ if not relayed or return_code != EXACT_ARGUMENTS_START_FAILED_EXIT_CODE:
200
+ return None
201
+ error_text = stderr.decode("utf-8", "replace").strip()
202
+ if not error_text.startswith(EXACT_ARGUMENTS_START_FAILURE_PREFIX):
203
+ return None
204
+ return error_text
205
+
206
+
207
+ def stopped_read_leaves_child(platform: str) -> bool:
208
+ """Tell whether stopping a read on ``platform`` can leave running a program it started, with its files still open."""
209
+ return platform in STOPPED_READ_LEAVES_CHILD_PLATFORM_SET
210
+
211
+
212
+ def is_supervised_request(request: ProcessRequest, platform: str = sys.platform) -> bool:
213
+ """Tell whether a request must run under the supervisor that tracks its native descendants on ``platform``.
214
+
215
+ Writes and waited-for commands always are. A read is only when its caller deletes the files it opens right after
216
+ its end, and only on a system where stopping it can leave a program it started holding them: elsewhere the read
217
+ stays a plain process, which costs no interpreter and no group.
218
+ """
219
+ if request.profile in TRACKED_PROCESS_PROFILE_SET or request.wait_for_exit_on_stop:
220
+ return True
221
+ return request.opens_files_deleted_at_end and stopped_read_leaves_child(platform)
222
+
223
+
224
+ def git_subcommand_index(arguments: list[str]) -> int | None:
225
+ """Return the position of the Git subcommand behind the global options placed in front of it, or nothing."""
226
+ skip_value = False
227
+ for position, argument in enumerate(arguments):
228
+ if skip_value:
229
+ skip_value = False
230
+ continue
231
+ if argument in GIT_GLOBAL_VALUE_OPTION_SET:
232
+ skip_value = True
233
+ continue
234
+ option_name, separator, _value = argument.partition("=")
235
+ if separator and option_name in GIT_GLOBAL_VALUE_OPTION_SET:
236
+ continue
237
+ if argument in GIT_GLOBAL_FLAG_SET:
238
+ continue
239
+ return position
240
+ return None
241
+
242
+
243
+ def git_subcommand(arguments: list[str]) -> str:
244
+ """Return the Git subcommand hidden behind the global options placed in front of it, or an empty name."""
245
+ position = git_subcommand_index(arguments)
246
+ return "" if position is None else arguments[position]
247
+
248
+
249
+ OPTION_PREFIX = "-"
250
+ OPTION_VALUE_SEPARATOR = "="
251
+ PATHSPEC_SEPARATOR = "--"
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class ReadForm:
256
+ """Describe the shape a subcommand must have to write nothing and run no program of the user.
257
+
258
+ Each field narrows the accepted shape: options that make the command write or convert are forbidden, one of the
259
+ required option groups must be complete, a non-empty permitted list closes the form to anything else, the first
260
+ word may be restricted to a few modes, and the number of words may be capped.
261
+ """
262
+
263
+ forbidden_option_set: frozenset[str] = frozenset()
264
+ required_option_sets: tuple[frozenset[str], ...] = ()
265
+ permitted_option_set: frozenset[str] = frozenset()
266
+ first_word_set: frozenset[str] = frozenset()
267
+ maximum_word_count: int | None = None
268
+
269
+
270
+ # One shape shared by the three comparison subcommands: porcelain and plumbing alike run the user's external diff
271
+ # program and text conversion as soon as they are asked for a patch, so all three demand the same positive proof.
272
+ DIFF_READ_FORM = ReadForm(forbidden_option_set=frozenset({"--ext-diff", "--textconv"}),
273
+ required_option_sets=(frozenset({"--no-ext-diff", "--no-textconv"}), frozenset({"--name-only"}), frozenset({"--name-status"})))
274
+
275
+ # The only list of Git subcommands that read. It is closed on purpose: an unknown subcommand is not a read, because
276
+ # being wrong the other way would let a hook or a filter still run after the repository has been handed back. The
277
+ # quiet form of a comparison is not accepted either: an external diff program whose exit code is declared reliable
278
+ # still runs, and its answer becomes the exit code of the command.
279
+ READ_FORM_BY_SUBCOMMAND: dict[str, ReadForm] = {
280
+ "--version": ReadForm(),
281
+ "--exec-path": ReadForm(),
282
+ "check-attr": ReadForm(),
283
+ "check-ignore": ReadForm(),
284
+ "check-ref-format": ReadForm(),
285
+ "for-each-ref": ReadForm(),
286
+ "ls-files": ReadForm(),
287
+ "ls-tree": ReadForm(),
288
+ "merge-base": ReadForm(),
289
+ "rev-list": ReadForm(),
290
+ "rev-parse": ReadForm(),
291
+ "status": ReadForm(),
292
+ "var": ReadForm(),
293
+ "version": ReadForm(),
294
+ "cat-file": ReadForm(forbidden_option_set=frozenset({"--filters", "--textconv"})),
295
+ "fast-export": ReadForm(required_option_sets=(frozenset({"--no-data", "--no-renames", "--show-original-ids", "--use-done-feature"}),),
296
+ permitted_option_set=frozenset({"--no-data", "--no-renames", "--show-original-ids", "--use-done-feature", "--reencode", "--signed-commits", "--stdin"})),
297
+ "diff": DIFF_READ_FORM,
298
+ "diff-index": DIFF_READ_FORM,
299
+ "diff-tree": DIFF_READ_FORM,
300
+ # Closed by its permitted options rather than by forbidden ones: the flags that produce a patch are too many to
301
+ # list without missing the next one, and a patch runs the user's external diff program or text conversion.
302
+ "log": ReadForm(permitted_option_set=frozenset({"--no-walk", "--ignore-missing", "--no-decorate", "--no-notes", "--no-show-signature",
303
+ "--encoding", "--format", "--max-count", "--stdin", "-z", "--topo-order"})),
304
+ "show": ReadForm(forbidden_option_set=frozenset({"--ext-diff", "--textconv"}),
305
+ required_option_sets=(frozenset({"-s"}), frozenset({"--no-patch"}), frozenset({"--no-ext-diff", "--no-textconv"}))),
306
+ "hash-object": ReadForm(forbidden_option_set=frozenset({"-w"}), required_option_sets=(frozenset({"--no-filters"}),)),
307
+ "merge-file": ReadForm(required_option_sets=(frozenset({"-p"}), frozenset({"--stdout"}))),
308
+ "config": ReadForm(forbidden_option_set=frozenset({"--add", "--unset", "--unset-all", "--replace-all", "--rename-section", "--remove-section", "--edit", "-e"}),
309
+ required_option_sets=(frozenset({"--get"}), frozenset({"--get-all"}), frozenset({"--get-regexp"}), frozenset({"--get-urlmatch"}),
310
+ frozenset({"--list"}), frozenset({"-l"}))),
311
+ "symbolic-ref": ReadForm(forbidden_option_set=frozenset({"-d", "--delete", "-m"}), maximum_word_count=1),
312
+ "remote": ReadForm(first_word_set=frozenset({"get-url"})),
313
+ "worktree": ReadForm(first_word_set=frozenset({"list"})),
314
+ "reflog": ReadForm(first_word_set=frozenset({"show"})),
315
+ "submodule": ReadForm(first_word_set=frozenset({"status"})),
316
+ "ls-remote": ReadForm(required_option_sets=(frozenset({"--get-url"}),)),
317
+ }
318
+
319
+ READ_SUBCOMMAND_SET = frozenset(READ_FORM_BY_SUBCOMMAND)
320
+
321
+
322
+ def is_read_command(arguments: list[str]) -> bool:
323
+ """Tell whether a Git command only reads: it changes nothing and starts no program belonging to the user.
324
+
325
+ Options are read the way this program writes them, one per argument and never grouped into a single letter block.
326
+ An option is an argument starting with a dash and placed before the pathspec separator, compared by the name in
327
+ front of its first equals sign; everything else is a word, including every argument after that separator.
328
+ """
329
+ position = git_subcommand_index(arguments)
330
+ if position is None:
331
+ return False
332
+ form = READ_FORM_BY_SUBCOMMAND.get(arguments[position])
333
+ if form is None:
334
+ return False
335
+ option_set: set[str] = set()
336
+ words: list[str] = []
337
+ after_separator = False
338
+ for argument in arguments[position + 1:]:
339
+ if not after_separator and argument == PATHSPEC_SEPARATOR:
340
+ after_separator = True
341
+ continue
342
+ if not after_separator and argument.startswith(OPTION_PREFIX):
343
+ option_name, _separator, _value = argument.partition(OPTION_VALUE_SEPARATOR)
344
+ option_set.add(option_name)
345
+ continue
346
+ words.append(argument)
347
+ if option_set & form.forbidden_option_set:
348
+ return False
349
+ if form.required_option_sets:
350
+ satisfied = False
351
+ for required_option_set in form.required_option_sets:
352
+ if required_option_set.issubset(option_set):
353
+ satisfied = True
354
+ break
355
+ if not satisfied:
356
+ return False
357
+ if form.permitted_option_set and not option_set.issubset(form.permitted_option_set):
358
+ return False
359
+ first_word_index = 0
360
+ if form.first_word_set and words and words[first_word_index] not in form.first_word_set:
361
+ return False
362
+ if form.maximum_word_count is None:
363
+ return True
364
+ return len(words) <= form.maximum_word_count
365
+
366
+
367
+ @dataclass
368
+ class _RunningProcess:
369
+ """Keep process resources alive until both termination and final draining complete."""
370
+
371
+ request: ProcessRequest
372
+ process: QProcess
373
+ deadline: QTimer
374
+ inactivity: QTimer
375
+ kill_timer: QTimer
376
+ group_timer: QTimer
377
+ group: ProcessGroup | None = None
378
+ stderr: bytearray = field(default_factory=bytearray)
379
+ stdout_size: int = 0
380
+ status: ProcessStatus = ProcessStatus.RUNNING
381
+ cause: str = ""
382
+ return_code: int | None = None
383
+ exited: bool = False
384
+ drain_pending: bool = False
385
+ stdout_paused: bool = False
386
+ credential_stream: SensitiveTextStream | None = None
387
+ ssh_stdout_stream: SensitiveTextStream | None = None
388
+ stderr_truncated: bool = False
389
+ drainage_uncertain: bool = False
390
+ editor_waiting: bool = False
391
+ editor_deadline_remaining_ms: int = 0
392
+ editor_idle_remaining_ms: int = 0
393
+ received_ms: int = 0
394
+ launched_ms: int = 0
395
+ relayed: bool = False
396
+
397
+
398
+ class ProcessRunner(QObject):
399
+ """Own queued and running QProcess objects without blocking the Qt event loop."""
400
+
401
+ stdout_chunk = Signal(object, bytes)
402
+ stderr_chunk = Signal(object, bytes)
403
+ finished = Signal(object)
404
+ idle = Signal()
405
+ stop_requested = Signal(object, str)
406
+ started = Signal(object)
407
+
408
+ def __init__(self, parent: QObject | None = None, global_limit: int = GLOBAL_PROCESS_LIMIT, session_limit: int = SESSION_PROCESS_LIMIT,
409
+ global_queue_limit: int = GLOBAL_QUEUE_LIMIT, session_queue_limit: int = SESSION_QUEUE_LIMIT, credential_consumer: Callable[[str, bytes], None] | None = None,
410
+ process_group_factory: Callable[[], ProcessGroup] = NativeProcessGroup, clock_ms: Callable[[], int] | None = None) -> None:
411
+ """Allow small injected bounds in tests while keeping production limits centralized.
412
+
413
+ The clock is injectable too, so timing tests read exact durations instead of depending on how fast the machine is.
414
+ """
415
+ super().__init__(parent)
416
+ if min(global_limit, session_limit, global_queue_limit, session_queue_limit) < 1:
417
+ raise ValueError("Process and queue limits must be positive")
418
+ self.global_limit = global_limit
419
+ self.session_limit = session_limit
420
+ self.global_queue_limit = global_queue_limit
421
+ self.session_queue_limit = session_queue_limit
422
+ self.active_by_id: dict[str, _RunningProcess] = {}
423
+ self.pending_requests: list[ProcessRequest] = []
424
+ self.closed = False
425
+ # Set while the queue is being emptied: a subscriber reacting to a launch cannot start a second, nested pass.
426
+ self.pumping = False
427
+ self.credential_consumer = credential_consumer
428
+ self.process_group_factory = process_group_factory
429
+ self.elapsed_timer = QElapsedTimer()
430
+ self.elapsed_timer.start()
431
+ self.clock_ms: Callable[[], int] = clock_ms if clock_ms is not None else self.elapsed_timer.elapsed
432
+ self.received_ms_by_id: dict[str, int] = {}
433
+ # Qt deletes the QProcess children after the runner itself, and a QProcess deleted while its program runs kills
434
+ # it, waits for it, and emits its output, error and end signals from inside that deletion. Their callbacks would
435
+ # then reach a runner and a QProcess whose C++ objects are already gone. Forgetting every running process when the
436
+ # runner goes makes each callback's ownership check turn them away; the dying QProcess still kills its program.
437
+ # The callback holds the dictionary, not the runner, so the runner's wrapper stays free to go with it.
438
+ active_by_id = self.active_by_id
439
+ self.destroyed.connect(lambda: active_by_id.clear())
440
+
441
+ def start(self, request: ProcessRequest) -> None:
442
+ """Snapshot arguments and working directory before launching or queuing work."""
443
+ if request.timeout_ms <= 0 or request.idle_timeout_ms <= 0 or request.stderr_limit_bytes < 0 or (request.stdout_limit_bytes is not None and request.stdout_limit_bytes < 0):
444
+ raise ValueError("Invalid timeouts or output limits")
445
+ if request.request_id in self.active_by_id or any(pending.request_id == request.request_id for pending in self.pending_requests):
446
+ raise ValueError("Request ID is already active")
447
+ if request.profile not in ACCEPTED_PROCESS_PROFILE_SET:
448
+ raise ValueError("Unsupported process profile")
449
+ if request.stdin_filename is not None and (not os.path.isabs(request.stdin_filename) or "\0" in request.stdin_filename):
450
+ raise ValueError("The input file path must be absolute and contain no NUL")
451
+ # The exact type is required: a boolean or a plain number would otherwise pass for a level by accident.
452
+ if type(request.priority) is not ProcessPriority:
453
+ raise ValueError("Unsupported process priority")
454
+ if type(request.suppress_editor) is not bool or request.suppress_editor and (request.profile != LOCAL_WRITE_PROFILE or request.editor_context is not None):
455
+ raise ValueError("Editor suppression requires a local write without an interactive editor.")
456
+ editor_environment_by_name: dict[str, str] = {}
457
+ if request.editor_context is not None:
458
+ from gitpane.tool.editor import EditorExecutionContext
459
+ if (not isinstance(request.editor_context, EditorExecutionContext) or request.profile != LOCAL_WRITE_PROFILE
460
+ or not request.editor_context.owns(request.operation_id) or request.editor_context.process_request_id):
461
+ raise ValueError("The editor context is expired, already bound or belongs to another operation.")
462
+ editor_environment_by_name = request.editor_context.environment()
463
+ for name, value in request.environment_by_name.items():
464
+ editor_allowed = name in editor_environment_by_name and value == editor_environment_by_name[name]
465
+ # One domain-selected directory, never a caller-supplied Git search list. Git's quoting is applied only at launch.
466
+ alternate_allowed = (name == GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME and request.profile in {READ_PROFILE, LOCAL_WRITE_PROFILE}
467
+ and os.path.isabs(value) and "\0" not in value and os.path.isdir(value))
468
+ network_allowed = request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and value in NETWORK_ENVIRONMENT_VALUE_SET_BY_NAME.get(name, ())
469
+ if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and name == "GIT_CONFIG_GLOBAL" and value == os.devnull:
470
+ network_allowed = True
471
+ if request.profile == NETWORK_PROFILE:
472
+ if name == "GIT_SSH":
473
+ network_allowed = os.path.isabs(value) and os.path.isfile(value) and os.access(value, os.X_OK)
474
+ elif name == "GIT_SSH_VARIANT":
475
+ network_allowed = value == "ssh"
476
+ elif name == "PATH":
477
+ network_allowed = os.name == "nt" and all(os.path.isabs(directory) for directory in value.split(os.pathsep) if directory)
478
+ elif name in {"SSH_ASKPASS", "GIT_ASKPASS"}:
479
+ network_allowed = os.path.isabs(value) and os.path.isfile(value) and os.access(value, os.X_OK)
480
+ elif name == "SSH_ASKPASS_REQUIRE":
481
+ network_allowed = value == "force"
482
+ elif name == "DISPLAY":
483
+ network_allowed = value == "gitpane:0"
484
+ elif name == "GIT_CEILING_DIRECTORIES":
485
+ # Only narrows where Git looks for a repository, so a command run from an empty folder reads no repository's settings.
486
+ network_allowed = all(os.path.isabs(directory) for directory in value.split(os.pathsep) if directory)
487
+ elif name in SSH_ENVIRONMENT_NAMES:
488
+ network_allowed = bool(request.operation_id) and len(value) <= 512 and not any(character in value for character in "\r\n")
489
+ if (name not in GIT_MUTATION_ENVIRONMENT_NAME_SET and not network_allowed and not editor_allowed and not alternate_allowed) or not value or "\0" in value:
490
+ raise ValueError("Unsupported Git process environment override")
491
+ if name == "GIT_INDEX_FILE" and not os.path.isabs(value):
492
+ raise ValueError("The candidate index path must be absolute")
493
+ stderr_limit = TRACE_STORAGE_LIMIT_BYTES if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and request.stderr_limit_bytes == STDERR_LIMIT_BYTES else request.stderr_limit_bytes
494
+ captured = replace(request, arguments=list(request.arguments), working_dir=os.path.abspath(request.working_dir), environment_by_name=dict(request.environment_by_name), stderr_limit_bytes=stderr_limit)
495
+ if request.editor_context is not None:
496
+ captured.environment_by_name.update(editor_environment_by_name)
497
+ request.editor_context.process_request_id = request.request_id
498
+ received_ms = self.clock_ms()
499
+ queued_for_session = sum(pending.session_id == request.session_id for pending in self.pending_requests)
500
+ if self.closed or len(self.pending_requests) >= self.global_queue_limit or queued_for_session >= self.session_queue_limit:
501
+ status = ProcessStatus.CANCELLED if self.closed else ProcessStatus.QUEUE_FULL
502
+ cause = "Runner closed" if self.closed else "Process queue is full"
503
+ cause = self._close_editor(captured, cause)
504
+ QTimer.singleShot(0, self, lambda: self.finished.emit(self._timed_result(captured, status, None, cause, b"", received_ms, None)))
505
+ return
506
+ self.received_ms_by_id[captured.request_id] = received_ms
507
+ self.pending_requests.append(captured)
508
+ self._pump()
509
+
510
+ def cancel(self, request_id: str) -> None:
511
+ """Abandon a queued request or stop a running one while retaining write diagnostics."""
512
+ for request in tuple(self.pending_requests):
513
+ if request.request_id == request_id:
514
+ self.pending_requests.remove(request)
515
+ received_ms = self.received_ms_by_id.pop(request.request_id, self.clock_ms())
516
+ cause = self._close_editor(request, request.operation + " cancelled")
517
+ self.finished.emit(self._timed_result(request, ProcessStatus.CANCELLED, None, cause, b"", received_ms, None))
518
+ self._notify_idle()
519
+ return
520
+ active = self.active_by_id.get(request_id)
521
+ if active is not None:
522
+ self._stop(active, ProcessStatus.CANCELLED, active.request.operation + " cancelled")
523
+
524
+ def protect_secret(self, request_id: str, secret: str) -> None:
525
+ """Register an SSH answer before its child can echo it on either public stream."""
526
+ active = self.active_by_id.get(request_id)
527
+ if active is None or active.credential_stream is None or not secret:
528
+ return
529
+ active.credential_stream.filter.sensitive_value_set.add(secret)
530
+
531
+ def pause_stdout(self, request_id: str) -> None:
532
+ """Let a consumer finish its parsing turn before draining additional stdout."""
533
+ active = self.active_by_id.get(request_id)
534
+ if active is not None:
535
+ active.stdout_paused = True
536
+
537
+ def resume_stdout(self, request_id: str) -> None:
538
+ """Resume bounded reads after the consumer has released its parsing backlog."""
539
+ active = self.active_by_id.get(request_id)
540
+ if active is not None:
541
+ active.stdout_paused = False
542
+ self._schedule_drain(active)
543
+
544
+ def cancel_session(self, session_id: str) -> None:
545
+ """Cancel only one window's work, including requests still waiting for a slot."""
546
+ request_ids: list[str] = []
547
+ for request in self.pending_requests:
548
+ if request.session_id == session_id:
549
+ request_ids.append(request.request_id)
550
+ for request_id, active in self.active_by_id.items():
551
+ if active.request.session_id == session_id:
552
+ request_ids.append(request_id)
553
+ for request_id in request_ids:
554
+ self.cancel(request_id)
555
+
556
+ def close(self) -> None:
557
+ """Begin asynchronous shutdown and emit idle once all children have stopped."""
558
+ self.closed = True
559
+ request_ids: list[str] = []
560
+ for request in self.pending_requests:
561
+ request_ids.append(request.request_id)
562
+ request_ids.extend(self.active_by_id)
563
+ for request_id in request_ids:
564
+ self.cancel(request_id)
565
+ self._notify_idle()
566
+
567
+ def _pump(self) -> None:
568
+ """Fill every free slot with the most urgent eligible request, without letting a busy session block other sessions.
569
+
570
+ The state is read again before every launch, because a launch can publish synchronously and its subscribers can
571
+ start, cancel or close. A call made from inside one of those publications returns at once: the pass already
572
+ under way sees what it changed on its next turn.
573
+ """
574
+ if self.closed or self.pumping:
575
+ return
576
+ self.pumping = True
577
+ try:
578
+ while not self.closed and len(self.active_by_id) < self.global_limit:
579
+ request = self._next_eligible_request()
580
+ if request is None:
581
+ return
582
+ self.pending_requests.remove(request)
583
+ self._launch(request)
584
+ finally:
585
+ self.pumping = False
586
+
587
+ def _next_eligible_request(self) -> ProcessRequest | None:
588
+ """Answer the waiting request to launch next: the most urgent level first, and the earliest arrival within a level.
589
+
590
+ Only a request whose session still has a free slot is eligible, so a saturated session never holds back another one.
591
+ """
592
+ active_count_by_session: dict[str, int] = {}
593
+ for active in self.active_by_id.values():
594
+ active_count_by_session[active.request.session_id] = active_count_by_session.get(active.request.session_id, 0) + 1
595
+ selected: ProcessRequest | None = None
596
+ for request in self.pending_requests:
597
+ if active_count_by_session.get(request.session_id, 0) >= self.session_limit:
598
+ continue
599
+ if selected is None or request.priority < selected.priority:
600
+ selected = request
601
+ return selected
602
+
603
+ def _timed_result(self, request: ProcessRequest, status: ProcessStatus, return_code: int | None, cause: str, stderr: bytes, received_ms: int, launched_ms: int | None) -> ProcessResult:
604
+ """Build every outcome the runner publishes, so none of them reaches a subscriber without its two timings.
605
+
606
+ A request that never left the queue has no launch instant: it waited until its own outcome and ran for no time.
607
+ """
608
+ now_ms = self.clock_ms()
609
+ if launched_ms is None:
610
+ queue_wait_ms, duration_ms = now_ms - received_ms, 0
611
+ else:
612
+ queue_wait_ms, duration_ms = launched_ms - received_ms, now_ms - launched_ms
613
+ return ProcessResult(self._public_request(request), status, return_code, cause, stderr, queue_wait_ms=queue_wait_ms, duration_ms=duration_ms)
614
+
615
+ def _launch(self, request: ProcessRequest) -> None:
616
+ """Start the program the request names, as a bounded child, after clearing inherited repository-specific Git overrides.
617
+
618
+ Every command, on every system, gets the command-scope setting ``core.fsmonitor=false`` through
619
+ ``GIT_CONFIG_COUNT`` in place of the command-scope settings the user's shell passed: Git never starts its
620
+ fsmonitor daemon for GitPane, so no daemon can keep a supervised command's process group alive.
621
+ A supervised request hands that same program to its process group, which may wrap it in its own command line.
622
+ On macOS, a command whose program or an argument Qt would decompose is started through the exact-arguments
623
+ relay instead: QProcess starts the base interpreter on the relay, the exact command (the supervisor's, for a
624
+ supervised request) travels in the relay's variable, and the relay replaces itself with it, keeping its process
625
+ id. The published request and the log keep the logical command in every case.
626
+ """
627
+ launched_ms = self.clock_ms()
628
+ received_ms = self.received_ms_by_id.pop(request.request_id, launched_ms)
629
+ group = None
630
+ program_filename, arguments = request.program_filename, request.arguments
631
+ exact_command: list[str] | None = None
632
+ try:
633
+ if request.editor_context is not None and not request.editor_context.owns(request.operation_id):
634
+ raise ValueError("The editor context expired before command launch.")
635
+ if request.before_start is not None:
636
+ request.before_start()
637
+ if is_supervised_request(request):
638
+ group = (request.process_group_factory or self.process_group_factory)()
639
+ program_filename, arguments = group.prepare(program_filename, arguments)
640
+ if needs_exact_arguments(sys.platform, program_filename, arguments):
641
+ exact_command = [program_filename, *arguments]
642
+ program_filename, arguments = supervisor_interpreter_filename(), [*ISOLATED_INTERPRETER_OPTIONS, exact_arguments_relay_filename()]
643
+ except (OSError, ValueError, RuntimeError) as error:
644
+ if group is not None:
645
+ try:
646
+ group.close()
647
+ except OSError:
648
+ pass
649
+ QTimer.singleShot(0, self, lambda cause=str(error): self._launch_failed(request, cause, received_ms, launched_ms))
650
+ return
651
+ process = QProcess(self)
652
+ deadline = QTimer(process)
653
+ inactivity = QTimer(process)
654
+ kill_timer = QTimer(process)
655
+ group_timer = QTimer(process)
656
+ for timer in (deadline, inactivity, kill_timer, group_timer):
657
+ timer.setSingleShot(True)
658
+ active = _RunningProcess(request, process, deadline, inactivity, kill_timer, group_timer, group=group, received_ms=received_ms, launched_ms=launched_ms,
659
+ relayed=exact_command is not None)
660
+ if request.editor_context is not None:
661
+ text_filter = SensitiveTextFilter(request.environment_by_name)
662
+ text_filter.sensitive_value_set.add(request.editor_context.token)
663
+ active.credential_stream = SensitiveTextStream(text_filter)
664
+ active.ssh_stdout_stream = SensitiveTextStream(text_filter)
665
+ request.editor_context.bind_peer(request.operation_id, lambda peer: self.active_by_id.get(request.request_id) is active
666
+ and active.status == ProcessStatus.RUNNING and active.group is not None and active.group.contains_process(peer))
667
+ request.editor_context.wait_changed.connect(lambda waiting: self._editor_wait_changed(active, waiting))
668
+ request.editor_context.failed.connect(lambda cause: self._editor_failure(active, cause))
669
+ elif request.profile == CREDENTIAL_PROFILE:
670
+ active.credential_stream = SensitiveTextStream(SensitiveTextFilter(self._credential_value_by_name(request)))
671
+ elif request.profile == NETWORK_PROFILE and "GITPANE_SSH_NONCE" in request.environment_by_name:
672
+ text_filter = SensitiveTextFilter(request.environment_by_name)
673
+ active.credential_stream = SensitiveTextStream(text_filter)
674
+ active.ssh_stdout_stream = SensitiveTextStream(text_filter)
675
+ self.active_by_id[request.request_id] = active
676
+ process.setProgram(program_filename)
677
+ process.setArguments(arguments)
678
+ process.setWorkingDirectory(request.working_dir)
679
+ if request.stdin_filename is not None:
680
+ process.setStandardInputFile(request.stdin_filename)
681
+ environment = QProcessEnvironment.systemEnvironment()
682
+ inherited_names = environment.keys()
683
+ for name in inherited_names:
684
+ if name.upper() in GIT_LOCAL_ENVIRONMENT_NAMES or name.upper().startswith(GIT_LOCAL_ENVIRONMENT_PREFIXES):
685
+ environment.remove(name)
686
+ if name.upper().startswith(TRACE_ENVIRONMENT_PREFIXES):
687
+ environment.remove(name)
688
+ if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and name.upper().startswith(GCM_ENVIRONMENT_PREFIX):
689
+ environment.remove(name)
690
+ if request.profile == NETWORK_PROFILE and name.upper() in {"GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSH_VARIANT", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", "GIT_ASKPASS"}:
691
+ environment.remove(name)
692
+ if name.upper().startswith(("GITPANE_SSH_", "GITPANE_EDITOR_")) or name.upper() in EDITOR_ENVIRONMENT_NAME_SET:
693
+ environment.remove(name)
694
+ environment.insert("GIT_TERMINAL_PROMPT", "0")
695
+ environment.insert("GIT_OPTIONAL_LOCKS", "0")
696
+ environment.insert("GIT_NO_LAZY_FETCH", "1")
697
+ environment.insert("GIT_NO_REPLACE_OBJECTS", "1")
698
+ environment.insert("LC_ALL", "C")
699
+ # The inherited count and its keys were removed above, so GitPane's setting takes index 0.
700
+ fsmonitor_key, fsmonitor_value = GIT_FSMONITOR_SETTING
701
+ environment.insert("GIT_CONFIG_COUNT", "1")
702
+ environment.insert("GIT_CONFIG_KEY_0", fsmonitor_key)
703
+ environment.insert("GIT_CONFIG_VALUE_0", fsmonitor_value)
704
+ for name, value in request.environment_by_name.items():
705
+ if name == GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME and os.pathsep in value:
706
+ # Git splits unquoted alternates on the platform separator. Octal C escapes preserve every filename byte;
707
+ # JSON's Unicode escapes are not accepted by Git's path parser.
708
+ quoted_parts = ['"']
709
+ for byte in os.fsencode(value):
710
+ quoted_parts.append(f"\\{byte:03o}")
711
+ quoted_parts.append('"')
712
+ value = "".join(quoted_parts)
713
+ environment.insert(name, value)
714
+ if request.suppress_editor:
715
+ environment.insert("GIT_EDITOR", GIT_INERT_EDITOR)
716
+ # Set last, so neither the inherited environment nor the request can replace the command the relay runs.
717
+ if exact_command is not None:
718
+ environment.insert(EXACT_ARGUMENTS_ENVIRONMENT_NAME, json.dumps(exact_command))
719
+ process.setProcessEnvironment(environment)
720
+ process.readyReadStandardOutput.connect(lambda: self._schedule_drain(active))
721
+ process.readyReadStandardError.connect(lambda: self._schedule_drain(active))
722
+ process.finished.connect(lambda code, status: self._exit(active, code, status))
723
+ process.errorOccurred.connect(lambda error: self._error(active, error))
724
+ deadline.timeout.connect(lambda: self._stop(active, ProcessStatus.TIMEOUT, f"Timed out ({request.timeout_ms} ms)"))
725
+ inactivity.timeout.connect(lambda: self._stop(active, ProcessStatus.IDLE_TIMEOUT, f"No output for {request.idle_timeout_ms} ms"))
726
+ kill_timer.timeout.connect(process.kill)
727
+ group_timer.timeout.connect(lambda: self._poll_group(active))
728
+ deadline.start(request.timeout_ms)
729
+ inactivity.start(request.idle_timeout_ms)
730
+ self.started.emit(self._public_request(request))
731
+ process.start()
732
+ if request.stdin is not None:
733
+ process.write(request.stdin)
734
+ process.closeWriteChannel()
735
+
736
+ def _launch_failed(self, request: ProcessRequest, cause: str, received_ms: int, launched_ms: int) -> None:
737
+ """Publish setup failure without launching an untracked write as a fallback."""
738
+ if request.profile == CREDENTIAL_PROFILE:
739
+ cause = SensitiveTextFilter(self._credential_value_by_name(request)).redact(cause)
740
+ if request.editor_context is not None:
741
+ cause = SensitiveTextFilter(request.environment_by_name).redact(cause)
742
+ cause = self._close_editor(request, cause)
743
+ self.finished.emit(self._timed_result(request, ProcessStatus.START_FAILED, None, cause, b"", received_ms, launched_ms))
744
+ self._pump()
745
+ self._notify_idle()
746
+
747
+ def _close_editor(self, request: ProcessRequest, cause: str) -> str:
748
+ """Attach retryable private cleanup diagnostics without changing the command's exit status."""
749
+ if request.editor_context is None:
750
+ return cause
751
+ cleanup_cause = request.editor_context.close()
752
+ if not cleanup_cause:
753
+ return cause
754
+ return cause + "; " + cleanup_cause if cause else cleanup_cause
755
+
756
+ def _editor_wait_changed(self, active: _RunningProcess, waiting: bool) -> None:
757
+ """Account only live authenticated editing time outside this command's remaining budgets."""
758
+ context = active.request.editor_context
759
+ if self.active_by_id.get(active.request.request_id) is not active or active.status != ProcessStatus.RUNNING or active.exited or context is None:
760
+ return
761
+ if waiting:
762
+ if active.editor_waiting or not context.owns(active.request.operation_id) or context.pending_socket is None:
763
+ return
764
+ active.editor_deadline_remaining_ms = max(1, active.deadline.remainingTime())
765
+ active.editor_idle_remaining_ms = max(1, active.inactivity.remainingTime())
766
+ active.deadline.stop()
767
+ active.inactivity.stop()
768
+ active.editor_waiting = True
769
+ return
770
+ if not active.editor_waiting:
771
+ return
772
+ active.editor_waiting = False
773
+ active.deadline.start(active.editor_deadline_remaining_ms)
774
+ active.inactivity.start(active.editor_idle_remaining_ms)
775
+
776
+ def _editor_failure(self, active: _RunningProcess, cause: str) -> None:
777
+ """Expose a fixed bridge diagnostic without including credentials or arbitrary editor output."""
778
+ if self.active_by_id.get(active.request.request_id) is not active:
779
+ return
780
+ data = (cause + "\n").encode("utf-8")
781
+ active.stderr.extend(data)
782
+ self.stderr_chunk.emit(self._public_request(active.request), data)
783
+
784
+ def _schedule_drain(self, active: _RunningProcess) -> None:
785
+ """Schedule one bounded consumer turn for available process bytes."""
786
+ if self.active_by_id.get(active.request.request_id) is not active or active.drain_pending:
787
+ return
788
+ active.drain_pending = True
789
+ QTimer.singleShot(0, self, lambda: self._drain(active))
790
+
791
+ def _drain(self, active: _RunningProcess) -> None:
792
+ """Read bounded chunks within a time budget, then yield back to Qt."""
793
+ if self.active_by_id.get(active.request.request_id) is not active:
794
+ return
795
+ active.drain_pending = False
796
+ if active.exited and not active.process.isOpen():
797
+ self._complete(active)
798
+ return
799
+ budget = QElapsedTimer()
800
+ budget.start()
801
+ while budget.elapsed() < PARSING_BUDGET_MS:
802
+ consumed = False
803
+ for channel in (QProcess.ProcessChannel.StandardOutput, QProcess.ProcessChannel.StandardError):
804
+ if channel == QProcess.ProcessChannel.StandardOutput and active.stdout_paused and active.status == ProcessStatus.RUNNING:
805
+ continue
806
+ active.process.setReadChannel(channel)
807
+ data = bytes(active.process.read(PROCESS_CHUNK_BYTES))
808
+ if not data:
809
+ continue
810
+ consumed = True
811
+ if active.status != ProcessStatus.RUNNING and active.group is None:
812
+ continue
813
+ if active.status == ProcessStatus.RUNNING and not active.editor_waiting:
814
+ active.inactivity.start(active.request.idle_timeout_ms)
815
+ if channel == QProcess.ProcessChannel.StandardOutput:
816
+ active.stdout_size += len(data)
817
+ if active.request.stdout_limit_bytes is not None and active.stdout_size > active.request.stdout_limit_bytes:
818
+ self._stop(active, ProcessStatus.VOLUME, f"Standard output exceeds {active.request.stdout_limit_bytes} bytes; {active.request.operation.lower()} result incomplete")
819
+ continue
820
+ if active.request.profile == CREDENTIAL_PROFILE:
821
+ if self.credential_consumer is not None:
822
+ self.credential_consumer(active.request.request_id, data)
823
+ elif active.ssh_stdout_stream is not None:
824
+ for unit in active.ssh_stdout_stream.feed(data):
825
+ self.stdout_chunk.emit(self._public_request(active.request), (unit + "\n").encode("utf-8"))
826
+ else:
827
+ self.stdout_chunk.emit(self._public_request(active.request), data)
828
+ else:
829
+ if active.stderr_truncated:
830
+ continue
831
+ if len(active.stderr) + len(data) > active.request.stderr_limit_bytes:
832
+ if active.request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE}:
833
+ active.stderr_truncated = True
834
+ safe_data = b"Trace incomplete: standard error storage limit exceeded; remaining output withheld.\n"
835
+ active.stderr.extend(safe_data)
836
+ self.stderr_chunk.emit(self._public_request(active.request), safe_data)
837
+ continue
838
+ self._stop(active, ProcessStatus.VOLUME, f"Standard error exceeds {active.request.stderr_limit_bytes} bytes; diagnostic incomplete")
839
+ continue
840
+ if active.credential_stream is not None:
841
+ for piece in active.credential_stream.feed_pieces(data):
842
+ safe_data = piece.encode("utf-8")
843
+ active.stderr.extend(safe_data)
844
+ self.stderr_chunk.emit(self._public_request(active.request), safe_data)
845
+ else:
846
+ active.stderr.extend(data)
847
+ self.stderr_chunk.emit(active.request, data)
848
+ if not consumed:
849
+ if active.stdout_paused and active.status == ProcessStatus.RUNNING:
850
+ return
851
+ if active.exited:
852
+ self._complete(active)
853
+ return
854
+ self._schedule_drain(active)
855
+
856
+ def _stop(self, active: _RunningProcess, status: ProcessStatus, cause: str) -> None:
857
+ """Retain tracked writes and their last bytes until native descendant drainage is proven."""
858
+ if active.status != ProcessStatus.RUNNING:
859
+ return
860
+ active.status = status
861
+ active.cause = cause
862
+ active.deadline.stop()
863
+ active.inactivity.stop()
864
+ if active.request.wait_for_exit_on_stop and active.process.state() != QProcess.ProcessState.NotRunning:
865
+ # Killing Git alone can orphan hooks that keep writing. Retain the native parent
866
+ # and reservation until Git reaps its hooks and reports its actual exit.
867
+ self.stop_requested.emit(self._public_request(active.request), cause)
868
+ return
869
+ if active.group is not None:
870
+ self.stop_requested.emit(self._public_request(active.request), cause)
871
+ self._poll_group(active)
872
+ return
873
+ if active.process.state() != QProcess.ProcessState.NotRunning:
874
+ active.process.terminate()
875
+ active.kill_timer.start(PROCESS_TERMINATE_TIMEOUT_MS)
876
+ else:
877
+ active.exited = True
878
+ self._schedule_drain(active)
879
+
880
+ def _poll_group(self, active: _RunningProcess) -> None:
881
+ """Retry inconclusive native checks asynchronously without releasing the owner."""
882
+ if self.active_by_id.get(active.request.request_id) is not active:
883
+ return
884
+ try:
885
+ if active.status != ProcessStatus.RUNNING and not active.request.wait_for_exit_on_stop:
886
+ active.group.stop()
887
+ if active.exited and active.group.is_empty():
888
+ self._schedule_drain(active)
889
+ return
890
+ except OSError:
891
+ self._unknown_drainage(active)
892
+ active.group_timer.start(PROCESS_GROUP_POLL_MS)
893
+
894
+ def _unknown_drainage(self, active: _RunningProcess) -> None:
895
+ """Report uncertainty once while preserving the process slot and repository owner."""
896
+ if active.drainage_uncertain:
897
+ return
898
+ active.drainage_uncertain = True
899
+ cause = "Stopping: outcome unknown; descendant termination could not be confirmed"
900
+ active.cause = active.cause + "; " + cause if active.cause else cause
901
+ self.stop_requested.emit(self._public_request(active.request), cause)
902
+
903
+ def _error(self, active: _RunningProcess, error: QProcess.ProcessError) -> None:
904
+ if self.active_by_id.get(active.request.request_id) is not active:
905
+ return
906
+ if active.status == ProcessStatus.RUNNING:
907
+ active.cause = active.process.errorString()
908
+ if error == QProcess.ProcessError.FailedToStart:
909
+ active.status = ProcessStatus.START_FAILED
910
+ active.exited = True
911
+ self._schedule_drain(active)
912
+
913
+ def _exit(self, active: _RunningProcess, return_code: int, exit_status: QProcess.ExitStatus) -> None:
914
+ """Drain the supervisor's streams without treating its exit as descendant proof."""
915
+ if self.active_by_id.get(active.request.request_id) is not active:
916
+ return
917
+ active.return_code = return_code
918
+ active.exited = True
919
+ active.deadline.stop()
920
+ active.inactivity.stop()
921
+ active.kill_timer.stop()
922
+ if exit_status == QProcess.ExitStatus.CrashExit and not active.cause:
923
+ active.cause = active.process.errorString()
924
+ self._schedule_drain(active)
925
+
926
+ def _complete(self, active: _RunningProcess) -> None:
927
+ """Publish a final result only after draining and protecting credential output.
928
+
929
+ A relay that refused to start its command is recognised first: for a supervised request the command it could not
930
+ start is the supervisor, so no group was ever created and there is nothing to wait for before freeing the request.
931
+ """
932
+ relay_cause = relay_start_failure_cause(active.relayed, active.return_code, bytes(active.stderr)) if active.status == ProcessStatus.RUNNING else None
933
+ if relay_cause is not None:
934
+ # The refusal line becomes the cause, as for a program the system could not start; it is not the command's output.
935
+ active.status = ProcessStatus.START_FAILED
936
+ active.return_code = None
937
+ active.cause = relay_cause
938
+ active.stderr.clear()
939
+ if active.group is not None and active.status != ProcessStatus.START_FAILED:
940
+ try:
941
+ if not active.group.is_empty():
942
+ self._unknown_drainage(active)
943
+ active.group_timer.start(PROCESS_GROUP_POLL_MS)
944
+ return
945
+ if active.group.launch_failed() and active.status == ProcessStatus.RUNNING:
946
+ active.status = ProcessStatus.START_FAILED
947
+ active.cause = bytes(active.stderr).decode("utf-8", "replace").strip() or "The supervised command could not be started"
948
+ active.return_code = active.group.result_return_code(active.return_code)
949
+ except OSError:
950
+ self._unknown_drainage(active)
951
+ active.group_timer.start(PROCESS_GROUP_POLL_MS)
952
+ return
953
+ if self.active_by_id.pop(active.request.request_id, None) is not active:
954
+ return
955
+ for timer in (active.deadline, active.inactivity, active.kill_timer, active.group_timer):
956
+ timer.stop()
957
+ timer.timeout.disconnect()
958
+ # Release callbacks before deferred destruction: each closure otherwise retains the
959
+ # process and its timers through a Python/Qt reference cycle.
960
+ active.process.readyReadStandardOutput.disconnect()
961
+ active.process.readyReadStandardError.disconnect()
962
+ active.process.finished.disconnect()
963
+ active.process.errorOccurred.disconnect()
964
+ if active.status == ProcessStatus.RUNNING:
965
+ active.status = ProcessStatus.SUCCESS if active.return_code == 0 and not active.cause else ProcessStatus.FAILED
966
+ if active.status == ProcessStatus.FAILED and not active.cause:
967
+ active.cause = bytes(active.stderr).decode("utf-8", "replace").strip() or EXIT_CODE_CAUSE_TEMPLATE.format(return_code=active.return_code)
968
+ if active.credential_stream is not None and not active.stderr_truncated:
969
+ for piece in active.credential_stream.feed_pieces(b"", final=True):
970
+ safe_data = piece.encode("utf-8")
971
+ active.stderr.extend(safe_data)
972
+ self.stderr_chunk.emit(self._public_request(active.request), safe_data)
973
+ stderr = bytes(active.stderr)
974
+ cause = self._close_editor(active.request, active.cause)
975
+ if active.credential_stream is not None:
976
+ cause = active.credential_stream.filter.redact(cause)
977
+ if active.ssh_stdout_stream is not None:
978
+ for unit in active.ssh_stdout_stream.feed(b"", final=True):
979
+ self.stdout_chunk.emit(self._public_request(active.request), (unit + "\n").encode("utf-8"))
980
+ if active.group is not None:
981
+ try:
982
+ active.group.close()
983
+ except OSError:
984
+ # Drainage is already proven; an antivirus or file permission race during
985
+ # private-marker cleanup must not lose the terminal repository result.
986
+ cleanup_cause = "Process finished, but private supervisor files could not be removed"
987
+ cause = cause + "; " + cleanup_cause if cause else cleanup_cause
988
+ self.finished.emit(self._timed_result(active.request, active.status, active.return_code, cause, stderr, active.received_ms, active.launched_ms))
989
+ active.process.deleteLater()
990
+ self._pump()
991
+ self._notify_idle()
992
+
993
+ def _public_request(self, request: ProcessRequest) -> ProcessRequest:
994
+ """Keep the credential protocol input out of publicly emitted request metadata."""
995
+ if request.before_start is not None:
996
+ request = replace(request, before_start=None)
997
+ if request.editor_context is not None:
998
+ environment_by_name = dict(request.environment_by_name)
999
+ for name in EDITOR_ENVIRONMENT_NAME_SET:
1000
+ environment_by_name.pop(name, None)
1001
+ request = replace(request, editor_context=None, environment_by_name=environment_by_name)
1002
+ if any(name in request.environment_by_name for name in SSH_ENVIRONMENT_NAMES):
1003
+ environment_by_name = dict(request.environment_by_name)
1004
+ for name in SSH_ENVIRONMENT_NAMES:
1005
+ environment_by_name.pop(name, None)
1006
+ request = replace(request, environment_by_name=environment_by_name)
1007
+ if request.profile == CREDENTIAL_PROFILE:
1008
+ arguments: list[str] = []
1009
+ text_filter = SensitiveTextFilter(self._credential_value_by_name(request))
1010
+ masked_arguments = mask_sensitive_arguments(request.arguments).arguments
1011
+ for argument in masked_arguments:
1012
+ arguments.append(argument if argument == MASKED_VALUE else text_filter.redact(argument))
1013
+ return replace(request, stdin=None, arguments=arguments)
1014
+ return request
1015
+
1016
+ def _credential_value_by_name(self, request: ProcessRequest) -> dict[str, str]:
1017
+ """Hide protocol and argument secrets even if a helper repeats them in its trace."""
1018
+ value_by_name = dict(os.environ)
1019
+ # Only the secrets: a setting login stays readable in the helper's trace, like the URLs it is part of.
1020
+ secret_values = mask_sensitive_arguments(request.arguments).secret_values
1021
+ # The name, not the number, is what matters: the text filter protects every value stored under a secret-looking name.
1022
+ for position, hidden_value in enumerate(secret_values):
1023
+ value_by_name[f"credential_argument_{position}"] = hidden_value
1024
+ if request.stdin:
1025
+ for line in request.stdin.decode("utf-8", "replace").splitlines():
1026
+ name, separator, value = line.partition("=")
1027
+ if separator and SENSITIVE_NAME_PATTERN.search(name):
1028
+ value_by_name[name] = value
1029
+ return value_by_name
1030
+
1031
+ def _notify_idle(self) -> None:
1032
+ if not self.active_by_id and not self.pending_requests:
1033
+ self.idle.emit()