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,480 @@
1
+ """Locate a usable official Git Credential Manager on this machine, with the reason each refused candidate is refused.
2
+
3
+ Everything here is blocking and free of Qt objects: the search starts short processes, so it runs away from the
4
+ window's thread.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import TYPE_CHECKING
18
+
19
+ from gitpane.account.credential_manager_install import distribution_gcm_install_lines
20
+ from gitpane.account.ssh import GIT_FOR_WINDOWS_SOURCE_TEXT, PATH_SOURCE_TEXT, absolute_executable_available
21
+ from gitpane.config import (
22
+ CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
23
+ GCM_ENVIRONMENT_PREFIX,
24
+ GCM_MINIMUM_VERSION,
25
+ GCM_REQUIRED_TEXT,
26
+ GCM_VERSION_TIMEOUT_MS,
27
+ GIT_OPEN_TIMEOUT_MS,
28
+ MILLISECONDS_PER_SECOND,
29
+ WINDOWS_PLATFORM,
30
+ )
31
+ from gitpane.system.linux_distribution import DistributionFamily
32
+ from gitpane.system.network_folder import network_directory
33
+ from gitpane.tool.git_executable import GIT_BINARY_DIR_BASENAME, GIT_EXEC_PATH_ARGUMENT, PATH_ENVIRONMENT_NAME, UNKNOWN_OUTPUT_LOG_LIMIT, parse_exec_path_answer
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Mapping
37
+
38
+ from gitpane.account.credential_manager_install import GcmArchiveArchitecture
39
+ from gitpane.config import CredentialManagerPlatform
40
+
41
+ GCM_VERSION_ARGUMENT = "--version"
42
+ GIT_EXEC_PATH_ENVIRONMENT_NAME = "GIT_EXEC_PATH"
43
+ GCM_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)")
44
+ STANDARD_LOCATION_SOURCE_TEXT = "Standard location"
45
+ # Result written to --log for a candidate that is listed but never started.
46
+ NETWORK_SHARE_SKIP_TEXT = "network share"
47
+ OTHER_TOOLCHAIN_SKIP_TEXT = "another toolchain folder of the Git in use"
48
+
49
+
50
+ class CredentialManagerProblem(Enum):
51
+ """Say why no usable Git Credential Manager was found; NONE when the detection names a usable one."""
52
+
53
+ NONE = "none"
54
+ MISSING = "missing"
55
+ TOO_OLD = "too_old"
56
+ UNREADABLE_VERSION = "unreadable_version"
57
+ NOT_STARTING = "not_starting"
58
+ UNSUPPORTED_PLATFORM = "unsupported_platform"
59
+
60
+
61
+ # When no candidate is usable, the reason kept is the one that tells the user best what to do: an old version names
62
+ # what to update, an unreadable version or a program that does not start says the installation itself is wrong, and
63
+ # absence comes last.
64
+ MOST_USEFUL_FIRST_REFUSAL_PROBLEMS = (CredentialManagerProblem.TOO_OLD, CredentialManagerProblem.UNREADABLE_VERSION,
65
+ CredentialManagerProblem.NOT_STARTING, CredentialManagerProblem.MISSING)
66
+
67
+ # Each refusal has one constant sentence, defined here only: the refused operation shows it in red, and the window that
68
+ # explains how to install the credential manager recognises it. Each sentence starts with words of its own, so a reader
69
+ # of a failure cause can tell them apart line by line. The path of the refused credential manager ends the sentence when
70
+ # there is one, so the user knows which file to update or remove.
71
+ CREDENTIAL_MANAGER_MISSING_TEXT = "Git credential manager not found."
72
+ CREDENTIAL_MANAGER_TOO_OLD_TEXT = "Git credential manager {version} is too old, {minimum} or later is required: {filename}"
73
+ CREDENTIAL_MANAGER_UNREADABLE_VERSION_TEXT = "Could not read the Git credential manager version: {filename}"
74
+ CREDENTIAL_MANAGER_NOT_STARTING_TEXT = "Git credential manager did not start: {filename}"
75
+ CREDENTIAL_MANAGER_UNSUPPORTED_SYSTEM_TEXT = "Git credential manager is not supported on this system: {platform}"
76
+ REFUSAL_TEXT_BY_PROBLEM: dict[CredentialManagerProblem, str] = {
77
+ CredentialManagerProblem.MISSING: CREDENTIAL_MANAGER_MISSING_TEXT,
78
+ CredentialManagerProblem.TOO_OLD: CREDENTIAL_MANAGER_TOO_OLD_TEXT,
79
+ CredentialManagerProblem.UNREADABLE_VERSION: CREDENTIAL_MANAGER_UNREADABLE_VERSION_TEXT,
80
+ CredentialManagerProblem.NOT_STARTING: CREDENTIAL_MANAGER_NOT_STARTING_TEXT,
81
+ CredentialManagerProblem.UNSUPPORTED_PLATFORM: CREDENTIAL_MANAGER_UNSUPPORTED_SYSTEM_TEXT,
82
+ }
83
+ # The refusals that leave the user something to install, so they open the window explaining how. A system missing from
84
+ # the platform table has no official credential manager to offer, so its refusal only shows its red line.
85
+ INSTALL_WINDOW_PROBLEM_SET: frozenset[CredentialManagerProblem] = frozenset({CredentialManagerProblem.MISSING, CredentialManagerProblem.TOO_OLD,
86
+ CredentialManagerProblem.UNREADABLE_VERSION, CredentialManagerProblem.NOT_STARTING})
87
+ # First line of the install window for a credential manager that is too old. The path stays in the red line of the
88
+ # refused operation and in Settings, so this line names only the versions.
89
+ CREDENTIAL_MANAGER_TOO_OLD_HEADER_TEXT = "Your Git credential manager ({version}) is too old, {minimum} or later is required"
90
+ # Short line of the install window when a new search finds the same refusal the window already explains, a missing
91
+ # credential manager or the same version still too old alike.
92
+ CREDENTIAL_MANAGER_STILL_REFUSED_TEXT = "Still not good. (what did you expect? 😂)"
93
+ # Read-only line of Settings: the version and path of the credential manager in use, or its absence.
94
+ CREDENTIAL_MANAGER_FOUND_TEXT = "{version} — {filename}"
95
+ CREDENTIAL_MANAGER_NOT_FOUND_TEXT = "not found"
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class CredentialManagerDetection:
100
+ """Name the credential manager to use with the version read and where it was found, or say why none is usable.
101
+
102
+ ``exec_path`` is the folder of the Git in use; every direct start of the credential manager must receive it so the
103
+ credential manager finds Git without the PATH. It is empty when Git did not tell it.
104
+ """
105
+
106
+ filename: str = ""
107
+ version: str = ""
108
+ source: str = ""
109
+ problem: CredentialManagerProblem = CredentialManagerProblem.MISSING
110
+ exec_path: str = ""
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class CredentialManagerCandidate:
115
+ """Name one file to try and where it comes from.
116
+
117
+ ``skip_reason`` is not empty when the file is only listed to be written to --log as skipped: it is never started.
118
+ """
119
+
120
+ filename: str
121
+ source: str
122
+ skip_reason: str = ""
123
+
124
+
125
+ def _other_toolchain_bin_dir(path_dir: str, installation_dir: str, git_bin_dir: str) -> bool:
126
+ """Say whether ``path_dir`` is a second-level folder of the Git installation other than the bin folder of the Git in use.
127
+
128
+ All three paths are compared normalized and case-folded the way the system compares them.
129
+ """
130
+ path_key = os.path.normcase(os.path.normpath(path_dir))
131
+ grandparent_key = os.path.dirname(os.path.dirname(path_key))
132
+ if grandparent_key != os.path.normcase(os.path.normpath(installation_dir)):
133
+ return False
134
+ return path_key != os.path.normcase(os.path.normpath(git_bin_dir))
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class BoundedCommandAnswer:
139
+ """Keep what a short command answered: its exit code and standard output, or why it gave no answer at all."""
140
+
141
+ exit_code: int | None = None
142
+ output: bytes = b""
143
+ failure: str = ""
144
+
145
+
146
+ def run_bounded_command(arguments: list[str], environment_by_name: Mapping[str, str], working_dir: str, timeout_ms: int) -> BoundedCommandAnswer:
147
+ """Run one short read-only command of an external program within a time limit, and never raise.
148
+
149
+ Its input is closed, so a program that waits for input fails instead of hanging, and on Windows no console window
150
+ opens. The console flag is chosen by os.name and not by a simulated platform, because it only exists on Windows.
151
+ The error output is dropped without being read: a warning written there never decides the version. The time limit
152
+ holds even when the program leaves children behind.
153
+ """
154
+ # Workaround: a pipe stays open as long as a child of the program keeps it. On Windows, subprocess.run reads the
155
+ # pipes again after killing the program at the time limit, so it waits for such a child (a launcher, or the warning
156
+ # stand-in that starts the real credential manager); on every system a program that answered and then left a child
157
+ # would look like it never answered. A file has no end to wait for, so the time limit bounds the call everywhere
158
+ # and only the started program is killed and waited for.
159
+ try:
160
+ with tempfile.TemporaryFile() as output_file:
161
+ completed = subprocess.run(arguments, stdin=subprocess.DEVNULL, stdout=output_file, stderr=subprocess.DEVNULL, cwd=working_dir,
162
+ env=dict(environment_by_name), timeout=timeout_ms / MILLISECONDS_PER_SECOND, check=False,
163
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
164
+ output_file.seek(0)
165
+ output = output_file.read()
166
+ except subprocess.TimeoutExpired:
167
+ return BoundedCommandAnswer(failure=f"no answer within {timeout_ms / MILLISECONDS_PER_SECOND:g} s")
168
+ except (OSError, ValueError) as error:
169
+ return BoundedCommandAnswer(failure=type(error).__name__ + ": " + str(error))
170
+ return BoundedCommandAnswer(completed.returncode, output)
171
+
172
+
173
+ def ignore_line(text: str) -> None:
174
+ """Write nothing: the log used when the caller keeps no --log file."""
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ProbeVerdict:
179
+ """Say what one --version answer means: the problem it shows (NONE when usable), the version read and the --log result."""
180
+
181
+ problem: CredentialManagerProblem
182
+ version: str
183
+ result_text: str
184
+
185
+
186
+ def _version_numbers(output: bytes) -> tuple[int, int, int] | None:
187
+ """Read the first three numbers of the first non-empty line of a --version output, or None when there are none."""
188
+ for line in output.decode("utf-8", errors="replace").splitlines():
189
+ if not line.strip():
190
+ continue
191
+ match = GCM_VERSION_PATTERN.search(line)
192
+ if match is None:
193
+ return None
194
+ major, minor, patch = match.groups()
195
+ return int(major), int(minor), int(patch)
196
+ return None
197
+
198
+
199
+ def _version_text(numbers: tuple[int, int, int]) -> str:
200
+ """Write a version as X.Y.Z, the form shown to the user whatever build suffix the program printed."""
201
+ major, minor, patch = numbers
202
+ return f"{major}.{minor}.{patch}"
203
+
204
+
205
+ def credential_manager_refusal_text(detection: CredentialManagerDetection, platform: str) -> str:
206
+ """Write the sentence of one refusal, ending with the path of the refused credential manager when there is one.
207
+
208
+ ``platform`` names the system only for the refusal of a system the search does not support.
209
+ """
210
+ return REFUSAL_TEXT_BY_PROBLEM[detection.problem].format(filename=detection.filename, version=detection.version,
211
+ minimum=_version_text(GCM_MINIMUM_VERSION), platform=platform)
212
+
213
+
214
+ def credential_manager_check_again_text(shown: CredentialManagerDetection, found: CredentialManagerDetection, platform: str) -> str:
215
+ """Write what the install window says after a new search that still refuses.
216
+
217
+ The short line only when the window would explain the refusal ``found`` with the very lines it already shows for
218
+ ``shown``; any other refusal, including the same reason with another version or file, gives its own sentence, path
219
+ included, and the window then shows that refusal's text.
220
+ """
221
+ if _install_header_lines(found, platform) == _install_header_lines(shown, platform):
222
+ return CREDENTIAL_MANAGER_STILL_REFUSED_TEXT
223
+ return credential_manager_refusal_text(found, platform)
224
+
225
+
226
+ def credential_manager_setting_text(detection: CredentialManagerDetection, platform: str) -> str:
227
+ """Write the Settings line: the version and path of the credential manager in use, ``not found`` for an absence, the refusal sentence otherwise."""
228
+ if detection.problem == CredentialManagerProblem.NONE:
229
+ return CREDENTIAL_MANAGER_FOUND_TEXT.format(version=detection.version, filename=detection.filename)
230
+ if detection.problem == CredentialManagerProblem.MISSING:
231
+ return CREDENTIAL_MANAGER_NOT_FOUND_TEXT
232
+ return credential_manager_refusal_text(detection, platform)
233
+
234
+
235
+ class CredentialManagerRefusal(ValueError):
236
+ """Raised when the credential manager GitPane looked for cannot be used; its message is the constant sentence of that refusal.
237
+
238
+ An operation that stops on it started no Git process for the refused destination, so its console reads it as a
239
+ refusal before launch. It keeps the detection too, so the window explaining how to install the credential manager
240
+ knows the reason and the version without reading the sentence back.
241
+ """
242
+
243
+ def __init__(self, detection: CredentialManagerDetection, platform: str) -> None:
244
+ """Build the refusal sentence of ``detection`` and keep the detection itself."""
245
+ super().__init__(credential_manager_refusal_text(detection, platform))
246
+ self.detection = detection
247
+
248
+
249
+ def _install_header_lines(detection: CredentialManagerDetection, platform: str) -> tuple[str, ...]:
250
+ """Write the lines that open the install window for this refusal, saying why it is refused when there is more to say than its absence.
251
+
252
+ The too-old line names only the version, so the same old version found in another file opens the window the same way.
253
+ """
254
+ if detection.problem == CredentialManagerProblem.TOO_OLD:
255
+ return (CREDENTIAL_MANAGER_TOO_OLD_HEADER_TEXT.format(version=detection.version, minimum=_version_text(GCM_MINIMUM_VERSION)),)
256
+ if detection.problem == CredentialManagerProblem.MISSING:
257
+ return ()
258
+ return (credential_manager_refusal_text(detection, platform),)
259
+
260
+
261
+ def platform_install_lines(entry: CredentialManagerPlatform, problem: CredentialManagerProblem) -> tuple[str, ...]:
262
+ """Pick the install lines of one refusal: the system's lines for that reason when it has its own, else its general lines.
263
+
264
+ A missing credential manager and one that is too old may need different advice (install versus upgrade); a system
265
+ that gives no lines of its own for a reason shows its general install lines for it.
266
+ """
267
+ reason_lines_by_problem = {CredentialManagerProblem.MISSING: entry.missing_install_lines, CredentialManagerProblem.TOO_OLD: entry.too_old_install_lines}
268
+ reason_lines = reason_lines_by_problem.get(problem, ())
269
+ if reason_lines:
270
+ return reason_lines
271
+ return entry.install_lines
272
+
273
+
274
+ def _probe_verdict(answer: BoundedCommandAnswer) -> ProbeVerdict:
275
+ """Classify one --version answer.
276
+
277
+ A non-zero exit code means the program does not start properly even when it printed a version. An output without
278
+ a version is kept apart from a program that does not start, and only a cut, single-line form of it is logged,
279
+ since it comes from an unknown program.
280
+ """
281
+ if answer.failure:
282
+ return ProbeVerdict(CredentialManagerProblem.NOT_STARTING, "", "did not start: " + answer.failure)
283
+ if answer.exit_code != 0:
284
+ return ProbeVerdict(CredentialManagerProblem.NOT_STARTING, "", f"did not start: exit code {answer.exit_code}")
285
+ numbers = _version_numbers(answer.output)
286
+ if numbers is None:
287
+ output_text = answer.output.decode("utf-8", errors="replace")[:UNKNOWN_OUTPUT_LOG_LIMIT]
288
+ return ProbeVerdict(CredentialManagerProblem.UNREADABLE_VERSION, "", "unreadable version: " + repr(output_text))
289
+ version = _version_text(numbers)
290
+ if numbers < GCM_MINIMUM_VERSION:
291
+ return ProbeVerdict(CredentialManagerProblem.TOO_OLD, version, f"too old ({version})")
292
+ return ProbeVerdict(CredentialManagerProblem.NONE, version, f"accepted ({version})")
293
+
294
+
295
+ class CredentialManagerLocator:
296
+ """Find the official Git Credential Manager for a Git executable and keep only usable answers for the session.
297
+
298
+ Blocking and free of Qt objects: it starts short processes and may wait up to its time limit for each candidate,
299
+ so callers run it away from the window's thread. Its search never raises. A credential manager installed after a
300
+ refusal is found by the next search, since refusals are never kept.
301
+ """
302
+
303
+ def __init__(self, platform: str, environment_by_name: Mapping[str, str], *, log_line: Callable[[str], None] = ignore_line,
304
+ timeout_ms: int = GCM_VERSION_TIMEOUT_MS, platform_entry_by_name: Mapping[str, CredentialManagerPlatform] = CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
305
+ available: Callable[[str], bool] = absolute_executable_available, network: Callable[[str], bool] = network_directory,
306
+ distribution_family: DistributionFamily = DistributionFamily.UNKNOWN, architecture: GcmArchiveArchitecture | None = None) -> None:
307
+ """Keep what the search needs; the distribution family and the processor only choose the Linux install lines.
308
+
309
+ Their defaults read nothing from the machine and give the lines of an unknown distribution on an unknown
310
+ processor; the composition root passes the ones of the running session.
311
+ """
312
+ self.platform = platform
313
+ self.distribution_family = distribution_family
314
+ self.architecture = architecture
315
+ self.environment_by_name = environment_by_name
316
+ self.log_line = log_line
317
+ self.timeout_ms = timeout_ms
318
+ self.platform_entry_by_name = platform_entry_by_name
319
+ self.available = available
320
+ self.network = network
321
+ self.detection_by_git_filename: dict[str, CredentialManagerDetection] = {}
322
+ # Several background tasks may need the credential manager at the same time (fetch, push, clone, accounts):
323
+ # one search at a time keeps them from starting the same probes twice and from mixing their --log lines.
324
+ self.lock = threading.Lock()
325
+
326
+ def refusal_install_lines(self, problem: CredentialManagerProblem) -> tuple[str, ...]:
327
+ """Write what to install for a refusal: the lines of this search's distribution and processor where the system's entry says they depend on them, else the entry's lines for that refusal.
328
+
329
+ On Linux, a credential manager too old, unreadable or not starting gets the same lines as a missing one: a
330
+ second extraction of the archive, or a second install of the package, replaces its files.
331
+ """
332
+ entry = self.platform_entry_by_name[self.platform]
333
+ if entry.install_lines_by_distribution:
334
+ return distribution_gcm_install_lines(self.distribution_family, self.architecture)
335
+ return platform_install_lines(entry, problem)
336
+
337
+ def install_lines(self, detection: CredentialManagerDetection) -> tuple[str, ...]:
338
+ """Write the lines of the install window for a refusal of this search: why it is refused when there is more to say than its absence, then what to install.
339
+
340
+ The install lines come from refusal_install_lines, read from this search's own platform table, the one it
341
+ searched with, and follow the reason of the refusal. Called only for a refusal of INSTALL_WINDOW_PROBLEM_SET,
342
+ which a system missing from that table never gives.
343
+ """
344
+ return (*_install_header_lines(detection, self.platform), GCM_REQUIRED_TEXT, *self.refusal_install_lines(detection.problem))
345
+
346
+ def _git_exec_path(self, git_filename: str) -> str:
347
+ """Ask the Git in use for the folder of its own programs, or return "" and log why it is unknown.
348
+
349
+ An inherited GIT_EXEC_PATH is removed first: Git would answer that variable instead of its own folder. Only one
350
+ absolute line counts as an answer; the search goes on without it otherwise.
351
+ """
352
+ environment_by_name = dict(self.environment_by_name)
353
+ environment_by_name.pop(GIT_EXEC_PATH_ENVIRONMENT_NAME, None)
354
+ answer = run_bounded_command([git_filename, GIT_EXEC_PATH_ARGUMENT], environment_by_name, os.path.dirname(git_filename), GIT_OPEN_TIMEOUT_MS)
355
+ cause = answer.failure
356
+ if not cause and answer.exit_code != 0:
357
+ cause = f"exit code {answer.exit_code}"
358
+ if not cause:
359
+ # Read as a file name, a folder whose name is not UTF-8 reaches the credential manager unchanged in
360
+ # GIT_EXEC_PATH.
361
+ try:
362
+ return parse_exec_path_answer(answer.output)
363
+ except ValueError as error:
364
+ cause = str(error)
365
+ self.log_line(f"Git exec path not read for {git_filename}: {cause}")
366
+ return ""
367
+
368
+ def _probe_environment(self, exec_path: str) -> dict[str, str]:
369
+ """Return the environment of a --version probe: the user's credential manager settings removed, Git's folder given.
370
+
371
+ A setting such as a debugger wait would make a sound credential manager look broken, and without Git's folder
372
+ the credential manager cannot find Git when Git is not on the PATH.
373
+ """
374
+ environment_by_name = dict(self.environment_by_name)
375
+ for name in tuple(environment_by_name):
376
+ if name.startswith(GCM_ENVIRONMENT_PREFIX):
377
+ environment_by_name.pop(name)
378
+ if exec_path:
379
+ environment_by_name[GIT_EXEC_PATH_ENVIRONMENT_NAME] = exec_path
380
+ return environment_by_name
381
+
382
+ def _candidates(self, entry: CredentialManagerPlatform, exec_path: str) -> list[CredentialManagerCandidate]:
383
+ """List the files to try with where each comes from, each file once, in search order.
384
+
385
+ The installation of the Git in use comes first when the system has one to search, then the PATH, then the
386
+ fixed locations of the system. Skipped candidates stay in the list at their place, so the search writes them in
387
+ order and stops before them once a credential manager is accepted.
388
+ """
389
+ windows = self.platform == WINDOWS_PLATFORM
390
+ listed_candidates: list[CredentialManagerCandidate] = []
391
+ installation_dir = ""
392
+ git_bin_dir = ""
393
+ if entry.searches_git_installation and exec_path:
394
+ # Git for Windows answers --exec-path with <prefix>/libexec/git-core, where <prefix> is the toolchain folder
395
+ # of this installation; that folder changes with the version and the architecture. Its credential manager
396
+ # sits in <prefix>/bin. No folder name is written here, so a later move is followed by itself, and the
397
+ # warning stand-in left in another toolchain folder is never taken, neither here nor from the PATH below.
398
+ prefix_dir = os.path.dirname(os.path.dirname(exec_path))
399
+ git_bin_dir = os.path.join(prefix_dir, GIT_BINARY_DIR_BASENAME)
400
+ installation_dir = os.path.dirname(prefix_dir)
401
+ listed_candidates.append(CredentialManagerCandidate(os.path.join(git_bin_dir, entry.executable_basename), GIT_FOR_WINDOWS_SOURCE_TEXT))
402
+ # The PATH is read here instead of with shutil.which, which on Windows looks in the current folder first: a
403
+ # program dropped there, or in a relative PATH entry, must never be started and handed account secrets.
404
+ for path_dir in self.environment_by_name.get(PATH_ENVIRONMENT_NAME, "").split(os.pathsep):
405
+ if not os.path.isabs(path_dir):
406
+ continue
407
+ filename = os.path.join(path_dir, entry.executable_basename)
408
+ if windows and self.network(path_dir):
409
+ listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT, skip_reason=NETWORK_SHARE_SKIP_TEXT))
410
+ continue
411
+ # The Git for Windows install option that adds its Unix tools to the PATH puts there the bin folder of
412
+ # another toolchain folder of the same installation. The credential manager found there is the warning
413
+ # stand-in that starts the real one and adds a warning to every call. It is skipped even when the credential
414
+ # manager of the Git in use is refused or missing, otherwise it would be accepted and kept for the session.
415
+ if installation_dir and _other_toolchain_bin_dir(path_dir, installation_dir, git_bin_dir):
416
+ listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT, skip_reason=OTHER_TOOLCHAIN_SKIP_TEXT))
417
+ continue
418
+ listed_candidates.append(CredentialManagerCandidate(filename, PATH_SOURCE_TEXT))
419
+ for fixed_filename in entry.fixed_filenames:
420
+ listed_candidates.append(CredentialManagerCandidate(fixed_filename, STANDARD_LOCATION_SOURCE_TEXT))
421
+ candidates: list[CredentialManagerCandidate] = []
422
+ identity_set: set[str] = set()
423
+ for candidate in listed_candidates:
424
+ identity = os.path.normcase(os.path.abspath(candidate.filename))
425
+ if identity in identity_set:
426
+ continue
427
+ identity_set.add(identity)
428
+ candidates.append(candidate)
429
+ return candidates
430
+
431
+ def locate(self, git_filename: str) -> CredentialManagerDetection:
432
+ """Return the usable credential manager for this Git, or the most useful reason none is usable; never raises.
433
+
434
+ Candidates are tried in search order and the first accepted one wins. When none is accepted, the refusal kept
435
+ is the most useful one; between equal reasons the first candidate seen keeps its file and version. A success
436
+ is kept for this Git and returned again without starting anything while its file and Git's folder still exist.
437
+ """
438
+ with self.lock:
439
+ entry = self.platform_entry_by_name.get(self.platform)
440
+ if entry is None:
441
+ self.log_line(f"Credential manager not searched: unsupported system {self.platform}")
442
+ return CredentialManagerDetection(problem=CredentialManagerProblem.UNSUPPORTED_PLATFORM)
443
+ kept = self.detection_by_git_filename.get(git_filename)
444
+ if kept is not None:
445
+ # An update of Git for Windows can move its programs folder while the credential manager file stays.
446
+ if self.available(kept.filename) and (not kept.exec_path or os.path.isdir(kept.exec_path)):
447
+ return kept
448
+ del self.detection_by_git_filename[git_filename]
449
+ self.log_line(f"Kept credential manager is gone, searching again: {kept.filename}")
450
+ detection = self._search(entry, git_filename)
451
+ if detection.problem == CredentialManagerProblem.NONE:
452
+ self.detection_by_git_filename[git_filename] = detection
453
+ return detection
454
+
455
+ def _search(self, entry: CredentialManagerPlatform, git_filename: str) -> CredentialManagerDetection:
456
+ """Try every candidate for this Git and return the first accepted one, or the most useful refusal."""
457
+ exec_path = self._git_exec_path(git_filename)
458
+ probe_environment_by_name = self._probe_environment(exec_path)
459
+ refusal = CredentialManagerDetection(exec_path=exec_path)
460
+ for candidate in self._candidates(entry, exec_path):
461
+ if candidate.skip_reason:
462
+ self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): skipped, {candidate.skip_reason}")
463
+ continue
464
+ if not self.available(candidate.filename):
465
+ self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): not found")
466
+ continue
467
+ answer = run_bounded_command([candidate.filename, GCM_VERSION_ARGUMENT], probe_environment_by_name, os.path.dirname(candidate.filename), self.timeout_ms)
468
+ verdict = _probe_verdict(answer)
469
+ self.log_line(f"Credential manager candidate {candidate.filename} ({candidate.source}): {verdict.result_text}")
470
+ detection = CredentialManagerDetection(os.path.abspath(candidate.filename), verdict.version, candidate.source, verdict.problem, exec_path)
471
+ if verdict.problem == CredentialManagerProblem.NONE:
472
+ self.log_line(f"Credential manager detected: {detection.filename} ({candidate.source}, {verdict.version})")
473
+ return detection
474
+ if MOST_USEFUL_FIRST_REFUSAL_PROBLEMS.index(verdict.problem) < MOST_USEFUL_FIRST_REFUSAL_PROBLEMS.index(refusal.problem):
475
+ refusal = detection
476
+ conclusion = "Credential manager not usable: " + refusal.problem.value
477
+ if refusal.filename:
478
+ conclusion += " " + refusal.filename + (f" ({refusal.version})" if refusal.version else "")
479
+ self.log_line(conclusion)
480
+ return refusal
@@ -0,0 +1,171 @@
1
+ """Write the commands that install the Git Credential Manager on a Linux distribution.
2
+
3
+ Each family of distributions has its own row in the table, and a family never receives the command of another family's
4
+ package manager: a command that does not exist on the user's system does not work and teaches a wrong command. Only
5
+ constants of GitPane and the table of processors fill the commands: nothing read from the machine (os-release, the
6
+ processor name) ever enters a command, it only picks a family or an architecture, members of an enum.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+
15
+ from gitpane.config import GCM_ARCHIVE_VERSION, GCM_CORE_DIR, GCM_RELEASES_URL
16
+ from gitpane.system.linux_distribution import DistributionFamily
17
+
18
+
19
+ class GcmArchiveArchitecture(Enum):
20
+ """A processor the official Linux archive of the Git Credential Manager is built for; the value is the word of the archive's file name."""
21
+
22
+ X64 = "x64"
23
+ ARM64 = "arm64"
24
+
25
+
26
+ # Keyed by platform.machine() on Linux. A processor missing from the table has no official archive.
27
+ GCM_ARCHIVE_ARCHITECTURE_BY_MACHINE: dict[str, GcmArchiveArchitecture] = {
28
+ "x86_64": GcmArchiveArchitecture.X64,
29
+ "aarch64": GcmArchiveArchitecture.ARM64,
30
+ }
31
+
32
+ GCM_ARCHIVE_BASENAME_TEMPLATE = "gcm-linux-{architecture}-{version}.tar.gz"
33
+ GCM_ARCHIVE_URL_TEMPLATE = "https://github.com/git-ecosystem/git-credential-manager/releases/download/v{version}/{basename}"
34
+
35
+
36
+ def gcm_archive_architecture(machine: str) -> GcmArchiveArchitecture | None:
37
+ """Return the archive architecture of a processor name as ``platform.machine()`` writes it, or None when no official archive exists for it."""
38
+ return GCM_ARCHIVE_ARCHITECTURE_BY_MACHINE.get(machine)
39
+
40
+
41
+ def session_gcm_archive_architecture() -> GcmArchiveArchitecture | None:
42
+ """Return the archive architecture of the running machine's processor, or None when no official archive exists for it."""
43
+ return gcm_archive_architecture(platform.machine())
44
+
45
+
46
+ class GcmInstallText(Enum):
47
+ """A line of the Linux install text of the Git Credential Manager, as the install window shows it."""
48
+
49
+ RUN_IN_TERMINAL = " - in a terminal, run :"
50
+ """Introduces the commands, each alone on its line so it can be copied as is."""
51
+ RELEASES = " - releases : " + GCM_RELEASES_URL
52
+ """Ends the archive commands with the page of the releases, the only link of the text."""
53
+ UNKNOWN_DISTRIBUTION = " - GitPane does not know this distribution; the official archive runs on most distributions that use glibc : " + GCM_RELEASES_URL
54
+ """Opens the text of a distribution GitPane does not know, the link first, before the archive commands."""
55
+ NO_BUILD_FOR_PROCESSOR = " - no official Git credential manager build exists for this processor : " + GCM_RELEASES_URL
56
+ """The only line of a processor the official archive is not built for."""
57
+ DEBIAN_DOWNLOAD = " - download a .deb here : " + GCM_RELEASES_URL
58
+ """First line of the Debian family, which installs the official .deb."""
59
+ DEBIAN_INSTALL = " - install it with : sudo apt install ./gcm-linux-<arch>-<version>.deb"
60
+ """Second line of the Debian family: apt installs the downloaded .deb."""
61
+ DEBIAN_OTHER_DISTRIBUTIONS = " - or for other distros, a .tar.gz here : " + GCM_RELEASES_URL
62
+ """Third line of the Debian family, kept word for word from the lines Debian and Ubuntu already showed."""
63
+ # -L follows GitHub's redirection to the file, -f refuses to write an error page under the archive's name.
64
+ ARCHIVE_DOWNLOAD = 'cd "$(mktemp -d)" && curl -fLO {url}'
65
+ """Downloads the official archive into a new temporary folder."""
66
+ # The archive gives its ./ folder to uid 1000: extracted as root without --no-same-owner --no-overwrite-dir, it would
67
+ # give a system folder to the first user. The folder is created first so it belongs to root. No configuration step
68
+ # follows, since GitPane gives its own helper to every command it starts.
69
+ ARCHIVE_INSTALL = "sudo mkdir -p {core_dir} && sudo tar --no-same-owner --no-overwrite-dir -xzf {basename} -C {core_dir}"
70
+ """Extracts the official archive into the folder GitPane searches after the PATH."""
71
+ FEDORA_PACKAGES = "sudo dnf install libsecret libicu"
72
+ """Installs the libraries the archive needs, which a minimal or server install may lack, on the whole Fedora and RHEL families.
73
+
74
+ The Fedora family also holds Mageia, Bazzite and the other image-based Fedora, and Amazon Linux, where this command
75
+ can fail (other package names, a read-only system image, yum alone). It is kept for the whole family on purpose: those
76
+ systems already carry both libraries or name them otherwise, and the archive commands after it still work.
77
+ """
78
+ LIBRARIES_IF_NOT_STARTING = " - if the Git credential manager does not start, install libsecret and ICU with the package manager"
79
+ """Names the libraries the archive needs where GitPane gives no package command, since their package names were not checked."""
80
+ ARCH_PACKAGES = "sudo pacman -S --needed libsecret icu"
81
+ """Installs on Arch the libraries the archive needs, which a system without a desktop may lack."""
82
+ GENTOO_PACKAGES = "sudo emerge --ask --noreplace app-crypt/libsecret dev-libs/icu net-misc/curl"
83
+ """Installs on Gentoo the libraries the archive needs and curl, which a system without a desktop may lack."""
84
+ VOID_PACKAGES = "sudo xbps-install -S curl libsecret icu"
85
+ """Installs on Void curl and the libraries the archive needs, which a system without a desktop may lack."""
86
+ VOID_MUSL = " - the musl variant of Void cannot run the official archive : " + GCM_RELEASES_URL
87
+ """Opens Void's lines: both variants share one os-release ID, and the archive is built against glibc."""
88
+ VOID_GLIBC_RUN_IN_TERMINAL = " - on the glibc variant of Void, in a terminal, run :"
89
+ """Introduces Void's commands, which only the glibc variant can run."""
90
+ ALPINE = " - no official Git credential manager build runs on Alpine (musl libc) : " + GCM_RELEASES_URL
91
+ """The only line of Alpine, whose musl C library runs no program built against glibc."""
92
+ NIXOS = " - the official Git credential manager archive does not run as is on NixOS or GNU Guix : " + GCM_RELEASES_URL
93
+ """The only line of NixOS and Guix, which have no dynamic loader at the standard path a prebuilt program expects."""
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class FamilyGcmInstall:
98
+ """How one family of distributions installs the Git Credential Manager: plain lines, or the archive commands framed by its own lines."""
99
+
100
+ # Lines given as they are, without any archive command; when set, the other fields are not read.
101
+ plain_lines: tuple[GcmInstallText, ...] = ()
102
+ # True when the plain lines point to a file of the releases page, which only exists for the processors of the
103
+ # archive: another processor then gets the single line saying no official build exists for it.
104
+ plain_lines_need_archive_processor: bool = False
105
+ # Lines before the archive commands.
106
+ lead_lines: tuple[GcmInstallText, ...] = ()
107
+ run_line: GcmInstallText = GcmInstallText.RUN_IN_TERMINAL
108
+ # Command of the family's own package manager that installs what the archive needs, before the download.
109
+ package_command: GcmInstallText | None = None
110
+ trailing_lines: tuple[GcmInstallText, ...] = (GcmInstallText.RELEASES,)
111
+
112
+
113
+ # Keyed by the family read from os-release; the value is text of the install window, another layer than the family.
114
+ GCM_INSTALL_BY_FAMILY: dict[DistributionFamily, FamilyGcmInstall] = {
115
+ DistributionFamily.DEBIAN: FamilyGcmInstall(plain_lines=(GcmInstallText.DEBIAN_DOWNLOAD, GcmInstallText.DEBIAN_INSTALL, GcmInstallText.DEBIAN_OTHER_DISTRIBUTIONS),
116
+ plain_lines_need_archive_processor=True),
117
+ # Kept for the whole family by choice, although the command can fail on Mageia, on image-based Fedora such as Bazzite
118
+ # and where only yum exists: see the docstring of the command.
119
+ DistributionFamily.FEDORA: FamilyGcmInstall(package_command=GcmInstallText.FEDORA_PACKAGES),
120
+ DistributionFamily.RHEL: FamilyGcmInstall(package_command=GcmInstallText.FEDORA_PACKAGES),
121
+ # The package names of these families were not checked, so the libraries are named rather than given as a command.
122
+ DistributionFamily.SUSE: FamilyGcmInstall(trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING, GcmInstallText.RELEASES)),
123
+ DistributionFamily.SOLUS: FamilyGcmInstall(trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING, GcmInstallText.RELEASES)),
124
+ DistributionFamily.ARCH: FamilyGcmInstall(package_command=GcmInstallText.ARCH_PACKAGES),
125
+ # The official Gentoo package of the credential manager only exists under a testing keyword, hence the archive.
126
+ DistributionFamily.GENTOO: FamilyGcmInstall(package_command=GcmInstallText.GENTOO_PACKAGES),
127
+ DistributionFamily.VOID: FamilyGcmInstall(lead_lines=(GcmInstallText.VOID_MUSL,), run_line=GcmInstallText.VOID_GLIBC_RUN_IN_TERMINAL,
128
+ package_command=GcmInstallText.VOID_PACKAGES, trailing_lines=()),
129
+ # The official program asks for glibc's dynamic loader, which musl does not provide.
130
+ DistributionFamily.ALPINE: FamilyGcmInstall(plain_lines=(GcmInstallText.ALPINE,)),
131
+ DistributionFamily.NIXOS: FamilyGcmInstall(plain_lines=(GcmInstallText.NIXOS,)),
132
+ # The link comes first, since nothing says the archive runs on a distribution GitPane does not know.
133
+ DistributionFamily.UNKNOWN: FamilyGcmInstall(lead_lines=(GcmInstallText.UNKNOWN_DISTRIBUTION,), trailing_lines=(GcmInstallText.LIBRARIES_IF_NOT_STARTING,)),
134
+ }
135
+
136
+
137
+ def _archive_command_lines(architecture: GcmArchiveArchitecture) -> tuple[str, str]:
138
+ """Write the download and the extraction commands of the official archive for one processor."""
139
+ basename = GCM_ARCHIVE_BASENAME_TEMPLATE.format(architecture=architecture.value, version=GCM_ARCHIVE_VERSION)
140
+ url = GCM_ARCHIVE_URL_TEMPLATE.format(version=GCM_ARCHIVE_VERSION, basename=basename)
141
+ download_line = GcmInstallText.ARCHIVE_DOWNLOAD.value.format(url=url)
142
+ install_line = GcmInstallText.ARCHIVE_INSTALL.value.format(core_dir=GCM_CORE_DIR, basename=basename)
143
+ return download_line, install_line
144
+
145
+
146
+ def distribution_gcm_install_lines(family: DistributionFamily, architecture: GcmArchiveArchitecture | None) -> tuple[str, ...]:
147
+ """Write the install lines of a distribution family on a processor.
148
+
149
+ A family with plain lines gets them on every processor, unless those lines point to an official file, which only
150
+ exists for the processors of the architecture table. Any other family installs the official archive, built for
151
+ the same processors. A processor without an official file gets a single line saying so, never a command that
152
+ cannot work.
153
+ """
154
+ family_install = GCM_INSTALL_BY_FAMILY[family]
155
+ if family_install.plain_lines and (architecture is not None or not family_install.plain_lines_need_archive_processor):
156
+ lines: list[str] = []
157
+ for text in family_install.plain_lines:
158
+ lines.append(text.value)
159
+ return tuple(lines)
160
+ if architecture is None:
161
+ return (GcmInstallText.NO_BUILD_FOR_PROCESSOR.value,)
162
+ lines = []
163
+ for text in family_install.lead_lines:
164
+ lines.append(text.value)
165
+ lines.append(family_install.run_line.value)
166
+ if family_install.package_command is not None:
167
+ lines.append(family_install.package_command.value)
168
+ lines.extend(_archive_command_lines(architecture))
169
+ for text in family_install.trailing_lines:
170
+ lines.append(text.value)
171
+ return tuple(lines)