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,1811 @@
1
+ """Select confirmed HTTPS accounts without persisting credentials in application settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shlex
9
+ import stat
10
+ import sys
11
+ import uuid
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import asdict, dataclass, field, fields, replace
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING, Self
16
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
17
+
18
+ from PySide6.QtCore import QObject, Qt, QTimer, Signal, Slot
19
+
20
+ from gitpane.account.credential_manager import CredentialManagerProblem, CredentialManagerRefusal
21
+ from gitpane.config import (
22
+ ACCOUNT_CHECK_TIMEOUT_MS,
23
+ ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY,
24
+ AUTHENTICATION_CONFIG_FILE_LIMIT,
25
+ AUTHENTICATION_CONFIG_LIMIT_BYTES,
26
+ AUTHENTICATION_IDLE_TIMEOUT_MS,
27
+ AUTHENTICATION_PREFERENCE_KEY,
28
+ AUTHENTICATION_TIMEOUT_MS,
29
+ BROWSER_SIGN_IN_STOP_GRACE_MS,
30
+ BROWSER_SIGN_IN_TIMEOUT_MS,
31
+ CREDENTIAL_MANAGER_PLATFORM_BY_NAME,
32
+ GCM_ENVIRONMENT_NAME_BY_SETTING,
33
+ GIT_DEFAULT_TEMPLATE_DIR,
34
+ HTTPS_PREFIX,
35
+ SECRET_ERASE_TIMEOUT_MS,
36
+ SECRET_LOOKUP_STOP_GRACE_MS,
37
+ SECRET_LOOKUP_TIMEOUT_MS,
38
+ application_module_interpreter_options,
39
+ )
40
+ from gitpane.operation.diagnostic import Diagnostic
41
+ from gitpane.operation.operation_status import OperationStatus, operation_status
42
+ from gitpane.process.process_group import NativeProcessGroup, console_interpreter_filename
43
+ from gitpane.process.process_runner import CREDENTIAL_PROFILE, ProcessRequest, ProcessRunner, ProcessStatus
44
+ from gitpane.system.path_identity import canonical_path
45
+ from gitpane.transport.refusal_notice import NO_ACCOUNT_CONFIGURED_TEXT, TransferAccount
46
+ from gitpane.worktree.command_sequence import GitCommand
47
+ from gitpane.worktree.worktree_snapshot import read_validated_size
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Callable, Generator
51
+ from concurrent.futures import Future
52
+
53
+ from gitpane.account.credential_manager import CredentialManagerDetection, CredentialManagerLocator
54
+ from gitpane.history.repository_value import RepositoryContext
55
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
56
+ from gitpane.preference.preference_store import PreferenceStore
57
+ from gitpane.process.process_runner import ProcessResult
58
+
59
+
60
+ GITHUB_PROVIDER = "GitHub"
61
+ GITLAB_PROVIDER = "GitLab"
62
+ BITBUCKET_PROVIDER = "Bitbucket"
63
+ PROVIDER_HOST_BY_NAME = {GITHUB_PROVIDER: "github.com", GITLAB_PROVIDER: "gitlab.com", BITBUCKET_PROVIDER: "bitbucket.org"}
64
+ # The providers a self-hosted server can be recognised as after it refused a password, each with the path, on that
65
+ # server, of the page where its users create a personal access token.
66
+ TOKEN_PAGE_PATH_BY_PROVIDER = {GITHUB_PROVIDER: "/settings/tokens", GITLAB_PROVIDER: "/-/user_settings/personal_access_tokens"}
67
+ # A generic account is a username with a password or token, valid for any HTTPS server (a self-hosted GitLab with an LDAP
68
+ # login, for example). Its identity is the username the user typed. Before it is saved, Git lists a repository of that
69
+ # server with the typed username and password; a refused test saves nothing.
70
+ GENERIC_PROVIDER = "Generic"
71
+ # GitHub, gitlab.com and Bitbucket Cloud refuse the account password over Git HTTPS, so a new username + password account
72
+ # is refused there before anything is stored; the address is put in front of this text.
73
+ CLOUD_PASSWORD_REFUSAL_TEXT = " does not accept account passwords for Git. Sign in with your browser or use a personal access token."
74
+ ACCOUNT_PROVIDER_SET = frozenset({*PROVIDER_HOST_BY_NAME, GENERIC_PROVIDER})
75
+
76
+
77
+ class SignInMethod(Enum):
78
+ """How an account signs in, with the text the Type column of Settings shows for it.
79
+
80
+ The value is the word recorded in the preferences file when an account is added or reconnected. An account saved
81
+ before that record existed is classified from its own fields; only an old GitHub account cannot be, since its browser
82
+ and token sign-ins give the same account fields, so it is shown as either and that method is never recorded.
83
+ """
84
+
85
+ BROWSER = ("browser", "Browser (OAuth)")
86
+ TOKEN = ("token", "Token")
87
+ PASSWORD = ("password", "Username + password")
88
+ BROWSER_OR_TOKEN = ("browser-or-token", "Browser or token")
89
+
90
+ def __new__(cls, value: str, type_label: str) -> Self:
91
+ """Keep the recorded word as the value and the Type column text beside it."""
92
+ member = object.__new__(cls)
93
+ member._value_ = value
94
+ member.type_label = type_label
95
+ return member
96
+
97
+
98
+ # The methods recorded when an account is added or reconnected: Browser or token is only ever deduced, never recorded.
99
+ RECORDED_SIGN_IN_METHOD_SET = frozenset((SignInMethod.BROWSER, SignInMethod.TOKEN, SignInMethod.PASSWORD))
100
+ # Git usernames that GitLab and Bitbucket require with a token obtained by browser (OAuth) sign-in.
101
+ GITLAB_OAUTH_GIT_USERNAME = "oauth2"
102
+ BITBUCKET_OAUTH_GIT_USERNAME = "x-token-auth"
103
+ BROWSER_GIT_USERNAME_SET = frozenset({GITLAB_OAUTH_GIT_USERNAME, BITBUCKET_OAUTH_GIT_USERNAME})
104
+ # Optional key of the saved account data: the name the user gave each service address (for example "GitLab at work").
105
+ # Data saved without it stays valid and is never rewritten just to add it.
106
+ SERVICE_NAME_KEY = "service_name"
107
+ ACCOUNT_DATA_KEY_SET = frozenset({"accounts", "global", "repository"})
108
+ RECURSIVE_AUTHENTICATION_KEY_PATTERN = r"^(credential\..*helper|http\..*extraheader)$"
109
+ RECURSIVE_CONFIG_PATH_PATTERN = r"^(include(if\..*)?\.path|init\.templatedir)$"
110
+ # One search of the credential manager at a time for the account service: the search already waits for any other
111
+ # search, and an account exchange never needs two of them.
112
+ CREDENTIAL_HELPER_WORKER_COUNT = 1
113
+ # Key of an exchange's private request that names the folder of Git's programs, given to every direct start of the
114
+ # credential manager so it finds Git even when Git is not on the PATH.
115
+ GIT_EXEC_PATH_KEY = "git_exec_path"
116
+ # Composing with this empty helper list builds a preview: every inherited helper is disabled and no helper command is
117
+ # named, so a captured destination can be compared without searching the credential manager.
118
+ PREVIEW_HELPER_COMMANDS: tuple[str, ...] = ()
119
+ # Written to --log, in front of the refusal sentence, when a recursive operation goes on without any credential helper.
120
+ CREDENTIAL_MANAGER_SKIPPED_LOG_TEXT = "Credential manager refused, continuing without a credential helper: "
121
+
122
+
123
+ def helper_after_refusal(refusal: ValueError, *, operation_uses_account: bool, log: DiagnosticLog | None) -> tuple[str, ...]:
124
+ """Decide what a recursive operation (clone, submodule initialization, Retry submodules) does when the credential manager is refused.
125
+
126
+ The search only happens when one of the operation's destinations has an account, often only a service default kept
127
+ for submodules on another service. The refusal stops the operation only when the operation itself uses an account:
128
+ a clone URL with an account, the HTTPS ``origin`` with an account of the repository that declares the submodule, or
129
+ an account chosen for the submodule before Retry submodules. Otherwise, with a parent reached over SSH,
130
+ ``file://``, a local folder or HTTPS without an account, the operation is never blocked by the credential manager:
131
+ it goes on with the preview, which gives Git no helper at all, and the refusal is written to --log. A submodule
132
+ that needed the account then fails on Git's own refusal.
133
+ """
134
+ if operation_uses_account:
135
+ raise refusal
136
+ if log is not None:
137
+ log.write_line(CREDENTIAL_MANAGER_SKIPPED_LOG_TEXT + str(refusal))
138
+ return PREVIEW_HELPER_COMMANDS
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class CredentialHelper:
143
+ """Name the credential helper one operation uses and the folder of the programs of the Git it belongs to.
144
+
145
+ ``exec_path`` is given to every direct start of the helper, so it finds Git even when Git is not on the PATH; it
146
+ stays empty for a helper injected by tests.
147
+ """
148
+
149
+ commands: tuple[str, ...]
150
+ exec_path: str = ""
151
+
152
+
153
+ # Where Git Credential Manager keeps the secrets of GitPane's accounts: the system's protected store, never a plaintext file.
154
+ # It is chosen per system, like the search of the credential manager; a system missing from the table gets no store, and
155
+ # the search refuses such a system before anything is started.
156
+ MANAGED_CREDENTIAL_STORE = CREDENTIAL_MANAGER_PLATFORM_BY_NAME[sys.platform].credential_store if sys.platform in CREDENTIAL_MANAGER_PLATFORM_BY_NAME else ""
157
+ # Git Credential Manager's provider for a stored account secret: it returns the secret as stored, without any provider sign-in.
158
+ MANAGED_CREDENTIAL_PROVIDER = "generic"
159
+ # Value of Git Credential Manager's yes/no settings that GitPane turns off: prompts and secrets in its traces.
160
+ GCM_DISABLED_VALUE = "false"
161
+ # The sign-in mode a browser sign-in asks each provider's part of Git Credential Manager for: the browser itself for
162
+ # GitHub and GitLab, OAuth (which opens the browser) for Bitbucket, never a prompt for a password or a token.
163
+ BROWSER_AUTH_MODE_BY_SETTING = {"gitHubAuthModes": "browser", "gitLabAuthModes": "browser", "bitbucketAuthModes": "oauth"}
164
+
165
+
166
+ class AuthenticationAction(Enum):
167
+ """The exchanges the account service asks its worker for, written as their value in the JSON the worker reads."""
168
+
169
+ SIGN_IN = "sign-in"
170
+ """Add a new account, by the browser, a typed token or a username + password."""
171
+ GET = "get"
172
+ """Read the stored secret of a listed account and check whom it belongs to."""
173
+ APPROVE = "approve"
174
+ """Reconnect a listed account: sign in again and replace its stored secret."""
175
+ REJECT = "reject"
176
+ """Erase the stored secret of one account and nothing else: after an explicit rejection, or when a new account's
177
+ secret was stored for a sign-in whose result nobody can see any more."""
178
+ REMOVE = "remove"
179
+ """Erase an account's stored secret like a rejection does, then take the account off the durable list."""
180
+ ERASE_CHECK = "erase-check"
181
+ """Erase only the temporary copy of a secret under test, when its test was stopped before it could erase the copy itself."""
182
+ LOOKUP = "lookup"
183
+ """Ask the helper, without any prompt and without contacting any server, whether each account's secret is still
184
+ stored: Git and Git Credential Manager erase an account's secret by themselves when a server refuses it."""
185
+
186
+
187
+ # Only a new sign-in or a Reconnect stores a fresh secret; checking or erasing an account never adds it to the list.
188
+ REMEMBERED_ACTION_SET = frozenset({AuthenticationAction.SIGN_IN, AuthenticationAction.APPROVE})
189
+ # Every credential namespace of a GitPane account starts with this, so no other program's secrets are ever addressed.
190
+ ACCOUNT_NAMESPACE_PREFIX = "gitpane-"
191
+ # The temporary copy of a secret under test has its own namespace, derived from the account's, so the test never touches
192
+ # the secret already saved for that account. A copy the test could not erase is erased again by the account service,
193
+ # and removing the account erases it too.
194
+ CHECK_NAMESPACE_SUFFIX = "-check"
195
+ # The key of an account test's result that tells whether its worker erased the temporary copy itself; any other answer
196
+ # makes the account service erase the copy again.
197
+ CHECK_COPY_ERASED_KEY = "check_copy_erased"
198
+ # The key of an exchange's result that says its error is GitPane's own sentence, refusing the account the sign-in
199
+ # returned, rather than the output of a program the worker ran.
200
+ GITPANE_REFUSAL_KEY = "gitpane_refusal"
201
+ EMPTY_CHECK_URL_TEXT = "Enter the URL of a repository on {address} to test the sign-in."
202
+ CHECK_URL_FORMAT_TEXT = "Enter the HTTPS URL of a repository on {address}, without a password, to test the sign-in."
203
+ CHECK_URL_ADDRESS_TEXT = "The repository to test must be on {address}."
204
+ GIT_MISSING_TEXT = "Git is not configured. Choose the Git executable in Settings › Tools."
205
+ ACCOUNT_LIST_UNREADABLE_TEXT = ("The saved account list could not be read, so nothing was changed. Another GitPane version may have written it: "
206
+ "close the other GitPane windows, then try again.")
207
+ # Said when Git could not complete the test for another reason than a refusal: repository not found, server unreachable.
208
+ CHECK_FAILED_TEXT = "The sign-in could not be tested with {url}. Nothing was saved."
209
+ # Text of a browser sign-in whose browser did not answer within the wait: the exchange was stopped before
210
+ # anything was stored. The worker writes it with its status and the sign-in window shows it.
211
+ BROWSER_TIMEOUT_TEXT = "The browser did not answer in time. Try again."
212
+ # Said instead of starting a browser sign-in when GitPane runs inside a job that forbids leaving it: the browser would
213
+ # then belong to the same job and be closed with it.
214
+ BROWSER_SIGN_IN_UNAVAILABLE_TEXT = "Browser sign-in is not available here: GitPane runs inside a job that would close the browser with it. Sign in with a token instead."
215
+ # A browser sign-in that ran out of time: stopped by its worker at the end of the wait, or, as a safety net, by the
216
+ # process supervisor when the worker itself no longer answers.
217
+ BROWSER_TIMEOUT_STATUS_SET = frozenset((OperationStatus.SIGN_IN_TIMEOUT, OperationStatus.TIMEOUT))
218
+
219
+
220
+ class SecretPresence(Enum):
221
+ """How a check of the stored secrets classifies each account, written as their value in the JSON the worker publishes.
222
+
223
+ Only a secret that is clearly gone counts as missing; anything ambiguous (no helper, time limit reached, an unknown
224
+ error) is unknown and never marks an account.
225
+ """
226
+
227
+ PRESENT = "present"
228
+ MISSING = "missing"
229
+ UNKNOWN = "unknown"
230
+
231
+
232
+ # The only fields of an account a check of its stored secret needs: where the secret is kept, the address and username
233
+ # the helper is asked about, and the label written to the diagnostics.
234
+ SECRET_LOOKUP_FIELDS = ("namespace", "origin", "git_username", "label")
235
+
236
+
237
+ def validate_configuration_filename(filename: str) -> str:
238
+ """Refuse configuration paths that could bind to another file in a descendant's cwd."""
239
+ if not os.path.isabs(filename) and os.path.normcase(filename) != os.path.normcase(os.devnull):
240
+ raise ValueError("Recursive authentication requires absolute Git configuration filenames. Update GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, HOME or XDG_CONFIG_HOME and retry.")
241
+ return filename
242
+
243
+
244
+ def configuration_identity(metadata: os.stat_result) -> tuple[int, int, int, int]:
245
+ """Answer what proves a configuration file is still the same one: its identity, its size and its modification time."""
246
+ return metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns
247
+
248
+
249
+ def configuration_signature(filename: str) -> tuple | None:
250
+ """Detect replacement and content changes without retaining configuration secrets.
251
+
252
+ The opened file must be the one looked at first, before anything is read, and exactly its size is read. The path
253
+ is looked at again after the read: on POSIX another program can move a new file over a path that is open, and the
254
+ open stream would keep describing the old file.
255
+ """
256
+ try:
257
+ before = os.stat(filename)
258
+ except FileNotFoundError:
259
+ return None
260
+ if not stat.S_ISREG(before.st_mode):
261
+ if canonical_path(filename) != canonical_path(os.devnull):
262
+ raise ValueError("Git authentication configuration must use regular files.")
263
+ # The null device holds nothing, yet macOS moves its modification time each time any program writes to it: its
264
+ # identity alone is its signature, so a write by another process never makes a queued recursive command stale.
265
+ return before.st_dev, before.st_ino
266
+ if before.st_size > AUTHENTICATION_CONFIG_LIMIT_BYTES:
267
+ raise ValueError("Git authentication configuration exceeds the inspection size limit.")
268
+ with open(filename, "rb") as stream:
269
+ opened = os.fstat(stream.fileno())
270
+ if configuration_identity(opened) != configuration_identity(before):
271
+ raise ValueError("Git authentication configuration changed during inspection. Retry the operation.")
272
+ content = read_validated_size(stream, opened.st_size)
273
+ after = os.fstat(stream.fileno())
274
+ try:
275
+ path_after = os.stat(filename)
276
+ except FileNotFoundError:
277
+ path_after = None
278
+ if (len(content) != opened.st_size or configuration_identity(after) != configuration_identity(before)
279
+ or path_after is None or configuration_identity(path_after) != configuration_identity(before)):
280
+ raise ValueError("Git authentication configuration changed during inspection. Retry the operation.")
281
+ return before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, hashlib.sha256(content).digest()
282
+
283
+
284
+ @dataclass(frozen=True)
285
+ class RecursiveTransportConfiguration:
286
+ """Keep cleared key names bound to the configuration files inspected before transport."""
287
+
288
+ keys: tuple[str, ...]
289
+ signature_by_filename: dict[str, tuple | None]
290
+ environment_by_name: dict[str, str | None]
291
+
292
+ def verify(self) -> None:
293
+ """Refuse stale key discovery before a queued recursive command can contact a server."""
294
+ for name, expected in self.environment_by_name.items():
295
+ if os.environ.get(name) != expected:
296
+ raise ValueError("Git authentication environment changed. Retry the operation.")
297
+ for filename, expected in self.signature_by_filename.items():
298
+ if configuration_signature(filename) != expected:
299
+ raise ValueError("Git authentication configuration changed. Retry the operation.")
300
+
301
+
302
+ def isolated_http_arguments(command_endpoint: str) -> list[str]:
303
+ """Prevent inherited HTTP headers from authenticating as another selected account."""
304
+ # Git prefers the longest matching URL path and then its username. An exact
305
+ # command-URL reset therefore covers global, included and repository header lists.
306
+ return ["-c", "http.extraHeader=", "-c", "http." + command_endpoint + ".extraHeader="]
307
+
308
+
309
+ def account_namespace(provider: str, origin: str, account: str) -> str:
310
+ """Bind each managed credential namespace to its full logical account identity."""
311
+ return ACCOUNT_NAMESPACE_PREFIX + hashlib.sha256(json.dumps([provider, origin, account], ensure_ascii=False).encode("utf-8")).hexdigest()
312
+
313
+
314
+ def validate_text(value: object, *, allow_empty: bool = False) -> str:
315
+ """Reject protocol delimiters without altering exact external account text."""
316
+ if not isinstance(value, str) or not allow_empty and not value or any(ord(character) < 32 or ord(character) == 127 for character in value):
317
+ raise ValueError("Account and host fields must contain text without control characters")
318
+ return value
319
+
320
+
321
+ def endpoint_origin(endpoint: str) -> str:
322
+ """Keep the exact HTTPS host and port while excluding repository paths and userinfo."""
323
+ validate_text(endpoint)
324
+ parsed = urlsplit(endpoint)
325
+ if parsed.scheme != "https" or not parsed.hostname or parsed.password is not None or parsed.query or parsed.fragment:
326
+ raise ValueError("Enter an HTTPS URL without a password, query or fragment")
327
+ port = parsed.port
328
+ if port == 0:
329
+ raise ValueError("The HTTPS port must be positive")
330
+ return HTTPS_PREFIX + parsed.netloc.rsplit("@", 1)[-1]
331
+
332
+
333
+ def origin_address(origin: str) -> str:
334
+ """Return the service address shown to the user: host and port, without the scheme."""
335
+ return endpoint_origin(origin).removeprefix(HTTPS_PREFIX)
336
+
337
+
338
+ def check_repository_url(check_url: str, origin: str) -> str:
339
+ """Accept the repository used to test a username + password account, and return it without any credentials part.
340
+
341
+ It must be an HTTPS repository URL on exactly the account's address, so the typed secret is only ever sent to the
342
+ server it belongs to; a username written in the URL is dropped, since the test always uses the typed username.
343
+ """
344
+ validate_text(check_url, allow_empty=True)
345
+ address = origin_address(origin)
346
+ if not check_url:
347
+ raise ValueError(EMPTY_CHECK_URL_TEXT.format(address=address))
348
+ parsed = urlsplit(check_url)
349
+ if parsed.scheme != "https" or not parsed.hostname or parsed.password is not None or parsed.query or parsed.fragment or parsed.path in ("", "/"):
350
+ raise ValueError(CHECK_URL_FORMAT_TEXT.format(address=address))
351
+ if endpoint_origin(check_url) != origin:
352
+ raise ValueError(CHECK_URL_ADDRESS_TEXT.format(address=address))
353
+ return urlunsplit((parsed.scheme, parsed.netloc.rsplit("@", 1)[-1], parsed.path, "", ""))
354
+
355
+
356
+ def helper_command_text(helper_commands: tuple[str, ...], environment_by_name: dict[str, str] | None = None) -> str:
357
+ """Return the helper setting Git runs for the verified helper command: a quoted shell fragment starting with ``!``.
358
+
359
+ Git runs such a fragment through a shell, so each variable of ``environment_by_name`` is written as a ``NAME=value``
360
+ assignment in front of the command: it is given to that helper process only, never to Git or to another helper.
361
+ """
362
+ words = []
363
+ for name, value in (environment_by_name or {}).items():
364
+ words.append(name + "=" + shlex.quote(value))
365
+ commands = []
366
+ for command in helper_commands:
367
+ commands.append(command.replace("\\", "/") if os.name == "nt" else command)
368
+ # Git's helper interface is a shell fragment; quote only the captured, verified command.
369
+ words.append(shlex.join(commands))
370
+ return "!" + " ".join(words)
371
+
372
+
373
+ def gcm_setting_value_by_name(namespace: str) -> dict[str, str]:
374
+ """Return the Git Credential Manager settings of one account's stored secret, keyed by their Git configuration name.
375
+
376
+ The secret is read from GitPane's protected store, in the account's own namespace, without any prompt and without
377
+ tracing secrets; both the ``-c`` settings and the helper's environment are built from this one mapping.
378
+ """
379
+ return {"provider": MANAGED_CREDENTIAL_PROVIDER, "namespace": namespace, "credentialStore": MANAGED_CREDENTIAL_STORE,
380
+ "interactive": GCM_DISABLED_VALUE, "traceSecrets": GCM_DISABLED_VALUE}
381
+
382
+
383
+ def credential_configuration(origin: str, git_username: str, namespace: str, helper_commands: tuple[str, ...], scope: str | None = None) -> list[str]:
384
+ """Return the ``-c`` settings that make one Git child use only this helper, username and namespace for one address.
385
+
386
+ Every inherited helper is disabled first, prompts are off and secrets are never traced. With ``scope`` the settings
387
+ apply to that URL only instead of the whole address. An empty ``helper_commands`` names no helper: a preview.
388
+
389
+ Git chooses the helper with the URL it contacts, repository path included, but Git Credential Manager only matches
390
+ its own settings against the host, because Git does not send it the path; a setting scoped to a repository URL would
391
+ be invisible to it and it would look for the secret in its default namespace. The helper is therefore started with
392
+ those settings in its environment, which Git Credential Manager reads first. The same settings stay in the ``-c``
393
+ list, where they describe the destination to anyone comparing two previews.
394
+ """
395
+ helper_scope = scope or origin
396
+ setting_value_by_name = gcm_setting_value_by_name(namespace)
397
+ helper_command = ""
398
+ if helper_commands:
399
+ environment_by_name = {}
400
+ for name, value in setting_value_by_name.items():
401
+ environment_by_name[GCM_ENVIRONMENT_NAME_BY_SETTING[name]] = value
402
+ helper_command = helper_command_text(helper_commands, environment_by_name)
403
+ interactive = setting_value_by_name["interactive"]
404
+ trace_secrets = setting_value_by_name["traceSecrets"]
405
+ arguments = ["-c", "credential.helper=", "-c", f"credential.{helper_scope}.helper=", "-c", f"credential.{helper_scope}.helper=" + helper_command,
406
+ "-c", f"credential.{origin}.provider=" + setting_value_by_name["provider"], "-c", f"credential.{origin}.username={git_username}",
407
+ "-c", f"credential.{origin}.useHttpPath=false", "-c", f"credential.{origin}.namespace=" + setting_value_by_name["namespace"],
408
+ "-c", "credential.interactive=" + interactive, "-c", f"credential.{origin}.interactive=" + interactive,
409
+ "-c", "credential.traceSecrets=" + trace_secrets, "-c", f"credential.{origin}.traceSecrets=" + trace_secrets,
410
+ "-c", f"credential.{origin}.credentialStore=" + setting_value_by_name["credentialStore"]]
411
+ if scope is None:
412
+ return arguments
413
+ scoped_arguments = []
414
+ for argument in arguments:
415
+ scoped_arguments.append(argument.replace("credential." + origin + ".", "credential." + scope + "."))
416
+ return scoped_arguments
417
+
418
+
419
+ @dataclass(frozen=True)
420
+ class AccountSelection:
421
+ """Keep a verified logical account distinct from the username used by Git."""
422
+
423
+ provider: str
424
+ origin: str
425
+ account: str
426
+ git_username: str
427
+ api_identity: str
428
+ label: str
429
+ namespace: str
430
+
431
+ def __post_init__(self) -> None:
432
+ """Reject edited selectors that could address an unrelated credential-store namespace."""
433
+ for value in asdict(self).values():
434
+ validate_text(value)
435
+ if self.provider not in ACCOUNT_PROVIDER_SET or endpoint_origin(self.origin) != self.origin or self.namespace != account_namespace(self.provider, self.origin, self.account):
436
+ raise ValueError("The account selector is invalid. Sign in again.")
437
+
438
+ def command_url(self, endpoint: str) -> str:
439
+ """Apply the selected Git username to an ephemeral URL while preserving the displayed URL."""
440
+ if endpoint_origin(endpoint) != self.origin:
441
+ raise ValueError("The account belongs to a different service endpoint")
442
+ parsed = urlsplit(endpoint)
443
+ return urlunsplit((parsed.scheme, quote(self.git_username, safe="") + "@" + parsed.netloc.rsplit("@", 1)[-1], parsed.path, "", ""))
444
+
445
+ def credential_arguments(self, helper_commands: tuple[str, ...], endpoint: str | None = None) -> list[str]:
446
+ """Override inherited helpers and account hints only for the intended Git child; an empty ``helper_commands`` gives a preview."""
447
+ self.command_url(endpoint or self.origin)
448
+ return credential_configuration(self.origin, self.git_username, self.namespace, helper_commands, endpoint)
449
+
450
+
451
+ def generic_account(origin: str, username: str) -> AccountSelection:
452
+ """Build the selector of a username + password account: the typed username is its account, Git username, identity and label.
453
+
454
+ Its namespace is derived like any other account's, so two usernames of one server never share a stored secret.
455
+ """
456
+ validate_text(username)
457
+ origin = endpoint_origin(origin)
458
+ return AccountSelection(GENERIC_PROVIDER, origin, username, username, username, username, account_namespace(GENERIC_PROVIDER, origin, username))
459
+
460
+
461
+ def transfer_account(selection: AccountSelection | None, endpoint: str = "", missing_namespace_set: set[str] | frozenset[str] = frozenset()) -> TransferAccount | None:
462
+ """Describe the address and account a transfer used, so its failure can name them without carrying any secret.
463
+
464
+ With an account, its own address and label are used, and it needs a reconnect when its namespace is in
465
+ ``missing_namespace_set``, the accounts whose secret a check found gone. Without one, an HTTPS ``endpoint`` still
466
+ gives the address (with no account), so the failure can say none is connected there; any other endpoint (SSH,
467
+ local path) gives None.
468
+ """
469
+ if selection is not None:
470
+ return TransferAccount(urlsplit(endpoint_origin(selection.origin)).netloc, selection.label, selection.namespace in missing_namespace_set)
471
+ try:
472
+ origin = endpoint_origin(endpoint)
473
+ except ValueError:
474
+ return None
475
+ return TransferAccount(urlsplit(origin).netloc)
476
+
477
+
478
+ @dataclass(frozen=True)
479
+ class RecursiveTransportContext:
480
+ """Bind a confirmed account to one effective Git destination without carrying a secret."""
481
+
482
+ endpoint: str
483
+ origin: str
484
+ selection: AccountSelection | None = None
485
+ repository: RepositoryContext | None = None
486
+
487
+ def __post_init__(self) -> None:
488
+ """Refuse selectors that refer to another destination before preparing transport."""
489
+ if endpoint_origin(self.endpoint) != self.origin:
490
+ raise ValueError("The recursive destination origin changed. Read the repository again.")
491
+ if self.selection is not None:
492
+ self.selection.command_url(self.endpoint)
493
+
494
+
495
+ ACCOUNT_FIELD_SET = frozenset(account_field.name for account_field in fields(AccountSelection))
496
+
497
+
498
+ def validate_service_names(service_name_by_origin: object) -> None:
499
+ """Accept only non-empty display names keyed by an exact HTTPS service address."""
500
+ if not isinstance(service_name_by_origin, dict):
501
+ raise TypeError("Unreadable service names")
502
+ for origin, service_name in service_name_by_origin.items():
503
+ validate_text(origin)
504
+ validate_text(service_name)
505
+ if endpoint_origin(origin) != origin:
506
+ raise ValueError("Unreadable service address")
507
+
508
+
509
+ def token_page_url(provider: str, origin: str) -> str:
510
+ """Return the page of this server where a user of a recognised provider creates a personal access token, or an empty text for any other server."""
511
+ path = TOKEN_PAGE_PATH_BY_PROVIDER.get(provider)
512
+ if path is None:
513
+ return ""
514
+ return endpoint_origin(origin) + path
515
+
516
+
517
+ def provider_for_browser_origin(origin: str) -> str | None:
518
+ """Name the provider whose browser sign-in is offered for exactly this address (github.com, gitlab.com or bitbucket.org), else None."""
519
+ for provider, host in PROVIDER_HOST_BY_NAME.items():
520
+ if origin == HTTPS_PREFIX + host:
521
+ return provider
522
+ return None
523
+
524
+
525
+ def recorded_sign_in_method(word: object) -> SignInMethod:
526
+ """Return the recorded sign-in method a saved word names, raising ValueError for a word that names none or one that is never recorded."""
527
+ try:
528
+ method = SignInMethod(word)
529
+ except ValueError as error:
530
+ raise ValueError("Unreadable account sign-in method") from error
531
+ if method not in RECORDED_SIGN_IN_METHOD_SET:
532
+ raise ValueError("Unreadable account sign-in method")
533
+ return method
534
+
535
+
536
+ def read_sign_in_method_json(value: object) -> dict[str, SignInMethod]:
537
+ """Read the saved JSON object mapping GitPane account namespaces to a recorded sign-in method, raising TypeError or ValueError when it is not one."""
538
+ if not isinstance(value, str):
539
+ raise TypeError("Unreadable account sign-in methods")
540
+ word_by_namespace = json.loads(value)
541
+ if not isinstance(word_by_namespace, dict):
542
+ raise TypeError("Unreadable account sign-in methods")
543
+ method_by_namespace = {}
544
+ for namespace, word in word_by_namespace.items():
545
+ validate_text(namespace)
546
+ if not namespace.startswith(ACCOUNT_NAMESPACE_PREFIX):
547
+ raise ValueError("Unreadable account sign-in method")
548
+ method_by_namespace[namespace] = recorded_sign_in_method(word)
549
+ return method_by_namespace
550
+
551
+
552
+ def validate_sign_in_method_json(value: object) -> str:
553
+ """Accept only a JSON object mapping GitPane account namespaces to a recorded sign-in method, and keep its text as saved."""
554
+ read_sign_in_method_json(value)
555
+ return value
556
+
557
+
558
+ def sign_in_method_json(method_by_namespace: dict[str, SignInMethod]) -> str:
559
+ """Write the sign-in methods as the JSON the preferences file holds: each method as its recorded word, which every system and older GitPane versions read."""
560
+ word_by_namespace = {}
561
+ for namespace, method in method_by_namespace.items():
562
+ word_by_namespace[namespace] = method.value
563
+ return json.dumps(word_by_namespace, ensure_ascii=False)
564
+
565
+
566
+ def exchange_sign_in_method(provider: str, action: AuthenticationAction, token: str) -> SignInMethod:
567
+ """Name how an add or reconnect exchange signs in: a password for a username + password account, else the browser or a typed token."""
568
+ if provider == GENERIC_PROVIDER:
569
+ return SignInMethod.PASSWORD
570
+ if is_browser_sign_in(action, provider, token):
571
+ return SignInMethod.BROWSER
572
+ return SignInMethod.TOKEN
573
+
574
+
575
+ def account_sign_in_method(selection: AccountSelection, recorded_method_by_namespace: dict[str, SignInMethod]) -> SignInMethod:
576
+ """Tell how a listed account signs in: its recorded method, else what its own fields show.
577
+
578
+ A username + password account is always one. GitLab and Bitbucket give a browser sign-in its own Git username, so
579
+ any other of their accounts used a token. An old GitHub account without record can be either.
580
+ """
581
+ if selection.provider == GENERIC_PROVIDER:
582
+ return SignInMethod.PASSWORD
583
+ recorded_method = recorded_method_by_namespace.get(selection.namespace)
584
+ if recorded_method is not None:
585
+ return recorded_method
586
+ if selection.git_username in BROWSER_GIT_USERNAME_SET:
587
+ return SignInMethod.BROWSER
588
+ if selection.provider in (GITLAB_PROVIDER, BITBUCKET_PROVIDER):
589
+ return SignInMethod.TOKEN
590
+ return SignInMethod.BROWSER_OR_TOKEN
591
+
592
+
593
+ def validate_selection_json(value: object) -> str:
594
+ """Allow only nonsecret account records, scope selectors and service names in persisted JSON."""
595
+ if not isinstance(value, str):
596
+ raise TypeError("Unreadable authentication preferences")
597
+ data_by_key = json.loads(value)
598
+ if not isinstance(data_by_key, dict) or set(data_by_key) - {SERVICE_NAME_KEY} != ACCOUNT_DATA_KEY_SET:
599
+ raise ValueError("Unreadable authentication preferences")
600
+ validate_service_names(data_by_key.get(SERVICE_NAME_KEY, {}))
601
+ if not isinstance(data_by_key["accounts"], list):
602
+ raise TypeError("Unreadable account list")
603
+ for account_by_key in data_by_key["accounts"]:
604
+ if not isinstance(account_by_key, dict) or set(account_by_key) != ACCOUNT_FIELD_SET:
605
+ raise ValueError("Authentication preferences may contain only account selectors")
606
+ for text in account_by_key.values():
607
+ validate_text(text)
608
+ if account_by_key["provider"] not in ACCOUNT_PROVIDER_SET or endpoint_origin(account_by_key["origin"]) != account_by_key["origin"]:
609
+ raise ValueError("Unreadable account endpoint")
610
+ AccountSelection(**account_by_key)
611
+ for scope in ("global", "repository"):
612
+ selection_by_key = data_by_key[scope]
613
+ if not isinstance(selection_by_key, dict):
614
+ raise TypeError("Unreadable account scope")
615
+ for key, selector in selection_by_key.items():
616
+ validate_text(key)
617
+ validate_text(selector)
618
+ return value
619
+
620
+
621
+ @dataclass(frozen=True)
622
+ class AuthOutcome:
623
+ """Report verified identity and credential-store success independently.
624
+
625
+ ``refused_before_start`` is set when the exchange ended before any worker or helper ran, so nothing was stored or
626
+ erased; a removal result still carries its account then, and the Accounts page says the account was not removed
627
+ without claiming an erase failed. ``credential_manager_refusal`` is set when that early end came from a refused
628
+ credential manager, so the window explaining how to install it can open. ``refused_by_gitpane`` is set when the
629
+ exchange ran but GitPane refused the account it returned, such as a Reconnect signed in as another account: the cause
630
+ is GitPane's own sentence, not a program's output.
631
+ """
632
+
633
+ operation_id: str
634
+ status: OperationStatus
635
+ selection: AccountSelection | None = None
636
+ authenticated: bool = False
637
+ stored: bool = False
638
+ diagnostic: PreparedDiagnostic | None = None
639
+ renewed: bool = False
640
+ became_default: bool = False
641
+ removal: AccountRemoval | None = None
642
+ check: AccountCheck | None = None
643
+ refused_before_start: bool = False
644
+ credential_manager_refusal: CredentialManagerDetection | None = None
645
+ refused_by_gitpane: bool = False
646
+
647
+
648
+ def store_was_refused(outcome: AuthOutcome) -> bool:
649
+ """Tell whether the step that failed is the secure store saving the account, read on the fields of the result only.
650
+
651
+ It is so when nothing was stored after the service accepted the sign-in, or after a username + password account
652
+ passed its Git test. A refusal before any program ran, an account GitPane itself refused, or a failed Git test is
653
+ another step. The output of the credential manager is never read for this: it names the secure store whatever the
654
+ failure is.
655
+ """
656
+ if outcome.stored or outcome.refused_before_start or outcome.refused_by_gitpane or outcome.check is not None:
657
+ return False
658
+ if outcome.selection is not None and outcome.selection.provider == GENERIC_PROVIDER:
659
+ return True
660
+ return outcome.authenticated
661
+
662
+
663
+ @dataclass(frozen=True)
664
+ class AccountCheck:
665
+ """Tell what Git said when the test of a username + password on a repository of its server failed.
666
+
667
+ ``git_output`` is Git's standard error as a form shows it, without any secret or URL credentials; ``url`` is the
668
+ tested repository, also without credentials. After an authentication refusal, ``server_provider`` names the
669
+ provider the server was recognised as (GitHub or GitLab), ``browser_provider`` the provider whose browser sign-in
670
+ that server offers, and ``token_page_url`` the page where a personal access token is created there; each is empty
671
+ when unknown.
672
+ """
673
+
674
+ git_output: str
675
+ url: str = ""
676
+ server_provider: str = ""
677
+ browser_provider: str = ""
678
+ token_page_url: str = ""
679
+
680
+
681
+ def account_check(value: object) -> AccountCheck | None:
682
+ """Read the nonsecret test result a worker published, refusing anything but text.
683
+
684
+ A recognised provider must be one a server can be recognised as, its browser sign-in can only be that provider's,
685
+ and the token page must be exactly the one of that provider on the tested server, so a worker result can never
686
+ point the user to another address.
687
+ """
688
+ if value is None:
689
+ return None
690
+ if not isinstance(value, dict) or not isinstance(value.get("git_output"), str):
691
+ raise TypeError("The credential helper returned an invalid test result")
692
+ url = validate_text(value.get("url", ""), allow_empty=True)
693
+ server_provider = validate_text(value.get("server_provider", ""), allow_empty=True)
694
+ browser_provider = validate_text(value.get("browser_provider", ""), allow_empty=True)
695
+ page_url = validate_text(value.get("token_page_url", ""), allow_empty=True)
696
+ if server_provider and (server_provider not in TOKEN_PAGE_PATH_BY_PROVIDER or not url):
697
+ raise ValueError("The credential helper returned an unknown server provider")
698
+ if browser_provider not in ("", server_provider):
699
+ raise ValueError("The credential helper returned an unknown browser sign-in")
700
+ if page_url and page_url != token_page_url(server_provider, url):
701
+ raise ValueError("The credential helper returned a token page on another address")
702
+ return AccountCheck(value["git_output"], url, server_provider, browser_provider, page_url)
703
+
704
+
705
+ @dataclass(frozen=True)
706
+ class AccountRemoval:
707
+ """Tell what a removed account leaves to choose again.
708
+
709
+ ``default_cleared`` says the account was its address's Global default, now cleared; ``repository_choice_count`` counts
710
+ the repositories that chose it explicitly, whose choices are kept so they ask for another account instead of silently
711
+ using a different one; ``address_has_account`` says another account of the same address is still listed.
712
+ """
713
+
714
+ default_cleared: bool
715
+ repository_choice_count: int
716
+ address_has_account: bool
717
+
718
+
719
+ @dataclass(frozen=True)
720
+ class AccountCatalogue:
721
+ """Hold the nonsecret accounts, the default of each service address and one repository's explicit choices, read together in one read.
722
+
723
+ ``needs_reconnect_namespace_set`` names the listed accounts whose secret a check of the system credential store
724
+ found gone: such an account must be reconnected before Git can use it again. ``sign_in_method_by_namespace`` tells
725
+ how each listed account signs in (see ``account_sign_in_method``).
726
+ """
727
+
728
+ accounts: tuple[AccountSelection, ...]
729
+ default_namespace_by_origin: dict[str, str]
730
+ repository_namespace_by_origin: dict[str, str]
731
+ service_name_by_origin: dict[str, str] = field(default_factory=dict)
732
+ needs_reconnect_namespace_set: frozenset[str] = frozenset()
733
+ sign_in_method_by_namespace: dict[str, SignInMethod] = field(default_factory=dict)
734
+
735
+
736
+ @dataclass(frozen=True)
737
+ class SecretLookup:
738
+ """Tell, for each account a check of the system credential store covered, whether its secret is still there.
739
+
740
+ It never carries a secret: only the namespaces of the accounts, sorted into present, missing and unknown.
741
+ """
742
+
743
+ missing_namespace_set: frozenset[str]
744
+ present_namespace_set: frozenset[str]
745
+ unknown_namespace_set: frozenset[str]
746
+
747
+
748
+ @dataclass(frozen=True)
749
+ class AccountListChange:
750
+ """Tell whether a remembered account was already listed and whether it became its address's default."""
751
+
752
+ renewed: bool
753
+ became_default: bool
754
+
755
+
756
+ def check_copy_erased(output: bytes | bytearray) -> bool:
757
+ """Tell whether an account test's worker reported, in its private result, that it erased the temporary copy of the tested secret.
758
+
759
+ An unreadable result, or one that does not say so, counts as not erased: the copy may still be in the secure store.
760
+ """
761
+ try:
762
+ value = json.loads(output) if output else None
763
+ except ValueError:
764
+ return False
765
+ return isinstance(value, dict) and value.get(CHECK_COPY_ERASED_KEY) is True
766
+
767
+
768
+ def authentication_worker_filename() -> str:
769
+ """Answer the absolute file of the account worker, as imported from the installation; a module that cannot be found or is not valid Python, or a missing file, is a launch error.
770
+
771
+ The account exchange ends with its own failure on a launch error, so a worker that cannot be found, or that Python
772
+ cannot compile, never leaves it waiting.
773
+ """
774
+ # Imported here, not at the top: the worker imports this module at its top, and only an account exchange needs it.
775
+ try:
776
+ import gitpane.account.authentication_worker
777
+ except (ImportError, SyntaxError) as error:
778
+ raise OSError("The account worker is not available: " + str(error)) from error
779
+
780
+ worker_filename = os.path.abspath(gitpane.account.authentication_worker.__file__)
781
+ if not os.path.isfile(worker_filename):
782
+ raise OSError("The account worker is not available: " + worker_filename)
783
+ return worker_filename
784
+
785
+
786
+ def account_worker_command(worker_filename: str) -> tuple[str, list[str]]:
787
+ """Answer the program and arguments that start the account worker of ``worker_filename`` on the Python GitPane runs on.
788
+
789
+ The Python variables the interpreter reads itself and the folders the worker starts from stay out of its import
790
+ path, while the user's site-packages folder, where pip may have installed GitPane, stays in it as long as the
791
+ running GitPane has it, and stays out when the running GitPane runs without it. GitPane and the test that
792
+ launches the worker both take the command from here, so the test runs what GitPane runs. A GitPane started without
793
+ a console runs on the windowless interpreter, so the worker runs on the console interpreter beside it: the worker
794
+ starts the credential manager and Git, which would each open a visible console window under a windowless worker. An
795
+ interpreter that is missing is a launch error: another Python is never searched for.
796
+ """
797
+ interpreter_filename = console_interpreter_filename(sys.executable)
798
+ if not os.path.isfile(interpreter_filename):
799
+ raise OSError("The account worker interpreter is not available: " + interpreter_filename)
800
+ return interpreter_filename, [*application_module_interpreter_options(bool(sys.flags.no_user_site)), worker_filename]
801
+
802
+
803
+ def breakaway_process_group() -> NativeProcessGroup:
804
+ """Create the process group of a browser sign-in worker: a member may leave it only by asking explicitly when it starts a process.
805
+
806
+ Only the worker asks, for the helper of a browser sign-in, which it then holds in a job of its own. Every other
807
+ worker keeps a closed group: Git's shell asks to leave any group that allows it for the programs it starts, so a
808
+ helper run by Git during an account test would otherwise outlive a Cancel or the closing of GitPane.
809
+ """
810
+ return NativeProcessGroup(breakaway_allowed=True)
811
+
812
+
813
+ def is_browser_sign_in(action: AuthenticationAction, provider: str, token: str) -> bool:
814
+ """Tell whether an exchange waits for the browser: a new service account or a Reconnect without a typed token."""
815
+ return action in REMEMBERED_ACTION_SET and provider != GENERIC_PROVIDER and not token
816
+
817
+
818
+ @dataclass
819
+ class _AuthenticationRun:
820
+ """Retain private output and the captured callback until descendants have stopped."""
821
+
822
+ callback: Callable[[AuthOutcome], None]
823
+ output: bytearray
824
+ is_current: Callable[[], bool] | None = None
825
+ cancelled: bool = False
826
+ action: AuthenticationAction | None = None
827
+ service_name: str = ""
828
+ sign_in_method: SignInMethod | None = None
829
+ selection: AccountSelection | None = None
830
+ check_selection: AccountSelection | None = None
831
+ # A check of the stored secrets: who gets its result, and each account's list generation when it started.
832
+ lookup_callback: Callable[[SecretLookup], None] | None = None
833
+ lookup_generation_by_namespace: dict[str, int] = field(default_factory=dict)
834
+ # An erase the service started itself, for a secret an exchange left behind: it runs on while GitPane closes.
835
+ cleanup: bool = False
836
+
837
+
838
+ def lookup_request(selection: AccountSelection) -> dict[str, str]:
839
+ """Describe one account to a check of its stored secret with the nonsecret fields that check needs, and nothing else."""
840
+ account_by_key = asdict(selection)
841
+ request_by_key = {}
842
+ for name in SECRET_LOOKUP_FIELDS:
843
+ request_by_key[name] = account_by_key[name]
844
+ return request_by_key
845
+
846
+
847
+ def lookup_result(value: object, requested_namespace_set: frozenset[str]) -> SecretLookup:
848
+ """Read the classification a secret check published, keeping only the accounts it was asked about.
849
+
850
+ An account the answer does not classify, or classifies twice, is unknown, so a malformed answer can never mark an
851
+ account as needing a reconnect.
852
+ """
853
+ namespace_set_by_presence: dict[SecretPresence, set[str]] = {}
854
+ for presence in SecretPresence:
855
+ namespace_set_by_presence[presence] = set()
856
+ if isinstance(value, dict) and value.get("status") == OperationStatus.SUCCESS.value:
857
+ for presence in SecretPresence:
858
+ namespaces = value.get(presence.value, [])
859
+ if not isinstance(namespaces, list):
860
+ continue
861
+ for namespace in namespaces:
862
+ if isinstance(namespace, str) and namespace in requested_namespace_set:
863
+ namespace_set_by_presence[presence].add(namespace)
864
+ classified_namespace_set: set[str] = set()
865
+ ambiguous_namespace_set: set[str] = set()
866
+ for presence in SecretPresence:
867
+ ambiguous_namespace_set |= classified_namespace_set & namespace_set_by_presence[presence]
868
+ classified_namespace_set |= namespace_set_by_presence[presence]
869
+ missing_namespace_set = namespace_set_by_presence[SecretPresence.MISSING] - ambiguous_namespace_set
870
+ present_namespace_set = namespace_set_by_presence[SecretPresence.PRESENT] - ambiguous_namespace_set
871
+ unknown_namespace_set = requested_namespace_set - missing_namespace_set - present_namespace_set
872
+ return SecretLookup(frozenset(missing_namespace_set), frozenset(present_namespace_set), frozenset(unknown_namespace_set))
873
+
874
+
875
+ def no_git_filename() -> str:
876
+ """Answer that no Git is known: the search of the credential manager then reads only the PATH and the fixed locations."""
877
+ return ""
878
+
879
+
880
+ class AuthenticationService(QObject):
881
+ """Share the account exchanges with the Git Credential Manager found for Git, and publish only observed, nonsecret account choices."""
882
+
883
+ idle = Signal()
884
+ # A search of the credential manager ended on the service's own thread: its request id and its finished future.
885
+ credential_helper_resolved = Signal(str, object)
886
+
887
+ def __init__(self, preference: PreferenceStore, parent: QObject | None = None,
888
+ *, credential_manager: CredentialManagerLocator, helper_commands: tuple[str, ...] | None = None, validation_origin: str | None = None, timeout_ms: int = AUTHENTICATION_TIMEOUT_MS,
889
+ check_timeout_ms: int = ACCOUNT_CHECK_TIMEOUT_MS,
890
+ browser_timeout_ms: int = BROWSER_SIGN_IN_TIMEOUT_MS, lookup_timeout_ms: int = SECRET_LOOKUP_TIMEOUT_MS,
891
+ erase_timeout_ms: int = SECRET_ERASE_TIMEOUT_MS,
892
+ current_git_filename: Callable[[], str] = no_git_filename) -> None:
893
+ """Inject the search of the credential manager, and for isolated tests a helper and a controlled endpoint.
894
+
895
+ ``credential_manager`` finds the Git Credential Manager each operation hands to Git and writes its install
896
+ lines; it is always given, since only the composition root knows the session's distribution and processor,
897
+ which those lines depend on. A non-empty ``helper_commands`` is used as it is by every exchange and every
898
+ operation, without any search. ``current_git_filename`` names the Git whose credential manager an account
899
+ exchange uses when the exchange itself names none (checks, removals, erases).
900
+
901
+ ``check_timeout_ms`` bounds the Git test of a username + password account before it is saved, and
902
+ ``browser_timeout_ms`` the wait for the browser during a browser sign-in; the sign-in window counts that wait down.
903
+ ``lookup_timeout_ms`` bounds a whole check of the stored secrets, whatever the number of accounts it covers, and
904
+ ``erase_timeout_ms`` the erase of a secret an exchange left behind.
905
+ Only the process group of a browser sign-in worker lets its helper leave, so that the browser it opens is
906
+ never held, nor stopped, with the group; every other worker, the Git test of an account included, stays in a
907
+ closed group that stops all it started.
908
+
909
+ ``missing_secret_namespace_set`` holds, in memory only, the accounts whose secret a check found gone; it is
910
+ found again by the next check and never saved. Each account's list generation grows with every successful
911
+ save or removal of that account, so a check started before a Reconnect cannot mark the renewed account.
912
+ """
913
+ super().__init__(parent)
914
+ self.preference = preference
915
+ self.helper_commands = helper_commands
916
+ self.credential_manager = credential_manager
917
+ self.validation_origin = validation_origin
918
+ self.timeout_ms = timeout_ms
919
+ self.check_timeout_ms = check_timeout_ms
920
+ self.browser_timeout_ms = browser_timeout_ms
921
+ self.lookup_timeout_ms = lookup_timeout_ms
922
+ self.erase_timeout_ms = erase_timeout_ms
923
+ self.missing_secret_namespace_set: set[str] = set()
924
+ self.account_generation_by_namespace: dict[str, int] = {}
925
+ self.runner = ProcessRunner(self, credential_consumer=self._consume)
926
+ self.runner.finished.connect(self._finished)
927
+ self.runner.idle.connect(self.idle)
928
+ self.active_by_id: dict[str, _AuthenticationRun] = {}
929
+ self.outcome_by_id: dict[str, AuthOutcome] = {}
930
+ self.closed = False
931
+ self.current_git_filename = current_git_filename
932
+ # The search may start short processes and wait for them, so it never runs on the window's thread; its result
933
+ # comes back there through a queued signal. Like the file executor of the worktree service, it is never shut
934
+ # down explicitly: a search still running when GitPane closes ends by itself and starts nothing.
935
+ self.helper_executor = ThreadPoolExecutor(max_workers=CREDENTIAL_HELPER_WORKER_COUNT, thread_name_prefix="gitpane-credential-manager")
936
+ # One table for both kinds of answer: the helper an exchange waits for, and the bare detection Settings and the
937
+ # install window ask for. Each request keeps the continuation of its own kind under its own ID.
938
+ self.helper_continuation_by_id: dict[str, Callable[[CredentialHelper | None, Exception | None], None] | Callable[[CredentialManagerDetection | None, Exception | None], None]] = {}
939
+ self.credential_helper_resolved.connect(self._credential_helper_resolved, Qt.ConnectionType.QueuedConnection)
940
+
941
+ def accounts(self, origin: str, provider: str) -> list[AccountSelection]:
942
+ """List the connected accounts of exactly this provider and service endpoint, as saved in the durable list."""
943
+ accounts = []
944
+ for data_by_key in self._read()["accounts"]:
945
+ selection = AccountSelection(**data_by_key)
946
+ if selection.origin == origin and selection.provider == provider:
947
+ accounts.append(selection)
948
+ return sorted(accounts, key=lambda selection: (selection.label, selection.account))
949
+
950
+ def account_for_endpoint(self, endpoint: str, context: RepositoryContext | None = None) -> AccountSelection | None:
951
+ """Resolve repository override before service default without substituting a missing account."""
952
+ origin = endpoint_origin(endpoint)
953
+ data_by_key = self._read()
954
+ namespace = data_by_key["repository"].get(self._scope_key(origin, context)) if context is not None else None
955
+ namespace = namespace if namespace is not None else data_by_key["global"].get(origin)
956
+ if namespace is None:
957
+ return None
958
+ for account_by_key in data_by_key["accounts"]:
959
+ if account_by_key["namespace"] == namespace and account_by_key["origin"] == origin:
960
+ return AccountSelection(**account_by_key)
961
+ raise ValueError("The selected account is absent. Sign in again or choose an account explicitly.")
962
+
963
+ def catalogue(self, context: RepositoryContext | None = None) -> AccountCatalogue:
964
+ """Read every connected account, the service defaults and, with a repository, its explicit choices in one read, without writing."""
965
+ data_by_key = self._read()
966
+ accounts = []
967
+ for account_by_key in data_by_key["accounts"]:
968
+ accounts.append(AccountSelection(**account_by_key))
969
+ accounts.sort(key=lambda selection: (selection.origin, selection.label, selection.account))
970
+ repository_namespace_by_origin: dict[str, str] = {}
971
+ if context is not None:
972
+ repository_dir = canonical_path(context.common_git_dir)
973
+ for scope_key, namespace in data_by_key["repository"].items():
974
+ scope = self._decode_scope_key(scope_key)
975
+ if scope is None:
976
+ continue
977
+ scope_dir, origin = scope
978
+ if scope_dir == repository_dir:
979
+ repository_namespace_by_origin[origin] = namespace
980
+ needs_reconnect_namespace_set = set()
981
+ for selection in accounts:
982
+ if selection.namespace in self.missing_secret_namespace_set:
983
+ needs_reconnect_namespace_set.add(selection.namespace)
984
+ recorded_method_by_namespace = self._read_sign_in_methods()
985
+ sign_in_method_by_namespace = {}
986
+ for selection in accounts:
987
+ sign_in_method_by_namespace[selection.namespace] = account_sign_in_method(selection, recorded_method_by_namespace)
988
+ return AccountCatalogue(tuple(accounts), dict(data_by_key["global"]), repository_namespace_by_origin, dict(data_by_key.get(SERVICE_NAME_KEY, {})),
989
+ frozenset(needs_reconnect_namespace_set), sign_in_method_by_namespace)
990
+
991
+ def remember_account(self, selection: AccountSelection, service_name: str = "", *, sign_in_method: SignInMethod | None = None) -> AccountListChange:
992
+ """Add or refresh a signed-in account in the durable list; the first new account of an address without default becomes its default.
993
+
994
+ Everything is written at once, so a failed write leaves the stored list unchanged. A default that names an account missing
995
+ from the list still counts as a default and is not replaced here: the user replaces it explicitly. A non-empty
996
+ ``service_name`` becomes the name of the account's address in that same write; an empty one keeps the current name.
997
+ A ``sign_in_method`` records how the account signed in, also in that write; None keeps the record.
998
+ """
999
+ validate_text(service_name, allow_empty=True)
1000
+ if sign_in_method is not None and sign_in_method not in RECORDED_SIGN_IN_METHOD_SET:
1001
+ raise ValueError("Unknown sign-in method")
1002
+ data_by_key = self._read_for_write()
1003
+ renewed = False
1004
+ retained_accounts = []
1005
+ for account_by_key in data_by_key["accounts"]:
1006
+ if account_by_key["namespace"] == selection.namespace:
1007
+ renewed = True
1008
+ continue
1009
+ retained_accounts.append(account_by_key)
1010
+ retained_accounts.append(asdict(selection))
1011
+ data_by_key["accounts"] = retained_accounts
1012
+ became_default = not renewed and selection.origin not in data_by_key["global"]
1013
+ if became_default:
1014
+ data_by_key["global"][selection.origin] = selection.namespace
1015
+ if service_name:
1016
+ data_by_key.setdefault(SERVICE_NAME_KEY, {})[selection.origin] = service_name
1017
+ value_by_key = {AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)}
1018
+ if sign_in_method is not None:
1019
+ method_by_namespace = self._read_sign_in_methods(for_write=True)
1020
+ method_by_namespace[selection.namespace] = sign_in_method
1021
+ value_by_key[ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY] = sign_in_method_json(method_by_namespace)
1022
+ self.preference.update(value_by_key)
1023
+ self._account_changed(selection.namespace)
1024
+ return AccountListChange(renewed, became_default)
1025
+
1026
+ def forget_account(self, selection: AccountSelection) -> AccountRemoval:
1027
+ """Take an account off the durable list, once its stored secret is erased, and clear the Global default that named it.
1028
+
1029
+ Repository choices naming it are kept on purpose: such a repository then asks for another account and its
1030
+ transport refuses, instead of silently using the Global default, which may be someone else; adding the same
1031
+ account again makes those choices valid again. The name of the address goes with its last account. Everything
1032
+ is written at once, and nothing is written when nothing changes, so calling it again is harmless.
1033
+ """
1034
+ data_by_key = self._read_for_write()
1035
+ changed = False
1036
+ retained_accounts = []
1037
+ for account_by_key in data_by_key["accounts"]:
1038
+ if account_by_key["namespace"] == selection.namespace:
1039
+ changed = True
1040
+ continue
1041
+ retained_accounts.append(account_by_key)
1042
+ data_by_key["accounts"] = retained_accounts
1043
+ default_cleared = data_by_key["global"].get(selection.origin) == selection.namespace
1044
+ if default_cleared:
1045
+ del data_by_key["global"][selection.origin]
1046
+ changed = True
1047
+ repository_choice_count = 0
1048
+ for namespace in data_by_key["repository"].values():
1049
+ if namespace == selection.namespace:
1050
+ repository_choice_count += 1
1051
+ address_has_account = False
1052
+ for account_by_key in retained_accounts:
1053
+ if account_by_key["origin"] == selection.origin:
1054
+ address_has_account = True
1055
+ service_name_by_origin = data_by_key.get(SERVICE_NAME_KEY, {})
1056
+ if not address_has_account and selection.origin in service_name_by_origin:
1057
+ del service_name_by_origin[selection.origin]
1058
+ changed = True
1059
+ value_by_key = {}
1060
+ if changed:
1061
+ value_by_key[AUTHENTICATION_PREFERENCE_KEY] = json.dumps(data_by_key, ensure_ascii=False)
1062
+ method_by_namespace = self._read_sign_in_methods(for_write=True)
1063
+ if method_by_namespace.pop(selection.namespace, None) is not None:
1064
+ value_by_key[ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY] = sign_in_method_json(method_by_namespace)
1065
+ if value_by_key:
1066
+ self.preference.update(value_by_key)
1067
+ self._account_changed(selection.namespace)
1068
+ return AccountRemoval(default_cleared, repository_choice_count, address_has_account)
1069
+
1070
+ def _account_changed(self, namespace: str) -> None:
1071
+ """Forget that this account needed a reconnect and make any check started before this save or removal stale for it."""
1072
+ self.account_generation_by_namespace[namespace] = self.account_generation_by_namespace.get(namespace, 0) + 1
1073
+ self.missing_secret_namespace_set.discard(namespace)
1074
+
1075
+ def save_global_defaults(self, namespace_by_origin: dict[str, str]) -> None:
1076
+ """Write only the service defaults the user changed, keeping every other default and all repository choices.
1077
+
1078
+ Every value is checked before anything changes and all are written at once, so a refused value or a failed write
1079
+ leaves the saved data as it was. A default can only be replaced by another account of the same address, never removed.
1080
+ """
1081
+ data_by_key = self._read_for_write()
1082
+ origins = sorted(namespace_by_origin)
1083
+ for origin in origins:
1084
+ self._require_connected_account(data_by_key, origin, namespace_by_origin[origin])
1085
+ for origin in origins:
1086
+ data_by_key["global"][origin] = namespace_by_origin[origin]
1087
+ self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
1088
+
1089
+ def save_repository_choices(self, namespace_by_origin: dict[str, str | None], context: RepositoryContext) -> None:
1090
+ """Write only this repository's changed rows; None removes the explicit choice so the row follows the global default.
1091
+
1092
+ Choices of other repositories and every default stay as saved. Nothing is written when no row changed.
1093
+ """
1094
+ if not namespace_by_origin:
1095
+ return
1096
+ data_by_key = self._read_for_write()
1097
+ origins = sorted(namespace_by_origin)
1098
+ for origin in origins:
1099
+ namespace = namespace_by_origin[origin]
1100
+ if namespace is not None:
1101
+ self._require_connected_account(data_by_key, origin, namespace)
1102
+ for origin in origins:
1103
+ namespace = namespace_by_origin[origin]
1104
+ scope_key = self._scope_key(origin, context)
1105
+ if namespace is None:
1106
+ data_by_key["repository"].pop(scope_key, None)
1107
+ else:
1108
+ data_by_key["repository"][scope_key] = namespace
1109
+ self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
1110
+
1111
+ def save_selection(self, origin: str, selection: AccountSelection | None, context: RepositoryContext | None = None) -> None:
1112
+ """Persist one confirmed nonsecret selector, preserving other repository overrides."""
1113
+ endpoint_origin(origin)
1114
+ data_by_key = self._read_for_write()
1115
+ scope = "repository" if context is not None else "global"
1116
+ key = self._scope_key(origin, context) if context is not None else origin
1117
+ if selection is None:
1118
+ data_by_key[scope].pop(key, None)
1119
+ else:
1120
+ known_accounts = self.accounts(origin, selection.provider)
1121
+ if selection not in known_accounts:
1122
+ raise ValueError("Sign in successfully before saving this account")
1123
+ retained_accounts = []
1124
+ for item in data_by_key["accounts"]:
1125
+ if item["namespace"] != selection.namespace:
1126
+ retained_accounts.append(item)
1127
+ data_by_key["accounts"] = retained_accounts
1128
+ data_by_key["accounts"].append(asdict(selection))
1129
+ data_by_key[scope][key] = selection.namespace
1130
+ self.preference.update({AUTHENTICATION_PREFERENCE_KEY: json.dumps(data_by_key, ensure_ascii=False)})
1131
+
1132
+ def sign_in(self, provider: str, endpoint: str, callback: Callable[[AuthOutcome], None], *, token: str = "", account_hint: str = "", api_email: str = "",
1133
+ git_filename: str = "", is_current: Callable[[], bool] | None = None, username: str = "", service_name: str = "", check_url: str = "") -> str:
1134
+ """Add an account in a supervised private child: a service account's browser or token identity is verified first,
1135
+ a generic account (``username`` with its password or token) only once Git lists the repository ``check_url`` of that
1136
+ server with them, using ``git_filename``.
1137
+
1138
+ A non-empty ``service_name`` becomes the name shown for this address once the account is listed.
1139
+ """
1140
+ return self._start(AuthenticationAction.SIGN_IN, provider, endpoint, callback, token=token, account_hint=account_hint, api_email=api_email, git_filename=git_filename,
1141
+ is_current=is_current, username=username, service_name=service_name, check_url=check_url)
1142
+
1143
+ def get(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, git_filename: str = "") -> str:
1144
+ """Check the exact stored account and report expiration without falling back to another."""
1145
+ return self._start(AuthenticationAction.GET, selection.provider, selection.origin, callback, selection=selection, git_filename=git_filename)
1146
+
1147
+ def credential_helper(self, git_filename: str) -> CredentialHelper:
1148
+ """Return the credential manager found for this Git, or raise CredentialManagerRefusal, a ValueError carrying the sentence and the detection of its refusal.
1149
+
1150
+ Blocking: the search may start short processes, so it is called only from a background task, never from the
1151
+ window's thread. An injected helper is returned as it is, without any search.
1152
+ """
1153
+ if self.helper_commands:
1154
+ return CredentialHelper(tuple(self.helper_commands))
1155
+ detection = self.credential_manager.locate(git_filename)
1156
+ if detection.problem != CredentialManagerProblem.NONE:
1157
+ raise CredentialManagerRefusal(detection, self.credential_manager.platform)
1158
+ return CredentialHelper((detection.filename,), detection.exec_path)
1159
+
1160
+ def resolve_credential_helper(self, git_filename: str, continuation: Callable[[CredentialHelper | None, Exception | None], None]) -> None:
1161
+ """Give ``continuation``, on the window's thread, the credential manager found for this Git or the error of its refusal.
1162
+
1163
+ An injected helper answers at once, before this returns. Otherwise the search runs on the service's own thread,
1164
+ since it may start processes and wait for them, and the answer comes back through a queued signal.
1165
+ """
1166
+ if self.helper_commands:
1167
+ continuation(self.credential_helper(git_filename), None)
1168
+ return
1169
+ self._submit_search(self.credential_helper, git_filename, continuation)
1170
+
1171
+ def locate_credential_manager(self, git_filename: str, continuation: Callable[[CredentialManagerDetection | None, Exception | None], None]) -> None:
1172
+ """Give ``continuation``, on the window's thread, what the search finds for this Git, success or refusal, or the error that kept it from running.
1173
+
1174
+ An injected helper is ignored, since the question is what the search itself finds. The search runs on the
1175
+ service's own thread, since it may start processes and wait for them, and the answer comes back through the
1176
+ same queued signal as the helper of an exchange.
1177
+ """
1178
+ self._submit_search(self.credential_manager.locate, git_filename, continuation)
1179
+
1180
+ def _submit_search(self, search: Callable[[str], CredentialHelper | CredentialManagerDetection], git_filename: str,
1181
+ continuation: Callable[[CredentialHelper | None, Exception | None], None] | Callable[[CredentialManagerDetection | None, Exception | None], None]) -> None:
1182
+ """Run one credential manager search on the service's own thread and give its answer, or the error that kept it from running, to ``continuation`` on the window's thread through the queued signal.
1183
+
1184
+ An executor that no longer accepts work answers on the next turn of the event loop, never before this returns.
1185
+ """
1186
+ request_id = uuid.uuid4().hex
1187
+ self.helper_continuation_by_id[request_id] = continuation
1188
+ try:
1189
+ future = self.helper_executor.submit(search, git_filename)
1190
+ except RuntimeError as failure:
1191
+ self.helper_continuation_by_id.pop(request_id, None)
1192
+ QTimer.singleShot(0, self, lambda failure=failure: continuation(None, failure))
1193
+ return
1194
+ future.add_done_callback(lambda completed: self.credential_helper_resolved.emit(request_id, completed))
1195
+
1196
+ @Slot(str, object)
1197
+ def _credential_helper_resolved(self, request_id: str, future: Future) -> None:
1198
+ """Hand a finished search to the exchange, window or Settings line waiting for it, on the window's thread."""
1199
+ continuation = self.helper_continuation_by_id.pop(request_id, None)
1200
+ if continuation is None:
1201
+ return
1202
+ error = future.exception()
1203
+ if error is not None:
1204
+ continuation(None, error)
1205
+ return
1206
+ continuation(future.result(), None)
1207
+
1208
+ def command_configuration(self, endpoint: str, context: RepositoryContext | None = None, *, selection: AccountSelection | None = None,
1209
+ helper_commands: tuple[str, ...]) -> tuple[str, list[str]]:
1210
+ """Return an ephemeral command URL and helper configuration for one account.
1211
+
1212
+ Every caller names the helper it composes, since nothing is searched here: the preview (``PREVIEW_HELPER_COMMANDS``),
1213
+ which disables every helper and is used to capture and compare a destination, or the credential manager its
1214
+ operation already found in the background.
1215
+ """
1216
+ if selection is not None and selection not in self.accounts(endpoint_origin(endpoint), selection.provider):
1217
+ raise ValueError("Choose a confirmed account for this service before continuing.")
1218
+ selection = selection or self.account_for_endpoint(endpoint, context)
1219
+ if selection is None:
1220
+ raise ValueError(NO_ACCOUNT_CONFIGURED_TEXT)
1221
+ return selection.command_url(endpoint), selection.credential_arguments(tuple(helper_commands))
1222
+
1223
+ def recursive_configuration(self, destinations: tuple[RecursiveTransportContext, ...], inherited_keys: tuple[str, ...] | RecursiveTransportConfiguration = (), *,
1224
+ helper_commands: tuple[str, ...]) -> list[str]:
1225
+ """Clear inherited authentication and authorize helpers only for captured destinations.
1226
+
1227
+ Nothing is searched here: a recursive operation finds the credential manager once, in its background task, and
1228
+ passes its ``helper_commands`` for all its destinations, so the Git of every submodule starts that same helper;
1229
+ ``PREVIEW_HELPER_COMMANDS`` composes a preview that names no helper.
1230
+ """
1231
+ if isinstance(inherited_keys, RecursiveTransportConfiguration):
1232
+ inherited_keys.verify()
1233
+ inherited_keys = inherited_keys.keys
1234
+ arguments = ["-c", "credential.helper=", "-c", "http.extraHeader=", "-c", "credential.interactive=" + GCM_DISABLED_VALUE, "-c", "credential.traceSecrets=" + GCM_DISABLED_VALUE]
1235
+ for key in sorted(set(inherited_keys)):
1236
+ validate_text(key)
1237
+ if key.lower().startswith("credential.") and key.lower().endswith(".helper") or key.lower().startswith("http.") and key.lower().endswith(".extraheader"):
1238
+ arguments.extend(["-c", key + "="])
1239
+ for destination in destinations:
1240
+ if destination.selection is None:
1241
+ continue
1242
+ _endpoint, selected_arguments = self.command_configuration(destination.endpoint, destination.repository, selection=destination.selection,
1243
+ helper_commands=helper_commands)
1244
+ for argument in selected_arguments:
1245
+ if argument == "credential.helper=":
1246
+ continue
1247
+ if argument == "-c":
1248
+ continue
1249
+ # Path-scoped selectors prevent another repository on the same host from adopting this account. Only Git
1250
+ # reads them with the path: the helper carries the account's store namespace in its own environment,
1251
+ # since Git Credential Manager ignores a setting scoped to a repository path.
1252
+ argument = argument.replace("credential." + destination.origin + ".", "credential." + destination.endpoint + ".")
1253
+ if argument.startswith("credential.helper="):
1254
+ argument = "credential." + destination.endpoint + ".helper=" + argument.partition("=")[2]
1255
+ arguments.extend(["-c", argument])
1256
+ arguments.extend(isolated_http_arguments(destination.endpoint))
1257
+ return arguments
1258
+
1259
+ def recursive_context(self, endpoint: str | None = None, selection: AccountSelection | None = None) -> tuple[RecursiveTransportContext, ...]:
1260
+ """Capture each service's default for the whole service, then the parent's own account for its URL only.
1261
+
1262
+ A service default authorizes every repository of that service, the parent's host included, as submodule
1263
+ initialization, Fetch and Push already do: a submodule on the parent's host finds that default. The parent's
1264
+ account comes last because Git applies every matching credential setting in order, so its URL-scoped settings
1265
+ override the host's default for the parent alone, and other repositories of the host never inherit it.
1266
+ """
1267
+ destinations = []
1268
+ for selected_origin in sorted(self._read()["global"]):
1269
+ selected_account = self.account_for_endpoint(selected_origin)
1270
+ if selected_account is not None:
1271
+ destinations.append(RecursiveTransportContext(selected_origin, selected_origin, selected_account))
1272
+ if endpoint is not None:
1273
+ selection = selection or self.account_for_endpoint(endpoint)
1274
+ destinations.append(RecursiveTransportContext(endpoint, endpoint_origin(endpoint), selection))
1275
+ return tuple(destinations)
1276
+
1277
+ def recursive_inherited_keys(self, repository_dirs: tuple[str, ...]) -> Generator[GitCommand, bytes, RecursiveTransportConfiguration]:
1278
+ """Inspect conditional includes and templates before future children can activate headers."""
1279
+ key_set: set[str] = set()
1280
+ pending_files: list[tuple[str, str | None, bool]] = []
1281
+ visited_file_set: set[tuple[str, str | None, bool]] = set()
1282
+ signature_by_filename: dict[str, tuple | None] = {}
1283
+ environment_by_name = {}
1284
+ for name in ("GIT_TEMPLATE_DIR", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "HOME", "XDG_CONFIG_HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"):
1285
+ environment_by_name[name] = os.environ.get(name)
1286
+ for name in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"):
1287
+ if environment_by_name[name]:
1288
+ validate_configuration_filename(environment_by_name[name])
1289
+
1290
+ def add_template(directory: str) -> None:
1291
+ """Inspect copied config paths without changing Git's template selection."""
1292
+ if not directory:
1293
+ return
1294
+ if not os.path.isabs(directory):
1295
+ raise ValueError("Recursive authentication requires an absolute Git template directory. Update GIT_TEMPLATE_DIR or init.templateDir and retry.")
1296
+ for basename in ("config", "config.worktree"):
1297
+ pending_files.append((os.path.join(directory, basename), directory, False))
1298
+
1299
+ if not repository_dirs:
1300
+ return RecursiveTransportConfiguration((), {}, environment_by_name)
1301
+ first_repository_dir = next(iter(repository_dirs))
1302
+ for variable in ("GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL"):
1303
+ output = yield GitCommand(["-C", first_repository_dir, "var", variable], accepted_codes=(0, 1))
1304
+ for raw_filename in output.splitlines():
1305
+ pending_files.append((validate_configuration_filename(os.fsdecode(raw_filename)), None, True))
1306
+ template_directory = os.environ.get("GIT_TEMPLATE_DIR")
1307
+ if template_directory is not None:
1308
+ add_template(template_directory)
1309
+ else:
1310
+ output = yield GitCommand(["-C", first_repository_dir, "-c", "gitpane.templatedir=" + GIT_DEFAULT_TEMPLATE_DIR,
1311
+ "config", "--path", "--get", "gitpane.templatedir"])
1312
+ add_template(os.fsdecode(output).rstrip("\r\n"))
1313
+ for repository_dir in repository_dirs:
1314
+ output = yield GitCommand(["-C", repository_dir, "config", "--includes", "--null", "--show-scope", "--show-origin", "--name-only", "--list"])
1315
+ fields = iter(output.removesuffix(b"\0").split(b"\0") if output else ())
1316
+ for scope, origin, _key in zip(fields, fields, fields, strict=True):
1317
+ if not origin.startswith(b"file:"):
1318
+ continue
1319
+ filename = os.fsdecode(origin.removeprefix(b"file:"))
1320
+ if not os.path.isabs(filename):
1321
+ filename = os.path.join(repository_dir, filename)
1322
+ pending_files.append((filename, None, scope in (b"system", b"global")))
1323
+ inspected_size = 0
1324
+ while pending_files:
1325
+ filename, template_dir, protected = pending_files.pop()
1326
+ filename = os.path.abspath(filename)
1327
+ if template_dir is not None and os.path.commonpath((canonical_path(template_dir), canonical_path(filename))) != canonical_path(template_dir):
1328
+ raise ValueError("A Git template configuration link escapes its copied directory. Use a regular configuration file and retry.")
1329
+ identity = (canonical_path(filename), canonical_path(template_dir) if template_dir else None, protected)
1330
+ if identity in visited_file_set:
1331
+ continue
1332
+ visited_file_set.add(identity)
1333
+ if len(visited_file_set) > AUTHENTICATION_CONFIG_FILE_LIMIT:
1334
+ raise ValueError("Git authentication configuration exceeds the included-file limit.")
1335
+ signature = configuration_signature(filename)
1336
+ signature_by_filename[filename] = signature
1337
+ if signature is None:
1338
+ continue
1339
+ inspected_size += os.stat(filename).st_size
1340
+ if inspected_size > AUTHENTICATION_CONFIG_LIMIT_BYTES:
1341
+ raise ValueError("Git authentication configuration exceeds the inspection size limit.")
1342
+ output = yield GitCommand(["config", "--file", filename, "--no-includes", "--null", "--name-only", "--get-regexp", RECURSIVE_AUTHENTICATION_KEY_PATTERN], accepted_codes=(0, 1))
1343
+ for raw_key in output.split(b"\0"):
1344
+ if raw_key:
1345
+ key_set.add(validate_text(raw_key.decode("utf-8", "strict")))
1346
+ # Parse every include independently of its condition: the next Git process
1347
+ # may have a different gitdir, branch or remote URL than this inspection.
1348
+ output = yield GitCommand(["config", "--file", filename, "--no-includes", "--null", "--path", "--get-regexp", RECURSIVE_CONFIG_PATH_PATTERN], accepted_codes=(0, 1))
1349
+ for record in output.split(b"\0"):
1350
+ if not record:
1351
+ continue
1352
+ key, raw_path = record.split(b"\n", 1)
1353
+ path = os.fsdecode(raw_path)
1354
+ if key == b"init.templatedir":
1355
+ if protected and template_directory is None:
1356
+ add_template(path)
1357
+ continue
1358
+ if not path:
1359
+ continue
1360
+ included_template_dir = None
1361
+ if not os.path.isabs(path):
1362
+ path = os.path.abspath(os.path.join(os.path.dirname(filename), path))
1363
+ if template_dir is not None:
1364
+ if os.path.commonpath((canonical_path(template_dir), canonical_path(path))) != canonical_path(template_dir):
1365
+ raise ValueError("A relative Git template include escapes its copied directory. Use an absolute include path and retry.")
1366
+ included_template_dir = template_dir
1367
+ pending_files.append((path, included_template_dir, protected))
1368
+ configuration = RecursiveTransportConfiguration(tuple(sorted(key_set)), signature_by_filename, environment_by_name)
1369
+ configuration.verify()
1370
+ return configuration
1371
+
1372
+ def reject(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, git_filename: str = "") -> str:
1373
+ """Erase only the selected account's private namespace after an explicit rejection."""
1374
+ return self._start(AuthenticationAction.REJECT, selection.provider, selection.origin, callback, selection=selection, git_filename=git_filename)
1375
+
1376
+ def remove_account(self, selection: AccountSelection, callback: Callable[[AuthOutcome], None], *, is_current: Callable[[], bool] | None = None) -> str:
1377
+ """Erase this account's stored secret, and only its own, then take it off Connected accounts.
1378
+
1379
+ Nothing of any repository is touched: no Git command runs and no repository file is read or written. Once the
1380
+ secret is erased the account is taken off the list even if ``is_current`` no longer accepts the result, so no
1381
+ account stays listed without its secret; if erasing fails the account stays listed and usable.
1382
+ """
1383
+ return self._start(AuthenticationAction.REMOVE, selection.provider, selection.origin, callback, selection=selection, is_current=is_current)
1384
+
1385
+ def lookup_secrets(self, selections: tuple[AccountSelection, ...] | list[AccountSelection], callback: Callable[[SecretLookup], None], *,
1386
+ is_current: Callable[[], bool] | None = None) -> str:
1387
+ """Check whether each account's secret is still in the system credential store, and remember the accounts whose secret is gone.
1388
+
1389
+ Git and Git Credential Manager erase an account's secret by themselves when a server refuses it, so an account
1390
+ can stay listed without any secret. The check first finds the credential manager of the current Git, away from
1391
+ the window's thread, then only asks it for each secret, in a worker of its own, never interactively, without
1392
+ contacting any server and without writing or erasing anything; it stops after ``lookup_timeout_ms``. It neither
1393
+ waits for nor blocks a sign-in, a Reconnect or a removal. ``callback`` receives the classification while
1394
+ ``is_current`` still accepts it; the remembered accounts change either way. A refused credential manager, or a
1395
+ worker that cannot start, answers through ``callback`` too, every account unknown, and changes no remembered
1396
+ account. Returns the check's operation id, or an empty text when nothing was started (no account, closing).
1397
+ """
1398
+ requested_namespace_set = set()
1399
+ account_by_key_list: list[dict[str, str]] = []
1400
+ for selection in selections:
1401
+ if selection.namespace in requested_namespace_set:
1402
+ continue
1403
+ requested_namespace_set.add(selection.namespace)
1404
+ account_by_key_list.append(lookup_request(selection))
1405
+ if self.closed or not account_by_key_list:
1406
+ return ""
1407
+ operation_id = uuid.uuid4().hex
1408
+ generation_by_namespace = {}
1409
+ for namespace in requested_namespace_set:
1410
+ generation_by_namespace[namespace] = self.account_generation_by_namespace.get(namespace, 0)
1411
+ data_by_key = {"action": AuthenticationAction.LOOKUP.value, "accounts": account_by_key_list, "lookup_timeout_ms": self.lookup_timeout_ms}
1412
+ self.active_by_id[operation_id] = _AuthenticationRun(lambda _outcome: None, bytearray(), is_current=is_current, action=AuthenticationAction.LOOKUP, lookup_callback=callback,
1413
+ lookup_generation_by_namespace=generation_by_namespace)
1414
+
1415
+ def before_start() -> None:
1416
+ """A queued check is not worth starting once GitPane is closing."""
1417
+ if self.closed:
1418
+ raise ValueError("Authentication is shutting down")
1419
+
1420
+ def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
1421
+ """Start the check with the credential manager found, or answer every account unknown on the refusal."""
1422
+ self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, before_start,
1423
+ self.lookup_timeout_ms + SECRET_LOOKUP_STOP_GRACE_MS))
1424
+
1425
+ self.resolve_credential_helper(self.current_git_filename(), launch)
1426
+ return operation_id
1427
+
1428
+ def approve(self, selection: AccountSelection, token: str, callback: Callable[[AuthOutcome], None], *, git_filename: str = "",
1429
+ api_email: str = "", is_current: Callable[[], bool] | None = None, check_url: str = "") -> str:
1430
+ """Reconnect exactly this account, by browser when the token is empty, and refuse any other account before storing a secret.
1431
+
1432
+ Bitbucket token checks need the account e-mail: an entered one wins, otherwise the one saved with the account is used.
1433
+ A username + password account keeps its old secret unless Git lists the repository ``check_url`` with the new one.
1434
+ ``is_current`` lets the requesting form drop a result it can no longer show, so nothing is written for it either.
1435
+ """
1436
+ bitbucket_email = api_email or selection.api_identity
1437
+ return self._start(AuthenticationAction.APPROVE, selection.provider, selection.origin, callback, token=token, selection=selection,
1438
+ api_email=bitbucket_email if selection.provider == BITBUCKET_PROVIDER else "", git_filename=git_filename, is_current=is_current,
1439
+ check_url=check_url)
1440
+
1441
+ def cancel(self, operation_id: str) -> None:
1442
+ """Invalidate publication immediately while retaining the process until drainage."""
1443
+ active = self.active_by_id.get(operation_id)
1444
+ if active is not None:
1445
+ active.cancelled = True
1446
+ self.runner.cancel(operation_id)
1447
+
1448
+ def close(self) -> None:
1449
+ """Stop owned helpers before application exit; no result published afterwards can reach a form.
1450
+
1451
+ A stopped account test still gets its temporary secret copy erased, and a new account's secret stored for a
1452
+ result nobody sees is erased too: the runner stays open until those erases end.
1453
+ """
1454
+ self.closed = True
1455
+ for operation_id, active in tuple(self.active_by_id.items()):
1456
+ if not active.cleanup:
1457
+ self.cancel(operation_id)
1458
+ if not self._secret_cleanup_pending():
1459
+ self.runner.close()
1460
+
1461
+ def _start(self, action: AuthenticationAction, provider: str, endpoint: str, callback: Callable[[AuthOutcome], None], *, token: str = "", account_hint: str = "", api_email: str = "",
1462
+ selection: AccountSelection | None = None, git_filename: str = "", is_current: Callable[[], bool] | None = None, username: str = "",
1463
+ service_name: str = "", check_url: str = "") -> str:
1464
+ """Check one credential exchange, find the credential manager for it, then start it; recheck queued work just before it starts.
1465
+
1466
+ Adding or reconnecting a username + password account needs a repository of its server to test the secret on and
1467
+ an absolute Git executable to run the test; both are checked here, before anything starts.
1468
+ An account exchange holds no repository: it only writes GitPane's account list and the system credential store,
1469
+ and only tests a secret against a server address, so no write running on a local repository has any reason to
1470
+ refuse it. The exchange counts as running while its credential manager is searched, away from the window's
1471
+ thread; a refused credential manager ends it with the sentence of the refusal, and nothing is stored.
1472
+ """
1473
+ operation_id = uuid.uuid4().hex
1474
+ try:
1475
+ if self.closed:
1476
+ raise ValueError("Authentication is shutting down")
1477
+ if self._exchange_running():
1478
+ raise ValueError("Another sign-in is still running or stopping. Wait for it to finish, then try again.")
1479
+ if provider not in ACCOUNT_PROVIDER_SET:
1480
+ raise ValueError("Choose GitHub, GitLab, Bitbucket or a username and password account explicitly")
1481
+ origin = endpoint_origin(endpoint)
1482
+ for value in (token, account_hint, api_email, username, service_name, check_url, git_filename):
1483
+ validate_text(value, allow_empty=True)
1484
+ self._require_account_fields(action, provider, origin, username, token)
1485
+ check_selection = None
1486
+ if provider == GENERIC_PROVIDER and action in REMEMBERED_ACTION_SET:
1487
+ check_url = check_repository_url(check_url, origin)
1488
+ if not os.path.isabs(git_filename):
1489
+ raise ValueError(GIT_MISSING_TEXT)
1490
+ check_selection = selection if selection is not None else generic_account(origin, username)
1491
+ account_hint = account_hint or unquote(urlsplit(endpoint).username or "")
1492
+ validate_text(account_hint, allow_empty=True)
1493
+ except (OSError, ValueError, KeyError) as error:
1494
+ refused_selection = selection if action == AuthenticationAction.REMOVE else None
1495
+ outcome = AuthOutcome(operation_id, OperationStatus.FAILED, selection=refused_selection, diagnostic=Diagnostic("Authentication", "", "", [], str(error)).prepare(),
1496
+ refused_before_start=True)
1497
+ self.outcome_by_id[operation_id] = outcome
1498
+ QTimer.singleShot(0, self, lambda: callback(outcome) if not self.closed and (is_current is None or is_current()) else None)
1499
+ return operation_id
1500
+ data_by_key = {"action": action.value, "provider": provider, "origin": origin, "token": token, "account_hint": account_hint, "api_email": api_email, "username": username,
1501
+ "validation_origin": self.validation_origin, "selection": asdict(selection) if selection else None,
1502
+ "check_url": check_url, "git_filename": git_filename, "check_timeout_ms": self.check_timeout_ms, "browser_timeout_ms": self.browser_timeout_ms}
1503
+ # A browser sign-in is stopped by its worker at the end of the browser wait; the supervisor only steps in
1504
+ # if the worker itself no longer answers, a little later.
1505
+ browser_sign_in = is_browser_sign_in(action, provider, token)
1506
+ run_timeout_ms = self.browser_timeout_ms + BROWSER_SIGN_IN_STOP_GRACE_MS if browser_sign_in else self.timeout_ms
1507
+ sign_in_method = exchange_sign_in_method(provider, action, token) if action in REMEMBERED_ACTION_SET else None
1508
+ self.active_by_id[operation_id] = _AuthenticationRun(callback, bytearray(), is_current=is_current, action=action, service_name=service_name, selection=selection,
1509
+ check_selection=check_selection, sign_in_method=sign_in_method)
1510
+
1511
+ def before_start() -> None:
1512
+ """A queued helper no longer starts once GitPane is closing or the window that asked for it no longer waits for its result."""
1513
+ if self.closed or is_current is not None and not is_current():
1514
+ raise ValueError("Authentication is no longer authorized")
1515
+
1516
+ def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
1517
+ """Start the worker with the credential manager found, or end the exchange on the refusal."""
1518
+ self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, before_start, run_timeout_ms,
1519
+ browser_sign_in=browser_sign_in))
1520
+
1521
+ self.resolve_credential_helper(git_filename or self.current_git_filename(), launch)
1522
+ return operation_id
1523
+
1524
+ def _launch_exchange(self, operation_id: str, data_by_key: dict[str, object], helper: CredentialHelper | None, error: Exception | None,
1525
+ start_worker: Callable[[], None]) -> None:
1526
+ """Start an exchange's worker once its credential manager is known, or end the exchange without any worker.
1527
+
1528
+ An exchange cancelled during the search, or one whose service closed meanwhile, starts nothing; neither does one
1529
+ whose credential manager was refused, whose sentence becomes the exchange's failure. A cleanup erase alone still
1530
+ starts while GitPane closes, so a secret left behind is not kept. The private request learns which helper to
1531
+ start and the folder of Git's programs the helper needs.
1532
+ """
1533
+ active = self.active_by_id.get(operation_id)
1534
+ if active is None:
1535
+ return
1536
+ if error is None and (active.cancelled or self.closed and not active.cleanup):
1537
+ error = ValueError("Authentication is no longer authorized")
1538
+ if error is not None:
1539
+ refusal = error.detection if isinstance(error, CredentialManagerRefusal) else None
1540
+ self._end_without_worker(operation_id, str(error), credential_manager_refusal=refusal)
1541
+ return
1542
+ data_by_key["helper_commands"] = list(helper.commands)
1543
+ data_by_key[GIT_EXEC_PATH_KEY] = helper.exec_path
1544
+ try:
1545
+ start_worker()
1546
+ except (OSError, ValueError, KeyError) as launch_error:
1547
+ self._end_without_worker(operation_id, str(launch_error))
1548
+
1549
+ def _end_without_worker(self, operation_id: str, cause: str, *, credential_manager_refusal: CredentialManagerDetection | None = None) -> None:
1550
+ """End an exchange that never got a worker: drop it, publish its failure with ``cause`` when still wanted, and let a closing GitPane go on.
1551
+
1552
+ Nothing ran, so nothing was stored or erased: a removal keeps its account and a check classifies every account
1553
+ as unknown. A removal refused before any worker still carries its account, as every removal result does. No
1554
+ worker means the runner never says it is idle for this exchange, so the service says it here. A
1555
+ ``credential_manager_refusal`` is carried by the failure the exchange publishes, so a sign-in or a removal can
1556
+ open the install window; the lookup of stored secrets, which Settings starts by itself, never carries it.
1557
+ """
1558
+ active = self.active_by_id.pop(operation_id, None)
1559
+ if active is None:
1560
+ return
1561
+ publish = not active.cancelled and not self.closed and (active.is_current is None or active.is_current())
1562
+ if active.action == AuthenticationAction.LOOKUP:
1563
+ lookup = lookup_result(None, frozenset(active.lookup_generation_by_namespace))
1564
+ if publish and active.lookup_callback is not None:
1565
+ QTimer.singleShot(0, self, lambda: active.lookup_callback(lookup) if not self.closed and (active.is_current is None or active.is_current()) else None)
1566
+ elif not active.cleanup:
1567
+ selection = active.selection if active.action == AuthenticationAction.REMOVE else None
1568
+ outcome = AuthOutcome(operation_id, OperationStatus.FAILED, selection=selection, diagnostic=Diagnostic("Authentication", "", "", [], cause).prepare(), refused_before_start=True,
1569
+ credential_manager_refusal=credential_manager_refusal)
1570
+ self.outcome_by_id[operation_id] = outcome
1571
+ if publish:
1572
+ QTimer.singleShot(0, self, lambda: active.callback(outcome) if not self.closed and (active.is_current is None or active.is_current()) else None)
1573
+ if self.closed and not self.runner.closed and not self._secret_cleanup_pending():
1574
+ self.runner.close()
1575
+ if not self.active_by_id:
1576
+ self.idle.emit()
1577
+
1578
+ def _launch_worker(self, operation_id: str, data_by_key: dict[str, object], before_start: Callable[[], None], timeout_ms: int, *, browser_sign_in: bool = False) -> None:
1579
+ """Start the worker of one exchange on the command ``account_worker_command`` builds, handing it its private request on stdin only; ``timeout_ms`` bounds the whole exchange.
1580
+
1581
+ Only a ``browser_sign_in`` worker gets a process group its helper may leave; any other worker's group is closed.
1582
+ """
1583
+ worker_filename = authentication_worker_filename()
1584
+ program_filename, arguments = account_worker_command(worker_filename)
1585
+ self.runner.start(ProcessRequest(operation_id, program_filename, arguments, os.path.dirname(worker_filename), timeout_ms,
1586
+ session_id=operation_id, stdin=json.dumps(data_by_key, ensure_ascii=False).encode("utf-8"), profile=CREDENTIAL_PROFILE, operation_id=operation_id,
1587
+ operation="Authentication", idle_timeout_ms=AUTHENTICATION_IDLE_TIMEOUT_MS, before_start=before_start,
1588
+ process_group_factory=breakaway_process_group if browser_sign_in else None))
1589
+
1590
+ def _erase_left_secret(self, selection: AccountSelection, *, account_secret: bool = False) -> None:
1591
+ """Erase a secret an exchange may have left behind: the temporary copy of an account test, or with ``account_secret`` the secret of a new account nobody listed.
1592
+
1593
+ The account's own secret is only erased for an account missing from the saved list, whose secret nothing else
1594
+ uses; for a username + password account the test copy goes with it. It runs even while GitPane is closing, and
1595
+ nobody waits for its result: a test copy it cannot erase either is erased by the next test of the same account
1596
+ or by the account's removal. Nothing but that account's namespaces is touched. The erase counts as pending while
1597
+ the credential manager of the current Git is searched; a refused credential manager drops it silently.
1598
+ """
1599
+ operation_id = uuid.uuid4().hex
1600
+ action = AuthenticationAction.REJECT if account_secret else AuthenticationAction.ERASE_CHECK
1601
+ data_by_key = {"action": action.value, "provider": selection.provider, "origin": selection.origin, "token": "", "account_hint": "", "api_email": "",
1602
+ "username": "", "validation_origin": self.validation_origin, "selection": asdict(selection)}
1603
+ self.active_by_id[operation_id] = _AuthenticationRun(lambda _outcome: None, bytearray(), action=action, selection=selection, cleanup=True)
1604
+
1605
+ def launch(helper: CredentialHelper | None, error: Exception | None) -> None:
1606
+ """Start the erase with the credential manager found, or drop it on the refusal."""
1607
+ self._launch_exchange(operation_id, data_by_key, helper, error, lambda: self._launch_worker(operation_id, data_by_key, lambda: None, self.erase_timeout_ms))
1608
+
1609
+ self.resolve_credential_helper(self.current_git_filename(), launch)
1610
+
1611
+ def _account_listed(self, namespace: str) -> bool:
1612
+ """Tell whether an account with this namespace is on the saved list; an unreadable list counts as listing it, so no secret is erased on a guess."""
1613
+ try:
1614
+ accounts = self._read()["accounts"]
1615
+ for account_by_key in accounts:
1616
+ if account_by_key["namespace"] == namespace:
1617
+ return True
1618
+ except (ValueError, KeyError, TypeError):
1619
+ return True
1620
+ return False
1621
+
1622
+ def _exchange_running(self) -> bool:
1623
+ """Tell whether an exchange other than a check of the stored secrets is still running; such a check never blocks a sign-in."""
1624
+ for active in self.active_by_id.values():
1625
+ if active.action != AuthenticationAction.LOOKUP:
1626
+ return True
1627
+ return False
1628
+
1629
+ def _secret_cleanup_pending(self) -> bool:
1630
+ """Tell whether an account test, or an erase of a secret an exchange left behind, is still running."""
1631
+ for active in self.active_by_id.values():
1632
+ if active.check_selection is not None or active.cleanup:
1633
+ return True
1634
+ return False
1635
+
1636
+ def _consume(self, operation_id: str, content: bytes) -> None:
1637
+ active = self.active_by_id.get(operation_id)
1638
+ if active is not None:
1639
+ active.output.extend(content)
1640
+
1641
+ def _finished(self, result: ProcessResult) -> None:
1642
+ """Publish a finished exchange and, once its secret is stored, remember the account in the durable list.
1643
+
1644
+ This runs as a Qt slot. An account test whose worker did not finish on its own (cancelled, timed out, crashed),
1645
+ or did not report its temporary secret copy erased, may have left that copy behind, so it is erased next; when
1646
+ that stopped test was adding a new account, the secret it may already have stored for the account is erased
1647
+ with it. The runner of a closing service closes once no such erase is left.
1648
+ """
1649
+ operation_id = result.request.request_id
1650
+ active = self.active_by_id.pop(operation_id, None)
1651
+ if active is None:
1652
+ return
1653
+ publish = not active.cancelled and not self.closed and (active.is_current is None or active.is_current())
1654
+ check_selection = active.check_selection
1655
+ if check_selection is not None and (result.status != ProcessStatus.SUCCESS or not check_copy_erased(active.output)):
1656
+ stopped_new_account = result.status != ProcessStatus.SUCCESS and active.action == AuthenticationAction.SIGN_IN and not self._account_listed(check_selection.namespace)
1657
+ self._erase_left_secret(check_selection, account_secret=stopped_new_account)
1658
+ if active.action == AuthenticationAction.LOOKUP:
1659
+ self._finish_lookup(active, result, publish)
1660
+ else:
1661
+ self._finish_exchange(active, result, publish)
1662
+ if self.closed and not self.runner.closed and not self._secret_cleanup_pending():
1663
+ self.runner.close()
1664
+
1665
+ def _finish_exchange(self, active: _AuthenticationRun, result: ProcessResult, publish: bool) -> None:
1666
+ """Read the result of an exchange other than a check of the stored secrets, keep the saved list in step with the secure store, and publish it.
1667
+
1668
+ A failed list write is reported as a partial result instead of escaping, so the requesting form always receives an
1669
+ outcome. A secret stored for a result that can no longer be shown (cancelled, window closed, GitPane closing)
1670
+ still keeps the list in step: an account already listed is refreshed, and a new account's secret is erased, so a
1671
+ cancelled sign-in leaves no secret behind that Connected accounts would not show.
1672
+ """
1673
+ operation_id = result.request.request_id
1674
+ selection = None
1675
+ try:
1676
+ data_by_key = json.loads(active.output) if active.output else {}
1677
+ selection = AccountSelection(**data_by_key["selection"]) if data_by_key.get("selection") else None
1678
+ diagnostic = Diagnostic("Authentication", "", "", [], data_by_key.get("error") or result.cause, stderr=result.stderr).prepare()
1679
+ status = OperationStatus(data_by_key.get("status", OperationStatus.FAILED.value)) if result.status == ProcessStatus.SUCCESS else operation_status(result.status)
1680
+ outcome = AuthOutcome(operation_id, status, selection, bool(data_by_key.get("authenticated")), bool(data_by_key.get("stored")), diagnostic if status != OperationStatus.SUCCESS else None,
1681
+ check=account_check(data_by_key.get("check")), refused_by_gitpane=data_by_key.get(GITPANE_REFUSAL_KEY) is True)
1682
+ except (ValueError, TypeError, KeyError):
1683
+ selection = None
1684
+ outcome = AuthOutcome(operation_id, OperationStatus.FAILED, diagnostic=Diagnostic("Authentication", "", "", [], "The credential helper returned an invalid response").prepare())
1685
+ finally:
1686
+ active.output.clear()
1687
+ if active.action == AuthenticationAction.REMOVE and active.selection is not None:
1688
+ outcome = self._finish_removal(active.selection, outcome)
1689
+ elif selection is not None and outcome.stored and active.action in REMEMBERED_ACTION_SET and not publish and not self._account_listed(selection.namespace):
1690
+ self._erase_left_secret(selection, account_secret=True)
1691
+ elif selection is not None and outcome.stored and active.action in REMEMBERED_ACTION_SET:
1692
+ try:
1693
+ change = self.remember_account(selection, active.service_name, sign_in_method=active.sign_in_method)
1694
+ outcome = replace(outcome, renewed=change.renewed, became_default=change.became_default)
1695
+ except (OSError, ValueError, TypeError) as error:
1696
+ outcome = AuthOutcome(operation_id, OperationStatus.PARTIAL, selection, authenticated=outcome.authenticated, stored=True, diagnostic=Diagnostic("Authentication", "", "", [], str(error)).prepare())
1697
+ self.outcome_by_id[operation_id] = outcome
1698
+ if publish:
1699
+ active.callback(outcome)
1700
+
1701
+ def _finish_lookup(self, active: _AuthenticationRun, result: ProcessResult, publish: bool) -> None:
1702
+ """Remember the accounts a finished check found without secret, forget those it found with one, and publish the classification.
1703
+
1704
+ A stopped or failed check changes nothing and classifies every account as unknown. An account saved again or
1705
+ removed after the check started keeps its current state: the missing secret belonged to the old sign-in.
1706
+ """
1707
+ requested_namespace_set = frozenset(active.lookup_generation_by_namespace)
1708
+ value = None
1709
+ if result.status == ProcessStatus.SUCCESS and not active.cancelled:
1710
+ try:
1711
+ value = json.loads(active.output) if active.output else None
1712
+ except ValueError:
1713
+ value = None
1714
+ active.output.clear()
1715
+ lookup = lookup_result(value, requested_namespace_set)
1716
+ stale_namespace_set = set()
1717
+ for namespace in lookup.missing_namespace_set:
1718
+ if self.account_generation_by_namespace.get(namespace, 0) != active.lookup_generation_by_namespace[namespace]:
1719
+ stale_namespace_set.add(namespace)
1720
+ continue
1721
+ self.missing_secret_namespace_set.add(namespace)
1722
+ for namespace in lookup.present_namespace_set:
1723
+ self.missing_secret_namespace_set.discard(namespace)
1724
+ if stale_namespace_set:
1725
+ lookup = SecretLookup(lookup.missing_namespace_set - stale_namespace_set, lookup.present_namespace_set, lookup.unknown_namespace_set | stale_namespace_set)
1726
+ if publish and active.lookup_callback is not None:
1727
+ active.lookup_callback(lookup)
1728
+
1729
+ def _finish_removal(self, selection: AccountSelection, outcome: AuthOutcome) -> AuthOutcome:
1730
+ """Take the account off the list once its secret was really erased, whether or not the result can still be shown.
1731
+
1732
+ The person already confirmed the removal, and an account listed without its secret would be worse than a finished
1733
+ removal nobody sees. The removed account is attached to every result, so a failure can name the step that failed.
1734
+ A list that cannot be saved after the erase gives a partial result.
1735
+ """
1736
+ if outcome.status != OperationStatus.SUCCESS:
1737
+ return replace(outcome, selection=selection)
1738
+ try:
1739
+ removal = self.forget_account(selection)
1740
+ except (OSError, ValueError, TypeError) as error:
1741
+ return AuthOutcome(outcome.operation_id, OperationStatus.PARTIAL, selection, diagnostic=Diagnostic("Remove account", "", "", [], str(error)).prepare())
1742
+ return replace(outcome, selection=selection, removal=removal)
1743
+
1744
+ def _read(self) -> dict[str, object]:
1745
+ """Read the saved accounts, defaults and repository choices to show or use them."""
1746
+ return json.loads(self.preference.read(AUTHENTICATION_PREFERENCE_KEY))
1747
+
1748
+ def _read_for_write(self) -> dict[str, object]:
1749
+ """Read the saved account data as stored right now, to build a write on it.
1750
+
1751
+ Every write replaces the whole list, so it must start from what is stored now, never from a copy kept in memory:
1752
+ another window or GitPane process may have removed or added an account meanwhile. When the stored list cannot
1753
+ be read, nothing is written.
1754
+ """
1755
+ try:
1756
+ return json.loads(self.preference.read_saved(AUTHENTICATION_PREFERENCE_KEY))
1757
+ except ValueError as error:
1758
+ raise ValueError(ACCOUNT_LIST_UNREADABLE_TEXT) from error
1759
+
1760
+ def _read_sign_in_methods(self, *, for_write: bool = False) -> dict[str, SignInMethod]:
1761
+ """Read the recorded sign-in method of each account, by namespace; ``for_write`` reads it as stored right now, as ``_read_for_write`` does."""
1762
+ if not for_write:
1763
+ return read_sign_in_method_json(self.preference.read(ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY))
1764
+ try:
1765
+ return read_sign_in_method_json(self.preference.read_saved(ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY))
1766
+ except ValueError as error:
1767
+ raise ValueError(ACCOUNT_LIST_UNREADABLE_TEXT) from error
1768
+
1769
+ def _require_account_fields(self, action: AuthenticationAction, provider: str, origin: str, username: str, token: str) -> None:
1770
+ """Refuse an exchange missing what its account kind needs, before any child starts.
1771
+
1772
+ A generic account is added with a username and a password or token and reconnected with a new password only: its
1773
+ username never changes. A service account keeps the identity its provider verifies, so a typed username is refused.
1774
+ A new generic account is refused on GitHub, gitlab.com and Bitbucket Cloud, whose Git refuses account passwords;
1775
+ one saved there earlier stays usable and can still be reconnected.
1776
+ """
1777
+ if provider != GENERIC_PROVIDER:
1778
+ if username:
1779
+ raise ValueError("A username is only entered for a username and password account.")
1780
+ return
1781
+ if action == AuthenticationAction.SIGN_IN and provider_for_browser_origin(origin) is not None:
1782
+ raise ValueError(origin_address(origin) + CLOUD_PASSWORD_REFUSAL_TEXT)
1783
+ if action == AuthenticationAction.SIGN_IN and (not username or not token):
1784
+ raise ValueError("Enter a username and a password or token.")
1785
+ if action == AuthenticationAction.APPROVE and not token:
1786
+ raise ValueError("Enter a username and a password or token.")
1787
+
1788
+ def _scope_key(self, origin: str, context: RepositoryContext) -> str:
1789
+ return json.dumps([canonical_path(context.common_git_dir), origin], ensure_ascii=False)
1790
+
1791
+ def _require_connected_account(self, data_by_key: dict[str, object], origin: str, namespace: str | None) -> None:
1792
+ """Refuse a choice that is empty or names no connected account of exactly this service address."""
1793
+ if namespace and endpoint_origin(origin) == origin:
1794
+ for account_by_key in data_by_key["accounts"]:
1795
+ if account_by_key["namespace"] == namespace and account_by_key["origin"] == origin:
1796
+ return
1797
+ raise ValueError("Choose a connected account for " + origin_address(origin) + " before saving.")
1798
+
1799
+ def _decode_scope_key(self, scope_key: str) -> tuple[str, str] | None:
1800
+ """Split a repository choice key back into its repository directory and origin; a key not built by _scope_key gives None."""
1801
+ scope_part_count = 2
1802
+ try:
1803
+ scope = json.loads(scope_key)
1804
+ except ValueError:
1805
+ return None
1806
+ if not isinstance(scope, list) or len(scope) != scope_part_count:
1807
+ return None
1808
+ repository_dir, origin = scope
1809
+ if not isinstance(repository_dir, str) or not isinstance(origin, str):
1810
+ return None
1811
+ return repository_dir, origin