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,35 @@
1
+ """Decide when a failed Git network step is started again because the credential manager could not read back a secret.
2
+
3
+ Git Credential Manager sometimes fails to read a secret it keeps, right after storing it, while the reads before and
4
+ after succeed. It fails before Git sends any authenticated request, so a step that is safe to repeat is started again
5
+ a moment later, a bounded number of times, instead of asking the user to click Retry. Each service decides which of its
6
+ own steps are safe to repeat; this module holds the rule they share and the line the console shows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from gitpane.config import CREDENTIAL_READ_RETRY_DELAY_MS, CREDENTIAL_READ_RETRY_LIMIT, MILLISECONDS_PER_SECOND, SharedText
14
+ from gitpane.process.process_runner import ProcessStatus
15
+ from gitpane.transport.git_refusal import credential_store_read_failed
16
+
17
+ if TYPE_CHECKING:
18
+ from gitpane.process.process_runner import ProcessResult
19
+
20
+
21
+ def credential_read_retry_due(result: ProcessResult, retry_count: int) -> bool:
22
+ """Tell whether a finished step must be started again: it failed on the credential manager's own words and has retries left.
23
+
24
+ Only a command that ran to a failing end counts: a stop, a timeout or a launch that never happened is not a failed
25
+ secret read. ``retry_count`` is how many times this same step was already started again.
26
+ """
27
+ if result.status != ProcessStatus.FAILED or retry_count >= CREDENTIAL_READ_RETRY_LIMIT:
28
+ return False
29
+ return credential_store_read_failed(result.stderr.decode("utf-8", "replace"))
30
+
31
+
32
+ def credential_read_retry_text(attempt: int) -> str:
33
+ """Return the console and ``--log`` line that announces the retry numbered ``attempt``, counted from 1."""
34
+ seconds = f"{CREDENTIAL_READ_RETRY_DELAY_MS / MILLISECONDS_PER_SECOND:g}"
35
+ return SharedText.CREDENTIAL_READ_RETRY.value.format(seconds=seconds, attempt=attempt, limit=CREDENTIAL_READ_RETRY_LIMIT)
@@ -0,0 +1,207 @@
1
+ """Read what Git says when a transfer fails.
2
+
3
+ Git prints the server's own explanation as ``remote:`` lines, then its ``fatal:`` line. A form shows that output as Git
4
+ wrote it, only without debug traces, with each progress line reduced to its last value and with the credentials part of
5
+ every URL removed. The usual authentication refusals are recognised from the words of the answer, never from the name
6
+ of a service, so a self-hosted server that explains itself the same way is read the same way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from enum import Enum
13
+ from typing import TYPE_CHECKING
14
+
15
+ from gitpane.config import HTTPS_PREFIX
16
+ from gitpane.operation.diagnostic import ANSI_PATTERN, URL_USERINFO_PATTERN, terminal_text
17
+
18
+ if TYPE_CHECKING:
19
+ from gitpane.operation.diagnostic import PreparedDiagnostic
20
+
21
+
22
+ class RefusalKind(Enum):
23
+ """Say why a server refused to let Git in."""
24
+
25
+ PASSWORD_NOT_ACCEPTED = "password_not_accepted"
26
+ CREDENTIALS_REFUSED = "credentials_refused"
27
+ CREDENTIALS_MISSING = "credentials_missing"
28
+
29
+
30
+ REMOTE_LINE_PREFIX = "remote:"
31
+ # Git, curl (through GIT_TRACE_CURL) and Git Credential Manager all start their trace records with the time of day.
32
+ TRACE_TIME_PATTERN_TEXT = r"\d{2}:\d{2}:\d{2}\.\d{6} "
33
+ TRACE_RECORD_PATTERN = re.compile("^" + TRACE_TIME_PATTERN_TEXT)
34
+ # A local remote runs the server side of a Push on this machine with the trace settings it inherits from the pushing Git,
35
+ # and Git sends what that side writes back to the client after "remote:", so its trace records arrive with that prefix.
36
+ REMOTE_TRACE_RECORD_PATTERN = re.compile("^" + re.escape(REMOTE_LINE_PREFIX) + r"\s+" + TRACE_TIME_PATTERN_TEXT)
37
+ # curl's header, data and information markers, for a trace line that reaches the output without its time of day.
38
+ CURL_TRACE_PATTERN = re.compile(r"^(?:=> Send|<= Recv|== Info)\b")
39
+ # Git Credential Manager writes the .NET stack of an exception on its standard error, which Git passes on unchanged: one
40
+ # indented "at <method>" line per frame, and "--- End of ... stack trace ..." separators between the parts.
41
+ STACK_TRACE_LINE_PATTERN = re.compile(r"^\s+at \S|^\s*--- End of .*stack trace")
42
+ # Git could not ask anyone for a username or a password: no account answered and prompts are disabled. Older Git says it
43
+ # could not read them, Git with credential.interactive=false says it could not get the password, and Git Credential
44
+ # Manager says it may not prompt.
45
+ CREDENTIALS_MISSING_PATTERN = re.compile(
46
+ r"could not read (?:Username|Password)\b"
47
+ r"|unable to get password from user"
48
+ r"|Cannot prompt because user interactivity has been disabled",
49
+ re.IGNORECASE,
50
+ )
51
+ # Git Credential Manager's words when the system's credential store cannot be reached or stays locked: no desktop
52
+ # session, no session bus, a keyring nobody unlocked, or an unlock the user cancelled. Such a message never proves that
53
+ # a secret is missing; the store simply did not answer.
54
+ CREDENTIAL_STORE_UNAVAILABLE_PATTERN = re.compile(r"secret\s?service|d-?bus|keyring|unlock", re.IGNORECASE)
55
+ # Git Credential Manager's words when it could not read back a secret it keeps, although the store answered: seen now and
56
+ # then right after it stored that secret, while the reads before and after succeed. It fails on its "get", before Git
57
+ # sends any authenticated request, so nothing reached the server. It is neither a refusal of the server nor a locked or
58
+ # unreachable store, and the same step usually succeeds a moment later.
59
+ CREDENTIAL_STORE_READ_FAILURE_PATTERN = re.compile(r"Received invalid secret from the secret storage|Failed to load the secret", re.IGNORECASE)
60
+ # A server line saying plainly that account passwords are refused for Git. "password" must be followed in the same
61
+ # sentence by the refusal word, so a message that only mentions a password among other causes does not match.
62
+ PASSWORD_NOT_ACCEPTED_PATTERN = re.compile(
63
+ r"password[^.!?\n]*\b(?:removed|not available|not supported|no longer|not accepted|disabled)\b"
64
+ r"|stopped supporting[^.!?\n]*password"
65
+ r"|must use a personal access token",
66
+ re.IGNORECASE,
67
+ )
68
+ CREDENTIALS_REFUSED_PATTERN = re.compile(r"Authentication failed for|returned error: 401\b", re.IGNORECASE)
69
+ # Git's own line when the server answers that the repository does not exist: a private repository hidden from the
70
+ # account used gets the same answer. It is not an authentication refusal, so it is kept apart from refusal_kind.
71
+ REPOSITORY_NOT_FOUND_PATTERN = re.compile(r"^fatal: repository '[^']*' not found$")
72
+ # Git's own line when protocol.file.allow refuses the file transport to a clone Git starts by itself, such as the clone
73
+ # of a submodule whose URL is a local folder or a file:// URL. Only a whole line counts, so a server cannot fake it inside
74
+ # another sentence.
75
+ FILE_TRANSPORT_REFUSED_PATTERN = re.compile(r"^fatal: transport 'file' not allowed$", re.MULTILINE)
76
+ # The form shows only the end of a long output: the refusal and the fatal line are always last.
77
+ GIT_MESSAGE_LINE_LIMIT = 20
78
+ EARLIER_OUTPUT_TEXT = "… earlier lines are in Git's full output."
79
+ # The URL Git quotes in its own refusal line. A submodule initialization keeps no URL of its own, so the notice reads the
80
+ # address from there.
81
+ REFUSED_URL_PATTERN = re.compile(r"(?:Authentication failed for|could not read (?:Username|Password) for) '(?P<url>[^']+)'"
82
+ r"|unable to access '(?P<status_url>[^']+)': The requested URL returned error: 401\b", re.IGNORECASE)
83
+
84
+
85
+ def is_trace_line(line: str) -> bool:
86
+ """Tell whether a line is a debug trace record or a .NET stack line rather than something Git says to the user.
87
+
88
+ The forms and the operation consoles both use it, so a line left out of one is left out of the other.
89
+ """
90
+ return bool(TRACE_RECORD_PATTERN.match(line) or REMOTE_TRACE_RECORD_PATTERN.match(line) or CURL_TRACE_PATTERN.match(line)
91
+ or STACK_TRACE_LINE_PATTERN.match(line))
92
+
93
+
94
+ def git_output_lines(stderr_text: str) -> list[str]:
95
+ """Return every line of Git's standard error worth showing, in order.
96
+
97
+ Trace records and stack lines are dropped, each progress line keeps its last value, terminal colour and control
98
+ sequences are removed as a terminal would not show them, the credentials part of any URL is removed (including a
99
+ part already replaced by a mask) and blank lines around the output are dropped.
100
+ """
101
+ lines: list[str] = []
102
+ for line in terminal_text(ANSI_PATTERN.sub("", stderr_text)).split("\n"):
103
+ if is_trace_line(line):
104
+ continue
105
+ lines.append(URL_USERINFO_PATTERN.sub(r"\1", line.rstrip()))
106
+ while lines and not lines[-1]:
107
+ lines.pop()
108
+ first_index = 0
109
+ while first_index < len(lines) and not lines[first_index]:
110
+ first_index += 1
111
+ return lines[first_index:]
112
+
113
+
114
+ def git_message_text(stderr_text: str) -> str:
115
+ """Return Git's standard error as a form shows it: readable, without secrets, and short enough to fit.
116
+
117
+ Only the last lines are kept, where Git puts the server's refusal and its own fatal line; a first line then says
118
+ that the beginning is in Git's full output. Dropped trace and stack lines are never counted as cut lines.
119
+ """
120
+ lines = git_output_lines(stderr_text)
121
+ if len(lines) <= GIT_MESSAGE_LINE_LIMIT:
122
+ return "\n".join(lines)
123
+ return "\n".join([EARLIER_OUTPUT_TEXT, *lines[-GIT_MESSAGE_LINE_LIMIT:]])
124
+
125
+
126
+ def credential_store_unavailable(text: str) -> bool:
127
+ """Tell whether a credential manager's message says the system's credential store could not be reached or unlocked.
128
+
129
+ Git Credential Manager wraps its messages at about 80 columns, so line breaks and repeated spaces are merged first,
130
+ and words cut across two lines are still found.
131
+ """
132
+ return CREDENTIAL_STORE_UNAVAILABLE_PATTERN.search(" ".join(text.split())) is not None
133
+
134
+
135
+ def credential_store_read_failed(stderr_text: str) -> bool:
136
+ """Tell whether Git's standard error says the credential manager could not read back a secret it keeps.
137
+
138
+ Only the credential manager's own words count, never "any failure": a real refusal or a wrong account must fail at
139
+ once, while this read failure passes by itself and calls for no decision of the user. The lines are read as a form
140
+ shows them, so a trace record never counts, and a server's ``remote:`` line that quotes the same words is left out.
141
+ Git Credential Manager wraps its messages at about 80 columns, so the remaining lines are joined first.
142
+ """
143
+ own_lines: list[str] = []
144
+ for line in git_output_lines(stderr_text):
145
+ if line.lstrip().startswith(REMOTE_LINE_PREFIX):
146
+ continue
147
+ own_lines.append(line)
148
+ return CREDENTIAL_STORE_READ_FAILURE_PATTERN.search(" ".join(" ".join(own_lines).split())) is not None
149
+
150
+
151
+ def refusal_kind(text: str) -> RefusalKind | None:
152
+ """Recognise an authentication refusal from Git's output, or return None for any other failure.
153
+
154
+ Only the words of the answer are read. An ambiguous answer, such as one that says the password was wrong or that
155
+ a token may be required, is read as a plain refusal: the same text is sent for a mistyped password.
156
+ """
157
+ if CREDENTIALS_MISSING_PATTERN.search(text):
158
+ return RefusalKind.CREDENTIALS_MISSING
159
+ for line in text.split("\n"):
160
+ if line.lstrip().startswith(REMOTE_LINE_PREFIX) and PASSWORD_NOT_ACCEPTED_PATTERN.search(line):
161
+ return RefusalKind.PASSWORD_NOT_ACCEPTED
162
+ if CREDENTIALS_REFUSED_PATTERN.search(text):
163
+ return RefusalKind.CREDENTIALS_REFUSED
164
+ return None
165
+
166
+
167
+ def authentication_refused(diagnostic: PreparedDiagnostic) -> bool:
168
+ """Tell whether a failed transfer was refused for authentication, reading Git's output as a form shows it."""
169
+ return refusal_kind("\n".join(git_output_lines(diagnostic.stderr))) is not None
170
+
171
+
172
+ def repository_not_found(lines: list[str]) -> bool:
173
+ """Tell whether Git said the repository does not exist, reading the lines already cleaned by ``git_output_lines``.
174
+
175
+ Kept apart from ``refusal_kind`` on purpose: a missing repository is not an authentication refusal, so checking a
176
+ new account must not start recognising the server for it.
177
+ """
178
+ for line in lines:
179
+ if REPOSITORY_NOT_FOUND_PATTERN.match(line):
180
+ return True
181
+ return False
182
+
183
+
184
+ def file_transport_refused(stderr_text: str) -> bool:
185
+ """Tell whether Git refused the file transport to a submodule clone, reading its whole standard error.
186
+
187
+ The console then explains the refusal in plain words, whatever kind of URL the parent was cloned from. Windows line
188
+ ends and the carriage returns that end Git's progress lines become plain line ends first, so the line is matched
189
+ whole on every system.
190
+ """
191
+ return FILE_TRANSPORT_REFUSED_PATTERN.search(stderr_text.replace("\r\n", "\n").replace("\r", "\n")) is not None
192
+
193
+
194
+ def refused_https_url(stderr_text: str) -> str:
195
+ """Return the HTTPS URL Git named in its authentication refusal, without any credentials part, or an empty text.
196
+
197
+ The lines are read as a form shows them, so a username or a secret written in the URL is already gone. A refusal
198
+ quoting another kind of URL (plain HTTP, SSH, a local path) gives nothing, since no account applies there.
199
+ """
200
+ for line in git_output_lines(stderr_text):
201
+ match = REFUSED_URL_PATTERN.search(line)
202
+ if match is None:
203
+ continue
204
+ url = match.group("url") or match.group("status_url")
205
+ if url.lower().startswith(HTTPS_PREFIX):
206
+ return url
207
+ return ""
@@ -0,0 +1,121 @@
1
+ """Decide whether a pushed branch is linked to its remote branch.
2
+
3
+ A Push asks whether to link the branch only when Git would not already follow a remote branch for it, and the answer
4
+ the user keeps for a repository is saved in the preferences file, never in the repository's Git configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING, Self
12
+
13
+ from gitpane.config import LOCAL_PREFIX, PUSH_TRACKING_PREFERENCE_KEY, SharedText
14
+ from gitpane.system.path_identity import canonical_path
15
+ from gitpane.transport.remote_config import MERGE_TRACKING_FIELD, remote_tracking_ref, tracking_values
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
20
+ from gitpane.preference.preference_store import PreferenceStore
21
+ from gitpane.transport.remote_config import RemoteSnapshot
22
+
23
+
24
+ class PushTrackingChoice(Enum):
25
+ """What a Push does about linking a branch that follows no remote branch: ask each time, always link, or never link.
26
+
27
+ The value is the word saved in the preferences file, which every system and older GitPane versions read; the label
28
+ is the text Settings shows. ``ASK`` is never written: it is the absence of an entry for the repository, so going
29
+ back to it removes the entry.
30
+ """
31
+
32
+ ASK = ("ask", "Ask")
33
+ ALWAYS_ADD = ("always-add", "Always add")
34
+ NEVER_ADD = ("never-add", "Never add")
35
+
36
+ def __new__(cls, value: str, label: str) -> Self:
37
+ """Keep the saved word as the value and the Settings text beside it."""
38
+ member = object.__new__(cls)
39
+ member._value_ = value
40
+ member.label = label
41
+ return member
42
+
43
+
44
+ def tracking_question_applies(snapshot: RemoteSnapshot, remote_name: str, source_ref: str, destination_ref: str, tag_refs: Sequence[tuple[str, str]]) -> bool:
45
+ """Tell whether a push of ``source_ref`` to ``destination_ref`` on ``remote_name`` may link the branch to its remote branch.
46
+
47
+ Only a local branch pushed alone to an active configured remote qualifies, and only when it has no
48
+ ``branch.<name>.merge`` at all: a branch whose upstream is gone, one that follows a local branch (remote ".") and one
49
+ whose setting comes from a global file already have an upstream that Git uses. A push to a typed address has no
50
+ remote name, and Git does not know an inactive remote as a remote, so neither can be linked. A destination that no
51
+ Fetch refspec of the remote maps to a remote-tracking branch (a push-only remote, a mirror, a narrowed refspec)
52
+ cannot be linked either, since Git only links a branch to a remote-tracking branch: asking would only end on a link
53
+ that is not set, at every Push of that branch.
54
+ """
55
+ if not remote_name or tag_refs or not source_ref.startswith(LOCAL_PREFIX):
56
+ return False
57
+ entry = snapshot.entry_by_name.get(remote_name)
58
+ if entry is None or not entry.active:
59
+ return False
60
+ if not remote_tracking_ref(snapshot, remote_name, destination_ref):
61
+ return False
62
+ return not tracking_values(snapshot, source_ref, MERGE_TRACKING_FIELD)
63
+
64
+
65
+ def push_tracking_entry_by_repository(value: object) -> dict[str, object]:
66
+ """Read the saved JSON object mapping a repository's shared Git folder to its kept entry, each entry as stored.
67
+
68
+ This version writes a word per repository. A word it does not know, or an entry of another shape (a number, an
69
+ object a later GitPane may write), is kept as it is and only reads as no choice: refusing it would make the whole
70
+ key unreadable and block every later "Don't ask again" and Settings save. Only a value that is not a JSON object is
71
+ refused, TypeError when it is JSON of another shape and ValueError when it is not JSON at all, with a text that
72
+ names the key, since only removing it from the preferences file brings the choices back.
73
+ """
74
+ if not isinstance(value, str):
75
+ raise TypeError(SharedText.PUSH_TRACKING_UNREADABLE.value)
76
+ try:
77
+ entry_by_repository = json.loads(value)
78
+ except ValueError as error:
79
+ raise ValueError(SharedText.PUSH_TRACKING_UNREADABLE.value) from error
80
+ if not isinstance(entry_by_repository, dict):
81
+ raise TypeError(SharedText.PUSH_TRACKING_UNREADABLE.value)
82
+ return entry_by_repository
83
+
84
+
85
+ def validate_push_tracking_json(value: object) -> str:
86
+ """Accept any JSON object keyed by repository, whatever its entries hold, and keep its text as saved."""
87
+ push_tracking_entry_by_repository(value)
88
+ return value
89
+
90
+
91
+ def push_tracking_choice(preference: PreferenceStore, common_git_dir: str) -> PushTrackingChoice:
92
+ """Return the choice kept for the repository whose shared Git folder is ``common_git_dir``, or ``ASK`` when none is kept.
93
+
94
+ Linked worktrees share that folder, so they share the choice. A word this version does not know, and an entry that
95
+ is not a word, read as ``ASK``.
96
+ """
97
+ entry_by_repository = push_tracking_entry_by_repository(preference.read(PUSH_TRACKING_PREFERENCE_KEY))
98
+ word = entry_by_repository.get(canonical_path(common_git_dir))
99
+ if not isinstance(word, str):
100
+ return PushTrackingChoice.ASK
101
+ try:
102
+ return PushTrackingChoice(word)
103
+ except ValueError:
104
+ return PushTrackingChoice.ASK
105
+
106
+
107
+ def save_push_tracking_choice(preference: PreferenceStore, common_git_dir: str, choice: PushTrackingChoice) -> None:
108
+ """Keep ``choice`` for the repository whose shared Git folder is ``common_git_dir``, leaving every other entry as it is.
109
+
110
+ The write starts from the value stored right now, never from the copy in memory: another GitPane open at the same
111
+ time may have kept the choice of another repository meanwhile. The entries of other repositories are written back
112
+ exactly as stored, whatever they hold. ``ASK`` removes the entry. OSError, ValueError and TypeError are left to the
113
+ caller, and nothing is written then.
114
+ """
115
+ entry_by_repository = push_tracking_entry_by_repository(preference.read_saved(PUSH_TRACKING_PREFERENCE_KEY))
116
+ repository_key = canonical_path(common_git_dir)
117
+ if choice == PushTrackingChoice.ASK:
118
+ entry_by_repository.pop(repository_key, None)
119
+ else:
120
+ entry_by_repository[repository_key] = choice.value
121
+ preference.update({PUSH_TRACKING_PREFERENCE_KEY: json.dumps(entry_by_repository, ensure_ascii=False)})
@@ -0,0 +1,225 @@
1
+ """Say a transfer refusal of Git in plain words.
2
+
3
+ A failed transfer starts with one factual sentence naming the address and the account used, when one applies, and a
4
+ refusal that needs an account is described for the Accounts page without any secret.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from gitpane.transport.git_refusal import REMOTE_LINE_PREFIX, credential_store_unavailable, git_message_text, git_output_lines, refusal_kind, repository_not_found
14
+
15
+ if TYPE_CHECKING:
16
+ from gitpane.operation.diagnostic import PreparedDiagnostic
17
+ from gitpane.transport.branch_exchange import ServerRefusal
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TransferAccount:
22
+ """Say which address a transfer contacted and with which account, so a failure can name both without any secret.
23
+
24
+ ``address`` is the host and port only, without scheme or path; an empty ``label`` means no account was used.
25
+ ``needs_reconnect`` says a check of the system credential store found that account's secret gone.
26
+ """
27
+
28
+ address: str
29
+ label: str = ""
30
+ needs_reconnect: bool = False
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AccountNotice:
35
+ """Say which remote needs an account, at which address, and which account it was refused with, without any secret.
36
+
37
+ ``subject`` names what was contacted (a remote name, a submodule, the repository to clone); ``address`` is the host
38
+ and port only; an empty ``account_label`` means no account was used. It only describes the refusal: the caller
39
+ decides where to show it.
40
+ """
41
+
42
+ subject: str
43
+ address: str
44
+ account_label: str = ""
45
+
46
+
47
+ # GitPane's own explanation, shown once after Git's lines: Git decides, the user changes the setting if they want to.
48
+ FILE_TRANSPORT_REFUSED_TEXT = ("Git's protocol.file.allow setting does not allow cloning submodules from local folders or file:// URLs, so the submodule"
49
+ " clone was refused. Change this Git setting if you want to allow it.")
50
+ NO_GIT_MESSAGE_TEXT = "Git gave no other explanation."
51
+ # Cause shown under a failed Fetch, Push or clone when the server refused and no account is connected for its address.
52
+ NO_CONNECTED_ACCOUNT_NOTICE_TEXT = "No account is connected for {address}. Click Add account… to add one."
53
+ # GitPane's own refusal to start an HTTPS Fetch or Push when no account is chosen for its address: Git never runs, so
54
+ # this cause is the only sign that the transfer failed for lack of an account.
55
+ NO_ACCOUNT_CONFIGURED_TEXT = "No account configured. Choose an account in Settings before continuing."
56
+ AUTHENTICATION_FAILED_TEXT = "Authentication failed for {address} with account {account}. Reconnect it in Settings › Accounts."
57
+ # Added under that sentence once a check confirmed the account's secret is gone: Git and Git Credential Manager erase a
58
+ # secret by themselves when the server refuses it.
59
+ NEEDS_RECONNECT_TEXT = "{account}: Needs reconnect. Its saved sign-in is no longer in the system credential store."
60
+ REPOSITORY_HIDDEN_TEXT = "The account used ({account}) cannot see this repository. Check the URL, or choose another account."
61
+ # The orange notice shown at the top of Settings › Accounts after a refusal; the address part is left out when unknown.
62
+ ACCOUNT_NOTICE_MISSING_TEXT = "{subject}{address_part} needs an account. Add one with Add account…"
63
+ ACCOUNT_NOTICE_REFUSED_TEXT = "{subject}{address_part} refused account {account}. Select it in Connected accounts and click Reconnect."
64
+ ACCOUNT_NOTICE_ADDRESS_TEXT = " ({address})"
65
+ SUBMODULE_NOTICE_SUBJECT_TEXT = "Submodule {path}"
66
+ # A clone has no remote name yet, so the notice names what the user typed the address for.
67
+ CLONE_NOTICE_SUBJECT_TEXT = "The repository to clone"
68
+ # The first sentence of a Push the server itself refused (a hook, for instance), naming every refused branch or tag.
69
+ SERVER_REFUSAL_TEXT = "The server refused the push of {names}."
70
+ SERVER_REFUSAL_NAME_SEPARATOR = ", "
71
+ SERVER_REFUSAL_REASON_TEXT = "({reason})"
72
+ # A progress line the server sends during a Push ("Resolving deltas: 100% (3/3), done."): it says nothing about a refusal.
73
+ PROGRESS_LINE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z ]*:\s+\d+% \(\d+/\d+\)")
74
+
75
+
76
+ def _own_cause_text(cause: str, stderr_text: str) -> str:
77
+ """Return the part of a failure cause that is not Git's standard error itself.
78
+
79
+ A process that fails without its own explanation takes its whole standard error as cause, sometimes after a sentence
80
+ of GitPane's (a preserved partial clone). A cause that does not end like the standard error (a timeout, a modified
81
+ folder) is entirely GitPane's own and is returned whole.
82
+ """
83
+ cause_lines = cause.strip().split("\n")
84
+ stderr_lines: list[str] = []
85
+ for line in stderr_text.strip().split("\n"):
86
+ stderr_lines.append(line.rstrip())
87
+ if not stderr_lines or cause_lines[-1].rstrip() != stderr_lines[-1]:
88
+ return cause.strip()
89
+ own_lines: list[str] = []
90
+ for line in cause_lines:
91
+ if line.rstrip() == stderr_lines[0]:
92
+ break
93
+ own_lines.append(line)
94
+ return "\n".join(own_lines).strip()
95
+
96
+
97
+ def failure_headline(diagnostic: PreparedDiagnostic, account: TransferAccount | None) -> str:
98
+ """Return the one factual sentence that explains a failed transfer, or an empty text when none applies.
99
+
100
+ Without a known address (an SSH or local transfer) nothing is said. An authentication refusal names the account
101
+ that was used and points to Reconnect; without an account it says none is connected and how to add one. The
102
+ sentence never guesses a cause from the server's advice, since the same words come back for a revoked token. A
103
+ repository reported missing after an account was used may just be hidden from that account, so the account is
104
+ named; without an account Git's own line already says the repository does not exist. When the account's secret is
105
+ known to be gone, a second line says so right under the refusal sentence. A credential store that could not be
106
+ reached gets no sentence: the credential manager's own message says it.
107
+ """
108
+ if account is None:
109
+ return ""
110
+ lines = git_output_lines(diagnostic.stderr)
111
+ git_output = "\n".join(lines)
112
+ # The credential manager's own message, already in the console, says the system's credential store did not answer;
113
+ # the account's secret may well be intact, so no account is named as refused and no reconnect is suggested.
114
+ if credential_store_unavailable(git_output):
115
+ return ""
116
+ if refusal_kind(git_output) is not None:
117
+ if not account.label:
118
+ return NO_CONNECTED_ACCOUNT_NOTICE_TEXT.format(address=account.address)
119
+ headline = AUTHENTICATION_FAILED_TEXT.format(address=account.address, account=account.label)
120
+ if account.needs_reconnect:
121
+ headline += "\n" + NEEDS_RECONNECT_TEXT.format(account=account.label)
122
+ return headline
123
+ if account.label and repository_not_found(lines):
124
+ return REPOSITORY_HIDDEN_TEXT.format(account=account.label)
125
+ return ""
126
+
127
+
128
+ def is_no_account_refusal(text: str) -> bool:
129
+ """Tell whether a shown text holds, as one whole line, GitPane's refusal to start an HTTPS transfer without an account.
130
+
131
+ Every window recognises the refusal here instead of copying its sentence; the sentence only counts as a whole line,
132
+ so a server or Git line that merely quotes it is not taken for it.
133
+ """
134
+ return NO_ACCOUNT_CONFIGURED_TEXT in text.split("\n")
135
+
136
+
137
+ def account_notice(diagnostic: PreparedDiagnostic, account: TransferAccount | None, subject: str) -> AccountNotice | None:
138
+ """Describe the account a refused HTTPS transfer needs, or return None when the failure is not about an account.
139
+
140
+ A transfer GitPane refused to start because no account is chosen for its address counts as a refusal too. Without
141
+ an address (an SSH or local transfer) or without an authentication refusal (a missing repository, a refused
142
+ connection), nothing is asked of the user, so Settings is left alone; a credential store that could not be reached
143
+ says nothing about the account either.
144
+ """
145
+ if account is None:
146
+ return None
147
+ if not account.label and is_no_account_refusal(diagnostic.cause):
148
+ return AccountNotice(subject, account.address)
149
+ git_output = "\n".join(git_output_lines(diagnostic.stderr))
150
+ if credential_store_unavailable(git_output) or refusal_kind(git_output) is None:
151
+ return None
152
+ return AccountNotice(subject, account.address, account.label)
153
+
154
+
155
+ def account_notice_text(notice: AccountNotice) -> str:
156
+ """Say who needs an account and where, or which account was refused, in the words shown on the Accounts page."""
157
+ address_part = ACCOUNT_NOTICE_ADDRESS_TEXT.format(address=notice.address) if notice.address else ""
158
+ if notice.account_label:
159
+ return ACCOUNT_NOTICE_REFUSED_TEXT.format(subject=notice.subject, address_part=address_part, account=notice.account_label)
160
+ return ACCOUNT_NOTICE_MISSING_TEXT.format(subject=notice.subject, address_part=address_part)
161
+
162
+
163
+ def server_refusal_headline(refusals: tuple[ServerRefusal, ...], stderr_text: str) -> str:
164
+ """Say in a few lines which references the server refused and why, or return an empty text when it refused none.
165
+
166
+ The sentence names every refused branch or tag, then come the server's own lines without their "remote:" prefix
167
+ (trace records and progress left out), then Git's reasons in parentheses, each said once. ``stderr_text`` is Git's
168
+ standard error as already filtered for secrets, so a window can show these lines as they are.
169
+ """
170
+ if not refusals:
171
+ return ""
172
+ names: list[str] = []
173
+ reasons: list[str] = []
174
+ for refusal in refusals:
175
+ names.append(refusal.short_name())
176
+ if refusal.reason and refusal.reason not in reasons:
177
+ reasons.append(refusal.reason)
178
+ lines = [SERVER_REFUSAL_TEXT.format(names=SERVER_REFUSAL_NAME_SEPARATOR.join(names))]
179
+ for line in git_output_lines(stderr_text):
180
+ if not line.startswith(REMOTE_LINE_PREFIX):
181
+ continue
182
+ message = line.removeprefix(REMOTE_LINE_PREFIX).strip()
183
+ if message and not PROGRESS_LINE_PATTERN.match(message):
184
+ lines.append(message)
185
+ for reason in reasons:
186
+ lines.append(SERVER_REFUSAL_REASON_TEXT.format(reason=reason))
187
+ return "\n".join(lines)
188
+
189
+
190
+ def headline_first(headline: str, text: str) -> str:
191
+ """Put a failure headline above the rest of a summary, leaving out whichever part is empty.
192
+
193
+ A window that filters what it shows filters the rest on its own and keeps the headline out of the filter: the
194
+ headline holds only an address and an account label, or the server's lines already filtered with the rest of Git's
195
+ output, and a filter that has learned the Git username from a URL would otherwise hide the account it names.
196
+ """
197
+ parts: list[str] = []
198
+ for part in (headline, text):
199
+ if part:
200
+ parts.append(part)
201
+ return "\n".join(parts)
202
+
203
+
204
+ def failure_summary(diagnostic: PreparedDiagnostic, account: TransferAccount | None = None) -> str:
205
+ """Return what a form shows for a failed Git transfer: its own cause, the factual headline, then Git's output.
206
+
207
+ ``account`` is the address and account the transfer used, so the headline can name them; None when the transfer
208
+ was not an HTTPS one. Without any standard error the cause is all there is to say. The complete, filtered detail
209
+ stays available to the user through the details view.
210
+ """
211
+ if not diagnostic.stderr.strip():
212
+ return diagnostic.cause
213
+ parts: list[str] = []
214
+ own_cause = _own_cause_text(diagnostic.cause, diagnostic.stderr)
215
+ if own_cause:
216
+ parts.append(own_cause)
217
+ headline = failure_headline(diagnostic, account)
218
+ if headline:
219
+ parts.append(headline)
220
+ message = git_message_text(diagnostic.stderr)
221
+ if message:
222
+ parts.append(message)
223
+ elif not own_cause:
224
+ parts.append(NO_GIT_MESSAGE_TEXT)
225
+ return "\n".join(parts)