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,164 @@
1
+ """Read the list of stashes from the stash reflog file, the way `git stash list` shows it, without starting Git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.config import GIT_REFTABLE_DIR_BASENAME, LINE_SEPARATOR
11
+ from gitpane.history.repository_value import FULL_OID_PATTERN
12
+ from gitpane.reference.stash import stash_reflog_filename
13
+ from gitpane.sequencer.rebase_state import checked_metadata, metadata_signature
14
+ from gitpane.worktree.worktree_snapshot import read_validated_size
15
+
16
+ if TYPE_CHECKING:
17
+ from gitpane.history.repository_value import RepositoryContext
18
+
19
+ # One reflog line per stash, about a hundred bytes each: a mebibyte holds thousands of stashes. A larger reflog is
20
+ # not shown at all rather than read in part.
21
+ STASH_REFLOG_LIMIT_BYTES = 1024 * 1024
22
+ STASH_LIST_TOO_LARGE_CAUSE = "Stash list too large to show"
23
+ STASH_LIST_NOT_REGULAR_CAUSE = "Stash list unreadable: the stash reflog is not a regular file"
24
+ STASH_LIST_CHANGED_CAUSE = "Stash list changed while reading; refresh"
25
+ STASH_LIST_UNREADABLE_PREFIX = "Stash list unreadable: "
26
+ STASH_LIST_REFTABLE_CAUSE = "Stash list is not available for repositories using the reftable reference format"
27
+ # A reflog line is `<old oid> <new oid> <name> <<email>> <timestamp> <timezone>[\t<message>]`. The pattern keeps the
28
+ # lines Git keeps when it numbers `stash@{N}` and nothing else: the identity ends at the first ">", the timestamp is
29
+ # digits, the timezone is a sign and four digits, and the tab and message are written only for a non-empty message.
30
+ # A line Git skips must not take a number, or every older stash would be shown under another stash's selector.
31
+ _REFLOG_OID_PATTERN = FULL_OID_PATTERN.pattern.encode("ascii")
32
+ REFLOG_LINE_PATTERN = re.compile(
33
+ rb"(?P<old>" + _REFLOG_OID_PATTERN + rb") (?P<new>" + _REFLOG_OID_PATTERN + rb") [^>]*> (?P<timestamp>[0-9]+) (?P<timezone>[+-][0-9]{4})"
34
+ rb"(?:\t(?P<message>.*)|(?P<trailing>.*))",
35
+ re.DOTALL,
36
+ )
37
+ # Git reads a zero timestamp as a failed parse and skips the line.
38
+ REFLOG_INVALID_TIMESTAMP = 0
39
+ # The reflog is read as bytes; this form is drawn from the one text line end rather than written again.
40
+ LINE_SEPARATOR_BYTES = LINE_SEPARATOR.encode("ascii")
41
+ CARRIAGE_RETURN = b"\r"
42
+ # Message Git writes for a stash made without a message. A reference name cannot contain ":", so the first ": "
43
+ # ends the branch name even when the subject holds more.
44
+ WIP_MESSAGE_PATTERN = re.compile(r"^WIP on (?P<branch>[^:]+): (?P<commit>[0-9a-f]+) (?P<subject>.*)$")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class StashEntry:
49
+ """One stash as `git stash list` shows it: its selector index, stash commit, reflog message and creation time."""
50
+
51
+ index: int
52
+ oid: str
53
+ message: str
54
+ timestamp: int
55
+ timezone: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class StashList:
60
+ """The stashes of a repository, newest first, or the reason they could not be read.
61
+
62
+ An empty list without a cause means the repository has no stash.
63
+ """
64
+
65
+ entries: tuple[StashEntry, ...] = ()
66
+ cause: str = ""
67
+
68
+
69
+ def stash_selector(index: int) -> str:
70
+ """Name a stash with Git's own selector syntax, `stash@{N}`."""
71
+ return f"stash@{{{index}}}"
72
+
73
+
74
+ def stash_base_commit(message: str) -> str:
75
+ """Name the commit a stash was made from, `<short oid> <subject>`, or an empty text when the message does not say.
76
+
77
+ Only a stash made without a message tells it: Git then writes `WIP on <branch>: <short oid> <subject>`. A stash
78
+ made with a message is written `On <branch>: <message>` and keeps no trace of its starting commit in the reflog.
79
+ """
80
+ match = WIP_MESSAGE_PATTERN.fullmatch(message)
81
+ if match is None:
82
+ return ""
83
+ return f"{match['commit']} {match['subject']}".rstrip()
84
+
85
+
86
+ def _parse_reflog_line(line: bytes, index: int) -> StashEntry | None:
87
+ """Turn one reflog line into a stash entry, or None for a line Git itself skips when it lists the stashes.
88
+
89
+ Only the lines Git keeps may take a `stash@{N}` number, so the numbers shown match the ones Git accepts in commands.
90
+ """
91
+ match = REFLOG_LINE_PATTERN.fullmatch(line.removesuffix(CARRIAGE_RETURN))
92
+ if match is None:
93
+ return None
94
+ timestamp = int(match["timestamp"])
95
+ if timestamp == REFLOG_INVALID_TIMESTAMP:
96
+ return None
97
+ # Without a tab, Git shows whatever follows the timezone as the message, which is empty for a line it wrote.
98
+ message = match["message"] if match["message"] is not None else match["trailing"]
99
+ return StashEntry(index, match["new"].decode("ascii"), message.decode("utf-8", "replace"), timestamp, match["timezone"].decode("ascii"))
100
+
101
+
102
+ def parse_stash_reflog(content: bytes) -> tuple[StashEntry, ...]:
103
+ """Turn the stash reflog, written oldest first, into the newest-first list `git stash list` prints.
104
+
105
+ Lines that do not have the reflog layout are skipped instead of failing the whole list.
106
+ """
107
+ entries: list[StashEntry] = []
108
+ for line in reversed(content.split(LINE_SEPARATOR_BYTES)):
109
+ if not line.strip():
110
+ continue
111
+ entry = _parse_reflog_line(line, len(entries))
112
+ if entry is not None:
113
+ entries.append(entry)
114
+ return tuple(entries)
115
+
116
+
117
+ def _read_stash_reflog(filename: str) -> StashList:
118
+ """Read the stash reflog with a bounded size, refusing links, special files and a file replaced while reading."""
119
+ try:
120
+ before = checked_metadata(filename)
121
+ except FileNotFoundError:
122
+ return StashList()
123
+ except ValueError:
124
+ return StashList(cause=STASH_LIST_NOT_REGULAR_CAUSE)
125
+ if before.st_size > STASH_REFLOG_LIMIT_BYTES:
126
+ return StashList(cause=STASH_LIST_TOO_LARGE_CAUSE)
127
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
128
+ with os.fdopen(os.open(filename, flags), "rb") as stream:
129
+ opened = os.fstat(stream.fileno())
130
+ # Windows descriptor and path stat disagree about ctime; compare it only within the same API.
131
+ if metadata_signature(opened, include_change_time=False) != metadata_signature(before, include_change_time=False):
132
+ return StashList(cause=STASH_LIST_CHANGED_CAUSE)
133
+ if opened.st_size > STASH_REFLOG_LIMIT_BYTES:
134
+ return StashList(cause=STASH_LIST_TOO_LARGE_CAUSE)
135
+ content = read_validated_size(stream, opened.st_size)
136
+ if len(content) != opened.st_size:
137
+ return StashList(cause=STASH_LIST_CHANGED_CAUSE)
138
+ try:
139
+ after = checked_metadata(filename)
140
+ except ValueError:
141
+ # The path became a link or special file while it was read: the content read may not be the stash reflog.
142
+ return StashList(cause=STASH_LIST_CHANGED_CAUSE)
143
+ if metadata_signature(after) != metadata_signature(before):
144
+ return StashList(cause=STASH_LIST_CHANGED_CAUSE)
145
+ return StashList(parse_stash_reflog(content))
146
+
147
+
148
+ def read_stash_list(context: RepositoryContext) -> StashList:
149
+ """Read the stashes of a repository from its shared stash reflog, newest first, without ever raising.
150
+
151
+ The reflog lives in the shared Git directory, so a linked worktree lists the stashes of its main repository and a
152
+ submodule its own. An absent reflog means no stash; anything unreadable gives an empty list with a cause.
153
+ A repository using the reftable format keeps the reflog in its tables, so its list is not read at all rather
154
+ than shown as empty, even when a reflog file is left over from before a conversion.
155
+ """
156
+ try:
157
+ if os.path.isdir(os.path.join(context.common_git_dir, GIT_REFTABLE_DIR_BASENAME)):
158
+ return StashList(cause=STASH_LIST_REFTABLE_CAUSE)
159
+ return _read_stash_reflog(stash_reflog_filename(context))
160
+ except FileNotFoundError:
161
+ return StashList()
162
+ except (OSError, ValueError) as error:
163
+ detail = error.strerror if isinstance(error, OSError) and error.strerror else type(error).__name__
164
+ return StashList(cause=STASH_LIST_UNREADABLE_PREFIX + detail)
File without changes
@@ -0,0 +1,154 @@
1
+ """Judge a commit message empty the way Git's own cleanup would."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.worktree.command_sequence import GitCommand
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Generator
14
+
15
+ # Git's comment character when the configuration names none, and the value of core.commentChar that lets Git pick one
16
+ # per message.
17
+ DEFAULT_COMMENT_PREFIX = "#"
18
+ AUTOMATIC_COMMENT_PREFIX = "auto"
19
+ # What Git 2.53 writes after the comment prefix on the cut line of a message file when commit.cleanup is scissors: that
20
+ # line and everything below it are dropped.
21
+ SCISSORS_LINE_SUFFIX = " ------------------------ >8 ------------------------"
22
+ # The trailer Git ignores when it judges a message: a message made only of sign-offs aborts the commit like an empty one.
23
+ SIGN_OFF_PREFIX = "Signed-off-by: "
24
+ # The only characters Git's own character table counts as blank. A vertical tab, a form feed or a no-break space is
25
+ # message text for Git, so a message made of them is committed.
26
+ GIT_BLANK_CHARACTERS = " \t\n\r"
27
+ # Git cuts a message file into lines on line feeds only; a visual wrap in an editor is not a character of the file.
28
+ MESSAGE_LINE_SEPARATOR = "\n"
29
+ # The settings the rule depends on, as git config --get-regexp lists them: Git writes section and key names in lower case.
30
+ COMMENT_CHAR_KEY = "core.commentchar"
31
+ COMMENT_STRING_KEY = "core.commentstring"
32
+ CLEANUP_KEY = "commit.cleanup"
33
+ COMMIT_ENCODING_KEY = "i18n.commitencoding"
34
+ COMMENT_KEY_SET = frozenset((COMMENT_CHAR_KEY, COMMENT_STRING_KEY))
35
+ COMMIT_MESSAGE_SETTING_PATTERN = "^(" + "|".join(map(re.escape, (COMMENT_CHAR_KEY, COMMENT_STRING_KEY, CLEANUP_KEY, COMMIT_ENCODING_KEY))) + ")$"
36
+ # git config --get-regexp writes one setting per line, its key then a space then its value; a key written without "="
37
+ # in the file is listed alone.
38
+ SETTING_KEY_VALUE_SEPARATOR = " "
39
+ SETTING_OUTPUT_CODEC = "utf-8"
40
+
41
+
42
+ class CleanupMode(Enum):
43
+ """How Git cleans a commit message before judging it, as commit.cleanup names it."""
44
+
45
+ DEFAULT = "default"
46
+ STRIP = "strip"
47
+ WHITESPACE = "whitespace"
48
+ VERBATIM = "verbatim"
49
+ SCISSORS = "scissors"
50
+
51
+ @property
52
+ def drops_comment_lines(self) -> bool:
53
+ """Tell whether this cleanup removes comment lines; default does, because it means strip when Git opens an editor."""
54
+ return self in (CleanupMode.DEFAULT, CleanupMode.STRIP)
55
+
56
+
57
+ def cleanup_mode(value: str) -> CleanupMode:
58
+ """Read a commit.cleanup value, with the default cleanup for a value Git does not know (Git itself refuses it)."""
59
+ try:
60
+ return CleanupMode(value)
61
+ except ValueError:
62
+ return CleanupMode.DEFAULT
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class CommitMessageRule:
67
+ """The repository settings that decide how Git cleans and decodes a commit message the rebase asks for.
68
+
69
+ ``comment_prefix`` is None when core.commentChar is auto: Git then picks a character per message, so a comment
70
+ cannot be told from text. ``commit_encoding`` is empty when i18n.commitEncoding is not set.
71
+ """
72
+
73
+ comment_prefix: str | None
74
+ cleanup: CleanupMode
75
+ commit_encoding: str
76
+
77
+
78
+ def message_is_empty(text: str, rule: CommitMessageRule) -> bool:
79
+ """Tell whether Git would abort the commit for an empty message once its own cleanup has run.
80
+
81
+ ``text`` is the content of the message file. verbatim keeps every character, so only an empty text is empty.
82
+ scissors drops the cut line and everything below it, and keeps the comments above it. default and strip drop the
83
+ lines that start, at the first column, with the comment prefix. What remains is empty when every line, trimmed of
84
+ Git's blanks, is empty or a sign-off. Without a known comment prefix only a blank text is refused: Git judges the
85
+ rest itself and pauses the rebase when it refuses the message.
86
+ """
87
+ if rule.cleanup == CleanupMode.VERBATIM:
88
+ return text == ""
89
+ lines = text.split(MESSAGE_LINE_SEPARATOR)
90
+ if rule.comment_prefix is None:
91
+ return _all_lines_blank(lines)
92
+ if rule.cleanup == CleanupMode.SCISSORS:
93
+ lines = _lines_above_cut(lines, rule.comment_prefix)
94
+ for line in lines:
95
+ if rule.cleanup.drops_comment_lines and line.startswith(rule.comment_prefix):
96
+ continue
97
+ trimmed_line = line.rstrip(GIT_BLANK_CHARACTERS)
98
+ if trimmed_line and not trimmed_line.startswith(SIGN_OFF_PREFIX):
99
+ return False
100
+ return True
101
+
102
+
103
+ def _all_lines_blank(lines: list[str]) -> bool:
104
+ """Tell whether every line holds only characters Git counts as blank."""
105
+ for line in lines:
106
+ if line.strip(GIT_BLANK_CHARACTERS):
107
+ return False
108
+ return True
109
+
110
+
111
+ def _lines_above_cut(lines: list[str], comment_prefix: str) -> list[str]:
112
+ """Keep the lines above Git's scissors cut line, the first line included as a possible cut.
113
+
114
+ Git only takes the cut line as one when a line feed follows it, so a cut line that ends the file is message text.
115
+ """
116
+ cut_line = comment_prefix + SCISSORS_LINE_SUFFIX
117
+ last_index = len(lines) - 1
118
+ for index, line in enumerate(lines):
119
+ if line == cut_line and index < last_index:
120
+ return lines[:index]
121
+ return lines
122
+
123
+
124
+ def parse_commit_message_setting(output: bytes) -> CommitMessageRule:
125
+ """Build the rule from the settings git config listed, with Git's defaults for the ones not set.
126
+
127
+ Git lists the settings in the order it reads them, the command line last, so the last value of a key wins, and the
128
+ later of core.commentChar and core.commentString wins as it does for Git. The output is decoded with replacement
129
+ characters: a value that is not UTF-8 must not stop the user from editing the message.
130
+ """
131
+ comment_prefix: str | None = DEFAULT_COMMENT_PREFIX
132
+ cleanup = CleanupMode.DEFAULT
133
+ commit_encoding = ""
134
+ for line in output.decode(SETTING_OUTPUT_CODEC, errors="replace").split(MESSAGE_LINE_SEPARATOR):
135
+ key, separator, value = line.partition(SETTING_KEY_VALUE_SEPARATOR)
136
+ if not separator:
137
+ continue
138
+ if key in COMMENT_KEY_SET:
139
+ comment_prefix = None if value == AUTOMATIC_COMMENT_PREFIX else value
140
+ elif key == CLEANUP_KEY:
141
+ cleanup = cleanup_mode(value)
142
+ elif key == COMMIT_ENCODING_KEY:
143
+ commit_encoding = value
144
+ return CommitMessageRule(comment_prefix, cleanup, commit_encoding)
145
+
146
+
147
+ def read_commit_message_rule() -> Generator[GitCommand, bytes, CommitMessageRule]:
148
+ """Read the comment prefix, the cleanup and the commit encoding of the repository in one Git process.
149
+
150
+ git config exits with 1 when none of the settings is set, which is the usual case, so that status is accepted and
151
+ gives Git's defaults.
152
+ """
153
+ output = yield GitCommand(["config", "--get-regexp", COMMIT_MESSAGE_SETTING_PATTERN], accepted_codes=(0, 1))
154
+ return parse_commit_message_setting(output)