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,341 @@
1
+ """Hold the values every reader of a repository shares, from its context to its commits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Self
9
+
10
+ from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, TAG_PREFIX
11
+
12
+ if TYPE_CHECKING:
13
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RequestIdentity:
18
+ """Identify a window and its current read to reject superseded responses."""
19
+
20
+ session_id: str
21
+ generation: int = 0
22
+ selection_generation: int = 0
23
+ oid: str | None = None
24
+ search_generation: int = 0
25
+ relation_generation: int = 0
26
+
27
+
28
+ class FilePanel(Enum):
29
+ """The place a read of one file or file list fills, which a new read of the same place replaces and a retry reads again.
30
+
31
+ Four are panels of a repository window: the diff, the file tree, the file content and the history of one file. The
32
+ last two are not panels but reads of their own: the files handed to an external comparison tool, and a file exported to disk.
33
+ """
34
+
35
+ DIFF = "diff"
36
+ TREE = "tree"
37
+ BLOB = "blob"
38
+ FILE_HISTORY = "file_history"
39
+ DIFFTOOL = "difftool"
40
+ EXPORT = "export"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FileReadIdentity:
45
+ """Keep a panel's comparison independent of the commit detail request."""
46
+
47
+ request_identity: RequestIdentity
48
+ panel_kind: FilePanel = FilePanel.DIFF
49
+ panel_generation: int = 0
50
+ parent_oid: str | None = None
51
+ old_path: bytes | None = None
52
+ new_path: bytes | None = None
53
+ full_context: bool = False
54
+
55
+
56
+ class FileStatus(Enum):
57
+ """Change Git reports for one file, by the status letter it writes in raw diffs, name-status lists and status records."""
58
+
59
+ ADDED = "A"
60
+ """The file exists only on the new side, or is not tracked yet."""
61
+ BROKEN = "B"
62
+ """Git broke the pairing of a heavily rewritten file into a deletion and an addition."""
63
+ COPIED = "C"
64
+ """The file is a copy of another one."""
65
+ DELETED = "D"
66
+ """The file exists only on the old side."""
67
+ MODIFIED = "M"
68
+ """The content or the mode of the file changed."""
69
+ RENAMED = "R"
70
+ """The file moved from another path."""
71
+ TYPE_CHANGED = "T"
72
+ """The file changed type, between a regular file, a symbolic link and a submodule."""
73
+ UNMERGED = "U"
74
+ """The file is in conflict."""
75
+ UNKNOWN = "X"
76
+ """Git could not tell; it only writes this letter on an internal error."""
77
+
78
+
79
+ # Git writes the status letter first, followed by the similarity score of a rename or a copy (R100, C075).
80
+ STATUS_LETTER_LENGTH = 1
81
+
82
+
83
+ def file_status(raw_status: str) -> FileStatus:
84
+ """Read the status Git wrote for a file by its letter, the similarity score of a rename or a copy left aside.
85
+
86
+ A letter Git does not document raises ValueError, like any other output that cannot be read.
87
+ """
88
+ return FileStatus(raw_status[:STATUS_LETTER_LENGTH])
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class RevisionEntry:
93
+ """Retain both sides of a historical change without consulting disk files."""
94
+
95
+ commit_oid: str
96
+ parent_oid: str | None
97
+ old_path: bytes | None
98
+ new_path: bytes | None
99
+ old_blob_oid: str | None
100
+ new_blob_oid: str | None
101
+ old_mode: str
102
+ new_mode: str
103
+ status: FileStatus
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class TreeEntry:
108
+ """Retain a revision's literal Git path and object identity without local paths."""
109
+
110
+ path: bytes
111
+ mode: str
112
+ object_type: str
113
+ oid: str
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class HistoricalFileSelection:
118
+ """Keep an immutable blob and its real historical side for file operations."""
119
+
120
+ commit_oid: str
121
+ parent_oid: str | None
122
+ path: bytes
123
+ blob_oid: str
124
+ mode: str
125
+ selection_identity: FileReadIdentity
126
+
127
+
128
+ def historical_selection(entry: RevisionEntry | TreeEntry, identity: FileReadIdentity) -> HistoricalFileSelection:
129
+ """Choose the after side, or the selected parent's before side for a deletion."""
130
+ if isinstance(entry, TreeEntry):
131
+ if identity.request_identity.oid is None:
132
+ raise ValueError("No historical commit selected")
133
+ return HistoricalFileSelection(identity.request_identity.oid, None, entry.path, entry.oid, entry.mode, identity)
134
+ if entry.new_path is not None and entry.new_blob_oid is not None:
135
+ return HistoricalFileSelection(entry.commit_oid, entry.parent_oid, entry.new_path, entry.new_blob_oid, entry.new_mode, identity)
136
+ if entry.parent_oid is not None and entry.old_path is not None and entry.old_blob_oid is not None:
137
+ return HistoricalFileSelection(entry.commit_oid, entry.parent_oid, entry.old_path, entry.old_blob_oid, entry.old_mode, identity)
138
+ raise ValueError("The selected entry has no historical file")
139
+
140
+
141
+ class BlobContentKind(Enum):
142
+ """What the preview of a file version holds, which decides how the reader shows it."""
143
+
144
+ TEXT = "text"
145
+ """Text decoded without error."""
146
+ EMPTY = "empty"
147
+ """A file of zero bytes."""
148
+ BINARY = "binary"
149
+ """Bytes with a NUL and no text byte order mark: not shown as text."""
150
+ ENCODING = "encoding"
151
+ """Text in an unknown or invalid encoding, shown with replacement characters."""
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class BlobContent:
156
+ """Keep original bytes separate from the reader's display representation."""
157
+
158
+ oid: str
159
+ raw_bytes: bytes
160
+ display_text: str
161
+ content_kind: BlobContentKind
162
+ complete: bool
163
+ size_bytes: int = 0
164
+ diagnostic: str = ""
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class ComparisonGroup:
169
+ """Associate each changed file with the real parent used for comparison."""
170
+
171
+ parent_oid: str | None
172
+ parent_short_oid: str
173
+ parent_subject: str
174
+ entries: list[RevisionEntry]
175
+
176
+ @property
177
+ def file_count(self) -> int:
178
+ """Count only the changes belonging to this parent comparison."""
179
+ return len(self.entries)
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class RepositoryContext:
184
+ """Describe Git-resolved repository locations including worktrees and bare repos."""
185
+
186
+ worktree_dir: str | None
187
+ git_dir: str
188
+ common_git_dir: str
189
+ is_bare: bool
190
+
191
+ @property
192
+ def repository_dir(self) -> str:
193
+ """Choose the actual repository directory even when no working tree exists."""
194
+ return self.worktree_dir or self.git_dir
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Commit:
199
+ """Keep complete Git identity, ancestry and readable commit metadata together."""
200
+
201
+ oid: str
202
+ short_oid: str
203
+ parent_oids: list[str]
204
+ author_name: str
205
+ author_email: str
206
+ author_timestamp: int
207
+ committer_timestamp: int
208
+ encoding: str
209
+ message: str
210
+ detail_error: str | None = None
211
+ body_preview: str = ""
212
+ # Git accepts commits whose time is missing or beyond what a date can hold, as some old imported histories have;
213
+ # such a commit keeps its message and author, and only its dates read as unavailable.
214
+ time_unavailable: bool = False
215
+
216
+
217
+ class ReferenceKind(Enum):
218
+ """Group of a displayed reference, in the order the history and the graph list them."""
219
+
220
+ HEAD = ("head", "", "HEAD")
221
+ HEADS = ("heads", LOCAL_PREFIX, "Local branches")
222
+ REMOTES = ("remotes", REMOTE_PREFIX, "Remote branches")
223
+ TAGS = ("tags", TAG_PREFIX, "Tags")
224
+
225
+ def __new__(cls, value: str, prefix: str, label: str) -> Self:
226
+ """Keep the group name as the value, with the namespace Git lists it under and the heading the window shows."""
227
+ member = object.__new__(cls)
228
+ member._value_ = value
229
+ member.prefix = prefix
230
+ member.label = label
231
+ return member
232
+
233
+ def __lt__(self, other: object) -> bool:
234
+ """Order kinds as they are declared: HEAD, local branches, remote branches, tags.
235
+
236
+ Sorting references on their kind keeps the order the group names had when they were plain text. Another type of
237
+ object is left to Python, which tries the other side of the comparison and refuses it when neither knows.
238
+ """
239
+ if not isinstance(other, ReferenceKind):
240
+ return NotImplemented
241
+ members = list(type(self))
242
+ return members.index(self) < members.index(other)
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class Reference:
247
+ """Keep a displayed reference and its peeled commit target, if one exists."""
248
+
249
+ full_name: str
250
+ kind: ReferenceKind
251
+ target_oid: str | None
252
+ symbolic_target: str | None = None
253
+ is_head: bool = False
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class CommitLink:
258
+ """Identify a direct relative with its Git abbreviation and full subject."""
259
+
260
+ oid: str
261
+ short_oid: str
262
+ subject: str
263
+ available: bool = True
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class CommitRelation:
268
+ """Separate incomplete child discovery from a proven absence of children."""
269
+
270
+ oid: str
271
+ parents: list[CommitLink]
272
+ child_links: list[CommitLink]
273
+ children_complete: bool
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class OpeningHeadRead:
278
+ """Remember the shallow flag and HEAD that one opening read, for the first history capture of that same request.
279
+
280
+ It is display data only and authorizes nothing. It belongs to one request of one window, with the Git and the
281
+ context it was read with, and is used at most once; any other opening, cancellation or closing drops it.
282
+ """
283
+
284
+ identity: RequestIdentity
285
+ context: RepositoryContext
286
+ git_filename: str
287
+ shallow: bool
288
+ head_oid: str | None
289
+ head_ref: str | None = None
290
+
291
+
292
+ @dataclass
293
+ class HistorySnapshot:
294
+ """Associate one stable reference capture with its progressively read ancestry."""
295
+
296
+ generation: int
297
+ references: list[Reference]
298
+ tip_oids: list[str]
299
+ head_oid: str | None
300
+ ordered_oids: list[str] = field(default_factory=list)
301
+ parent_oids_by_oid: dict[str, list[str]] = field(default_factory=dict)
302
+ child_oids_by_oid: dict[str, list[str]] = field(default_factory=dict)
303
+ complete: bool = False
304
+ shallow: bool = False
305
+ error: str | None = None
306
+ explicit_commits: list[Commit] = field(default_factory=list)
307
+ # The reference capture this history was built from, when one was shared with it; handed over to the window that
308
+ # finally shows this history so it does not list the same references again. Display data only.
309
+ reference_snapshot: ReferenceSnapshot | None = field(default=None, compare=False, repr=False)
310
+
311
+
312
+ # A full Git object ID: 40 hexadecimal digits in a SHA-1 repository, 64 in a SHA-256 one.
313
+ FULL_OID_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})")
314
+ # Suffix that makes Git follow a revision to the commit it leads to, and fail when it leads to none.
315
+ COMMIT_PEEL_SUFFIX = "^{commit}"
316
+
317
+
318
+ def validate_oid(oid: str) -> str:
319
+ """Reject anything except a full Git object ID before using it as an argument."""
320
+ if FULL_OID_PATTERN.fullmatch(oid) is None:
321
+ raise ValueError("Invalid Git object ID")
322
+ return oid
323
+
324
+
325
+ SUCCESS_EXIT_CODE = 0
326
+ # Exit status of a cherry-pick, revert or rebase that stopped on a conflict. Any other failure status (128 for a fatal
327
+ # error, a crash) is a real failure even when it left unmerged entries behind.
328
+ GIT_CONFLICT_RETURN_CODE = 1
329
+
330
+
331
+ @dataclass(frozen=True)
332
+ class RepositoryFailure:
333
+ """Preserve the concrete command failure for useful error presentation."""
334
+
335
+ operation: str
336
+ repository_dir: str
337
+ program_filename: str
338
+ arguments: list[str]
339
+ cause: str
340
+ stderr: bytes = b""
341
+ return_code: int | None = None
@@ -0,0 +1,180 @@
1
+ """Answer questions about commits from the history graph already held in memory, without starting Git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from gitpane.config import COMMAND_LINE_CONTROL_CHARACTER_SET, LOCAL_PREFIX, REFERENCE_ROOT_PREFIX, REMOTE_PREFIX, TAG_PREFIX
9
+ from gitpane.history.repository_value import FULL_OID_PATTERN
10
+
11
+ if TYPE_CHECKING:
12
+ from gitpane.history.repository_value import Reference
13
+
14
+ # Signs and equality text of the Git Extensions commit counter shown beside a revision field ("-13", "+2", "-3+2", "=").
15
+ REMOVED_COMMIT_SIGN = "-"
16
+ ADDED_COMMIT_SIGN = "+"
17
+ SAME_COMMIT_TEXT = "="
18
+
19
+ # Why a typed revision is refused before any Git process: Git would read a leading dash as an option, and a control
20
+ # character cannot belong to a revision and could split what Git or the logs receive.
21
+ EMPTY_REVISION_CAUSE = "Enter a revision"
22
+ OPTION_REVISION_CAUSE = "A revision cannot start with '-'"
23
+ CONTROL_CHARACTER_REVISION_CAUSE = "A revision cannot contain control characters"
24
+ REVISION_OPTION_PREFIX = "-"
25
+ HEAD_REVISION = "HEAD"
26
+ # The symbolic reference a remote keeps for its default branch, which a bare remote name designates.
27
+ REMOTE_DEFAULT_BRANCH_SUFFIX = "/HEAD"
28
+ # Names Git first looks up as files beside HEAD in the repository directory (FETCH_HEAD, ORIG_HEAD, MERGE_HEAD...).
29
+ # They are not in the reference listing, so such a name is always left to Git.
30
+ ROOT_REFERENCE_NAME_PATTERN = re.compile(r"[A-Z_]+")
31
+
32
+
33
+ def ancestor_oid_set(start_oid: str, parent_oids_by_oid: dict[str, list[str]]) -> set[str]:
34
+ """Return a commit and every ancestor reachable through the known parent links.
35
+
36
+ The walk is iterative so a long linear history cannot overflow Python's call stack; parents missing from the
37
+ mapping (a shallow boundary, or a graph still being read) simply end that branch of the walk.
38
+ """
39
+ visited_oid_set: set[str] = set()
40
+ pending_oids = [start_oid]
41
+ while pending_oids:
42
+ oid = pending_oids.pop()
43
+ if oid in visited_oid_set:
44
+ continue
45
+ visited_oid_set.add(oid)
46
+ pending_oids.extend(parent_oids_by_oid.get(oid, []))
47
+ return visited_oid_set
48
+
49
+
50
+ def unmerged_branch_refs_in_memory(branch_oid_by_ref: dict[str, str], base_oid_by_ref: dict[str, str | None], parent_oids_by_oid: dict[str, list[str]],
51
+ graph_complete: bool) -> tuple[str, ...] | None:
52
+ """Tell which branches are not merged into their base, from the history already in memory, or None when it cannot tell.
53
+
54
+ A branch is merged when its commit is its base or one of the base's ancestors. A branch without a base is never
55
+ merged, as in Git. None means memory is not enough: the graph is still being read, or a base commit is not in it,
56
+ so the caller has to ask Git. The answer keeps the order of ``branch_oid_by_ref``. The ancestors of each distinct
57
+ base are walked once, however many branches share it.
58
+ """
59
+ if not graph_complete:
60
+ return None
61
+ ancestor_oid_set_by_base_oid: dict[str, set[str]] = {}
62
+ for base_oid in base_oid_by_ref.values():
63
+ if base_oid is None or base_oid in ancestor_oid_set_by_base_oid:
64
+ continue
65
+ if base_oid not in parent_oids_by_oid:
66
+ return None
67
+ ancestor_oid_set_by_base_oid[base_oid] = ancestor_oid_set(base_oid, parent_oids_by_oid)
68
+ unmerged_refs: list[str] = []
69
+ for full_ref, branch_oid in branch_oid_by_ref.items():
70
+ base_oid = base_oid_by_ref.get(full_ref)
71
+ if base_oid is None or branch_oid not in ancestor_oid_set_by_base_oid[base_oid]:
72
+ unmerged_refs.append(full_ref)
73
+ return tuple(unmerged_refs)
74
+
75
+
76
+ def commit_difference_in_memory(target_oid: str, head_ancestor_oid_set: set[str] | None, parent_oids_by_oid: dict[str, list[str]]) -> tuple[int, int] | None:
77
+ """Count the commits only in HEAD and the commits only in the target, or None when memory cannot prove it.
78
+
79
+ The answer has the order of ``git rev-list --left-right --count HEAD...target``. None means the HEAD ancestry is
80
+ unknown (incomplete graph, or HEAD without a commit) or the target is not part of the loaded graph, so the caller
81
+ has to ask Git instead.
82
+ """
83
+ if head_ancestor_oid_set is None or target_oid not in parent_oids_by_oid:
84
+ return None
85
+ target_ancestor_oid_set = ancestor_oid_set(target_oid, parent_oids_by_oid)
86
+ return len(head_ancestor_oid_set - target_ancestor_oid_set), len(target_ancestor_oid_set - head_ancestor_oid_set)
87
+
88
+
89
+ def commit_count_text(difference: tuple[int, int] | None) -> str:
90
+ """Write a commit's position relative to HEAD the way Git Extensions does: "-13", "+2", "-3+2", "=" or nothing.
91
+
92
+ The first count is the commits HEAD has that the target lacks (shown with a minus), the second the commits the
93
+ target adds (shown with a plus). An unknown difference, such as HEAD without any commit, shows no text.
94
+ """
95
+ if difference is None:
96
+ return ""
97
+ removed, added = difference
98
+ if removed == 0 and added == 0:
99
+ return SAME_COMMIT_TEXT
100
+ text = ""
101
+ if removed > 0:
102
+ text += REMOVED_COMMIT_SIGN + str(removed)
103
+ if added > 0:
104
+ text += ADDED_COMMIT_SIGN + str(added)
105
+ return text
106
+
107
+
108
+ def revision_text_problem(text: str) -> str:
109
+ """Say why a typed revision cannot be handed to Git at all, or answer an empty text when it may be.
110
+
111
+ This only guards the command line: a text that passes still has to be resolved, in memory or by Git.
112
+ """
113
+ if not text:
114
+ return EMPTY_REVISION_CAUSE
115
+ if text.startswith(REVISION_OPTION_PREFIX):
116
+ return OPTION_REVISION_CAUSE
117
+ for character in text:
118
+ if character in COMMAND_LINE_CONTROL_CHARACTER_SET:
119
+ return CONTROL_CHARACTER_REVISION_CAUSE
120
+ return ""
121
+
122
+
123
+ def reference_candidate_names(name: str) -> list[str]:
124
+ """List the full reference names Git tries for a short name, in Git's own order of preference.
125
+
126
+ The order is the one of the gitrevisions documentation: the name itself, then under refs/, refs/tags/,
127
+ refs/heads/, refs/remotes/, and finally the default branch of a remote with that name.
128
+ """
129
+ return [name, REFERENCE_ROOT_PREFIX + name, TAG_PREFIX + name, LOCAL_PREFIX + name, REMOTE_PREFIX + name,
130
+ REMOTE_PREFIX + name + REMOTE_DEFAULT_BRANCH_SUFFIX]
131
+
132
+
133
+ def resolve_revision_in_memory(text: str, references: tuple[Reference, ...], head_oid: str | None, parent_oids_by_oid: dict[str, list[str]]) -> str | None:
134
+ """Resolve a typed revision to a full commit ID from what the window already holds, or None to ask Git.
135
+
136
+ Only answers Git would give the same way are taken: HEAD, a full commit ID already in the loaded graph, and a
137
+ reference name that exactly one reference matches under Git's lookup order. Everything else goes to Git: an
138
+ abbreviated ID (Git knows objects the window never loaded), a name several references match, a special name
139
+ kept beside HEAD, and any revision expression such as ``main~1`` or ``v1.0^2``.
140
+ """
141
+ if text == HEAD_REVISION:
142
+ return head_oid
143
+ if FULL_OID_PATTERN.fullmatch(text) is not None:
144
+ return text if text in parent_oids_by_oid else None
145
+ if ROOT_REFERENCE_NAME_PATTERN.fullmatch(text) is not None:
146
+ return None
147
+ reference_by_name: dict[str, Reference] = {}
148
+ for reference in references:
149
+ reference_by_name[reference.full_name] = reference
150
+ matched_references: list[Reference] = []
151
+ for candidate_name in reference_candidate_names(text):
152
+ reference = reference_by_name.get(candidate_name)
153
+ if reference is not None:
154
+ matched_references.append(reference)
155
+ if len(matched_references) != 1:
156
+ return None
157
+ return matched_references[0].target_oid
158
+
159
+
160
+
161
+ def complete_ancestor_oid_set(oid: str, parent_oids_by_oid: dict[str, list[str]], graph_complete: bool) -> set[str] | None:
162
+ """Return a commit and all its ancestors from the graph in memory, or None when the graph cannot prove the whole set.
163
+
164
+ Only a completely read graph that holds the commit knows every one of its ancestors; a graph still being read, or
165
+ one that does not contain the commit, leaves the question to Git.
166
+ """
167
+ if not graph_complete or oid not in parent_oids_by_oid:
168
+ return None
169
+ return ancestor_oid_set(oid, parent_oids_by_oid)
170
+
171
+
172
+ def is_ancestor_in_memory(ancestor_oid: str, descendant_ancestor_oid_set: set[str] | None) -> bool | None:
173
+ """Tell whether a commit is an ancestor of (or equal to) a descendant whose ancestry is known, or None when it is not known.
174
+
175
+ This answers ``git merge-base --is-ancestor`` from memory: ``descendant_ancestor_oid_set`` is the descendant and
176
+ all its ancestors, as ``complete_ancestor_oid_set`` returns it, and None sends the question to Git.
177
+ """
178
+ if descendant_ancestor_oid_set is None:
179
+ return None
180
+ return ancestor_oid in descendant_ancestor_oid_set