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,253 @@
1
+ """Read what earlier GitPane versions left behind for Cherry-pick or Revert, so their anchor references can be retired without losing a backup.
2
+
3
+ Earlier versions saved a journal and copies of the working files in a private folder before every Cherry-pick or
4
+ Revert, and pinned the objects they saved with one reference per journal under refs/gitpane/replay-recovery/. The
5
+ current version runs Git's own commands and writes none of this. Nothing here ever changes that folder: only the
6
+ references are chosen for removal. The reference of a journal an earlier version left in the middle of a restoration
7
+ is kept in every repository that holds it, including one moved or copied since the journal was written, because the
8
+ objects it pins may be the only copy of that work outside the backup folder.
9
+
10
+ This module has no Qt dependency beyond the location of the application's private data folder, and starts no process.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import sys
19
+ from dataclasses import dataclass
20
+
21
+ from PySide6.QtCore import QStandardPaths
22
+
23
+ from gitpane.config import REPLAY_RECORD_COUNT_LIMIT, REPLAY_RECORD_LIMIT_BYTES
24
+ from gitpane.history.repository_value import validate_oid
25
+ from gitpane.system.path_identity import canonical_path
26
+ from gitpane.system.qt_folder import writable_folder
27
+
28
+ LEGACY_REPLAY_STORE_BASENAME = "gitpane-replay-recovery"
29
+ LEGACY_RECORD_BASENAME = "record.json"
30
+ LEGACY_ANCHOR_REF_PREFIX = "refs/gitpane/replay-recovery/"
31
+ LEGACY_RECORD_ID_LENGTH = 32
32
+ LEGACY_RECORD_ID_CHARACTER_SET = frozenset("0123456789abcdef")
33
+ # Every phase an earlier journal could be in. A journal naming any other phase is read as unreadable.
34
+ LEGACY_REPLAY_PHASE_SET = frozenset(("prepared", "applying", "observed", "quitting", "quit_observed", "finalizing", "verified", "cleanup", "abort_prepared",
35
+ "abort_started", "abort_observed", "restitution_pending", "restituted", "external"))
36
+ # The phases in which an earlier version was restoring the checkout (an Abort) or finishing a replay without a commit
37
+ # when it stopped. The only copy of that work may then be in the backup folder and in the objects the anchor pins, so
38
+ # the anchor of such a journal stays in whichever repository holds it, and the folder is named to the user there.
39
+ LEGACY_RESTORATION_PHASE_SET = frozenset(("abort_prepared", "abort_started", "abort_observed", "restitution_pending", "quitting", "quit_observed", "finalizing"))
40
+ # The phase given to a journal whose record cannot be read: nothing is known about it, so its anchor is kept.
41
+ LEGACY_UNREADABLE_PHASE = ""
42
+ # The fields of an earlier record this module reads; every other field is left alone.
43
+ LEGACY_TEXT_FIELDS = ("record_id", "worktree_key", "common_git_dir", "phase", "anchor_ref", "anchor_oid")
44
+ # The listing asked of for-each-ref: each reference name and the object it points to, separated by a NUL byte.
45
+ LEGACY_ANCHOR_LISTING_FORMAT = "%(refname)%00%(objectname)"
46
+ LEGACY_ANCHOR_LISTING_SEPARATOR = b"\0"
47
+ LEGACY_ANCHOR_LISTING_FIELD_COUNT = 2
48
+ # One deletion line of "update-ref --stdin -z": the command and the reference, then the value it must still hold.
49
+ UPDATE_REF_DELETE_COMMAND = b"delete "
50
+ UPDATE_REF_FIELD_TERMINATOR = b"\0"
51
+
52
+
53
+ def legacy_replay_store_dir() -> str:
54
+ """Answer the folder in which earlier versions kept their Cherry-pick and Revert journals.
55
+
56
+ Called through this module's name everywhere, never bound to another name, so that the test suite can replace
57
+ it once and keep every test away from the folder of the person running it.
58
+ """
59
+ return os.path.join(writable_folder(QStandardPaths.StandardLocation.AppLocalDataLocation, sys.platform), LEGACY_REPLAY_STORE_BASENAME)
60
+
61
+
62
+ def legacy_worktree_store_dir(store_dir: str, worktree_git_dir: str) -> str:
63
+ """Answer the subfolder an earlier version used for the journals of one working tree, named by the hash of its own Git folder."""
64
+ return os.path.join(store_dir, hashlib.sha256(os.fsencode(worktree_git_dir)).hexdigest())
65
+
66
+
67
+ def is_legacy_record_id(name: str) -> bool:
68
+ """Tell whether a name is a journal identifier an earlier version generated: 32 lowercase hexadecimal characters."""
69
+ return len(name) == LEGACY_RECORD_ID_LENGTH and set(name) <= LEGACY_RECORD_ID_CHARACTER_SET
70
+
71
+
72
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
73
+ """Refuse a JSON object that names a field twice, instead of letting the last value silently win."""
74
+ value_by_name = {}
75
+ for name, value in pairs:
76
+ if name in value_by_name:
77
+ raise ValueError("An earlier replay journal repeats a field.")
78
+ value_by_name[name] = value
79
+ return value_by_name
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LegacyReplayRecord:
84
+ """The part of one earlier journal that decides whether its anchor may go: which repository it served, its phase, its anchor."""
85
+
86
+ record_id: str
87
+ worktree_git_dir: str
88
+ common_git_dir: str
89
+ phase: str
90
+ anchor_ref: str
91
+ anchor_oid: str
92
+ record_dir: str
93
+
94
+
95
+ def _unreadable_record(record_id: str, record_dir: str) -> LegacyReplayRecord:
96
+ """Describe a journal whose record cannot be trusted: only its folder name is known."""
97
+ return LegacyReplayRecord(record_id, "", "", LEGACY_UNREADABLE_PHASE, "", "", record_dir)
98
+
99
+
100
+ def read_legacy_replay_record(record_dir: str) -> LegacyReplayRecord:
101
+ """Read the record of one earlier journal, bounded in size and refusing a repeated field, a missing field or a record of another folder.
102
+
103
+ Raises OSError, ValueError or TypeError when the record cannot be trusted. Nothing is written.
104
+ """
105
+ record_id = os.path.basename(record_dir)
106
+ filename = os.path.join(record_dir, LEGACY_RECORD_BASENAME)
107
+ with open(filename, "rb") as stream:
108
+ content = stream.read(REPLAY_RECORD_LIMIT_BYTES + 1)
109
+ if len(content) > REPLAY_RECORD_LIMIT_BYTES:
110
+ raise ValueError("An earlier replay journal is larger than its limit.")
111
+ try:
112
+ value_by_name = json.loads(content, object_pairs_hook=_unique_json_object)
113
+ except (UnicodeError, RecursionError) as error:
114
+ raise ValueError("An earlier replay journal cannot be decoded.") from error
115
+ if not isinstance(value_by_name, dict):
116
+ raise TypeError("An earlier replay journal is not a JSON object.")
117
+ text_by_name = {}
118
+ for name in LEGACY_TEXT_FIELDS:
119
+ value = value_by_name.get(name)
120
+ if not isinstance(value, str):
121
+ raise TypeError("An earlier replay journal lacks its " + name + " field.")
122
+ text_by_name[name] = value
123
+ if text_by_name["record_id"] != record_id or text_by_name["phase"] not in LEGACY_REPLAY_PHASE_SET:
124
+ raise ValueError("An earlier replay journal does not belong to its folder or names an unknown phase.")
125
+ # The subfolder is named by the hash of the working tree the journal served; a record found elsewhere was moved.
126
+ if legacy_worktree_store_dir(os.path.dirname(os.path.dirname(record_dir)), text_by_name["worktree_key"]) != os.path.dirname(record_dir):
127
+ raise ValueError("An earlier replay journal is not in the folder of its working tree.")
128
+ return LegacyReplayRecord(record_id, text_by_name["worktree_key"], text_by_name["common_git_dir"], text_by_name["phase"], text_by_name["anchor_ref"],
129
+ text_by_name["anchor_oid"], record_dir)
130
+
131
+
132
+ def legacy_replay_store_in_use(store_dir: str) -> bool:
133
+ """Tell whether an earlier version left at least one working tree subfolder in its journal folder; reads metadata only."""
134
+ if not os.path.isdir(store_dir):
135
+ return False
136
+ with os.scandir(store_dir) as entries:
137
+ for entry in entries:
138
+ if entry.is_dir(follow_symlinks=False):
139
+ return True
140
+ return False
141
+
142
+
143
+ def read_legacy_replay_records(store_dir: str) -> tuple[LegacyReplayRecord, ...]:
144
+ """Read every earlier journal of every working tree, sorted by identifier, without changing a single file.
145
+
146
+ A journal whose record cannot be read is kept in the answer as unreadable, so that its anchor is kept too. More
147
+ journal folders than the limit raise ValueError: a journal that was not read could be one in the middle of a
148
+ restoration. The order in which the folders are listed is never used.
149
+ """
150
+ if not os.path.isdir(store_dir):
151
+ return ()
152
+ worktree_dirs = []
153
+ with os.scandir(store_dir) as entries:
154
+ for entry in entries:
155
+ if entry.is_dir(follow_symlinks=False):
156
+ worktree_dirs.append(entry.path)
157
+ record_dirs = []
158
+ for worktree_dir in sorted(worktree_dirs):
159
+ with os.scandir(worktree_dir) as entries:
160
+ for entry in entries:
161
+ if not entry.is_dir(follow_symlinks=False) or not is_legacy_record_id(entry.name):
162
+ continue
163
+ record_dirs.append(entry.path)
164
+ if len(record_dirs) > REPLAY_RECORD_COUNT_LIMIT:
165
+ raise ValueError("The folder of earlier replay journals holds too many journals to be read.")
166
+ records = []
167
+ for record_dir in record_dirs:
168
+ try:
169
+ records.append(read_legacy_replay_record(record_dir))
170
+ except (OSError, ValueError, TypeError):
171
+ records.append(_unreadable_record(os.path.basename(record_dir), record_dir))
172
+ records.sort(key=lambda record: (record.record_id, record.record_dir))
173
+ return tuple(records)
174
+
175
+
176
+ def parse_legacy_anchor_listing(output: bytes) -> dict[str, str]:
177
+ """Read a for-each-ref listing written with LEGACY_ANCHOR_LISTING_FORMAT into the object each reference points to.
178
+
179
+ A line that does not hold exactly one name and one object raises ValueError, so that nothing is removed on a
180
+ listing that was not understood.
181
+ """
182
+ target_oid_by_ref = {}
183
+ for line in output.splitlines():
184
+ if not line:
185
+ continue
186
+ fields = line.split(LEGACY_ANCHOR_LISTING_SEPARATOR)
187
+ if len(fields) != LEGACY_ANCHOR_LISTING_FIELD_COUNT:
188
+ raise ValueError("Git gave back an unreadable reference listing.")
189
+ ref_field, oid_field = fields
190
+ target_oid_by_ref[ref_field.decode("utf-8")] = oid_field.decode("ascii")
191
+ return target_oid_by_ref
192
+
193
+
194
+ def legacy_anchor_deletions(target_oid_by_ref: dict[str, str], records: tuple[LegacyReplayRecord, ...]) -> tuple[tuple[str, str], ...]:
195
+ """Choose the anchor references to remove from one repository, each with the object it must still point to when removed.
196
+
197
+ A candidate is a reference directly under LEGACY_ANCHOR_REF_PREFIX whose last component is a journal identifier and
198
+ whose target is a full object ID. It is kept when a journal of the same identifier is unreadable or stopped in the
199
+ middle of a restoration, whatever repository path that journal recorded. Every other candidate goes, including an
200
+ anchor whose journal is gone. The answer is sorted by reference name.
201
+ """
202
+ kept_record_id_set = set()
203
+ for record in records:
204
+ # The identifier is random, so a reference of that name in this repository comes from the repository the journal
205
+ # served, moved, renamed or copied since. Comparing paths would remove its anchor after a move; keeping it costs
206
+ # only one reference.
207
+ if record.phase == LEGACY_UNREADABLE_PHASE or record.phase in LEGACY_RESTORATION_PHASE_SET:
208
+ kept_record_id_set.add(record.record_id)
209
+ deletions = []
210
+ for ref, target_oid in target_oid_by_ref.items():
211
+ if not ref.startswith(LEGACY_ANCHOR_REF_PREFIX):
212
+ continue
213
+ record_id = ref.removeprefix(LEGACY_ANCHOR_REF_PREFIX)
214
+ if not is_legacy_record_id(record_id) or record_id in kept_record_id_set:
215
+ continue
216
+ try:
217
+ validate_oid(target_oid)
218
+ except ValueError:
219
+ continue
220
+ deletions.append((ref, target_oid))
221
+ return tuple(sorted(deletions))
222
+
223
+
224
+ def kept_legacy_backup_dirs(records: tuple[LegacyReplayRecord, ...], target_oid_by_ref: dict[str, str], common_git_dir: str) -> tuple[str, ...]:
225
+ """Name, sorted, the journal folders an earlier version left in the middle of a restoration of the open repository, or could not read.
226
+
227
+ The anchor ties a journal to the open repository even after the repository moved, so a restoration or an
228
+ unreadable journal whose anchor this repository holds is named. A restoration recorded for this very repository
229
+ is named too when its anchor is gone, removed by hand or lost with a new clone: its backups are then the only copy
230
+ of that work. The recorded path only ever adds a folder to name; it never decides which anchor goes.
231
+ """
232
+ open_common_git_dir = canonical_path(common_git_dir)
233
+ kept_dirs = []
234
+ for record in records:
235
+ if record.phase != LEGACY_UNREADABLE_PHASE and record.phase not in LEGACY_RESTORATION_PHASE_SET:
236
+ continue
237
+ anchor_held = LEGACY_ANCHOR_REF_PREFIX + record.record_id in target_oid_by_ref
238
+ recorded_here = record.phase in LEGACY_RESTORATION_PHASE_SET and canonical_path(record.common_git_dir) == open_common_git_dir
239
+ if anchor_held or recorded_here:
240
+ kept_dirs.append(record.record_dir)
241
+ return tuple(sorted(kept_dirs))
242
+
243
+
244
+ def legacy_anchor_deletion_input(deletions: tuple[tuple[str, str], ...]) -> bytes:
245
+ """Write the input of "git update-ref --stdin -z" that deletes each reference only while it still points to its listed object.
246
+
247
+ Git applies the lines as one transaction: a reference that moved in between makes the whole command fail and
248
+ removes nothing.
249
+ """
250
+ lines = []
251
+ for ref, old_oid in deletions:
252
+ lines.append(UPDATE_REF_DELETE_COMMAND + ref.encode("utf-8") + UPDATE_REF_FIELD_TERMINATOR + old_oid.encode("ascii") + UPDATE_REF_FIELD_TERMINATOR)
253
+ return b"".join(lines)