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,954 @@
1
+ """Prepare one secret-filtered diagnostic for every place that shows, copies or logs it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import uuid
10
+ from contextlib import ExitStack
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from enum import Enum
14
+ from typing import TYPE_CHECKING
15
+ from urllib.parse import unquote, urlsplit, urlunsplit
16
+
17
+ from gitpane.config import AUTHORIZATION_CODE_FIELD_NAME, DIAGNOSTIC_DECODE_LIMIT, LINE_SEPARATOR, NON_SECRET_ENVIRONMENT_NAME_SET, SENSITIVE_FIELD_NAMES, SENSITIVE_VALUE_MIN_LENGTH, TRACE_UNIT_LIMIT_BYTES
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable, Mapping
21
+ from typing import TextIO
22
+
23
+ SENSITIVE_NAME_PATTERN = re.compile(r"password|passwd|token|secret|authorization|api[_-]?key|credential|cookie|verifier|nonce", re.IGNORECASE)
24
+ URL_USERINFO_PATTERN = re.compile(r"(\b[a-z][a-z0-9+.-]*://)[^/\s@]+@", re.IGNORECASE)
25
+ MASKED_VALUE = "[hidden]"
26
+ # A "credential.username" or "credential.<url>.username" setting given on the command line: the key stays readable, the
27
+ # login after "=" is hidden. Git reads configuration names without regard to case.
28
+ CREDENTIAL_USERNAME_SETTING_PATTERN = re.compile(r"(credential\.(?:.+\.)?username=)(.+)", re.IGNORECASE | re.DOTALL)
29
+ FIELD_PATTERN = "|".join(re.escape(name).replace("_", "[-_]") for name in SENSITIVE_FIELD_NAMES)
30
+ # Words that make the "code" after them the result of a process or a request, never an OAuth authorization code. The
31
+ # word must start a word of its own, but may follow an underscore ("process_exit_code"), and only blanks of the same
32
+ # line, "_" or "-" may separate it from "code": a word that ends one line says nothing about a field on the next one.
33
+ RESULT_CODE_QUALIFIER_PATTERN = r"(?<![A-Za-z0-9])(?:exit|status|error|return)[ \t_-]*"
34
+ # Characters that, written right before a field name, make it the end of a longer name such as "passcode" or "otp_code".
35
+ WORD_CHARACTER_PATTERN = re.compile(r"[A-Za-z0-9_-]")
36
+ FIELD_ASSIGNMENT_PATTERN = re.compile(
37
+ r"(?P<prefix>(?P<qualifier>" + RESULT_CODE_QUALIFIER_PATTERN + r")?[\"']?(?P<name>" + FIELD_PATTERN + r")[\"']?\s*[:=]\s*)"
38
+ r"(?P<value>\[hidden\]|\[REDACTED\]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|[^\s&;,}\]]+)",
39
+ re.IGNORECASE,
40
+ )
41
+ # A whole number, with the minus sign of a signal exit (-15) or of a Windows status read as signed (-1073741819).
42
+ NUMERIC_CODE_VALUE_PATTERN = re.compile(r"-?(?P<digits>[0-9]+)")
43
+ # Longest number a bare "code" field may show: exit codes 0 to 255, signal exits, HTTP statuses and Git's 128 all fit,
44
+ # while a six-digit one-time code, the numeric authorization code a server can send, stays hidden.
45
+ BARE_CODE_DIGIT_LIMIT = 3
46
+ # Longest number an "exit code", "status code", "error code" or "return code" may show: a Windows exit status is a
47
+ # 32-bit value, up to 4294967295 (10 digits), such as 3221225477 for an access violation.
48
+ QUALIFIED_CODE_DIGIT_LIMIT = 10
49
+ SECRET_HEADER_PATTERN = re.compile(r"((?:proxy-authorization|authorization|set-cookie|cookie)\s*:\s*)([^\r\n]+)", re.IGNORECASE)
50
+ URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'<>]+", re.IGNORECASE)
51
+ ANSI_PATTERN = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
52
+ SENSITIVE_NORMALIZED_NAME_SET = frozenset(name.replace("-", "").replace("_", "") for name in SENSITIVE_FIELD_NAMES)
53
+ CURL_DATA_PATTERN = re.compile(rb"(?:<= Recv|=> Send) data: (.*)")
54
+ CURL_DATA_SIZE_PATTERN = re.compile(rb"(?:<= Recv|=> Send) data, [0-9]+ bytes")
55
+ DIAGNOSTIC_OPERATION_PREFIX = "Operation: "
56
+ DIAGNOSTIC_CAUSE_PREFIX = "Cause: "
57
+ DIAGNOSTIC_STDERR_HEADER = "Standard error:"
58
+ DIAGNOSTIC_TECHNICAL_DETAIL_HEADER = "Technical details:"
59
+ # Start of the one-line description that replaces a binary output; the console leaves it out, the records and --log keep it.
60
+ OPAQUE_BODY_PREFIX = "Opaque body: "
61
+ # A text unit is read as JSON when its first non-blank byte opens an object or an array. On a bytes pattern, \s covers
62
+ # exactly the six ASCII blanks bytes.lstrip() removes, and, like a strip of the rest of the output, it crosses line
63
+ # breaks: a blank line followed by a JSON line is read together with it.
64
+ JSON_UNIT_START_PATTERN = re.compile(rb"\s*[\[{]")
65
+ LEADING_BLANK_PATTERN = re.compile(rb"\s*")
66
+ # JSON tokens, read the way json.loads reads them (NaN and Infinity included). A string cannot hold a raw line break and
67
+ # numbers and literals end at one, so no token straddles the end of a line: each complete line can be scanned on its own.
68
+ JSON_BLANK_PATTERN = re.compile(rb"[ \t\n\r]*")
69
+ JSON_TOKEN_PATTERN = re.compile(
70
+ rb'(?P<punctuation>[\[\]{},:])|(?P<string>"(?:[^"\\\x00-\x1f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")'
71
+ rb"|(?P<scalar>-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?|true|false|null|NaN|-?Infinity)"
72
+ )
73
+ JSON_ARRAY_OPENING = b"["
74
+ JSON_OBJECT_OPENING = b"{"
75
+ JSON_OPENING_BY_CLOSING = {b"]": JSON_ARRAY_OPENING, b"}": JSON_OBJECT_OPENING}
76
+ JSON_NAME_SEPARATOR = b":"
77
+ JSON_VALUE_SEPARATOR = b","
78
+ INNERMOST_BRACKET_INDEX = -1
79
+ EXIT_STATUS_RUNNING = "running"
80
+ EXIT_STATUS_NOT_STARTED = "not started"
81
+ # The status of a failure that came after its process had already ended normally: the process is not the cause.
82
+ EXIT_STATUS_ENDED_BEFORE_FAILURE = "ended before the failure"
83
+ TERMINAL_REDRAW_BREAK = "\r"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class MaskedArguments:
88
+ """A command with its sensitive words replaced, and the raw secrets it replaced.
89
+
90
+ ``secret_values`` are values written after a secret-named option, such as a password or a token: every text of the
91
+ command, Git's own error output included, must hide them.
92
+
93
+ The login of a ``credential.<url>.username=<login>`` setting is hidden in ``arguments`` only, as the value of its
94
+ setting, and is not returned: it is no secret, and replacing that word anywhere else would damage the text around
95
+ it. On GitHub a login is also the owner part of that user's repository URLs, often the user's local account name in
96
+ every path, and a short one is part of ordinary words.
97
+ """
98
+
99
+ arguments: list[str]
100
+ secret_values: list[str]
101
+
102
+
103
+ def mask_sensitive_arguments(arguments: list[str]) -> MaskedArguments:
104
+ """Return the command with every value written after a secret-named option replaced, plus those raw values.
105
+
106
+ This rule lives here alone: every surface that shows a command — error diagnostics, the request published to
107
+ subscribers, the process log line, the console command line — must hide exactly the same words, and separate
108
+ copies have already drifted apart. A second sensitive option in a row is hidden too, because hiding one word too
109
+ many is the safe reading.
110
+
111
+ The value of every ``credential.<url>.username=<login>`` setting is hidden in the command as well: commands of a
112
+ repository with submodules carry the login of each saved default account in such a setting. Only that value is
113
+ hidden, in the command's own words; the login is not returned, so no filter replaces it elsewhere.
114
+ """
115
+ masked_arguments: list[str] = []
116
+ secret_values: list[str] = []
117
+ hide_next = False
118
+ for argument in arguments:
119
+ username_setting = CREDENTIAL_USERNAME_SETTING_PATTERN.fullmatch(argument)
120
+ if hide_next:
121
+ masked_arguments.append(MASKED_VALUE)
122
+ secret_values.append(argument)
123
+ elif username_setting is not None:
124
+ masked_arguments.append(username_setting.group(1) + MASKED_VALUE)
125
+ else:
126
+ masked_arguments.append(argument)
127
+ hide_next = argument.startswith("-") and SENSITIVE_NAME_PATTERN.fullmatch(argument.lstrip("-")) is not None
128
+ return MaskedArguments(masked_arguments, secret_values)
129
+
130
+
131
+ class SensitiveTextFilter:
132
+ """Filter complete text units and remember discovered secrets for later echoes."""
133
+
134
+ def __init__(self, environment_by_name: Mapping[str, str] | None = None) -> None:
135
+ """Accept an explicit environment for isolated tests and credential consumers."""
136
+ self.sensitive_value_set: set[str] = set()
137
+ for name, value in (os.environ if environment_by_name is None else environment_by_name).items():
138
+ if value and name.upper() not in NON_SECRET_ENVIRONMENT_NAME_SET and SENSITIVE_NAME_PATTERN.search(name):
139
+ self.sensitive_value_set.add(value)
140
+
141
+ @staticmethod
142
+ def decode(value: str) -> str:
143
+ """Recognize repeatedly encoded query keys without an unbounded decoding loop."""
144
+ for _ in range(DIAGNOSTIC_DECODE_LIMIT):
145
+ decoded = unquote(value)
146
+ if decoded == value:
147
+ break
148
+ value = decoded
149
+ return value
150
+
151
+ @classmethod
152
+ def normalized_name(cls, name: str) -> str:
153
+ """Bring a field name to the form the sensitive names are compared in: decoded, lowercase, without - or _."""
154
+ return cls.decode(name).lower().replace("-", "").replace("_", "")
155
+
156
+ @classmethod
157
+ def is_sensitive_name(cls, name: str) -> bool:
158
+ """Use one policy for nested JSON fields, query keys and protocol assignments."""
159
+ return cls.normalized_name(name) in SENSITIVE_NORMALIZED_NAME_SET
160
+
161
+ @classmethod
162
+ def is_readable_code_value(cls, name: str, value: object, qualified: bool = False) -> bool:
163
+ """Tell whether a "code" field holds a short whole number, an exit, HTTP or Git error code that stays readable.
164
+
165
+ The OAuth authorization code shares that field name, but it is a long string of letters and digits: any value
166
+ that is not a short number, and every value of the other sensitive fields, stays hidden. ``qualified`` says the
167
+ field was written after a word such as "exit" or "status", which allows the longer Windows exit statuses.
168
+ """
169
+ if cls.normalized_name(name) != AUTHORIZATION_CODE_FIELD_NAME:
170
+ return False
171
+ # JSON true and false are ints in Python, but no exit code: their text is not a number and they stay hidden.
172
+ if isinstance(value, int):
173
+ value = str(value)
174
+ if not isinstance(value, str):
175
+ return False
176
+ number = NUMERIC_CODE_VALUE_PATTERN.fullmatch(value)
177
+ if number is None:
178
+ return False
179
+ digit_limit = QUALIFIED_CODE_DIGIT_LIMIT if qualified else BARE_CODE_DIGIT_LIMIT
180
+ return len(number.group("digits")) <= digit_limit
181
+
182
+ def redact(self, text: str) -> str:
183
+ """Retain safe response text while hiding structured, encoded and known secrets."""
184
+ text = ANSI_PATTERN.sub("", text).replace("\r\n", "\n").replace("\r", "\n")
185
+ candidate = text.strip()
186
+ if candidate.startswith(("{", "[")):
187
+ try:
188
+ value = json.loads(candidate)
189
+ self._collect_json(value)
190
+ return json.dumps(self._redact_json(value), ensure_ascii=False)
191
+ except (ValueError, RecursionError):
192
+ pass
193
+ text = URL_PATTERN.sub(self._redact_url, text)
194
+ text = SECRET_HEADER_PATTERN.sub(self._redact_header, text)
195
+ text = FIELD_ASSIGNMENT_PATTERN.sub(self._redact_assignment, text)
196
+ return self.hide_known_values(text)
197
+
198
+ def hide_known_values(self, text: str) -> str:
199
+ """Replace every remembered value in already-parsed text, without learning anything new from it."""
200
+ for value in self.masked_values():
201
+ text = text.replace(value, MASKED_VALUE)
202
+ return text
203
+
204
+ def remember_values(self, values: list[str]) -> None:
205
+ """Hide these raw values in every later text, such as the words a command hid; an empty value is skipped."""
206
+ for value in values:
207
+ # An empty value would make the replacement insert the masking text between every single character.
208
+ if value:
209
+ self.sensitive_value_set.add(value)
210
+
211
+ def masked_values(self) -> list[str]:
212
+ """Return the remembered values that are long enough to hide, longest first so a value never splits a longer one.
213
+
214
+ The length rule lives here, where values are used, rather than where they are added: environment variables,
215
+ JSON fields, URLs and callers that add a value directly all end up in the same set, and none of them can bring
216
+ back a one-character value that would hide every occurrence of that character in the output.
217
+ """
218
+ long_enough_values: list[str] = []
219
+ for value in self.sensitive_value_set:
220
+ if len(value) >= SENSITIVE_VALUE_MIN_LENGTH:
221
+ long_enough_values.append(value)
222
+ return sorted(long_enough_values, key=len, reverse=True)
223
+
224
+ def _remember(self, value: object) -> None:
225
+ """Collect nested secret values before processing any public echo in the same unit."""
226
+ if isinstance(value, dict):
227
+ for nested in value.values():
228
+ self._remember(nested)
229
+ elif isinstance(value, list):
230
+ for nested in value:
231
+ self._remember(nested)
232
+ elif isinstance(value, str) and value and value not in {MASKED_VALUE, "[REDACTED]"}:
233
+ self.sensitive_value_set.add(value)
234
+ self.sensitive_value_set.add(self.decode(value))
235
+
236
+ def _collect_json(self, value: object) -> None:
237
+ """Discover secret fields before rendering sibling fields that can echo them."""
238
+ if isinstance(value, dict):
239
+ for name, nested in value.items():
240
+ if self.is_sensitive_name(name) and not self.is_readable_code_value(name, nested):
241
+ self._remember(nested)
242
+ else:
243
+ self._collect_json(nested)
244
+ elif isinstance(value, list):
245
+ for nested in value:
246
+ self._collect_json(nested)
247
+
248
+ def _redact_json(self, value: object) -> object:
249
+ """Preserve nested JSON structure while masking protected fields and echoed values."""
250
+ if isinstance(value, dict):
251
+ result_by_name: dict[str, object] = {}
252
+ for name, nested in value.items():
253
+ hidden = self.is_sensitive_name(name) and not self.is_readable_code_value(name, nested)
254
+ result_by_name[name] = MASKED_VALUE if hidden else self._redact_json(nested)
255
+ return result_by_name
256
+ if isinstance(value, list):
257
+ results: list[object] = []
258
+ for nested in value:
259
+ results.append(self._redact_json(nested))
260
+ return results
261
+ if isinstance(value, str):
262
+ return self.redact(value)
263
+ return value
264
+
265
+ def _redact_url(self, match: re.Match[str]) -> str:
266
+ """Keep the destination path while masking userinfo and secret query values.
267
+
268
+ Only a userinfo that carries a password is remembered, to hide it wherever it is echoed. A user name alone, such
269
+ as the account login GitPane writes in the URL of a command, is hidden in its URL only: it is no secret, and the
270
+ same word is often the owner of the user's repositories and the user's own folder name, which must stay readable.
271
+ """
272
+ value = match.group()
273
+ try:
274
+ parsed = urlsplit(value)
275
+ authority = parsed.netloc
276
+ if "@" in authority:
277
+ userinfo, _, destination = authority.rpartition("@")
278
+ _user, password_separator, password = userinfo.partition(":")
279
+ if password_separator:
280
+ self._remember(userinfo)
281
+ self._remember(password)
282
+ authority = MASKED_VALUE + "@" + destination
283
+ components: list[str] = []
284
+ for component in parsed.query.split("&"):
285
+ name, separator, content = component.partition("=")
286
+ if self.is_sensitive_name(name) and not self.is_readable_code_value(name, self.decode(content)):
287
+ self._remember(content)
288
+ component = name + separator + MASKED_VALUE
289
+ components.append(component)
290
+ return urlunsplit((parsed.scheme, authority, parsed.path, "&".join(components), parsed.fragment))
291
+ except ValueError:
292
+ return URL_USERINFO_PATTERN.sub(r"\1" + MASKED_VALUE + "@", value)
293
+
294
+ def _redact_header(self, match: re.Match[str]) -> str:
295
+ value = match.group(2)
296
+ self._remember(value)
297
+ for part in value.split(";"):
298
+ if "=" in part:
299
+ self._remember(part.partition("=")[-1].strip())
300
+ if value.lower().startswith(("bearer ", "basic ")):
301
+ self._remember(value.partition(" ")[-1])
302
+ return match.group(1) + MASKED_VALUE + ("\ufffd" if "\ufffd" in value else "")
303
+
304
+
305
+ @staticmethod
306
+ def name_ends_another_word(match: re.Match[str]) -> bool:
307
+ """Tell whether the field name found is only the end of a longer name, such as "code" in "passcode" or "otp_code".
308
+
309
+ The pattern finds a field name anywhere in a word, which keeps "my_token=..." hidden; but the readable short
310
+ number is allowed only to a "code" written as a word of its own, or right after "exit", "status", "error" or
311
+ "return".
312
+ """
313
+ name_start = match.start("name")
314
+ if match.group("qualifier") is not None or name_start == 0:
315
+ return False
316
+ return WORD_CHARACTER_PATTERN.fullmatch(match.string[name_start - 1]) is not None
317
+
318
+ def _redact_assignment(self, match: re.Match[str]) -> str:
319
+ """Mask protected assignments and remember their values for later echoes; a short exit or error code stays as is."""
320
+ value = match.group("value").strip("\"'")
321
+ if not self.name_ends_another_word(match) and self.is_readable_code_value(match.group("name"), value, match.group("qualifier") is not None):
322
+ return match.group()
323
+ self._remember(value)
324
+ return match.group("prefix") + MASKED_VALUE
325
+
326
+
327
+ class JsonScanStatus(Enum):
328
+ """Where a JSON unit stands at the end of a scanned line."""
329
+
330
+ UNFINISHED = "unfinished"
331
+ COMPLETE = "complete"
332
+ INVALID = "invalid"
333
+
334
+
335
+ class JsonExpectation(Enum):
336
+ """The token kinds the JSON grammar accepts next."""
337
+
338
+ VALUE = "value"
339
+ VALUE_OR_CLOSE = "value or close"
340
+ KEY = "key"
341
+ KEY_OR_CLOSE = "key or close"
342
+ COLON = "colon"
343
+ COMMA_OR_CLOSE = "comma or close"
344
+
345
+
346
+ JSON_VALUE_EXPECTATION_SET = frozenset({JsonExpectation.VALUE, JsonExpectation.VALUE_OR_CLOSE})
347
+ JSON_KEY_EXPECTATION_SET = frozenset({JsonExpectation.KEY, JsonExpectation.KEY_OR_CLOSE})
348
+ JSON_CLOSE_EXPECTATION_SET = frozenset({JsonExpectation.VALUE_OR_CLOSE, JsonExpectation.KEY_OR_CLOSE, JsonExpectation.COMMA_OR_CLOSE})
349
+
350
+
351
+ class JsonUnitScanner:
352
+ """Follow one JSON value line by line, so a stream knows at each line end whether to wait for more lines.
353
+
354
+ Re-parsing the whole unit with json.loads at every line cost the unit's size times its line count, and any line
355
+ that merely starts with a bracket, like Git's "[main 1a2b3c4] subject", kept failing and pulled in the rest of the
356
+ output. The scan reads each byte once and rejects such a line on the line where it appears. It only checks the
357
+ grammar; SensitiveTextFilter.redact still parses the finished unit itself.
358
+ """
359
+
360
+ def __init__(self) -> None:
361
+ """Start before the first token of the value."""
362
+ self.open_brackets: list[bytes] = []
363
+ self.expectation = JsonExpectation.VALUE
364
+ self.finished = False
365
+
366
+ def scan_line(self, content: bytearray, position: int, end: int) -> JsonScanStatus:
367
+ """Read the complete line content[position:end] and tell whether the value ended, can continue, or is not JSON."""
368
+ while True:
369
+ position = JSON_BLANK_PATTERN.match(content, position, end).end()
370
+ if position == end:
371
+ return JsonScanStatus.COMPLETE if self.finished else JsonScanStatus.UNFINISHED
372
+ if self.finished:
373
+ return JsonScanStatus.INVALID
374
+ match = JSON_TOKEN_PATTERN.match(content, position, end)
375
+ if match is None or not self._accept(match):
376
+ return JsonScanStatus.INVALID
377
+ position = match.end()
378
+
379
+ def _accept(self, match: re.Match[bytes]) -> bool:
380
+ """Apply one token to the grammar state, or return False when the grammar does not allow it here."""
381
+ punctuation = match.group("punctuation")
382
+ if punctuation is None:
383
+ if match.group("string") is not None and self.expectation in JSON_KEY_EXPECTATION_SET:
384
+ self.expectation = JsonExpectation.COLON
385
+ return True
386
+ if self.expectation not in JSON_VALUE_EXPECTATION_SET:
387
+ return False
388
+ self._end_value()
389
+ return True
390
+ if punctuation in (JSON_ARRAY_OPENING, JSON_OBJECT_OPENING):
391
+ if self.expectation not in JSON_VALUE_EXPECTATION_SET:
392
+ return False
393
+ self.open_brackets.append(punctuation)
394
+ self.expectation = JsonExpectation.VALUE_OR_CLOSE if punctuation == JSON_ARRAY_OPENING else JsonExpectation.KEY_OR_CLOSE
395
+ return True
396
+ if punctuation == JSON_NAME_SEPARATOR:
397
+ if self.expectation is not JsonExpectation.COLON:
398
+ return False
399
+ self.expectation = JsonExpectation.VALUE
400
+ return True
401
+ if punctuation == JSON_VALUE_SEPARATOR:
402
+ if self.expectation is not JsonExpectation.COMMA_OR_CLOSE:
403
+ return False
404
+ self.expectation = JsonExpectation.VALUE if self.open_brackets[INNERMOST_BRACKET_INDEX] == JSON_ARRAY_OPENING else JsonExpectation.KEY
405
+ return True
406
+ if not self.open_brackets or self.open_brackets[INNERMOST_BRACKET_INDEX] != JSON_OPENING_BY_CLOSING[punctuation]:
407
+ return False
408
+ if self.expectation not in JSON_CLOSE_EXPECTATION_SET:
409
+ return False
410
+ self.open_brackets.pop()
411
+ self._end_value()
412
+ return True
413
+
414
+ def _end_value(self) -> None:
415
+ """After a complete value, expect a separator or a closing bracket, or mark the whole unit finished."""
416
+ if self.open_brackets:
417
+ self.expectation = JsonExpectation.COMMA_OR_CLOSE
418
+ return
419
+ self.finished = True
420
+
421
+
422
+ class TextUnitEnd(Enum):
423
+ """Say whether a filtered unit completes a line or only redraws its current value."""
424
+
425
+ LINE = "line"
426
+ REDRAW = "redraw"
427
+ HIDDEN_TAIL = "hidden tail"
428
+
429
+
430
+ class TextValueSource(Enum):
431
+ """Distinguish a newly received visible value from an already published preview."""
432
+
433
+ NEW = "new"
434
+ PUBLISHED = "published"
435
+
436
+
437
+ @dataclass(frozen=True)
438
+ class TextUnit:
439
+ """Keep a complete filtered record separate from the value the console shows."""
440
+
441
+ text: str
442
+ shown_text: str | None
443
+ end: TextUnitEnd = TextUnitEnd.LINE
444
+ source: TextValueSource = TextValueSource.NEW
445
+
446
+
447
+ class SensitiveTextStream:
448
+ """Hold incomplete UTF-8 and JSON units until the common filter can protect them."""
449
+
450
+ def __init__(self, text_filter: SensitiveTextFilter | None = None, hides_value: Callable[[str], bool] | None = None) -> None:
451
+ """Share a filter when several streams can repeat the same operation secret."""
452
+ self.filter = text_filter or SensitiveTextFilter()
453
+ self.hides_value = hides_value
454
+ self.buffer = bytearray()
455
+ self.complete = True
456
+ self.discarded = False
457
+ self.secret_header = False
458
+ # A JSON unit still open at the end of the previous call: it starts at the head of the buffer and its lines are
459
+ # scanned up to json_scan_end, so the next call resumes there instead of reading those lines again.
460
+ self.json_scanner: JsonUnitScanner | None = None
461
+ self.json_scan_end = 0
462
+ self.published_end = 0
463
+ self.redraw_end = 0
464
+ self.redraw_value = ""
465
+ self.redraw_value_start: int | None = None
466
+
467
+ def feed(self, data: bytes, final: bool = False) -> list[str]:
468
+ """Return complete filtered records without the console-only redraws."""
469
+ texts: list[str] = []
470
+ for unit in self.feed_units(data, final):
471
+ if unit.end != TextUnitEnd.REDRAW:
472
+ texts.append(unit.text)
473
+ return texts
474
+
475
+ def feed_units(self, data: bytes, final: bool = False) -> list[TextUnit]:
476
+ """Return complete records with safe previews of the currently open line."""
477
+ return self._feed_units(data, final)
478
+
479
+ def feed_pieces(self, data: bytes, final: bool = False) -> list[str]:
480
+ """Return filtered pieces a downstream trace can read with the original carriage returns.
481
+
482
+ Complete values of an open line are filtered separately so progress can arrive before the newline without
483
+ exposing a partial secret. Protected units stay whole; completed records still use the shared unit scanner.
484
+ """
485
+ pieces: list[str] = []
486
+ self._feed_units(data, final, pieces)
487
+ return pieces
488
+
489
+ def _feed_units(self, data: bytes, final: bool, pieces: list[str] | None = None) -> list[TextUnit]:
490
+ """Return only safe complete units; a quota failure withholds the remaining stream.
491
+
492
+ A unit is one line, or, when its first non-blank byte opens JSON, the lines up to the end of that JSON value; an
493
+ unfinished JSON value waits for more output unless the stream ends. A line that cannot continue the value, such
494
+ as Git's "[main 1a2b3c4] subject", ends its unit at once, so it never holds back the lines after it. The buffer
495
+ is walked with an index and cut once per call, and each line is scanned once even when a JSON unit spans several
496
+ calls, so the cost grows with the output instead of with the output times its line count.
497
+ """
498
+ if self.discarded:
499
+ return []
500
+ if len(self.buffer) + len(data) > TRACE_UNIT_LIMIT_BYTES:
501
+ self.buffer = bytearray()
502
+ self.json_scanner = None
503
+ self.complete = False
504
+ self.discarded = True
505
+ self.published_end = 0
506
+ self.reset_redraw()
507
+ text = f"Trace incomplete: text unit exceeds {TRACE_UNIT_LIMIT_BYTES} bytes; remaining stream withheld."
508
+ if pieces is not None:
509
+ pieces.append(text + "\n")
510
+ return [TextUnit(text, text)]
511
+ content = self.buffer
512
+ content.extend(data)
513
+ units: list[TextUnit] = []
514
+ if b"\n" not in data and not final:
515
+ # No unit can end without a new line break, and every earlier line break in the buffer was already tried.
516
+ if b"\r" in data:
517
+ self._publish_redraw(bytes(content), units, pieces)
518
+ return units
519
+ start = 0
520
+ scanner = self.json_scanner
521
+ scan_end = self.json_scan_end
522
+ # Where the blank run that begins at or after start ends: a run of blank lines is scanned once, not once per line.
523
+ blank_end = -1
524
+ while True:
525
+ line_start = start if scanner is None else scan_end
526
+ newline = content.find(b"\n", line_start)
527
+ if newline < 0:
528
+ break
529
+ end = newline + 1
530
+ if scanner is None:
531
+ if start > blank_end:
532
+ blank_end = LEADING_BLANK_PATTERN.match(content, start).end()
533
+ if not JSON_UNIT_START_PATTERN.match(content, blank_end):
534
+ units.append(self._finish_unit(bytes(content[start:end]), pieces))
535
+ start = end
536
+ continue
537
+ scanner = JsonUnitScanner()
538
+ if scanner.scan_line(content, line_start, end) is JsonScanStatus.UNFINISHED:
539
+ scan_end = end
540
+ continue
541
+ # The value is complete, or this line proves the unit is not JSON after all: either way the unit ends here,
542
+ # kept whole so a secret split across its lines is still filtered together.
543
+ units.append(self._finish_unit(bytes(content[start:end]), pieces))
544
+ scanner = None
545
+ start = end
546
+ del content[:start]
547
+ if final and content:
548
+ units.append(self._finish_unit(bytes(content), pieces))
549
+ content.clear()
550
+ scanner = None
551
+ self.json_scanner = scanner
552
+ self.json_scan_end = scan_end - start if scanner is not None else 0
553
+ if not final and b"\r" in data:
554
+ self._publish_redraw(bytes(content), units, pieces)
555
+ if final:
556
+ self.reset_redraw()
557
+ return units
558
+
559
+ def _allows_redraw(self, content: bytes, *, complete_unit: bool = False) -> bool:
560
+ """Keep protected units whole before either previewing or forwarding any value."""
561
+ if (not complete_unit and self.json_scanner is not None) or b"\0" in content or JSON_UNIT_START_PATTERN.match(content):
562
+ return False
563
+ return not (self.secret_header and content.startswith((b" ", b"\t")))
564
+
565
+ def _publish_redraw(self, content: bytes, units: list[TextUnit], pieces: list[str] | None) -> None:
566
+ """Forward every new complete value, or provide just the last console preview."""
567
+ if pieces is None:
568
+ redraw = self.open_line_redraw(content)
569
+ if redraw is not None:
570
+ units.append(redraw)
571
+ return
572
+ if not self._allows_redraw(content):
573
+ return
574
+ end = content.rfind(b"\r") + 1
575
+ if end <= self.published_end:
576
+ return
577
+ try:
578
+ text = content[self.published_end:end].decode("utf-8")
579
+ except UnicodeDecodeError:
580
+ return
581
+ for value in text.removesuffix("\r").split("\r"):
582
+ pieces.append(self.filter.redact(value) + "\r")
583
+ self.published_end = end
584
+
585
+ def _finish_unit(self, content: bytes, pieces: list[str] | None) -> TextUnit:
586
+ """Finish a shared record while forwarding only values not already published."""
587
+ can_reuse_redraw = self.redraw_value_start is not None and self._allows_redraw(content, complete_unit=True) and b"\n" not in content.removesuffix(b"\n")
588
+ if pieces is None:
589
+ unit = self._prepare_unit(content)
590
+ elif self.published_end:
591
+ # A later binary value cannot retract earlier progress. Filter each remaining value so its opaque
592
+ # description does not discard the readable values after it or repeat the already forwarded prefix.
593
+ secret_header = self.secret_header
594
+ texts: list[str] = []
595
+ for value in content[self.published_end:].removesuffix(b"\n").split(b"\r"):
596
+ texts.append(self._prepare_unit(value).text)
597
+ self.secret_header = secret_header
598
+ pieces.append("\r".join(texts) + "\n")
599
+ unit = self._prepare_unit(content)
600
+ else:
601
+ preserves_redraw = self._allows_redraw(content, complete_unit=True) and b"\n" not in content.removesuffix(b"\n")
602
+ try:
603
+ content.decode("utf-8")
604
+ except UnicodeDecodeError:
605
+ preserves_redraw = False
606
+ unit = self._prepare_unit(content)
607
+ # In a single ordinary line, every newline introduced by the filter came from a carriage return.
608
+ text = unit.text.replace("\n", "\r") if preserves_redraw else unit.text
609
+ pieces.append(text + "\n")
610
+ if can_reuse_redraw:
611
+ end = len(content.removesuffix(b"\n"))
612
+ source = self._learn_redraw(content, end) or TextValueSource.NEW
613
+ unit = TextUnit(unit.text, unit.shown_text, unit.end, source)
614
+ self.reset_redraw()
615
+ self.published_end = 0
616
+ return unit
617
+
618
+ def open_line_redraw(self, open_line: bytes) -> TextUnit | None:
619
+ """Learn new complete redraw values before choosing the last visible filtered preview.
620
+
621
+ The preview cursor is separate from the buffered record and forwarded pieces, which retain every value.
622
+ A quoted value split by a carriage return is filtered piece by piece, just as it is at a line feed. JSON,
623
+ binary text and folded secret headers must instead stay private until their whole unit can be filtered.
624
+ """
625
+ if b"\r" not in open_line or not self._allows_redraw(open_line):
626
+ return None
627
+ end = open_line.rfind(b"\r") + 1
628
+ source = self._learn_redraw(open_line, end)
629
+ if source is None:
630
+ return None
631
+ # Another segment or stream may have taught the shared filter a secret contained in the retained value.
632
+ shown = terminal_line(self.filter.redact(self.redraw_value), LINE_SEPARATOR, self.hides_value)
633
+ if not shown:
634
+ return None
635
+ self.redraw_value = shown
636
+ return TextUnit(shown, shown, TextUnitEnd.REDRAW, source)
637
+
638
+ def _learn_redraw(self, content: bytes, end: int) -> TextValueSource | None:
639
+ """Learn new segments while identifying their visible value by byte position, never by text equality."""
640
+ try:
641
+ text = content[self.redraw_end:end].decode("utf-8")
642
+ except UnicodeDecodeError:
643
+ return None
644
+ previous_start = self.redraw_value_start
645
+ start = self.redraw_end
646
+ for value in text.removesuffix("\r").split("\r"):
647
+ shown = terminal_line(self.filter.redact(value), LINE_SEPARATOR, self.hides_value)
648
+ if shown:
649
+ self.redraw_value = shown
650
+ self.redraw_value_start = start
651
+ start += len(value.encode("utf-8")) + len(TERMINAL_REDRAW_BREAK)
652
+ self.redraw_end = end
653
+ return TextValueSource.NEW if self.redraw_value_start != previous_start else TextValueSource.PUBLISHED
654
+
655
+ def reset_redraw(self) -> None:
656
+ """Forget the completed line's preview without forgetting operation secrets."""
657
+ self.redraw_end = 0
658
+ self.redraw_value = ""
659
+ self.redraw_value_start = None
660
+
661
+ def _prepare_unit(self, content: bytes) -> TextUnit:
662
+ """Remove terminal control sequences and folded secret headers before publication."""
663
+ if b"\0" in content:
664
+ text = OPAQUE_BODY_PREFIX + f"type=application/octet-stream; length={len(content)} bytes; result=opaque."
665
+ return TextUnit(text, text)
666
+ valid_utf8 = True
667
+ try:
668
+ text = content.decode("utf-8").rstrip("\n")
669
+ except UnicodeDecodeError:
670
+ valid_utf8 = False
671
+ self.complete = False
672
+ text = "Trace incomplete: invalid UTF-8 text. " + content.decode("utf-8", "replace").rstrip("\n")
673
+ if self.secret_header and text.startswith((" ", "\t")):
674
+ return TextUnit(MASKED_VALUE, MASKED_VALUE)
675
+ self.secret_header = bool(SECRET_HEADER_PATTERN.search(text))
676
+ text = self.filter.redact(text)
677
+ if not valid_utf8 or b"\n" in content.removesuffix(b"\n"):
678
+ return TextUnit(text, text)
679
+ # The shared filter has already turned each carriage return into a line feed, so those breaks are redraws here.
680
+ shown_text = terminal_line(text, LINE_SEPARATOR, self.hides_value)
681
+ last_value = terminal_line(text, LINE_SEPARATOR)
682
+ end = TextUnitEnd.LINE
683
+ if shown_text is not None and last_value and self.hides_value is not None and self.hides_value(last_value):
684
+ end = TextUnitEnd.HIDDEN_TAIL
685
+ return TextUnit(text, shown_text, end)
686
+
687
+ class NetworkTextStream:
688
+ """Reconstruct curl display fragments before filtering complete HTTP bodies."""
689
+
690
+ def __init__(self, text_filter: SensitiveTextFilter, hides_value: Callable[[str], bool] | None = None) -> None:
691
+ """Share discovered secrets with ordinary output and final error publication."""
692
+ self.filter = text_filter
693
+ self.stream = SensitiveTextStream(text_filter, hides_value)
694
+ self.buffer = b""
695
+ self.body = bytearray()
696
+ self.complete = True
697
+ self.discarded = False
698
+
699
+ def feed(self, data: bytes, final: bool = False) -> list[tuple[str, str]]:
700
+ """Return complete filtered network records without console-only redraws."""
701
+ records: list[tuple[str, str]] = []
702
+ for kind, unit in self.feed_units(data, final):
703
+ if unit.end != TextUnitEnd.REDRAW:
704
+ records.append((kind, unit.text))
705
+ return records
706
+
707
+ def feed_units(self, data: bytes, final: bool = False) -> list[tuple[str, TextUnit]]:
708
+ """Return safe ordinary records and reconstructed bodies, withholding oversized units."""
709
+ if self.discarded:
710
+ return []
711
+ content = self.buffer + data
712
+ self.buffer = b""
713
+ if len(content) > TRACE_UNIT_LIMIT_BYTES:
714
+ return self._discard()
715
+ records: list[tuple[str, TextUnit]] = []
716
+ while b"\n" in content or final and content:
717
+ line, separator, content = content.partition(b"\n")
718
+ match = CURL_DATA_PATTERN.search(line)
719
+ if match is not None:
720
+ self.stream.reset_redraw()
721
+ self.body.extend(match.group(1).rstrip(b"\r"))
722
+ if len(self.body) > TRACE_UNIT_LIMIT_BYTES:
723
+ return records + self._discard()
724
+ continue
725
+ if not CURL_DATA_SIZE_PATTERN.search(line):
726
+ records.extend(self._flush_body())
727
+ # Finish the unterminated last line directly: a discarded intermediate preview was never published.
728
+ for unit in self.stream.feed_units(line + separator, final=final and not separator):
729
+ records.append(("stderr", unit))
730
+ self.stream.reset_redraw()
731
+ self.buffer = content
732
+ if not final and b"\r" in data and not self.body and not CURL_DATA_PATTERN.search(content) and not CURL_DATA_SIZE_PATTERN.search(content):
733
+ redraw = self.stream.open_line_redraw(content)
734
+ if redraw is not None:
735
+ records.append(("stderr", redraw))
736
+ if final:
737
+ records.extend(self._flush_body())
738
+ for unit in self.stream.feed_units(b"", final=True):
739
+ records.append(("stderr", unit))
740
+ self.complete = self.complete and self.stream.complete
741
+ return records
742
+
743
+ def _flush_body(self) -> list[tuple[str, TextUnit]]:
744
+ """Filter a complete body before any consumer receives its fragmented fields."""
745
+ if not self.body:
746
+ return []
747
+ stream = SensitiveTextStream(self.filter)
748
+ records = []
749
+ for unit in stream.feed_units(bytes(self.body), final=True):
750
+ records.append(("http-body", unit))
751
+ self.body.clear()
752
+ self.complete = self.complete and stream.complete
753
+ return records
754
+
755
+ def _discard(self) -> list[tuple[str, TextUnit]]:
756
+ """Fail closed when a body or display line exceeds the bounded capture size."""
757
+ self.complete = False
758
+ self.discarded = True
759
+ self.body.clear()
760
+ self.buffer = b""
761
+ self.stream.reset_redraw()
762
+ text = "Trace incomplete: a network text unit exceeded the capture limit; remaining stream withheld."
763
+ return [("stderr", TextUnit(text, text))]
764
+
765
+
766
+ def terminal_line(line: str, redraw_break: str = TERMINAL_REDRAW_BREAK, hides_value: Callable[[str], bool] | None = None) -> str | None:
767
+ """Keep the last nonempty visible value of a redrawn line without trailing padding.
768
+
769
+ A break at the very end does not redraw anything, so interrupted progress keeps its value. Return None when
770
+ every nonempty value is hidden, or an empty string when the line never held a nonempty value.
771
+ """
772
+ hidden = False
773
+ for value in reversed(line.split(redraw_break)):
774
+ value = value.rstrip()
775
+ if not value:
776
+ continue
777
+ if hides_value is not None and hides_value(value):
778
+ hidden = True
779
+ continue
780
+ return value
781
+ return None if hidden else ""
782
+
783
+
784
+ def terminal_text(text: str) -> str:
785
+ """Return the final visible value of each terminal line without trailing padding.
786
+
787
+ Git redraws its progress lines with carriage returns ("Receiving objects: 10%", then "100%, done."), so only the
788
+ final value of each progress line is worth showing. A Windows line ending (carriage return then line feed) is an
789
+ ordinary line break, not a redraw. A final carriage return leaves the last nonempty value intact.
790
+ """
791
+ lines: list[str] = []
792
+ for line in text.replace("\r\n", "\n").split("\n"):
793
+ lines.append(terminal_line(line))
794
+ return "\n".join(lines)
795
+
796
+
797
+ @dataclass(frozen=True)
798
+ class PreparedDiagnostic:
799
+ """Hold already-filtered text so every output exposes the same details.
800
+
801
+ Only the "Arguments:" line of ``detail`` differs from Git's own text: it hides the value of each
802
+ ``credential.<url>.username`` setting, while ``stderr``, ``cause`` and the rest of ``detail`` keep the login
803
+ wherever Git wrote it, such as in the URL of a refused repository.
804
+
805
+ ``stderr`` is the filtered standard error alone, as a terminal would show it, so a form can show what Git said
806
+ without the command line and the rest of the detail.
807
+
808
+ ``return_code`` and ``process_id`` say which process failed and how, when one did: an operation console that
809
+ already ended that process with its own line does not repeat the failure, and one that hid the process gives its
810
+ exit code instead.
811
+ """
812
+
813
+ operation: str
814
+ cause: str
815
+ detail: str
816
+ masked: bool
817
+ stderr: str = ""
818
+ return_code: int | None = None
819
+ process_id: str = ""
820
+
821
+
822
+ @dataclass(frozen=True)
823
+ class Diagnostic:
824
+ """Describe a failure without recording command stdout or the whole environment.
825
+
826
+ ``process_started`` tells that the process was launched and has not exited yet, as for the record written when a
827
+ command starts: without an exit code, its status reads "running" instead of "not started".
828
+
829
+ ``technical_detail`` holds facts meant for the details and the --log file only, such as object IDs: they follow
830
+ the cause line in the detail, filtered like the rest, and never enter the cause shown on screen.
831
+
832
+ ``process_id`` names the process the failure comes from, when there is one, so an operation console can tell that
833
+ it already showed this failure and must not repeat it.
834
+
835
+ ``ended_before_failure`` says that the process named had already ended normally when the failure happened, such
836
+ as the cleanup of the copies a comparison tool used: the status line then does not read as that process's failure
837
+ nor as a process that never started.
838
+ """
839
+
840
+ operation: str
841
+ repository_dir: str
842
+ program_filename: str
843
+ arguments: list[str]
844
+ cause: str
845
+ stderr: bytes = b""
846
+ return_code: int | None = None
847
+ process_started: bool = False
848
+ technical_detail: str = ""
849
+ process_id: str = ""
850
+ ended_before_failure: bool = False
851
+
852
+ def prepare(self, environment_by_name: Mapping[str, str] | None = None) -> PreparedDiagnostic:
853
+ """Decode complete byte output before redacting secrets across fragment boundaries."""
854
+ text_filter = SensitiveTextFilter(environment_by_name)
855
+ redact = text_filter.redact
856
+ masked = mask_sensitive_arguments(self.arguments)
857
+ safe_arguments = masked.arguments
858
+ text_filter.remember_values(masked.secret_values)
859
+ safe_stderr = self._normalize_network(self.stderr, text_filter)
860
+ safe_cause = self._normalize_network(self.cause.encode("utf-8", "replace"), text_filter)
861
+
862
+ if self.ended_before_failure:
863
+ return_text = EXIT_STATUS_ENDED_BEFORE_FAILURE
864
+ elif self.return_code is not None:
865
+ return_text = str(self.return_code)
866
+ elif self.process_started:
867
+ return_text = EXIT_STATUS_RUNNING
868
+ else:
869
+ return_text = EXIT_STATUS_NOT_STARTED
870
+ technical_text = ""
871
+ safe_technical_text = ""
872
+ if self.technical_detail:
873
+ technical_text = f"{DIAGNOSTIC_TECHNICAL_DETAIL_HEADER}\n{self.technical_detail}\n"
874
+ safe_technical_text = f"{DIAGNOSTIC_TECHNICAL_DETAIL_HEADER}\n{self._normalize_network(self.technical_detail.encode('utf-8', 'replace'), text_filter)}\n"
875
+ detail = (
876
+ f"{DIAGNOSTIC_OPERATION_PREFIX}{self.operation}\nRepository: {self.repository_dir}\nExecutable: {self.program_filename}\n"
877
+ f"Arguments: {self.arguments!r}\nExit status: {return_text}\n{DIAGNOSTIC_CAUSE_PREFIX}{self.cause}\n{technical_text}"
878
+ f"{DIAGNOSTIC_STDERR_HEADER}\n{self.stderr.decode('utf-8', 'replace')}"
879
+ )
880
+ safe_detail = (
881
+ f"{DIAGNOSTIC_OPERATION_PREFIX}{self.operation}\nRepository: {self.repository_dir}\nExecutable: {self.program_filename}\n"
882
+ f"Arguments: {safe_arguments!r}\nExit status: {return_text}\n{DIAGNOSTIC_CAUSE_PREFIX}{safe_cause}\n{safe_technical_text}{DIAGNOSTIC_STDERR_HEADER}\n{safe_stderr}"
883
+ )
884
+ redacted_detail = redact(safe_detail)
885
+ return PreparedDiagnostic(redact(self.operation), redact(safe_cause), redacted_detail, redacted_detail != detail, redact(terminal_text(safe_stderr)),
886
+ self.return_code, self.process_id)
887
+
888
+ @staticmethod
889
+ def _normalize_network(content: bytes, text_filter: SensitiveTextFilter) -> str:
890
+ """Reuse live trace parsing when complete diagnostics contain curl body display lines."""
891
+ if not CURL_DATA_PATTERN.search(content):
892
+ return content.decode("utf-8", "replace")
893
+ records = NetworkTextStream(text_filter).feed(content, final=True)
894
+ return "\n".join(text for _kind, text in records)
895
+
896
+
897
+ class DiagnosticLog:
898
+ """Write UTF-8 diagnostics into a unique session directory only when requested."""
899
+
900
+ def __init__(self, enabled: bool, base_dir: str, now: datetime | None = None) -> None:
901
+ """Create an exclusive timestamped file without colliding with another launch."""
902
+ self.log_filename: str | None = None
903
+ self.stream: TextIO | None = None
904
+ self.warning = ""
905
+ self.resource = ExitStack()
906
+ # Lines also come from background tasks, such as the search of the credential manager: one writer at a time
907
+ # keeps lines whole. Reentrant, since a write that fails closes the log under the same lock.
908
+ self.lock = threading.RLock()
909
+ if not enabled:
910
+ return
911
+ try:
912
+ session_dir = os.path.join(base_dir, "diagnostic", uuid.uuid4().hex)
913
+ os.makedirs(session_dir, exist_ok=False)
914
+ basename = (now or datetime.now(UTC).astimezone()).strftime("%Y%m%d-%H%M%S_gitpane.log")
915
+ self.log_filename = os.path.join(session_dir, basename)
916
+ # The file context spans the application lifetime and closes with the owning ExitStack.
917
+ self.stream = self.resource.enter_context(open(self.log_filename, "x", encoding="utf-8", newline="\n")) # noqa: SIM115
918
+ except OSError as error:
919
+ self.warning = self._warning(error)
920
+
921
+ def write(self, diagnostic: PreparedDiagnostic) -> None:
922
+ """Write only the prepared details, making a disk error non-fatal to Git reads."""
923
+ self.write_line(diagnostic.detail)
924
+
925
+ def write_line(self, text: str) -> None:
926
+ """Write one already-filtered line, for records that are not error diagnostics; a disk error stays non-fatal.
927
+
928
+ Safe from any thread: a background task may write while the window's thread writes process lines.
929
+ """
930
+ with self.lock:
931
+ if self.stream is None:
932
+ return
933
+ try:
934
+ self.stream.write(text + "\n")
935
+ self.stream.flush()
936
+ except OSError as error:
937
+ self.warning = self._warning(error)
938
+ self.close()
939
+
940
+ def close(self) -> None:
941
+ """Flush and release this session's handler without touching another log; safe from any thread."""
942
+ with self.lock:
943
+ stream = self.stream
944
+ self.stream = None
945
+ if stream is None:
946
+ return
947
+ try:
948
+ self.resource.close()
949
+ except OSError as error:
950
+ self.warning = self._warning(error)
951
+
952
+ def _warning(self, error: OSError) -> str:
953
+ safe_error = Diagnostic("Log", "", "", [], str(error)).prepare().cause
954
+ return f"Could not write the log : {safe_error}. Error details remain available in the dialogs."