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,229 @@
1
+ """Describe the byte identities used by a private history rewrite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from gitpane.history.repository_value import RepositoryContext
11
+
12
+
13
+ class RewriteTargetKind(Enum):
14
+ """Choose exact-file or directory-descendant matching."""
15
+
16
+ FILE = "file"
17
+ FOLDER = "folder"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RewriteTarget:
22
+ """Keep a canonical Git path separate from its display label."""
23
+
24
+ path: bytes
25
+ kind: RewriteTargetKind
26
+
27
+ def __post_init__(self) -> None:
28
+ """Reject ambiguous repository paths before using them in any Git request."""
29
+ validate_path(self.path)
30
+
31
+
32
+ def validate_path(path: bytes) -> bytes:
33
+ """Accept only literal, canonical repository-relative path bytes."""
34
+ if not path or b"\0" in path or any(part in (b"", b".", b"..") for part in path.split(b"/")):
35
+ raise ValueError("Invalid repository-relative path; select a canonical file or folder")
36
+ return path
37
+
38
+
39
+ class RewriteStatus(Enum):
40
+ """Identify the visible contribution of a rewritten commit."""
41
+
42
+ MODIFIED = "M"
43
+ DELETED = "D"
44
+
45
+
46
+ class RewriteProtectedKind(Enum):
47
+ """Name the repository entries that cannot participate in history removal."""
48
+
49
+ SUBMODULE = "a submodule"
50
+ MODULE_CONFIGURATION = "the .gitmodules file"
51
+
52
+
53
+ class RewriteRefusalKind(Enum):
54
+ """Identify technical limits that cannot produce a faithful rewritten history."""
55
+
56
+ NO_SURVIVING_TIP = "no_surviving_tip"
57
+ INVALID_MERGE_PARENTS = "invalid_merge_parents"
58
+ UNSUPPORTED_METADATA = "unsupported_metadata"
59
+ DETACHED_HEAD = "detached_head"
60
+ UNBORN_BRANCH = "unborn_branch"
61
+ NATIVE_OPERATION = "native_operation"
62
+ UNMERGED_INDEX = "unmerged_index"
63
+ STAGED_TARGET = "staged_target"
64
+ PROTECTED_TARGET = "protected_target"
65
+ BEHIND_UPSTREAM = "behind_upstream"
66
+ OTHER_WORKTREE = "other_worktree"
67
+ STALE_CAPTURE = "stale_capture"
68
+ INCOMPLETE_HISTORY = "incomplete_history"
69
+ UNSUPPORTED_INDEX = "unsupported_index"
70
+
71
+
72
+ class RewriteRefusal(ValueError):
73
+ """Carry a typed preparation refusal with an actionable diagnostic."""
74
+
75
+ def __init__(self, kind: RewriteRefusalKind, detail: str, *, path: bytes | None = None, commit_oid: str | None = None, branch: str | None = None) -> None:
76
+ """Keep the machine-readable cause alongside the user-facing explanation."""
77
+ super().__init__(detail)
78
+ self.kind = kind
79
+ self.detail = detail
80
+ self.path = path
81
+ self.commit_oid = commit_oid
82
+ self.branch = branch
83
+
84
+
85
+ class ChangeKind(Enum):
86
+ """Identify the supported fast-export file directives."""
87
+
88
+ MODIFY = b"M"
89
+ DELETE = b"D"
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class FileChange:
94
+ """Keep a literal delta without loading its blob."""
95
+
96
+ kind: ChangeKind
97
+ path: bytes
98
+ mode: bytes = b""
99
+ oid: str = ""
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ExportCommit:
104
+ """Retain export order, ancestry references and file deltas."""
105
+
106
+ oid: str
107
+ mark: str
108
+ parent_refs: tuple[str, ...]
109
+ changes: tuple[FileChange, ...]
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class RawCommit:
114
+ """Locate an original message in its spool with the metadata needed to rebuild it."""
115
+
116
+ oid: str
117
+ tree_oid: str
118
+ parent_oids: tuple[str, ...]
119
+ author: bytes
120
+ committer: bytes
121
+ encoding: bytes | None
122
+ message_offset: int
123
+ message_length: int
124
+ header_names: tuple[bytes, ...]
125
+ signed: bool
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ImportCommit:
130
+ """Describe one generated private commit with remapped parents."""
131
+
132
+ raw: RawCommit
133
+ mark: str
134
+ parent_refs: tuple[str, ...]
135
+ changes: tuple[FileChange, ...]
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class RewriteRow:
140
+ """Project one changed contribution into bounded presentation text."""
141
+
142
+ oid: str
143
+ status: RewriteStatus
144
+ message: str
145
+ author: str
146
+ date: str
147
+ message_offset: int | None = None
148
+ message_length: int = 0
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class BranchRewrite:
153
+ """Describe a local reference's proposed destination."""
154
+
155
+ name: str
156
+ old_oid: str
157
+ new_oid: str
158
+ upstream: str | None = None
159
+ remote_refs: tuple[str, ...] = ()
160
+ tip_deleted: bool = False
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class RewriteCapture:
165
+ """Keep the source facts to which a rewrite proposal belongs.
166
+
167
+ ``git_version`` is the line ``git --version`` wrote for that executable; it says which options that Git accepts.
168
+ It is empty while the check of that Git, at start or in Settings, has not answered yet or after it failed, and
169
+ the preparation then reads it itself before its first export.
170
+ """
171
+
172
+ context: RepositoryContext
173
+ git_filename: str
174
+ head_ref: str | None
175
+ local_ref_by_name: dict[str, str] = field(default_factory=dict)
176
+ remote_ref_by_name: dict[str, str] = field(default_factory=dict)
177
+ index_identity: bytes | None = None
178
+ upstream_by_name: dict[str, str] = field(default_factory=dict)
179
+ worktrees: tuple[bytes, ...] = ()
180
+ native_state: tuple[str, ...] = ()
181
+ generation: int = 0
182
+ upstream_config: bytes = b""
183
+ index_stat: tuple[int, ...] | None = None
184
+ complete: bool = False
185
+ shared_index_filename: str = ""
186
+ shared_index_identity: bytes | None = None
187
+ git_version: str = ""
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class RewriteIndex:
192
+ """Bind private index files to their exact original and verified candidate digests."""
193
+
194
+ original_filename: str
195
+ candidate_filename: str
196
+ original_identity: bytes | None
197
+ candidate_identity: bytes | None
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class RewritePreparation:
202
+ """Expose only a verified proposal as executable preparation."""
203
+
204
+ capture: RewriteCapture
205
+ target: RewriteTarget
206
+ operation_dir: str = ""
207
+ stream_filename: str = ""
208
+ replacement_by_oid: dict[str, str | None] = field(default_factory=dict)
209
+ branches: tuple[BranchRewrite, ...] = ()
210
+ rows: tuple[RewriteRow, ...] = ()
211
+ signed_count: int = 0
212
+ current_absent: bool = False
213
+ complete: bool = False
214
+ refusal: str = ""
215
+ operation_identity: tuple[int, int] | None = None
216
+ refusal_kind: RewriteRefusalKind | None = None
217
+ index: RewriteIndex | None = None
218
+ message_filename: str = ""
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class HistoryAnalysis:
223
+ """Keep graph decisions separate from import serialization."""
224
+
225
+ replacement_by_oid: dict[str, str | None]
226
+ commits: tuple[ImportCommit, ...]
227
+ rows: tuple[RewriteRow, ...]
228
+ branches: tuple[BranchRewrite, ...]
229
+ signed_count: int
File without changes
@@ -0,0 +1,313 @@
1
+ """Decide the text an operation console shows of a record, while the ``--log`` file keeps the full detail.
2
+
3
+ The console reads like a terminal: a folder named once at the top, one ``$ `` line per command written as one would type
4
+ it, the raw output of the command below it, and a single line when a process fails. This module holds those rules
5
+ without any Qt type, so the trace, the view and the tests share one definition of each text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shlex
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ from gitpane.operation.console_tone import ConsoleTone
17
+ from gitpane.operation.diagnostic import MASKED_VALUE, mask_sensitive_arguments, terminal_text
18
+ from gitpane.operation.operation_status import OperationStatus
19
+ from gitpane.process.process_runner import EXIT_CODE_CAUSE_TEMPLATE, ProcessStatus
20
+ from gitpane.transport.git_refusal import is_trace_line
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+ from gitpane.process.process_runner import ProcessRequest, ProcessResult
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ConsoleLine:
30
+ """One console line as it is shown, with its colour; a text holding line breaks spans several lines of one tone."""
31
+
32
+ text: str
33
+ tone: ConsoleTone = ConsoleTone.NEUTRAL
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ConsoleHeader:
38
+ """The folder a console names once on its first line: the repository, or the destination of a clone."""
39
+
40
+ label: str
41
+ directory: str
42
+
43
+
44
+ REPOSITORY_HEADER_LABEL = "Repository"
45
+ DESTINATION_HEADER_LABEL = "Destination"
46
+ HEADER_TEXT_TEMPLATE = "{label}: {directory}"
47
+ COMMAND_PROMPT = "$ "
48
+ COMMAND_WORD_SEPARATOR = " "
49
+
50
+ # cmd.exe splits or interprets these characters, which Git accepts in a reference name (feature&x, for example).
51
+ # subprocess.list2cmdline only quotes a space, a tab or an empty argument, so it would leave them bare.
52
+ WINDOWS_SHELL_SPECIAL_CHARACTER_SET = frozenset(' \t&|<>^()%!"')
53
+ WINDOWS_QUOTE = '"'
54
+ WINDOWS_BACKSLASH = "\\"
55
+
56
+
57
+ def header_text(header: ConsoleHeader) -> str:
58
+ """Return the first line of a console, such as ``Repository: /home/user/project``."""
59
+ return HEADER_TEXT_TEMPLATE.format(label=header.label, directory=header.directory)
60
+
61
+
62
+ def quote_posix_argument(argument: str) -> str:
63
+ """Quote one word so a POSIX shell reads it back unchanged; a word with nothing special stays bare."""
64
+ return shlex.quote(argument)
65
+
66
+
67
+ def quote_windows_argument(argument: str) -> str:
68
+ """Quote one word the way the Microsoft C runtime reads it back, also when cmd.exe would interpret one of its characters.
69
+
70
+ The rules are those of subprocess.list2cmdline: backslashes are doubled only before a quote and at the end of a
71
+ quoted word, and an inner quote is escaped with a backslash.
72
+ """
73
+ # Workaround: list2cmdline decides alone when to quote, and it leaves cmd.exe's special characters bare, so its
74
+ # escaping loop is written again here with a wider test. A %VAR% stays expanded by cmd.exe even between quotes;
75
+ # there is no way to write it literally on a line pasted into cmd.exe, a limit accepted for a line meant to be read.
76
+ if argument and not any(character in WINDOWS_SHELL_SPECIAL_CHARACTER_SET for character in argument):
77
+ return argument
78
+ pieces: list[str] = [WINDOWS_QUOTE]
79
+ backslash_count = 0
80
+ for character in argument:
81
+ if character == WINDOWS_BACKSLASH:
82
+ backslash_count += 1
83
+ continue
84
+ if character == WINDOWS_QUOTE:
85
+ pieces.append(WINDOWS_BACKSLASH * (backslash_count * 2 + 1) + WINDOWS_QUOTE)
86
+ else:
87
+ pieces.append(WINDOWS_BACKSLASH * backslash_count + character)
88
+ backslash_count = 0
89
+ pieces.append(WINDOWS_BACKSLASH * (backslash_count * 2) + WINDOWS_QUOTE)
90
+ return "".join(pieces)
91
+
92
+
93
+ def quote_argument(argument: str, *, windows: bool | None = None) -> str:
94
+ """Quote one word in the style of the system's usual shell, so the line can be pasted there."""
95
+ windows = os.name == "nt" if windows is None else windows
96
+ if windows:
97
+ return quote_windows_argument(argument)
98
+ return quote_posix_argument(argument)
99
+
100
+
101
+ def command_text(program_filename: str, arguments: list[str], *, redact: Callable[[str], str] | None = None, windows: bool | None = None) -> str:
102
+ """Return a command as one would type it: the full executable path then each argument, quoted where a shell needs it.
103
+
104
+ A value written after a secret-named option is replaced by its placeholder, left unquoted so it reads as one. Every
105
+ other word goes through ``redact`` first, the filter that already hides the secrets of the trace, and is quoted
106
+ after, so a replacement holding a space is still one word.
107
+ """
108
+ masked_arguments = mask_sensitive_arguments(arguments).arguments
109
+ words: list[str] = []
110
+ for word in [program_filename, *masked_arguments]:
111
+ if word == MASKED_VALUE:
112
+ words.append(word)
113
+ continue
114
+ if redact is not None:
115
+ word = redact(word)
116
+ words.append(quote_argument(word, windows=windows))
117
+ return COMMAND_WORD_SEPARATOR.join(words)
118
+
119
+
120
+ WORKING_DIR_SUFFIX_TEMPLATE = " (in {working_dir})"
121
+
122
+
123
+ def _comparable_dir(directory: str) -> str:
124
+ """Return a folder spelled the way two spellings of the same folder compare equal: absolute, normalised, case folded where the system ignores case."""
125
+ return os.path.normcase(os.path.abspath(directory))
126
+
127
+
128
+ def working_dir_suffix(working_dir: str, header_dir: str) -> str:
129
+ """Return what follows a command run in another folder than the one the header names, or nothing when it runs there.
130
+
131
+ The suffix is about the folder the process really runs in. A folder given to Git with ``-C`` is already read in the
132
+ arguments, so a command that passes it and runs in the header's folder gets no suffix. The folder is written as
133
+ received and without quotes: it is information placed after the command, not part of what one would paste.
134
+ """
135
+ if _comparable_dir(working_dir) == _comparable_dir(header_dir):
136
+ return ""
137
+ return WORKING_DIR_SUFFIX_TEMPLATE.format(working_dir=working_dir)
138
+
139
+
140
+ def command_line(request: ProcessRequest, header: ConsoleHeader | None, *, redact: Callable[[str], str] | None = None, windows: bool | None = None) -> str:
141
+ """Return the ``$ `` line of a process: its full command, then its folder when it runs elsewhere than the header says.
142
+
143
+ Without a header there is no folder to compare with, so no suffix. ``redact`` filters every word of the command and
144
+ the suffix's folder, each on its own: filtering the joined line once more would let a hidden header value swallow
145
+ every word after it, since a header runs to the end of its line.
146
+ """
147
+ line = COMMAND_PROMPT + command_text(request.program_filename, request.arguments, redact=redact, windows=windows)
148
+ if header is None:
149
+ return line
150
+ suffix = working_dir_suffix(request.working_dir, header.directory)
151
+ if redact is not None:
152
+ suffix = redact(suffix)
153
+ return line + suffix
154
+
155
+
156
+ # A hosting service answers "repository not found" for a private repository the chosen account cannot see, so the
157
+ # console points at the account as the other likely cause.
158
+ REPOSITORY_NOT_FOUND_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?fatal: repository '.*' not found(?=\r?\n?$)", re.IGNORECASE)
159
+ WRONG_ACCOUNT_HINT = " (or wrong account selected in settings)"
160
+
161
+
162
+ def with_account_hint(line: str) -> str:
163
+ """Return a Git error line as the console shows it: a "repository not found" line also names a wrong account."""
164
+ match = REPOSITORY_NOT_FOUND_LINE_PATTERN.match(line)
165
+ if match is None:
166
+ return line
167
+ return match.group(0) + WRONG_ACCOUNT_HINT + line[match.end():]
168
+
169
+
170
+ FAILED_TEXT = "Failed"
171
+ FAILED_WITH_EXIT_TEMPLATE = "Failed (exit {return_code})"
172
+ NOT_STARTED_TEXT = "Not started"
173
+ CANCELLED_TEXT = "Cancelled"
174
+ STOPPED_TEXT = "Stopped"
175
+ # Endings of a request that may never have reached its launch: it did not start unless its command line was shown.
176
+ NOT_STARTED_STATUS_SET = frozenset({ProcessStatus.START_FAILED, ProcessStatus.QUEUE_FULL})
177
+ # Endings where GitPane stopped a running command at one of its limits: no output, too much output, total time.
178
+ STOPPED_STATUS_SET = frozenset({ProcessStatus.IDLE_TIMEOUT, ProcessStatus.VOLUME, ProcessStatus.TIMEOUT})
179
+ # Results after which nothing was written or nothing went through, so a form keeps what the user chose or typed for
180
+ # another try.
181
+ UNCHANGED_OUTCOME_STATUS_SET = frozenset((OperationStatus.REFUSED, OperationStatus.FAILED))
182
+ # Results the user may let the console dismiss through Keep dialog open. An Abort is only reported once a requested native
183
+ # Abort has been verified (rebase as well as cherry-pick and revert), so, like a success, nothing is left to inspect. A
184
+ # pause without failure cause is dismissed the same way, though it is not listed here: the Rebase window, the banner or
185
+ # the conflict question waits for the user, not the console.
186
+ COMPLETED_STATUS_SET = frozenset((OperationStatus.SUCCESS, OperationStatus.ABORTED))
187
+
188
+
189
+ END_TEXT_TEMPLATE = "{label}: {word}"
190
+ # Names a clone's console gives its end line: the clone itself, and the later retry of the submodules it left missing.
191
+ CLONE_LABEL = "Clone"
192
+ RETRY_SUBMODULES_LABEL = "Retry submodules"
193
+ # Name of the Stash pop operation: the title of its console, the start of its end line, and of the one its log keeps.
194
+ STASH_POP_LABEL = "Stash pop"
195
+ # Name of the rebase operation, used the same way by its console and its log.
196
+ REBASE_LABEL = "Rebase"
197
+ # Counts every reference whose result is not a success: refused, unknown or never sent. "Not pushed" rather than
198
+ # "rejected", since only some of them were refused by the server.
199
+ NOT_PUSHED_REFS_TEMPLATE = "{label}: {count} of {total} refs not pushed"
200
+ # Counts every remote whose Fetch did not succeed, including the ones that never started after an earlier failure.
201
+ FAILED_REMOTES_TEMPLATE = "{label}: {count} of {total} remotes failed"
202
+ # A tag is deleted locally first, then on the server: this says the second step did not happen.
203
+ DELETED_LOCALLY_ONLY_TEMPLATE = "{label}: deleted locally, not on the remote"
204
+ # A branch is deleted on the server first, then locally: this says the local step did not happen.
205
+ DELETED_REMOTELY_ONLY_TEMPLATE = "{label}: deleted on the remote, not locally"
206
+
207
+
208
+ def operation_end_text(label: str, status: OperationStatus) -> str:
209
+ """Return the line that ends an operation which had none a reader could understand, such as ``Push: done``.
210
+
211
+ The log keeps the bare status (``success``); this is what the console shows in its place.
212
+ """
213
+ return END_TEXT_TEMPLATE.format(label=label, word=status.end_word)
214
+
215
+
216
+ CAUSE_SEPARATOR = ": "
217
+ # Sets the reason of a refusal apart from the end line it completes, which already holds a ": " of its own.
218
+ END_CAUSE_SEPARATOR = " — "
219
+ LINE_BREAK = "\n"
220
+
221
+
222
+ def line_with_cause(head: str, cause: str) -> str:
223
+ """Return a head followed by its cause, or the head alone when there is no cause to give."""
224
+ if not cause:
225
+ return head
226
+ return head + CAUSE_SEPARATOR + cause
227
+
228
+
229
+ def end_line_with_cause(end_text: str, cause: str) -> str:
230
+ """Return the end line of an operation followed by the reason it ended so, such as ``Create tag: not started — <reason>``.
231
+
232
+ A refusal then reads as one line that says why, instead of an end line and a separate line for its reason. Without a
233
+ reason the end line stays alone.
234
+ """
235
+ if not cause:
236
+ return end_text
237
+ return end_text + END_CAUSE_SEPARATOR + cause
238
+
239
+
240
+ def _shown_lines(text: str) -> list[str]:
241
+ """Return the non-empty lines of a text as a terminal leaves them, without trace records and trailing blanks."""
242
+ lines: list[str] = []
243
+ for line in terminal_text(text).split(LINE_BREAK):
244
+ line = line.rstrip()
245
+ if line and not is_trace_line(line):
246
+ lines.append(line)
247
+ return lines
248
+
249
+
250
+ def cause_without_shown_output(cause: str, shown_output: str) -> str:
251
+ """Return the part of a failure cause the console has not shown yet.
252
+
253
+ The console already showed the error output line by line. A cause that copies it, at its start, in its middle or at
254
+ its end, keeps only what GitPane adds. Both texts are read as a terminal leaves them, without trace records and
255
+ trailing blanks; the first run of cause lines equal to the output's lines is removed, and the rest is returned.
256
+ """
257
+ cause_lines = _shown_lines(cause)
258
+ output_lines = _shown_lines(shown_output)
259
+ output_line_count = len(output_lines)
260
+ if output_line_count:
261
+ for start in range(len(cause_lines) - output_line_count + 1):
262
+ if cause_lines[start:start + output_line_count] == output_lines:
263
+ cause_lines = cause_lines[:start] + cause_lines[start + output_line_count:]
264
+ break
265
+ return LINE_BREAK.join(cause_lines).strip()
266
+
267
+
268
+ def cause_beyond_exit_code(cause: str, return_code: int | None) -> str:
269
+ """Return a failure cause without the generic sentence the runner writes for a silent failure.
270
+
271
+ That sentence only repeats the exit code the line already gives, so it becomes an empty cause. A cause without an
272
+ exit code, or different from that sentence, is returned as it is.
273
+ """
274
+ if return_code is not None and cause == EXIT_CODE_CAUSE_TEMPLATE.format(return_code=return_code):
275
+ return ""
276
+ return cause
277
+
278
+
279
+ def process_end_line(result: ProcessResult, *, command_shown: bool) -> ConsoleLine | None:
280
+ """Return the line a console writes when a process ends, or None when a terminal would not mark the ending.
281
+
282
+ ``command_shown`` tells whether the console already wrote the process's ``$ `` line, which is what separates a
283
+ request that never started from a program that could not run once launched.
284
+
285
+ | Ending | Line | Colour |
286
+ |------------------------------------------------------|--------------------------------------|------------------------------|
287
+ | success, or an exit code the caller accepts | none | |
288
+ | failed | ``Failed (exit N)``, then own cause | red, neutral during a pause |
289
+ | cancelled | ``Cancelled: <cause>`` | orange |
290
+ | stopped at a limit (no output, too much, total time) | ``Stopped: <cause>`` | red |
291
+ | refused before launch or queue full, no ``$`` line | ``Not started: <cause>`` | red |
292
+ | could not run after its ``$`` line, or anything else | ``Failed: <cause>`` | red |
293
+
294
+ An accepted exit code is a success: a lookup that may find nothing ends with 1 when nothing is there, and a red
295
+ line after it would make an operation that went well look broken. The cause loses the error output the console
296
+ already showed, and the runner's generic cause of a silent failure, which only repeats the exit code; an empty
297
+ cause leaves the word alone.
298
+ """
299
+ if result.status == ProcessStatus.SUCCESS:
300
+ return None
301
+ if result.status == ProcessStatus.FAILED and result.return_code in result.request.accepted_return_codes:
302
+ return None
303
+ cause = cause_beyond_exit_code(cause_without_shown_output(result.cause, result.stderr.decode("utf-8", "replace")), result.return_code)
304
+ if result.status == ProcessStatus.FAILED:
305
+ head = FAILED_TEXT if result.return_code is None else FAILED_WITH_EXIT_TEMPLATE.format(return_code=result.return_code)
306
+ return ConsoleLine(line_with_cause(head, cause), ConsoleTone.COMMAND_FAILURE)
307
+ if result.status == ProcessStatus.CANCELLED:
308
+ return ConsoleLine(line_with_cause(CANCELLED_TEXT, cause), ConsoleTone.CANCELLED)
309
+ if result.status in STOPPED_STATUS_SET:
310
+ return ConsoleLine(line_with_cause(STOPPED_TEXT, cause), ConsoleTone.FAILURE)
311
+ if result.status in NOT_STARTED_STATUS_SET and not command_shown:
312
+ return ConsoleLine(line_with_cause(NOT_STARTED_TEXT, cause), ConsoleTone.FAILURE)
313
+ return ConsoleLine(line_with_cause(FAILED_TEXT, cause), ConsoleTone.FAILURE)
@@ -0,0 +1,76 @@
1
+ """Decide the colour an operation console gives a record.
2
+
3
+ A Git error line is red, a warning orange, and the line that ends an operation takes the colour of its result. This
4
+ module holds those rules without any Qt type, so the trace, the view and the tests share one definition of each tone.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from enum import IntEnum
11
+
12
+ from gitpane.operation.operation_status import OperationStatus
13
+
14
+
15
+ class ConsoleTone(IntEnum):
16
+ """Say how a console line is coloured; the view stores it as the state of the line's Qt block."""
17
+
18
+ # Qt gives -1 to a block that never received a state, so a neutral line needs no state at all.
19
+ NEUTRAL = -1
20
+ # An operation that reached its goal, or an abort that put the repository back as it was.
21
+ SUCCESS = 1
22
+ # An operation stopped to wait for the user, such as a conflict to resolve.
23
+ PAUSED = 2
24
+ # An operation the user stopped.
25
+ CANCELLED = 3
26
+ # An error GitPane reports itself; it stays red whatever the operation's outcome.
27
+ FAILURE = 4
28
+ # A Git process that ended in failure; neutral while the operation waits on a pause, since that is why Git stopped.
29
+ COMMAND_FAILURE = 5
30
+ # Git's own error and fatal lines; neutral while the operation waits on a pause, for the same reason.
31
+ GIT_ERROR = 6
32
+ # Git's own warning lines.
33
+ GIT_WARNING = 7
34
+ # A cause GitPane writes to explain why Git refused something, such as a setting to change: red and bold whatever
35
+ # the operation's outcome, a pause included, so it is read at first glance among Git's lines.
36
+ FAILURE_CAUSE = 8
37
+
38
+
39
+ # During a pause, what Git says when it stops is the reason for the pause, not a failure.
40
+ PAUSE_NEUTRAL_TONE_SET = frozenset({ConsoleTone.GIT_ERROR, ConsoleTone.COMMAND_FAILURE})
41
+
42
+
43
+ GIT_ERROR_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?(?:error:|fatal:|Trace incomplete:)", re.IGNORECASE)
44
+ GIT_WARNING_LINE_PATTERN = re.compile(r"^(?:remote:\s*)?warning:", re.IGNORECASE)
45
+
46
+
47
+ def git_output_tone(line: str) -> ConsoleTone:
48
+ """Return the colour of a line Git wrote on its error output: red for an error, orange for a warning.
49
+
50
+ A ``hint:`` line and progress stay neutral: they explain or report, they do not tell that something went wrong.
51
+ """
52
+ if GIT_ERROR_LINE_PATTERN.match(line):
53
+ return ConsoleTone.GIT_ERROR
54
+ if GIT_WARNING_LINE_PATTERN.match(line):
55
+ return ConsoleTone.GIT_WARNING
56
+ return ConsoleTone.NEUTRAL
57
+
58
+
59
+ def result_tone(status: OperationStatus, *, failure_reported: bool = False, expected_stop: bool = False) -> ConsoleTone:
60
+ """Return the colour of the line that ends an operation, the one classification its console window also reads.
61
+
62
+ The end line and the state of the console window (paused or not) both come from here, so they cannot disagree.
63
+ Green is for an operation that reached its goal and for a verified Abort, which put the repository back as it was.
64
+ Orange is kept for what waits on the user: a pause, or a stop the caller expected (``expected_stop``, such as a
65
+ Stash pop stopped on conflicts), and a cancellation. A pause or an Abort that carries a failure cause
66
+ (``failure_reported``, such as a refused hook) is a real failure, red like everything else.
67
+ """
68
+ if status == OperationStatus.SUCCESS:
69
+ return ConsoleTone.SUCCESS
70
+ if status == OperationStatus.ABORTED and not failure_reported:
71
+ return ConsoleTone.SUCCESS
72
+ if (status == OperationStatus.PAUSED or expected_stop) and not failure_reported:
73
+ return ConsoleTone.PAUSED
74
+ if status == OperationStatus.CANCELLED:
75
+ return ConsoleTone.CANCELLED
76
+ return ConsoleTone.FAILURE