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,136 @@
1
+ """Choose the command a desktop shortcut runs to start the running GitPane installation.
2
+
3
+ pip and pipx write a small launcher for every script an installed package declares, in the scripts folder of the
4
+ Python that installed it. A desktop shortcut starts that launcher when the running installation has one, so it follows
5
+ whichever installation is running: a pipx environment, a global or ``--user`` pip install, or the editable install of a
6
+ development checkout. An installation without that launcher (an editable install made before GitPane declared its
7
+ command) is started through the running Python itself, on GitPane's application module.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ntpath
13
+ import os
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ import sysconfig
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ from gitpane.config import LINUX_PLATFORM, MACOS_PLATFORM, MODULE_OPTION, SAFE_PATH_OPTION, WINDOWS_PLATFORM
22
+ from gitpane.process.process_group import WINDOWLESS_INTERPRETER_BASENAME
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable, Sequence
26
+
27
+ # The name of the gui-script that pyproject.toml declares, hence the name of the launcher pip writes.
28
+ GITPANE_COMMAND_NAME = "gitpane"
29
+ LAUNCHER_BASENAME_BY_PLATFORM = {
30
+ WINDOWS_PLATFORM: GITPANE_COMMAND_NAME + ".exe",
31
+ LINUX_PLATFORM: GITPANE_COMMAND_NAME,
32
+ MACOS_PLATFORM: GITPANE_COMMAND_NAME,
33
+ }
34
+ SCRIPTS_PATH_NAME = "scripts"
35
+ USER_SCHEME_KIND = "user"
36
+ # How a program and its arguments are written as one line the user can paste: Windows' own quoting there, a POSIX
37
+ # shell's everywhere else.
38
+ COMMAND_LINE_JOIN_BY_PLATFORM: dict[str, Callable[[Sequence[str]], str]] = {
39
+ WINDOWS_PLATFORM: subprocess.list2cmdline,
40
+ }
41
+ DEFAULT_COMMAND_LINE_JOIN = shlex.join
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class StartCommand:
46
+ """The program a shortcut starts and the arguments it passes before its own, never ``--log``."""
47
+
48
+ program_filename: str
49
+ arguments: tuple[str, ...] = ()
50
+
51
+
52
+ def launcher_basename(platform: str) -> str:
53
+ """Return the file name pip gives the gitpane launcher on ``platform``; an unknown system raises OSError."""
54
+ basename = LAUNCHER_BASENAME_BY_PLATFORM.get(platform)
55
+ if basename is None:
56
+ raise OSError("No gitpane launcher name known for " + platform)
57
+ return basename
58
+
59
+
60
+ def running_in_virtual_environment() -> bool:
61
+ """Tell whether the running Python is a virtual environment (a checkout's .venv, a pipx environment)."""
62
+ return sys.prefix != sys.base_prefix
63
+
64
+
65
+ def installation_script_dirs(in_virtual_environment: bool) -> list[str]:
66
+ """List the scripts folders where the running installation keeps its launchers: its own, then, outside a venv, the one of ``pip install --user``.
67
+
68
+ A venv only looks in its own folder: a gitpane link that another installation (pipx) put in ``~/.local/bin`` must
69
+ never pass for the GitPane of that venv. The user folder comes from ``sysconfig.get_preferred_scheme("user")``,
70
+ never from ``os.name + "_user"``: on a macOS framework Python (Homebrew, python.org) the user scheme is
71
+ ``osx_framework_user``, whose scripts folder is not the one ``posix_user`` names. Empty and repeated folders are
72
+ left out, the order is kept.
73
+ """
74
+ candidate_dirs = [sysconfig.get_path(SCRIPTS_PATH_NAME)]
75
+ if not in_virtual_environment:
76
+ try:
77
+ user_scheme = sysconfig.get_preferred_scheme(USER_SCHEME_KIND)
78
+ candidate_dirs.append(sysconfig.get_path(SCRIPTS_PATH_NAME, user_scheme))
79
+ except KeyError:
80
+ pass
81
+ script_dirs = []
82
+ for script_dir in candidate_dirs:
83
+ if script_dir and script_dir not in script_dirs:
84
+ script_dirs.append(script_dir)
85
+ return script_dirs
86
+
87
+
88
+ def find_launcher_filename(script_dirs: Sequence[str], platform: str, is_file: Callable[[str], bool] = os.path.isfile) -> str:
89
+ """Return the first gitpane launcher found in ``script_dirs``, or an empty text when none of them holds one."""
90
+ basename = launcher_basename(platform)
91
+ for script_dir in script_dirs:
92
+ launcher_filename = os.path.join(script_dir, basename)
93
+ if is_file(launcher_filename):
94
+ return launcher_filename
95
+ return ""
96
+
97
+
98
+ def windowless_python_filename(python_filename: str, platform: str, is_file: Callable[[str], bool] = os.path.isfile) -> str:
99
+ """Return the Python a shortcut starts in place of ``python_filename``: on Windows the windowless pythonw.exe next to it when it is there.
100
+
101
+ A shortcut that started python.exe would open a console window beside GitPane. Elsewhere, and when pythonw.exe is
102
+ missing, ``python_filename`` is returned as it is: a venv's python is a link to the system's, so resolving it, or
103
+ making it absolute, would start GitPane outside its venv.
104
+ """
105
+ if platform != WINDOWS_PLATFORM:
106
+ return python_filename
107
+ # This only matters on Windows; ntpath keeps the path the same when the tests run it on another system.
108
+ windowless_filename = ntpath.join(ntpath.dirname(python_filename), WINDOWLESS_INTERPRETER_BASENAME)
109
+ if is_file(windowless_filename):
110
+ return windowless_filename
111
+ return python_filename
112
+
113
+
114
+ def start_command(script_dirs: Sequence[str], platform: str, python_filename: str, application_module_name: str,
115
+ is_file: Callable[[str], bool] = os.path.isfile) -> StartCommand | None:
116
+ """Choose what a shortcut starts: the gitpane command of ``script_dirs``, or else ``python_filename`` with ``-P -m`` on the application module.
117
+
118
+ The gitpane command is started with no argument. None is returned when there is no gitpane command and the Python
119
+ or the module cannot be named; an unknown system raises OSError.
120
+ """
121
+ launcher_filename = find_launcher_filename(script_dirs, platform, is_file)
122
+ if launcher_filename:
123
+ return StartCommand(launcher_filename)
124
+ if not python_filename or not application_module_name:
125
+ return None
126
+ return StartCommand(windowless_python_filename(python_filename, platform, is_file), (SAFE_PATH_OPTION, MODULE_OPTION, application_module_name))
127
+
128
+
129
+ def command_line(arguments: Sequence[str], platform: str) -> str:
130
+ """Write ``arguments`` as one command line of ``platform``, each one quoted the way that system's shell reads it back."""
131
+ return COMMAND_LINE_JOIN_BY_PLATFORM.get(platform, DEFAULT_COMMAND_LINE_JOIN)(arguments)
132
+
133
+
134
+ def start_command_line(command: StartCommand, platform: str) -> str:
135
+ """Write the program and the arguments of ``command`` as one command line of ``platform``, the way Settings shows it."""
136
+ return command_line((command.program_filename, *command.arguments), platform)
@@ -0,0 +1,151 @@
1
+ """Tell which Linux distribution os-release describes, as GitPane groups them.
2
+
3
+ The family is the one of os-release's ID when the table knows it, otherwise the one of the first word of ID_LIKE the
4
+ table knows, read from left to right, otherwise UNKNOWN. The precise distribution comes from the exact ID alone, and only
5
+ names the few distributions whose install commands differ from the rest of their family; any other ID gives OTHER, so a
6
+ derivative known only through ID_LIKE keeps the line of its family. ID and ID_LIKE are compared exactly as os-release
7
+ writes them, without changing their case. Nothing read from os-release ever becomes a text shown to the user or a
8
+ command: only a member of ``DistributionFamily`` or ``DistributionRelease`` leaves this module, and the commands GitPane
9
+ shows come from its own tables.
10
+ Python's ``freedesktop_os_release`` keeps os-release for the whole process once it was read successfully, so asking the
11
+ family again then costs no file read; on a system without os-release, each call tries both files again and raises
12
+ ``OSError``, which gives UNKNOWN and OTHER here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+ from platform import freedesktop_os_release
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Mapping
23
+
24
+ OS_RELEASE_ID_KEY = "ID"
25
+ OS_RELEASE_ID_LIKE_KEY = "ID_LIKE"
26
+
27
+
28
+ class DistributionFamily(Enum):
29
+ """A family of Linux distributions that install software the same way; UNKNOWN when os-release names none GitPane knows."""
30
+
31
+ DEBIAN = "debian"
32
+ """Debian, Ubuntu and their derivatives, which install ``.deb`` packages through apt."""
33
+ FEDORA = "fedora"
34
+ """Fedora and the distributions built on it, which install packages through dnf."""
35
+ RHEL = "rhel"
36
+ """Red Hat Enterprise Linux and its rebuilds, kept apart from Fedora because some tools only reach them through EPEL."""
37
+ SUSE = "suse"
38
+ """openSUSE and SUSE Linux Enterprise, which install packages through zypper."""
39
+ ARCH = "arch"
40
+ """Arch Linux and its derivatives, which install packages through pacman."""
41
+ GENTOO = "gentoo"
42
+ """Gentoo and Funtoo, which build packages through emerge."""
43
+ VOID = "void"
44
+ """Void Linux, which installs packages through xbps, in a glibc and a musl variant under the same ID."""
45
+ ALPINE = "alpine"
46
+ """Alpine Linux and its derivatives, built on the musl C library, which runs no program linked against glibc."""
47
+ NIXOS = "nixos"
48
+ """NixOS and Guix System, neither of which has the dynamic loader or the libraries at the standard paths a prebuilt Linux program expects."""
49
+ SOLUS = "solus"
50
+ """Solus, which installs packages through eopkg."""
51
+ UNKNOWN = "unknown"
52
+ """A distribution os-release describes with no ID or ID_LIKE word GitPane knows, or a system without os-release."""
53
+
54
+
55
+ # A key is an ID or a word of ID_LIKE, exactly as os-release writes it. Since ID is read first, a derivative is found by
56
+ # its own ID before any word of its ID_LIKE: Rocky Linux and AlmaLinux say ``ID_LIKE="rhel centos fedora"`` and find
57
+ # ``rhel`` first, while Nobara and Bazzite, Fedora derivatives whose ID_LIKE also starts with ``rhel``, stay Fedora
58
+ # through their own ID. Garuda and Artix write no ID_LIKE, so their own ID is the key.
59
+ FAMILY_BY_OS_RELEASE_ID: dict[str, DistributionFamily] = {
60
+ "debian": DistributionFamily.DEBIAN,
61
+ "ubuntu": DistributionFamily.DEBIAN,
62
+ "fedora": DistributionFamily.FEDORA,
63
+ "nobara": DistributionFamily.FEDORA,
64
+ "bazzite": DistributionFamily.FEDORA,
65
+ "rhel": DistributionFamily.RHEL,
66
+ "centos": DistributionFamily.RHEL,
67
+ "rocky": DistributionFamily.RHEL,
68
+ "almalinux": DistributionFamily.RHEL,
69
+ "ol": DistributionFamily.RHEL,
70
+ "opensuse": DistributionFamily.SUSE,
71
+ "suse": DistributionFamily.SUSE,
72
+ "arch": DistributionFamily.ARCH,
73
+ "garuda": DistributionFamily.ARCH,
74
+ "artix": DistributionFamily.ARCH,
75
+ "gentoo": DistributionFamily.GENTOO,
76
+ "funtoo": DistributionFamily.GENTOO,
77
+ "void": DistributionFamily.VOID,
78
+ "alpine": DistributionFamily.ALPINE,
79
+ "nixos": DistributionFamily.NIXOS,
80
+ "guix": DistributionFamily.NIXOS,
81
+ "solus": DistributionFamily.SOLUS,
82
+ }
83
+
84
+
85
+ def distribution_family(os_release_by_key: Mapping[str, str]) -> DistributionFamily:
86
+ """Return the family os-release describes: the one of its ID first, then of each word of ID_LIKE in order, otherwise UNKNOWN.
87
+
88
+ Ubuntu says ``ID_LIKE=debian`` and Linux Mint ``ID_LIKE="ubuntu debian"``, so both are Debian; Rocky Linux says
89
+ ``ID_LIKE="rhel centos fedora"`` and finds ``rhel`` before ``fedora``.
90
+ """
91
+ distribution_ids = [os_release_by_key.get(OS_RELEASE_ID_KEY, ""), *os_release_by_key.get(OS_RELEASE_ID_LIKE_KEY, "").split()]
92
+ for distribution_id in distribution_ids:
93
+ family = FAMILY_BY_OS_RELEASE_ID.get(distribution_id)
94
+ if family is not None:
95
+ return family
96
+ return DistributionFamily.UNKNOWN
97
+
98
+
99
+ def read_os_release_or_nothing(read_os_release: Callable[[], Mapping[str, str]]) -> dict[str, str]:
100
+ """Read os-release, or nothing when the system has none, so that a missing file gives the unknown family instead of an error."""
101
+ try:
102
+ return dict(read_os_release())
103
+ except OSError:
104
+ return {}
105
+
106
+
107
+ def session_distribution_family(read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DistributionFamily:
108
+ """Return the family of the running system; never raises, and gives UNKNOWN on a system without os-release."""
109
+ return distribution_family(read_os_release_or_nothing(read_os_release))
110
+
111
+
112
+ class DistributionRelease(Enum):
113
+ """A distribution whose install commands differ from the rest of its family; OTHER when the family alone says how to install."""
114
+
115
+ RHEL = "rhel"
116
+ """Red Hat Enterprise Linux, whose extra build repository, CodeReady Builder, is turned on through subscription-manager."""
117
+ CENTOS_STREAM = "centos"
118
+ """CentOS Stream, whose CRB repository is turned on through dnf config-manager."""
119
+ ROCKY_OR_ALMA = "rocky_or_alma"
120
+ """Rocky Linux and AlmaLinux, whose CRB repository is turned on by the ``crb`` script that epel-release installs."""
121
+ ORACLE_LINUX = "ol"
122
+ """Oracle Linux, which ships EPEL in Oracle's own repositories."""
123
+ GUIX = "guix"
124
+ """Guix System, told apart from NixOS, with which it shares its family, because it installs software through guix."""
125
+ OTHER = "other"
126
+ """Any other distribution, a derivative known only through ID_LIKE, or a system without os-release: the family alone says how to install."""
127
+
128
+
129
+ # A key is an ID exactly as os-release writes it, never a word of ID_LIKE, so that a derivative that names ``rhel`` in
130
+ # its ID_LIKE without its own ID here keeps the line of its family instead of borrowing one of these.
131
+ RELEASE_BY_OS_RELEASE_ID: dict[str, DistributionRelease] = {
132
+ "rhel": DistributionRelease.RHEL,
133
+ "centos": DistributionRelease.CENTOS_STREAM,
134
+ "rocky": DistributionRelease.ROCKY_OR_ALMA,
135
+ "almalinux": DistributionRelease.ROCKY_OR_ALMA,
136
+ "ol": DistributionRelease.ORACLE_LINUX,
137
+ "guix": DistributionRelease.GUIX,
138
+ }
139
+
140
+
141
+ def distribution_release(os_release_by_key: Mapping[str, str]) -> DistributionRelease:
142
+ """Return the precise distribution os-release's exact ID names, otherwise OTHER.
143
+
144
+ The ID is compared exactly as written: ``RHEL`` in capitals, or a derivative found only through ID_LIKE, gives OTHER.
145
+ """
146
+ return RELEASE_BY_OS_RELEASE_ID.get(os_release_by_key.get(OS_RELEASE_ID_KEY, ""), DistributionRelease.OTHER)
147
+
148
+
149
+ def session_distribution_release(read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DistributionRelease:
150
+ """Return the precise distribution of the running system; never raises, and gives OTHER on a system without os-release."""
151
+ return distribution_release(read_os_release_or_nothing(read_os_release))
@@ -0,0 +1,46 @@
1
+ """Tell whether a Windows folder lives on a network share, from the drive table alone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ import sys
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.config import WINDOWS_PLATFORM
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ # GetDriveTypeW answers: DRIVE_UNKNOWN when the type cannot be told, DRIVE_REMOTE for a drive letter mapped to a network share.
16
+ DRIVE_UNKNOWN_TYPE = 0
17
+ DRIVE_REMOTE_TYPE = 4
18
+ # A folder written \\server\share\... or //server/share/... is on a network share whatever the drive table says.
19
+ UNC_PATH_PREFIXES = ("\\\\", "//")
20
+
21
+
22
+ def windows_drive_type(root_dir: str, platform: str = sys.platform) -> int:
23
+ """Ask Windows what kind of drive a drive root is (a letter, a colon and a separator), from its own drive table, without contacting any server.
24
+
25
+ Other systems have no drive letters, so the type is unknown there.
26
+ """
27
+ if platform != WINDOWS_PLATFORM:
28
+ return DRIVE_UNKNOWN_TYPE
29
+ get_drive_type = ctypes.WinDLL("kernel32").GetDriveTypeW
30
+ get_drive_type.argtypes = [ctypes.c_wchar_p]
31
+ get_drive_type.restype = ctypes.c_uint
32
+ return get_drive_type(root_dir)
33
+
34
+
35
+ def network_directory(directory: str, drive_type: Callable[[str], int] = windows_drive_type) -> bool:
36
+ """Tell whether a Windows folder lives on a network share: a UNC path, or a drive letter mapped to a share.
37
+
38
+ Looking for a file in such a folder can freeze the window for seconds while Windows waits for a disconnected
39
+ share, so searches run from the window skip these folders. Only the path and the drive table are read.
40
+ """
41
+ drive, _rest = os.path.splitdrive(directory)
42
+ if drive.startswith(UNC_PATH_PREFIXES):
43
+ return True
44
+ if not drive:
45
+ return False
46
+ return drive_type(drive + os.sep) == DRIVE_REMOTE_TYPE
@@ -0,0 +1,95 @@
1
+ """Identity of a path, decided in one place.
2
+
3
+ Two paths name the same folder or file when they have the same ``canonical_path``. On macOS ``realpath`` keeps the
4
+ letter case and the Unicode form that were typed, while a default macOS disk finds the same folder under another case
5
+ and another form (composed or decomposed accents): the identity there is the spelling the file system itself gives.
6
+ Windows and Linux keep ``normcase(realpath)``, so short names, network drives and links the user made are identified
7
+ exactly as before.
8
+
9
+ Never compare the bytes of a name read from the disk with the bytes of a path Git printed: compare them only after
10
+ both went through ``canonical_path``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from typing import TYPE_CHECKING
18
+
19
+ from gitpane.config import MACOS_PLATFORM
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable
23
+
24
+ # MAXPATHLEN of macOS: the longest path the kernel gives back for an open descriptor.
25
+ MACOS_MAXIMUM_PATH_BYTES = 1024
26
+
27
+
28
+ def macos_disk_spelling(path: str) -> str:
29
+ """Return how an existing file or folder is spelled on a macOS disk, links resolved like realpath does.
30
+
31
+ No process is started: one open and one ``fcntl`` call, about the cost of a ``realpath``. Raises ``OSError`` when
32
+ the path does not exist or cannot be opened.
33
+ """
34
+ # fcntl does not exist on Windows; this function is only called on macOS.
35
+ import fcntl
36
+
37
+ # Workaround: F_GETPATH needs an open descriptor. O_EVTONLY asks for an event-only descriptor, so no data is read:
38
+ # a file evicted to the cloud is not downloaded, and O_NONBLOCK keeps a named pipe from blocking the open.
39
+ descriptor = os.open(path, os.O_EVTONLY | os.O_NONBLOCK | os.O_CLOEXEC)
40
+ try:
41
+ spelling = fcntl.fcntl(descriptor, fcntl.F_GETPATH, bytes(MACOS_MAXIMUM_PATH_BYTES))
42
+ finally:
43
+ os.close(descriptor)
44
+ return os.fsdecode(spelling.rstrip(b"\0"))
45
+
46
+
47
+ def _deepest_disk_spelling(resolved_path: str, disk_spelling: Callable[[str], str]) -> str:
48
+ """Spell the deepest element of an absolute, link-free path that can be opened as on disk, and keep the rest as written.
49
+
50
+ When no element can be opened, the path is returned unchanged.
51
+ """
52
+ existing_path = resolved_path
53
+ remaining_names: list[str] = []
54
+ while True:
55
+ try:
56
+ spelling = disk_spelling(existing_path)
57
+ except OSError:
58
+ parent_path = os.path.dirname(existing_path)
59
+ if parent_path == existing_path:
60
+ return resolved_path
61
+ remaining_names.append(os.path.basename(existing_path))
62
+ existing_path = parent_path
63
+ continue
64
+ for remaining_name in reversed(remaining_names):
65
+ spelling = os.path.join(spelling, remaining_name)
66
+ return spelling
67
+
68
+
69
+ def on_disk_path(path: str, platform: str = sys.platform, disk_spelling: Callable[[str], str] = macos_disk_spelling) -> str:
70
+ """Return the absolute path with links resolved, spelled as the disk spells it on macOS, as realpath spells it elsewhere.
71
+
72
+ On macOS a path whose end does not exist keeps that end as written after the disk spelling of its deepest existing
73
+ part; a part that cannot be read (permission refused) keeps its realpath spelling.
74
+ """
75
+ if platform != MACOS_PLATFORM:
76
+ return os.path.realpath(path)
77
+ if path:
78
+ try:
79
+ return disk_spelling(path)
80
+ except OSError:
81
+ pass
82
+ return _deepest_disk_spelling(os.path.realpath(path), disk_spelling)
83
+
84
+
85
+ def canonical_path(path: str, platform: str = sys.platform, disk_spelling: Callable[[str], str] = macos_disk_spelling) -> str:
86
+ """Return the key that identifies a folder or file, so separators, letter case, Unicode form and links cannot hide a difference."""
87
+ return os.path.normcase(on_disk_path(path, platform, disk_spelling))
88
+
89
+
90
+ def link_resolved_path(path: str) -> str:
91
+ """Resolve links and keep the typed spelling, to tell whether a path goes through a link by comparing it with the typed path.
92
+
93
+ Only for that check: it is not an identity, since on macOS another letter case or Unicode form stays different.
94
+ """
95
+ return os.path.normcase(os.path.realpath(path))
@@ -0,0 +1,47 @@
1
+ """Remove a folder tree that GitPane created for its own private files, read-only entries included.
2
+
3
+ Git writes its object files read-only. On Linux and macOS that does not stop their removal, which only needs a
4
+ writable parent folder, but on Windows deleting a read-only file (or folder) fails with "Access is denied". This
5
+ module only changes how a removal already decided by its caller is carried out: it removes exactly what
6
+ ``shutil.rmtree`` removes, keeps its handling of links and junctions (the link itself is removed, never what it
7
+ points to), and must never be given a user's worktree or submodule.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import stat
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+
21
+ def _is_link(information: os.stat_result) -> bool:
22
+ """Tell whether an entry is a symbolic link or a Windows reparse point (junction), which is never made writable."""
23
+ reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
24
+ return stat.S_ISLNK(information.st_mode) or bool(getattr(information, "st_file_attributes", 0) & reparse_point)
25
+
26
+
27
+ def _retry_read_only_entry(function: Callable[[str], object], path: str, error: BaseException) -> None:
28
+ """Make one read-only entry writable, then remove it again; any other failure is raised unchanged."""
29
+ if not isinstance(error, PermissionError) or function not in (os.unlink, os.remove, os.rmdir):
30
+ raise error
31
+ try:
32
+ information = os.lstat(path)
33
+ except OSError:
34
+ raise error from None
35
+ if _is_link(information) or information.st_mode & stat.S_IWRITE:
36
+ raise error
37
+ # Workaround: Windows refuses to delete an entry that has the read-only attribute, which Git puts on every
38
+ # object file it writes. Clearing that attribute on this one entry (never on a link, checked just above) lets
39
+ # the removal that rmtree already decided go through; on Linux and macOS this point is not reached for Git's
40
+ # read-only files, since their removal only depends on the parent folder.
41
+ os.chmod(path, stat.S_IMODE(information.st_mode) | stat.S_IWRITE)
42
+ function(path)
43
+
44
+
45
+ def remove_private_tree(directory: str) -> None:
46
+ """Remove a private folder of GitPane and everything inside it, including files Git made read-only."""
47
+ shutil.rmtree(directory, onexc=_retry_read_only_entry)
@@ -0,0 +1,63 @@
1
+ """Name the program a process of this session runs, as /proc tells it.
2
+
3
+ The name is first the base name of the target of ``/proc/<pid>/exe``. When the executable file was removed while the
4
+ process runs, as a package update does, the kernel appends `` (deleted)`` to the link's target (proc(5)), and that
5
+ suffix is taken off before the base name. The kernel refuses to read that link for a process that is not "dumpable",
6
+ even one of the same user: a program started with file capabilities (seen with ``kwin_wayland``, which carries
7
+ ``cap_sys_nice``) or one that calls ``prctl(PR_SET_DUMPABLE, 0)`` (KeePassXC). For such a refusal the name is read from ``/proc/<pid>/comm`` instead, which
8
+ the kernel lets any user read, even for a process of another user whose executable link it refuses; it is cut to 15
9
+ characters, so the table of known programs also holds the cut names. ``comm`` can be changed by the program itself,
10
+ but so can the owner of the Secret Service name, which any program of the user can take: the link gives no stronger
11
+ guarantee.
12
+ ``cmdline`` is never read. A process that ended meanwhile, or a name that cannot be read at all, gives an empty name.
13
+ Only Linux has these files; elsewhere they are missing and the name is empty.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import posixpath
20
+ from typing import TYPE_CHECKING
21
+
22
+ from gitpane.config import PROC_DIR
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ PROC_EXECUTABLE_BASENAME = "exe"
28
+ # The file of /proc/<pid>/ that holds the program's command name, cut to 15 characters by the kernel.
29
+ PROC_COMMAND_NAME_BASENAME = "comm"
30
+ # What the kernel appends to the target of an executable link once the executable file was removed (proc(5)).
31
+ DELETED_EXECUTABLE_SUFFIX = " (deleted)"
32
+
33
+
34
+ def program_name_from_executable_link(link_text: str) -> str:
35
+ """Return the base name of the program an executable link names, without the suffix the kernel adds after a package update."""
36
+ return posixpath.basename(link_text.removesuffix(DELETED_EXECUTABLE_SUFFIX))
37
+
38
+
39
+ def read_command_name_file(filename: str) -> str:
40
+ """Return the text of a /proc command name file; the kernel writes it as bytes, so a byte that is not UTF-8 is replaced rather than refused."""
41
+ with open(filename, encoding="utf-8", errors="replace") as command_name_file:
42
+ return command_name_file.read()
43
+
44
+
45
+ def program_name_of_process(pid: int, read_link: Callable[[str], str] = os.readlink, read_text: Callable[[str], str] = read_command_name_file) -> str:
46
+ """Return the name of the program process ``pid`` runs, or an empty text when /proc does not tell it.
47
+
48
+ The executable link is read first; only a refusal to read it (a process that is not dumpable) falls back on the
49
+ command name, since any other error means the process is gone or /proc is missing. ``read_link`` and ``read_text``
50
+ read the files, so the tests can give any target, name or refusal without a real process.
51
+ """
52
+ # The files are paths of Linux's /proc, written in POSIX form whatever system builds them, hence posixpath.
53
+ process_dir = posixpath.join(PROC_DIR, str(pid))
54
+ try:
55
+ return program_name_from_executable_link(read_link(posixpath.join(process_dir, PROC_EXECUTABLE_BASENAME)))
56
+ except PermissionError:
57
+ pass
58
+ except OSError:
59
+ return ""
60
+ try:
61
+ return read_text(posixpath.join(process_dir, PROC_COMMAND_NAME_BASENAME)).strip()
62
+ except OSError:
63
+ return ""
@@ -0,0 +1,38 @@
1
+ """Give a folder read from Qt the separator of the system it runs on.
2
+
3
+ Qt names the folders it knows (desktop, documents, application data) with ``/`` on every system, Windows included,
4
+ while Python's ``os.path.join`` adds the system's own separator: a path built from such a folder and shown to the user
5
+ would mix both on Windows. A folder read from Qt therefore goes through here where it enters the application, before
6
+ it is joined or shown.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ntpath
12
+ import posixpath
13
+
14
+ from PySide6.QtCore import QStandardPaths
15
+
16
+ from gitpane.config import WINDOWS_PLATFORM
17
+
18
+
19
+ def native_folder_path(folder: str, platform: str) -> str:
20
+ """Write ``folder`` with the separator ``platform`` writes its own paths with.
21
+
22
+ Qt names the folders it knows with ``/`` on every system, Windows included, while Windows writes its paths with
23
+ ``\\``: the desktop folder as Qt gives it, joined to the shortcut's name, would show both separators in the line
24
+ Settings shows. Only the separators change, so the folder stays the same folder.
25
+ """
26
+ if platform != WINDOWS_PLATFORM:
27
+ return folder
28
+ return folder.replace(posixpath.sep, ntpath.sep)
29
+
30
+
31
+ def writable_folder(location: QStandardPaths.StandardLocation, platform: str) -> str:
32
+ """Answer the folder Qt names for ``location``, written with the separator ``platform`` writes its own paths with.
33
+
34
+ A path built on such a folder then shows one kind of separator: the diagnostic log path shown in the error dialogs
35
+ and in the terminal, the file name a save dialog suggests, and the private folders GitPane keeps its recovery data
36
+ in.
37
+ """
38
+ return native_folder_path(QStandardPaths.writableLocation(location), platform)
@@ -0,0 +1,67 @@
1
+ """Watch the ready channel of a GitPane started detached, so its standard streams leave the gitpane command's pipes as soon as that command is gone.
2
+
3
+ The gitpane command never writes on the channel: GitPane's end only becomes readable when the command's end closes,
4
+ which happens when the command ends before GitPane said it is ready (its wait ran out, Ctrl+C, the terminal was
5
+ closed). From then on nothing reads the pipes GitPane's standard streams point to, so they are moved to the null device
6
+ at once, without waiting for the first window.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QObject, QSocketNotifier
15
+
16
+ from gitpane.system.terminal_detach import notify_ready, redirect_standard_streams_to_null
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+
22
+ class ReadyChannelWatch(QObject):
23
+ """Hold GitPane's end of the ready channel until GitPane is ready or the gitpane command is gone, whichever comes first.
24
+
25
+ ``channel`` is the open descriptor while neither happened, then ``None``. The two functions it calls are given to
26
+ the constructor so tests can record them instead of touching the standard streams of their own process.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ channel: int,
32
+ parent: QObject | None = None,
33
+ *,
34
+ release_streams: Callable[[], None] = redirect_standard_streams_to_null,
35
+ send_ready_signal: Callable[[int, str], None] = notify_ready,
36
+ ) -> None:
37
+ """Start watching ``channel``, which becomes readable only when the command's end of it closes."""
38
+ super().__init__(parent)
39
+ self.channel: int | None = channel
40
+ self._release_streams = release_streams
41
+ self._send_ready_signal = send_ready_signal
42
+ self._notifier = QSocketNotifier(channel, QSocketNotifier.Type.Read, self)
43
+ self._notifier.activated.connect(self._release_after_command_left)
44
+
45
+ def _release_after_command_left(self, *_signal_arguments: object) -> None:
46
+ """Move the standard streams off the command's pipes and close the channel, the command being gone."""
47
+ if self.channel is None:
48
+ return
49
+ self._notifier.setEnabled(False)
50
+ self._release_streams()
51
+ try:
52
+ os.close(self.channel)
53
+ except OSError:
54
+ pass
55
+ self.channel = None
56
+
57
+ def send_ready(self, log_filename: str) -> None:
58
+ """Tell the waiting command that GitPane is ready, unless that command is already gone.
59
+
60
+ The notifier is turned off first, so it never watches a descriptor the ready signal has closed.
61
+ """
62
+ if self.channel is None:
63
+ return
64
+ self._notifier.setEnabled(False)
65
+ channel = self.channel
66
+ self.channel = None
67
+ self._send_ready_signal(channel, log_filename)