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,766 @@
1
+ """Collect complete, filtered process records before exposing them to any consumer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import json
7
+ import struct
8
+ import tempfile
9
+ from contextlib import ExitStack
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from PySide6.QtCore import QObject, QTimer, Signal
14
+
15
+ from gitpane.config import LINE_SEPARATOR, TRACE_PAGE_SIZE, TRACE_STORAGE_LIMIT_BYTES, TRACE_UNIT_LIMIT_BYTES, TRACE_VIEW_LIMIT_BYTES
16
+ from gitpane.operation.console_line import (
17
+ CANCELLED_TEXT,
18
+ FAILED_TEXT,
19
+ FAILED_WITH_EXIT_TEMPLATE,
20
+ NOT_STARTED_TEXT,
21
+ ConsoleLine,
22
+ cause_beyond_exit_code,
23
+ cause_without_shown_output,
24
+ command_line,
25
+ header_text,
26
+ line_with_cause,
27
+ process_end_line,
28
+ with_account_hint,
29
+ )
30
+ from gitpane.operation.console_tone import ConsoleTone, git_output_tone, result_tone
31
+ from gitpane.operation.diagnostic import OPAQUE_BODY_PREFIX, Diagnostic, NetworkTextStream, PreparedDiagnostic, SensitiveTextFilter, SensitiveTextStream, TextUnit, TextUnitEnd, TextValueSource
32
+ from gitpane.operation.operation_status import OperationStatus
33
+ from gitpane.process.process_runner import CREDENTIAL_PROFILE, NETWORK_PROFILE, READ_PROFILE
34
+ from gitpane.transport.git_refusal import is_trace_line
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable
38
+
39
+ from gitpane.operation.console_line import ConsoleHeader
40
+ from gitpane.operation.diagnostic import DiagnosticLog
41
+ from gitpane.process.process_runner import ProcessRequest, ProcessResult, ProcessRunner
42
+
43
+
44
+ # Records of these kinds are errors GitPane reports itself, shown red unless the caller gives another tone.
45
+ FAILURE_KIND_SET = frozenset({"error", "incomplete"})
46
+ # Records of these kinds are what a process wrote; only they can be the description of a binary output.
47
+ PROCESS_OUTPUT_KIND_SET = frozenset({"stdout", "stderr", "http-body"})
48
+ # Kind of the event that tells the view about a console-only line, which is never stored as a record nor logged.
49
+ CONSOLE_ONLY_KIND = "console"
50
+
51
+
52
+ def publish_after_trace(publish: Callable[[], None], context: QObject | None = None) -> None:
53
+ """Call ``publish`` one event-loop turn later, once the operation's trace has heard the process that just ended.
54
+
55
+ A service hears a process end before the trace of its operation does, because the trace connects to the runner's
56
+ ``finished`` signal after the service. Writing the result one turn later lets the trace write the ended process's
57
+ own line first: a diagnostic then knows that line is already on screen, and the end line stays the last line. With
58
+ a ``context``, the call is dropped if that object is destroyed before the turn comes.
59
+ """
60
+ if context is None:
61
+ QTimer.singleShot(0, publish)
62
+ return
63
+ QTimer.singleShot(0, context, publish)
64
+
65
+
66
+ def cause_without_trace_lines(cause: str) -> str:
67
+ """Return a process failure cause without its trace records, for the result record written to the ``--log`` file.
68
+
69
+ A process that fails without its own explanation takes its whole standard error as cause. Its trace records already
70
+ went to the ``--log`` file one by one as standard error, so the result record repeats only what Git says to the user.
71
+ """
72
+ kept_lines: list[str] = []
73
+ for line in cause.split("\n"):
74
+ if not is_trace_line(line):
75
+ kept_lines.append(line)
76
+ return "\n".join(kept_lines)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class TraceEvent:
81
+ """Carry an operation's already-filtered record with its originating process."""
82
+
83
+ operation_id: str
84
+ process_id: str
85
+ kind: str
86
+ text: str
87
+ sequence: int = 0
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ToneSpan:
92
+ """A byte range of the console text and its colour; the view sets the tone on the Qt blocks of that range.
93
+
94
+ ``end`` is exclusive. Spans are added in text order and never overlap.
95
+ """
96
+
97
+ start: int
98
+ end: int
99
+ tone: ConsoleTone
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class OpenLine:
104
+ """The console's last line while the process that wrote it can still redraw it."""
105
+
106
+ process_id: str
107
+ kind: str
108
+ line_start: int
109
+
110
+
111
+ class OperationTrace(QObject):
112
+ """Retain safe complete units, withholding fragments until redaction is possible."""
113
+
114
+ event_added = Signal(object)
115
+
116
+ def __init__(self, operation_id: str, runner: ProcessRunner, log: DiagnosticLog | None = None, *, native_commands_only: bool = False,
117
+ header: ConsoleHeader | None = None) -> None:
118
+ """Subscribe to the injected runner without owning its process lifetime.
119
+
120
+ With ``native_commands_only``, the reads the operation runs for its own checks are left out: they are not
121
+ commands the user would type, and the console then shows only what a terminal would. They are not lost: the
122
+ ``--log`` file keeps one line for every finished process, written by the launcher whatever the trace keeps.
123
+ The status lines the application appends itself are always kept. Without it, a read still shows only its
124
+ ``$`` line: its standard output is recorded and logged but never shown (see ``shows_output``).
125
+
126
+ A ``header`` names the repository (or a clone's destination) once, on the first line of the console, just
127
+ before the first line shown: an empty trace stays empty. It is console text only and never reaches the log.
128
+ """
129
+ super().__init__()
130
+ self.operation_id = operation_id
131
+ self.runner = runner
132
+ self.log = log
133
+ self.native_commands_only = native_commands_only
134
+ self.header = header
135
+ self.header_written = False
136
+ self.tone_spans: list[ToneSpan] = []
137
+ # Processes the console already ended with their own line, and the error output each of them showed: a
138
+ # diagnostic about one of them adds nothing, and any diagnostic leaves out of its cause what was already shown.
139
+ self.ended_process_id_set: set[str] = set()
140
+ self.failed_outputs: list[str] = []
141
+ # Processes whose ``$ `` line the console wrote and that have not ended yet: a request that ends without one
142
+ # never started, which its end line says.
143
+ self.started_request_id_set: set[str] = set()
144
+ self.event_count = 0
145
+ self.stored_count = 0
146
+ self.storage_size = 0
147
+ self.text_size = 0
148
+ self.open_line: OpenLine | None = None
149
+ self.capture_failed = False
150
+ self.failure_event: TraceEvent | None = None
151
+ self.failure_text = b""
152
+ self.index_record = struct.Struct("!Q")
153
+ self.resource = ExitStack()
154
+ self.stream = None
155
+ self.index_stream = None
156
+ self.text_stream = None
157
+ self.filter = SensitiveTextFilter()
158
+ self.complete = True
159
+ self.stream_by_key: dict[tuple[str, str], SensitiveTextStream] = {}
160
+ self.config_buffer_by_id: dict[str, bytearray] = {}
161
+ self.network_stream_by_id: dict[str, NetworkTextStream] = {}
162
+ self.closed = False
163
+ self.close_requested = False
164
+ runner.started.connect(self._started)
165
+ runner.stdout_chunk.connect(self._stdout)
166
+ runner.stderr_chunk.connect(self._stderr)
167
+ runner.finished.connect(self._finished)
168
+ try:
169
+ # Anonymous private handles belong to this collector and disappear on close or process exit.
170
+ self.stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
171
+ self.index_stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
172
+ self.text_stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
173
+ except OSError:
174
+ self.mark_incomplete("Filtered output storage could not be created. Check temporary-directory permissions or available disk space.")
175
+
176
+ def append_event(self, process_id: str, kind: str, text: str, tone: ConsoleTone = ConsoleTone.NEUTRAL) -> None:
177
+ """Record a text and show it as it is, as a terminal would, without any label.
178
+
179
+ A Git, curl or Git Credential Manager trace record on standard error stays out of the console text and of what
180
+ it copies: there it buries what Git says to the user. It is still stored as a record, so a reader of the
181
+ records, such as a test, sees every one, and it is still written to the ``--log``
182
+ file, which keeps the complete output for diagnosis. Such a record notifies no view, since none shows it. The
183
+ one-line description that replaces a binary output is kept the same way: it says nothing a terminal would show.
184
+
185
+ Without a given tone, a line of standard error takes the colour of what Git says on it (error, warning), an
186
+ error GitPane reports itself is red, and anything else, the status lines GitPane writes included, is neutral.
187
+ """
188
+ self._append(process_id, kind, text, self._output_console_line(kind, text, tone), hide_trace_line=kind == "stderr", hide_opaque_body=kind in PROCESS_OUTPUT_KIND_SET)
189
+
190
+ @staticmethod
191
+ def _output_console_line(kind: str, text: str, tone: ConsoleTone = ConsoleTone.NEUTRAL) -> ConsoleLine:
192
+ """Choose an output line's colour and account hint consistently for records and redraws."""
193
+ is_stderr = kind == "stderr"
194
+ if tone == ConsoleTone.NEUTRAL:
195
+ if is_stderr:
196
+ tone = git_output_tone(text)
197
+ elif kind in FAILURE_KIND_SET:
198
+ tone = ConsoleTone.FAILURE
199
+ console_text = with_account_hint(text) if is_stderr else text
200
+ return ConsoleLine(console_text, tone)
201
+
202
+ def append_record(self, process_id: str, kind: str, text: str, console_line: ConsoleLine | None) -> None:
203
+ """Store and log a record as it is, and show a different, shorter line in the console, or nothing.
204
+
205
+ The ``--log`` file keeps the full detail, such as the block describing a command; the console shows what a
206
+ terminal would, such as the command line itself.
207
+ """
208
+ self._append(process_id, kind, text, console_line)
209
+
210
+ def append_failure_cause(self, process_id: str, text: str) -> None:
211
+ """Write a cause GitPane found for a failure, such as a setting that made Git refuse something, apart from Git's own lines.
212
+
213
+ It is stored as an error record, written to the ``--log`` file and shown as one console line of its own, red and
214
+ bold even during a pause, so the user reads in plain words why the operation failed and what to change.
215
+ """
216
+ self.append_record(process_id, "error", text, ConsoleLine(text, ConsoleTone.FAILURE_CAUSE))
217
+
218
+ def append_end_line(self, process_id: str, kind: str, log_text: str, console_text: str, status: OperationStatus, *, failure_reported: bool = False,
219
+ expected_stop: bool = False) -> None:
220
+ """Write the line that ends an operation: its old text in the records and the ``--log`` file, the readable one in the console.
221
+
222
+ Its colour comes from ``end_tone``, the one rule every end line follows.
223
+ """
224
+ tone = self.end_tone(status, failure_reported=failure_reported, expected_stop=expected_stop)
225
+ self.append_record(process_id, kind, log_text, ConsoleLine(console_text, tone))
226
+
227
+ def end_tone(self, status: OperationStatus, *, failure_reported: bool = False, expected_stop: bool = False) -> ConsoleTone:
228
+ """Return the colour of the line that ends an operation: the only rule for an end line's colour, wherever it is written.
229
+
230
+ It is the rule of the console window, which takes an incomplete trace for a reported failure: a pause on an
231
+ incomplete trace ends red, as its window ends failed, so the end line and the window cannot disagree.
232
+ """
233
+ return result_tone(status, failure_reported=failure_reported or not self.complete, expected_stop=expected_stop)
234
+
235
+ def unshown_cause(self, diagnostic: PreparedDiagnostic) -> str | None:
236
+ """Return the part of a GitPane diagnostic's cause the console has not shown yet, or None when it has nothing to add.
237
+
238
+ This is the one place that reads a cause for the console, so a diagnostic line and an end line that carries its
239
+ cause cannot disagree. Nothing is added for a process the console already ended with its own line, and the error
240
+ output the console already showed is removed; when nothing remains, the answer is None. The runner's generic
241
+ sentence for a silent failure only repeats the exit code, so it becomes an empty cause: the failure still has
242
+ something to say (its exit code), but no words to add.
243
+ """
244
+ if diagnostic.process_id and diagnostic.process_id in self.ended_process_id_set:
245
+ return None
246
+ cause = cause_without_shown_output(diagnostic.cause, "")
247
+ for failed_output in self.failed_outputs:
248
+ cause = cause_without_shown_output(cause, failed_output)
249
+ if not cause:
250
+ return None
251
+ return cause_beyond_exit_code(cause, diagnostic.return_code)
252
+
253
+ def diagnostic_console_line(self, diagnostic: PreparedDiagnostic, status: OperationStatus, *, paused: bool = False) -> ConsoleLine | None:
254
+ """Return the one line a console shows for a GitPane diagnostic, or None when it would only repeat what is on screen.
255
+
256
+ The rules apply in this order:
257
+ 1. a diagnostic about a process the console already ended with its own line adds nothing;
258
+ 2. the cause loses the error output the console already showed, and an empty cause adds nothing;
259
+ 3. the runner's generic sentence for a silent failure is never written, since the exit code already says it;
260
+ 4. during a pause the cause explains why the operation waits, so it is shown neutral, without a word of failure,
261
+ and nothing is added when no cause remains;
262
+ 5. a failure that carries an exit code (a process this console hid) reads ``Failed (exit N): <cause>``, or
263
+ ``Failed (exit N)`` alone when no cause remains: its failure is shown nowhere else;
264
+ 6. an operation GitPane refused before launching Git reads ``Not started: <cause>``;
265
+ 7. an operation the user cancelled reads ``Cancelled: <cause>``, in orange;
266
+ 8. anything else reads ``Failed: <cause>``.
267
+ Failures and refusals are red. The full diagnostic block is not shown: it belongs to the ``--log`` file.
268
+ """
269
+ cause = self.unshown_cause(diagnostic)
270
+ if cause is None:
271
+ return None
272
+ if paused:
273
+ return ConsoleLine(cause) if cause else None
274
+ if diagnostic.return_code is not None:
275
+ return ConsoleLine(line_with_cause(FAILED_WITH_EXIT_TEMPLATE.format(return_code=diagnostic.return_code), cause), ConsoleTone.FAILURE)
276
+ if status == OperationStatus.REFUSED:
277
+ return ConsoleLine(line_with_cause(NOT_STARTED_TEXT, cause), ConsoleTone.FAILURE)
278
+ if status == OperationStatus.CANCELLED:
279
+ return ConsoleLine(line_with_cause(CANCELLED_TEXT, cause), ConsoleTone.CANCELLED)
280
+ return ConsoleLine(line_with_cause(FAILED_TEXT, cause), ConsoleTone.FAILURE)
281
+
282
+ def append_diagnostic(self, process_id: str, kind: str, diagnostic: PreparedDiagnostic, status: OperationStatus, *, paused: bool = False) -> None:
283
+ """Log a GitPane diagnostic as its full block and show at most one line of it, by the rules of ``diagnostic_console_line``.
284
+
285
+ ``status`` is the operation's outcome, which tells a refusal from a failure; ``paused`` tells that the
286
+ operation stopped to wait for the user, which the diagnostic then explains.
287
+ """
288
+ self.append_record(process_id, kind, diagnostic.detail, self.diagnostic_console_line(diagnostic, status, paused=paused))
289
+
290
+ def append_console_line(self, console_line: ConsoleLine) -> None:
291
+ """Show a line in the console alone, such as the end line of an operation that no record carried.
292
+
293
+ The records and the ``--log`` file stay exactly as they were: no record is stored, none is counted and nothing
294
+ is logged. The line goes through the same filter as every shown text, and the view is told to show it.
295
+ """
296
+ if self.closed or self.capture_failed:
297
+ return
298
+ if len(console_line.text) > TRACE_UNIT_LIMIT_BYTES or len(console_line.text.encode("utf-8", "replace")) > TRACE_UNIT_LIMIT_BYTES:
299
+ self.mark_incomplete("An output record exceeded the text-unit limit.")
300
+ return
301
+ line_text = self.filter.redact(console_line.text)
302
+ self._close_open_line()
303
+ display_data, tone_span = self._display(line_text, console_line.tone)
304
+ if self.storage_size + len(display_data) > TRACE_STORAGE_LIMIT_BYTES:
305
+ self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
306
+ return
307
+ try:
308
+ self._write_display(display_data)
309
+ except OSError:
310
+ self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
311
+ return
312
+ self.storage_size += len(display_data)
313
+ self._displayed(display_data, tone_span)
314
+ self.event_added.emit(TraceEvent(self.operation_id, self.operation_id, CONSOLE_ONLY_KIND, line_text, self.event_count))
315
+
316
+ def tone_spans_between(self, start: int, end: int) -> list[ToneSpan]:
317
+ """Return, in text order, the coloured byte ranges that overlap the console text from ``start`` to ``end`` (exclusive)."""
318
+ spans: list[ToneSpan] = []
319
+ first_index = bisect.bisect_right(self.tone_spans, start, key=lambda span: span.end)
320
+ for span in self.tone_spans[first_index:]:
321
+ if span.start >= end:
322
+ break
323
+ spans.append(span)
324
+ return spans
325
+
326
+ def _display_prefix(self) -> str:
327
+ """Return what comes before the next shown line: a line break after earlier text, and the header before the first line."""
328
+ prefix = LINE_SEPARATOR if self.text_size else ""
329
+ if self.header is not None and not self.header_written:
330
+ prefix += self.filter.redact(header_text(self.header)) + LINE_SEPARATOR
331
+ return prefix
332
+
333
+ def _display(self, line_text: str, tone: ConsoleTone) -> tuple[bytes, ToneSpan | None]:
334
+ """Return the bytes a shown line adds to the console text, line break and header included, and the coloured range of the line if any."""
335
+ prefix_data = self._display_prefix().encode("utf-8", "replace")
336
+ line_data = line_text.encode("utf-8", "replace")
337
+ tone_span = None
338
+ if tone != ConsoleTone.NEUTRAL and line_data:
339
+ line_start = self.text_size + len(prefix_data)
340
+ tone_span = ToneSpan(line_start, line_start + len(line_data), tone)
341
+ return prefix_data + line_data, tone_span
342
+
343
+ def _write_display(self, display_data: bytes) -> None:
344
+ """Write shown bytes at the end of the console text, flushed so that a full disk fails here; raise OSError on failure."""
345
+ self.text_stream.seek(self.text_size)
346
+ self.text_stream.write(display_data)
347
+ self.text_stream.flush()
348
+
349
+ def _displayed(self, display_data: bytes, tone_span: ToneSpan | None) -> None:
350
+ """Count written console bytes as shown: the header is now on screen and the line keeps its colour."""
351
+ self.text_size += len(display_data)
352
+ self.header_written = True
353
+ if tone_span is not None:
354
+ self.tone_spans.append(tone_span)
355
+
356
+ def stable_size(self) -> int:
357
+ """Return the size of the console prefix that no later write can change."""
358
+ if self.open_line is not None:
359
+ return self.open_line.line_start
360
+ return self.text_size + len(self.failure_text)
361
+
362
+ def _close_open_line(self) -> None:
363
+ """Keep the last drawn value as finished text before another line is written."""
364
+ self.open_line = None
365
+
366
+ def _rewrite_open_line(self, line_text: str, tone: ConsoleTone) -> bool:
367
+ """Replace only the open line's bytes, counting the size difference against storage limits."""
368
+ line_start = self.open_line.line_start
369
+ line_data = line_text.encode("utf-8", "replace")
370
+ size_difference = line_start + len(line_data) - self.text_size
371
+ if self.storage_size + size_difference > TRACE_STORAGE_LIMIT_BYTES:
372
+ self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
373
+ return False
374
+ try:
375
+ self.text_stream.seek(line_start)
376
+ self.text_stream.write(line_data)
377
+ self.text_stream.truncate()
378
+ self.text_stream.flush()
379
+ except OSError:
380
+ self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
381
+ return False
382
+ self.storage_size += size_difference
383
+ self.text_size = line_start + len(line_data)
384
+ last_span_index = -1
385
+ if self.tone_spans and self.tone_spans[last_span_index].start >= line_start:
386
+ self.tone_spans.pop()
387
+ if tone != ConsoleTone.NEUTRAL and line_data:
388
+ self.tone_spans.append(ToneSpan(line_start, self.text_size, tone))
389
+ return True
390
+
391
+ def _draw_open_line(self, process_id: str, kind: str, value: str) -> None:
392
+ """Show a filtered redraw without adding a stored record or a log line."""
393
+ if self.closed or self.capture_failed:
394
+ return
395
+ console_line = self._output_console_line(kind, value)
396
+ line_text = self.filter.redact(console_line.text)
397
+ if self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == (process_id, kind):
398
+ if not self._rewrite_open_line(line_text, console_line.tone):
399
+ return
400
+ else:
401
+ self._close_open_line()
402
+ display_data, tone_span = self._display(line_text, console_line.tone)
403
+ line_start = self.text_size + len(display_data) - len(line_text.encode("utf-8", "replace"))
404
+ if self.storage_size + len(display_data) > TRACE_STORAGE_LIMIT_BYTES:
405
+ self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
406
+ return
407
+ try:
408
+ self._write_display(display_data)
409
+ except OSError:
410
+ self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
411
+ return
412
+ self.storage_size += len(display_data)
413
+ self._displayed(display_data, tone_span)
414
+ self.open_line = OpenLine(process_id, kind, line_start)
415
+ self.event_added.emit(TraceEvent(self.operation_id, process_id, CONSOLE_ONLY_KIND, line_text, self.event_count))
416
+
417
+ def _append(self, process_id: str, kind: str, text: str, console_line: ConsoleLine | None, *, hide_trace_line: bool = False, hide_opaque_body: bool = False,
418
+ console_text_filtered: bool = False, open_line_key: tuple[str, str] | None = None) -> None:
419
+ """Filter an entire record once before model, clipboard or optional log access, and show its console line if any.
420
+
421
+ The stored record and the logged text are the record itself, labelled with its process and kind; only the
422
+ console text differs. A console line equal to the record reuses its filtered text, so Git's output is filtered
423
+ once; a different line (a command, an end line) goes through the same filter, which knows every secret seen.
424
+ ``hide_trace_line`` leaves out of the console a record whose filtered text is a debug trace line, and
425
+ ``hide_opaque_body`` one that describes a binary output. ``console_text_filtered`` tells that the console line
426
+ already went through the filter piece by piece, as a command line does word by word, and must not be filtered
427
+ again as a whole.
428
+ """
429
+ if self.closed or self.capture_failed:
430
+ return
431
+ if len(text) > TRACE_UNIT_LIMIT_BYTES or len(text.encode("utf-8", "replace")) > TRACE_UNIT_LIMIT_BYTES:
432
+ self.mark_incomplete("An output record exceeded the text-unit limit.")
433
+ return
434
+ safe_text = self.filter.redact(text)
435
+ if '"result": "incomplete"' in safe_text or '"result":"incomplete"' in safe_text or "Trace incomplete:" in safe_text:
436
+ self.complete = False
437
+ event = TraceEvent(self.operation_id, process_id, kind, safe_text, self.event_count)
438
+ if hide_trace_line and is_trace_line(safe_text):
439
+ console_line = None
440
+ if hide_opaque_body and safe_text.startswith(OPAQUE_BODY_PREFIX):
441
+ console_line = None
442
+ data = json.dumps([event.operation_id, event.process_id, event.kind, event.text, event.sequence], ensure_ascii=True).encode("utf-8") + b"\n"
443
+ display_data = b""
444
+ tone_span = None
445
+ replaces_open_line = False
446
+ display_size = 0
447
+ if console_line is not None:
448
+ if console_text_filtered:
449
+ line_text = console_line.text
450
+ elif console_line.text == text:
451
+ line_text = safe_text
452
+ else:
453
+ line_text = self.filter.redact(console_line.text)
454
+ replaces_open_line = self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == open_line_key
455
+ if replaces_open_line:
456
+ if line_text:
457
+ display_size = self.open_line.line_start + len(line_text.encode("utf-8", "replace")) - self.text_size
458
+ else:
459
+ self._close_open_line()
460
+ display_data, tone_span = self._display(line_text, console_line.tone)
461
+ display_size = len(display_data)
462
+ size = len(data) + display_size + self.index_record.size
463
+ if self.storage_size + size > TRACE_STORAGE_LIMIT_BYTES:
464
+ self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
465
+ return
466
+ # Flushing each record makes a full disk fail here, before the counters count the record as stored, and leaves
467
+ # nothing buffered for close() to write. It adds no system call: the seek before the next record would flush anyway.
468
+ try:
469
+ self.stream.seek(0, 2)
470
+ offset = self.stream.tell()
471
+ self.stream.write(data)
472
+ self.stream.flush()
473
+ if replaces_open_line:
474
+ if line_text and not self._rewrite_open_line(line_text, console_line.tone):
475
+ return
476
+ self._close_open_line()
477
+ else:
478
+ self._write_display(display_data)
479
+ self.index_stream.seek(self.stored_count * self.index_record.size)
480
+ self.index_stream.write(self.index_record.pack(offset))
481
+ self.index_stream.flush()
482
+ except OSError:
483
+ self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
484
+ return
485
+ self.storage_size += size - display_size if replaces_open_line else size
486
+ self.stored_count += 1
487
+ self.event_count += 1
488
+ if console_line is not None and not replaces_open_line:
489
+ self._displayed(display_data, tone_span)
490
+ if self.log is not None:
491
+ self.log.write(PreparedDiagnostic("Operation", "", self.format_event(event), safe_text != text))
492
+ if console_line is not None:
493
+ self.event_added.emit(event)
494
+
495
+ def mark_incomplete(self, cause: str) -> None:
496
+ """Retain one bounded failure record even when no further disk writes are possible."""
497
+ if self.closed:
498
+ return
499
+ self._close_open_line()
500
+ self.complete = False
501
+ self.capture_failed = True
502
+ if self.failure_event is not None:
503
+ return
504
+ self.failure_event = TraceEvent(self.operation_id, self.operation_id, "incomplete", "Trace incomplete: " + self.filter.redact(cause), self.event_count)
505
+ prefix_data = self._display_prefix().encode("utf-8")
506
+ line_data = self.failure_event.text.encode("utf-8")
507
+ self.failure_text = prefix_data + line_data
508
+ line_start = self.text_size + len(prefix_data)
509
+ self.tone_spans.append(ToneSpan(line_start, line_start + len(line_data), ConsoleTone.FAILURE))
510
+ self.event_count += 1
511
+ if self.log is not None:
512
+ self.log.write(PreparedDiagnostic("Operation", "", self.format_event(self.failure_event), False))
513
+ self.event_added.emit(self.failure_event)
514
+
515
+ def read_page(self, start: int = 0, count: int = TRACE_PAGE_SIZE) -> list[TraceEvent]:
516
+ """Return a bounded page without lending mutable storage to the view."""
517
+ events: list[TraceEvent] = []
518
+ if self.closed:
519
+ return events
520
+ for index in range(max(0, start), min(self.event_count, max(0, start) + min(TRACE_PAGE_SIZE, max(0, count)))):
521
+ if index == self.stored_count and self.failure_event is not None:
522
+ events.append(self.failure_event)
523
+ continue
524
+ try:
525
+ self.index_stream.seek(index * self.index_record.size)
526
+ offset, = self.index_record.unpack(self.index_stream.read(self.index_record.size))
527
+ self.stream.seek(offset)
528
+ events.append(TraceEvent(*json.loads(self.stream.readline())))
529
+ except (OSError, ValueError, struct.error):
530
+ self.mark_incomplete("Some saved output records could not be read. Available text can still be copied.")
531
+ break
532
+ return events
533
+
534
+ def read_text_page(self, start: int = 0, count: int = TRACE_VIEW_LIMIT_BYTES) -> tuple[str, int, int]:
535
+ """Read one bounded UTF-8 page and return its exact byte boundaries for navigation."""
536
+ if self.closed:
537
+ return "", 0, 0
538
+ start = max(0, min(start, self.text_size + len(self.failure_text)))
539
+ limit = min(max(1, count), TRACE_VIEW_LIMIT_BYTES)
540
+ data = self._read_text(start, limit)
541
+ # Page requests can land inside UTF-8; advance only across the remaining continuation bytes.
542
+ first_byte_index = 0
543
+ while data and data[first_byte_index] & 0xC0 == 0x80:
544
+ start += 1
545
+ data = data[1:]
546
+ while data:
547
+ try:
548
+ return data.decode("utf-8"), start, start + len(data)
549
+ except UnicodeDecodeError as error:
550
+ if error.end == len(data):
551
+ data = data[:error.start]
552
+ else:
553
+ self.mark_incomplete("Saved output contained unreadable text.")
554
+ return data.decode("utf-8", "replace"), start, start + len(data)
555
+ return "", start, start
556
+
557
+ def _read_text(self, start: int, count: int) -> bytes:
558
+ """Combine saved text with the single in-memory storage-failure record."""
559
+ data = b""
560
+ if start < self.text_size:
561
+ try:
562
+ self.text_stream.seek(start)
563
+ expected_count = min(count, self.text_size - start)
564
+ data = self.text_stream.read(expected_count)
565
+ if len(data) != expected_count:
566
+ self.mark_incomplete("Saved output ended unexpectedly. The operation trace is incomplete.")
567
+ return data + self.failure_text[:max(0, count - len(data))]
568
+ except OSError:
569
+ self.mark_incomplete("Saved text could not be read. The operation trace is incomplete.")
570
+ return self.failure_text[:count]
571
+ remaining = count - len(data)
572
+ failure_start = max(0, start - self.text_size)
573
+ return data + self.failure_text[failure_start:failure_start + remaining]
574
+
575
+ def copy_text(self) -> str:
576
+ """Return exactly the safe records available to the dialog."""
577
+ if self.closed:
578
+ return ""
579
+ return self._read_text(0, self.text_size + len(self.failure_text)).decode("utf-8", "replace")
580
+
581
+ @staticmethod
582
+ def format_event(event: TraceEvent) -> str:
583
+ """Label process and stream so concurrent commands remain distinguishable."""
584
+ return f"[{event.process_id} {event.kind}] {event.text}"
585
+
586
+ def close(self) -> None:
587
+ """Disconnect collection and discard any unpublished incomplete fragments."""
588
+ if self.closed:
589
+ return
590
+ for active in self.runner.active_by_id.values():
591
+ if active.request.operation_id == self.operation_id:
592
+ self.close_requested = True
593
+ return
594
+ for request in self.runner.pending_requests:
595
+ if request.operation_id == self.operation_id:
596
+ self.close_requested = True
597
+ return
598
+ self.closed = True
599
+ self.runner.started.disconnect(self._started)
600
+ self.runner.stdout_chunk.disconnect(self._stdout)
601
+ self.runner.stderr_chunk.disconnect(self._stderr)
602
+ self.runner.finished.disconnect(self._finished)
603
+ self.stream_by_key.clear()
604
+ self.config_buffer_by_id.clear()
605
+ self.network_stream_by_id.clear()
606
+ self.resource.close()
607
+
608
+ def _keeps(self, request: ProcessRequest) -> bool:
609
+ """Tell whether a process belongs to this operation and to what the trace shows of it."""
610
+ if request.operation_id != self.operation_id:
611
+ return False
612
+ return not (self.native_commands_only and request.profile == READ_PROFILE)
613
+
614
+ @staticmethod
615
+ def shows_output(request: ProcessRequest, kind: str) -> bool:
616
+ """Tell whether the console shows what a process writes on one of its streams; the records and the log keep it either way.
617
+
618
+ A read is a check GitPane runs for itself, and its standard output is in a machine format (fields separated by
619
+ NUL characters, raw listings) that tells the user nothing: the console shows only its ``$`` line, plus its error
620
+ output and its exit code when it fails. The ``--log`` file keeps that output for diagnosis. A program started for
621
+ the user (a write, a network command) keeps all its output on screen, as a terminal would show it.
622
+ """
623
+ return not (kind == "stdout" and request.profile == READ_PROFILE)
624
+
625
+ def _append_output(self, request: ProcessRequest, kind: str, text: str) -> None:
626
+ """Record one complete unit a process wrote, and show it only when ``shows_output`` says so."""
627
+ if self.shows_output(request, kind):
628
+ self.append_event(request.request_id, kind, text)
629
+ return
630
+ self.append_record(request.request_id, kind, text, None)
631
+
632
+ def _append_stream_unit(self, request: ProcessRequest, kind: str, unit: TextUnit) -> None:
633
+ """Store complete stream records while drawing only the unit's visible terminal value."""
634
+ shown_text = unit.shown_text
635
+ owns_open_line = self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == (request.request_id, kind)
636
+ if unit.source == TextValueSource.PUBLISHED and not owns_open_line:
637
+ shown_text = None
638
+ if not self.shows_output(request, kind) or kind in PROCESS_OUTPUT_KIND_SET and unit.text.startswith(OPAQUE_BODY_PREFIX):
639
+ shown_text = None
640
+ if unit.end in (TextUnitEnd.REDRAW, TextUnitEnd.HIDDEN_TAIL):
641
+ if unit.end == TextUnitEnd.HIDDEN_TAIL:
642
+ self.append_record(request.request_id, kind, unit.text, None)
643
+ if shown_text:
644
+ self._draw_open_line(request.request_id, kind, shown_text)
645
+ return
646
+ console_line = self._output_console_line(kind, shown_text) if shown_text is not None else None
647
+ self._append(request.request_id, kind, unit.text, console_line, open_line_key=(request.request_id, kind))
648
+
649
+ def _started(self, request: ProcessRequest) -> None:
650
+ """Record the command of a process of this operation as soon as it starts.
651
+
652
+ The logged record is written while the process runs, so it cannot carry an exit code yet and says "running";
653
+ the result record that follows the process carries its status and exit code. The console shows the command as
654
+ one would type it, after a ``$ `` prompt, followed by its folder when it runs elsewhere than the header says.
655
+ """
656
+ if self._keeps(request):
657
+ self.started_request_id_set.add(request.request_id)
658
+ prepared = Diagnostic(request.operation, request.working_dir, request.program_filename, request.arguments, "", process_started=True).prepare()
659
+ console_line = ConsoleLine(command_line(request, self.header, redact=self.filter.redact))
660
+ self._append(request.request_id, "command", prepared.detail, console_line, console_text_filtered=True)
661
+
662
+ def _stdout(self, request: ProcessRequest, data: bytes) -> None:
663
+ self._receive(request, "stdout", data)
664
+
665
+ def _stderr(self, request: ProcessRequest, data: bytes) -> None:
666
+ self._receive(request, "stderr", data)
667
+
668
+ def _receive(self, request: ProcessRequest, kind: str, data: bytes) -> None:
669
+ """Retain incomplete lines so a chunk boundary cannot split a secret."""
670
+ if self.closed or self.capture_failed or not self._keeps(request):
671
+ return
672
+ if kind == "stdout" and (request.profile == CREDENTIAL_PROFILE or ("cat-file" in request.arguments and "--batch" in request.arguments)):
673
+ # Object bodies are repository contents, not diagnostics; keep command and result only.
674
+ return
675
+ if kind == "stdout" and "config" in request.arguments and "--null" in request.arguments:
676
+ buffer = self.config_buffer_by_id.setdefault(request.request_id, bytearray())
677
+ if len(buffer) + len(data) > TRACE_UNIT_LIMIT_BYTES:
678
+ self.mark_incomplete("A configuration response exceeded the text-unit limit.")
679
+ return
680
+ buffer.extend(data)
681
+ return
682
+ if kind == "stdout" and any("%(" in argument and "%00" in argument for argument in request.arguments):
683
+ data = data.replace(b"\0", b"\\0")
684
+ if kind == "stderr" and request.profile == NETWORK_PROFILE:
685
+ self._receive_network(request, data)
686
+ return
687
+ self._feed(request, kind, data)
688
+
689
+ def _feed(self, request: ProcessRequest, kind: str, data: bytes) -> None:
690
+ """Keep incomplete ordinary text units private until the shared filter can process them."""
691
+ key = (request.request_id, kind)
692
+ stream = self.stream_by_key.setdefault(key, SensitiveTextStream(self.filter, hides_value=is_trace_line if kind == "stderr" else None))
693
+ for unit in stream.feed_units(data):
694
+ self._append_stream_unit(request, kind, unit)
695
+ self.complete = self.complete and stream.complete
696
+
697
+ def _receive_network(self, request: ProcessRequest, data: bytes, final: bool = False) -> None:
698
+ """Reassemble curl body display lines so secrets split at trace widths stay private."""
699
+ process_id = request.request_id
700
+ stream = self.network_stream_by_id.setdefault(process_id, NetworkTextStream(self.filter, hides_value=is_trace_line))
701
+ for kind, unit in stream.feed_units(data, final):
702
+ self._append_stream_unit(request, kind, unit)
703
+ self.complete = self.complete and stream.complete
704
+ if final:
705
+ self.network_stream_by_id.pop(process_id, None)
706
+
707
+ def _finished(self, result: ProcessResult) -> None:
708
+ """Flush terminal fragments before publishing the process's actual result.
709
+
710
+ The log keeps the result record of every process; the console adds a line only when the process did not succeed,
711
+ and remembers that process and its error output so a later diagnostic does not show them again.
712
+ """
713
+ if self.closed or result.request.operation_id != self.operation_id:
714
+ return
715
+ if not self._keeps(result.request):
716
+ # A hidden read can be the operation's last process, and a close waits for that one to end.
717
+ if self.close_requested:
718
+ self.close()
719
+ return
720
+ if result.request.profile == NETWORK_PROFILE:
721
+ self._receive_network(result.request, b"", final=True)
722
+ buffer = self.config_buffer_by_id.pop(result.request.request_id, None)
723
+ if buffer is not None:
724
+ from gitpane.transport.remote_config import config_records
725
+ try:
726
+ content = bytes(buffer)
727
+ if any(argument in result.request.arguments for argument in ("--name-only", "--get-regexp", "--list")) and "--show-origin" not in result.request.arguments and "--show-scope" not in result.request.arguments:
728
+ if content and not content.endswith(b"\0"):
729
+ raise ValueError("Incomplete configuration record")
730
+ records = []
731
+ for setting in content.split(b"\0"):
732
+ if setting:
733
+ records.append(b"unknown\0unknown\0" + setting + b"\0")
734
+ content = b"".join(records)
735
+ if content and "--show-origin" in result.request.arguments and "--show-scope" not in result.request.arguments:
736
+ if not content.endswith(b"\0"):
737
+ raise ValueError("Incomplete configuration record")
738
+ fields = content.removesuffix(b"\0").split(b"\0")
739
+ field_count = 2
740
+ if len(fields) % field_count:
741
+ raise ValueError("Incomplete configuration record")
742
+ records = []
743
+ iterator = iter(fields)
744
+ for origin, setting in zip(iterator, iterator, strict=True):
745
+ records.append(b"unknown\0" + origin + b"\0" + setting + b"\0")
746
+ content = b"".join(records)
747
+ # The scope stays the word Git printed: a listing read without --show-scope is given "unknown" above.
748
+ for record in config_records(content):
749
+ self._append_output(result.request, "stdout", f"{record.scope_text} {record.origin} {record.key}={record.value!r}")
750
+ except ValueError:
751
+ self.mark_incomplete("A configuration response ended before its complete record could be filtered.")
752
+ for kind in ("stdout", "stderr"):
753
+ stream = self.stream_by_key.pop((result.request.request_id, kind), None)
754
+ if stream is not None:
755
+ for unit in stream.feed_units(b"", final=True):
756
+ self._append_stream_unit(result.request, kind, unit)
757
+ self.complete = self.complete and stream.complete
758
+ command_shown = result.request.request_id in self.started_request_id_set
759
+ self.started_request_id_set.discard(result.request.request_id)
760
+ end_line = process_end_line(result, command_shown=command_shown)
761
+ self.append_record(result.request.request_id, "result", f"{result.status.value}; exit={result.return_code}; {cause_without_trace_lines(result.cause)}", end_line)
762
+ if end_line is not None:
763
+ self.ended_process_id_set.add(result.request.request_id)
764
+ self.failed_outputs.append(result.stderr.decode("utf-8", "replace"))
765
+ if self.close_requested:
766
+ self.close()