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,481 @@
1
+ """Reconcile durable evidence of an interrupted history publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ from dataclasses import asdict, dataclass, fields, replace
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ from gitpane.config import LOCAL_PREFIX, MUTATION_RECORD_LIMIT_BYTES
15
+ from gitpane.history.file_history import WindowCapability
16
+ from gitpane.history.repository_value import validate_oid
17
+ from gitpane.history_rewrite.guard import GIT_STORAGE_ARGUMENTS, git_storage_matches, other_worktree_dir_by_branch, parse_worktree_listing
18
+ from gitpane.history_rewrite.index import index_digest
19
+ from gitpane.operation.diagnostic import Diagnostic
20
+ from gitpane.operation.operation_status import OperationStatus
21
+ from gitpane.operation.repository_operation import OperationAction, WriteScope
22
+ from gitpane.process.process_runner import READ_PROFILE
23
+ from gitpane.reference.reference_edit import reference_signature
24
+ from gitpane.system.path_identity import canonical_path
25
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
26
+ from gitpane.worktree.mutation import write_record
27
+ from gitpane.worktree.worktree_file import FILE_MODE_IDENTITY_FIELD, FILE_OBJECT_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Generator
31
+
32
+ from gitpane.history.repository_value import RepositoryContext
33
+ from gitpane.history_rewrite.publish import RewritePublicationOutcome
34
+ from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
35
+
36
+ HISTORY_REWRITE_STORAGE_BASENAME = "history-rewrite"
37
+ RECOVERY_SCHEMA_VERSION = 1
38
+ PRIVATE_REF_PREFIX = "refs/gitpane/history-rewrite/"
39
+ RECOVERY_REF_FORMAT = "%(refname)%00%(objectname)%00%(symref)"
40
+ EMPTY_LOCK_DIGEST = hashlib.sha256(b"").hexdigest()
41
+ LOCK_IDENTITY_FIELD_COUNT = 3
42
+
43
+
44
+ class RewritePublicationState(Enum):
45
+ """Name durable transitions without claiming a transaction across refs and index."""
46
+
47
+ PREPARED = "prepared"
48
+ IMPORTING = "importing"
49
+ IMPORTED = "imported"
50
+ REFS_PENDING = "refs_pending"
51
+ REFS_COMMITTED = "refs_committed"
52
+ INDEX_PENDING = "index_pending"
53
+ INDEX_PUBLISHED = "index_published"
54
+ COMPENSATING = "compensating"
55
+ RESTORED = "restored"
56
+ COMPLETE = "complete"
57
+ NEEDS_RECOVERY = "needs_recovery"
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class RewriteRecoveryRecord:
62
+ """Keep exact before/after identities outside Git for a single publication token."""
63
+
64
+ schema_version: int
65
+ operation_id: str
66
+ operation_dir: str
67
+ context_identity: tuple[str, str, str]
68
+ git_filename: str
69
+ head_ref: str
70
+ old_oid_by_ref: dict[str, str]
71
+ new_oid_by_ref: dict[str, str]
72
+ original_index_identity: str | None
73
+ candidate_index_identity: str | None
74
+ owned_lock_identity: tuple[int, int, int] | None
75
+ state: RewritePublicationState
76
+ private_ref_by_name: dict[str, str]
77
+ detail: str = ""
78
+ owned_lock_digest: str | None = None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RecoveryObservation:
83
+ """Report observed public state independently of the last journal transition."""
84
+
85
+ ref_oid_by_name: dict[str, str]
86
+ index_identity: str | None
87
+ restorable: bool
88
+ detail: str
89
+ head_ref: str | None
90
+ lock_identity: tuple[int, int, int] | None
91
+ private_ref_by_name: dict[str, str]
92
+ authority: tuple = ()
93
+ worktrees: tuple[bytes, ...] = ()
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class RecoveryDiscovery:
98
+ """Retain unreadable operation folders as visible diagnostics during discovery."""
99
+
100
+ operation_dir: str
101
+ record: RewriteRecoveryRecord | None
102
+ detail: str = ""
103
+
104
+
105
+ class RecoveryContextMismatch(ValueError):
106
+ """Distinguish another repository's valid record from corrupt recovery data."""
107
+
108
+
109
+ def context_identity(context: RepositoryContext) -> tuple[str, str, str]:
110
+ """Bind a record to the exact active repository and its two storage directories."""
111
+ return canonical_path(context.repository_dir), canonical_path(context.git_dir), canonical_path(context.common_git_dir)
112
+
113
+
114
+ def digest_text(identity: bytes | None) -> str | None:
115
+ """Serialize an absent index distinctly from every byte digest."""
116
+ return identity.hex() if identity is not None else None
117
+
118
+
119
+ def lock_identity(filename: str) -> tuple[int, int, int] | None:
120
+ """Identify a lock entry without following a replacement symlink."""
121
+ try:
122
+ information = os.lstat(filename)
123
+ except FileNotFoundError:
124
+ return None
125
+ return information.st_dev, information.st_ino, stat.S_IFMT(information.st_mode)
126
+
127
+
128
+ def sync_directory(directory: str) -> None:
129
+ """Persist renamed entries on systems that support opening directory descriptors."""
130
+ if not hasattr(os, "O_DIRECTORY"):
131
+ return
132
+ descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
133
+ try:
134
+ os.fsync(descriptor)
135
+ finally:
136
+ os.close(descriptor)
137
+
138
+
139
+ def validate_storage_root(storage_root_dir: str, context: RepositoryContext) -> str:
140
+ """Require recovery storage outside the worktree and all of its Git storage."""
141
+ storage_root_dir = canonical_path(storage_root_dir)
142
+ for source_dir in context_identity(context):
143
+ try:
144
+ inside = os.path.commonpath((source_dir, storage_root_dir)) == source_dir
145
+ except ValueError:
146
+ inside = False
147
+ if inside:
148
+ raise ValueError("History recovery storage must be outside the repository and its Git storage")
149
+ return storage_root_dir
150
+
151
+
152
+ def create_recovery_directory(storage_root_dir: str, operation_id: str, context: RepositoryContext) -> str:
153
+ """Claim a private durable operation folder without reusing an earlier publication."""
154
+ if re.fullmatch(r"[0-9a-f]{32}", operation_id) is None:
155
+ raise ValueError("Invalid history publication identifier; build a new preview")
156
+ storage_root_dir = validate_storage_root(storage_root_dir, context)
157
+ os.makedirs(storage_root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
158
+ operation_dir = os.path.join(storage_root_dir, operation_id)
159
+ os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
160
+ os.mkdir(os.path.join(operation_dir, "hooks"), PRIVATE_DIRECTORY_MODE)
161
+ sync_directory(storage_root_dir)
162
+ return operation_dir
163
+
164
+
165
+ def write_recovery_record(record: RewriteRecoveryRecord) -> None:
166
+ """Flush a bounded private JSON record before performing its announced action."""
167
+ if os.path.islink(record.operation_dir) or not os.path.isdir(record.operation_dir):
168
+ raise ValueError("Recovery directory changed; retain the recovery files for inspection")
169
+ record_by_name = asdict(record)
170
+ record_by_name["state"] = record.state.value
171
+ if len(json.dumps(record_by_name, sort_keys=True).encode("utf-8")) > MUTATION_RECORD_LIMIT_BYTES:
172
+ raise ValueError("History recovery record exceeds its size limit; retain the recovery files")
173
+ for basename in ("record.json", "record.next"):
174
+ filename = os.path.join(record.operation_dir, basename)
175
+ if os.path.lexists(filename):
176
+ information = os.lstat(filename)
177
+ if not stat.S_ISREG(information.st_mode) or information.st_nlink != 1:
178
+ raise ValueError("Linked or nonregular recovery record; inspect the recovery files")
179
+ write_record(record.operation_dir, record_by_name)
180
+ sync_directory(record.operation_dir)
181
+
182
+
183
+ def transition_record(record: RewriteRecoveryRecord, state: RewritePublicationState, **changes: object) -> RewriteRecoveryRecord:
184
+ """Persist a transition before returning it to the caller."""
185
+ record = replace(record, state=state, **changes)
186
+ write_recovery_record(record)
187
+ return record
188
+
189
+
190
+ def _unique_record_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
191
+ """Refuse duplicate JSON fields instead of silently trusting the last occurrence."""
192
+ value_by_name = {}
193
+ for name, value in pairs:
194
+ if name in value_by_name:
195
+ raise ValueError("Duplicate recovery record field; inspect it before continuing")
196
+ value_by_name[name] = value
197
+ return value_by_name
198
+
199
+
200
+ def _validate_record_reference(reference: str, prefix: str) -> None:
201
+ """Check the native reference namespace using Git's ASCII restrictions, preserving non-ASCII bytes."""
202
+ forbidden_byte_set = set(b" ~^:?*[\\")
203
+ encoded = os.fsencode(reference)
204
+ if (not reference.startswith(prefix) or any(value <= 32 or value == 127 or value in forbidden_byte_set for value in encoded)
205
+ or ".." in reference or "@{" in reference or reference.endswith(".")):
206
+ raise ValueError("Recovery reference is outside its allowed namespace")
207
+ for part in reference.split("/"):
208
+ if not part or part.startswith(".") or part.endswith(".lock"):
209
+ raise ValueError("Invalid recovery reference name")
210
+
211
+
212
+ def read_recovery_record(operation_dir: str, context: RepositoryContext) -> RewriteRecoveryRecord:
213
+ """Read only a supported journal bound to this repository and operation folder."""
214
+ validate_storage_root(os.path.dirname(operation_dir), context)
215
+ filename = os.path.join(operation_dir, "record.json")
216
+ if os.path.islink(operation_dir):
217
+ raise ValueError("Linked recovery directory; inspect it before continuing")
218
+ information = os.lstat(filename)
219
+ if not stat.S_ISREG(information.st_mode) or information.st_nlink != 1 or information.st_size > MUTATION_RECORD_LIMIT_BYTES:
220
+ raise ValueError("Linked, oversized or nonregular recovery record; inspect it before continuing")
221
+ with open(filename, "rb") as stream:
222
+ opened = os.fstat(stream.fileno())
223
+ current = os.lstat(filename)
224
+ if (opened.st_dev, opened.st_ino) != (information.st_dev, information.st_ino) or not stat.S_ISREG(current.st_mode):
225
+ raise ValueError("Recovery record was replaced while opening it; inspect it before continuing")
226
+ content = stream.read(MUTATION_RECORD_LIMIT_BYTES + 1)
227
+ if len(content) > MUTATION_RECORD_LIMIT_BYTES:
228
+ raise ValueError("Oversized recovery record; inspect it before continuing")
229
+ record_by_name = json.loads(content, object_pairs_hook=_unique_record_pairs)
230
+ if not isinstance(record_by_name, dict) or set(record_by_name) != {field.name for field in fields(RewriteRecoveryRecord)}:
231
+ raise ValueError("Unrecognized recovery record fields; inspect it before continuing")
232
+ if type(record_by_name["schema_version"]) is not int or record_by_name["schema_version"] != RECOVERY_SCHEMA_VERSION:
233
+ raise ValueError("Unsupported recovery record version; inspect it before continuing")
234
+ if record_by_name["operation_id"] != os.path.basename(operation_dir) or re.fullmatch(r"[0-9a-f]{32}", record_by_name["operation_id"]) is None:
235
+ raise ValueError("Recovery operation identifier does not match its folder")
236
+ # The folder is recorded with its canonical spelling, lower case under Windows: compare folders, not spellings.
237
+ if not isinstance(record_by_name["operation_dir"], str) or canonical_path(record_by_name["operation_dir"]) != canonical_path(operation_dir):
238
+ raise ValueError("Recovery record does not belong to this repository")
239
+ if record_by_name["context_identity"] != list(context_identity(context)):
240
+ raise RecoveryContextMismatch("Recovery record belongs to another repository")
241
+ record_by_name["context_identity"] = tuple(record_by_name["context_identity"])
242
+ record_by_name["state"] = RewritePublicationState(record_by_name["state"])
243
+ identity = record_by_name["owned_lock_identity"]
244
+ if identity is not None:
245
+ if (not isinstance(identity, list) or len(identity) != LOCK_IDENTITY_FIELD_COUNT or any(type(value) is not int or value < 0 for value in identity)
246
+ or identity[FILE_MODE_IDENTITY_FIELD] != stat.S_IFREG):
247
+ raise ValueError("Invalid recorded index lock identity")
248
+ record_by_name["owned_lock_identity"] = tuple(identity)
249
+ for name in ("original_index_identity", "candidate_index_identity", "owned_lock_digest"):
250
+ identity = record_by_name[name]
251
+ if identity is not None and (not isinstance(identity, str) or re.fullmatch(r"[0-9a-f]{64}", identity) is None):
252
+ raise ValueError("Invalid recorded index digest")
253
+ for name in ("old_oid_by_ref", "new_oid_by_ref", "private_ref_by_name"):
254
+ oid_by_ref = record_by_name[name]
255
+ if not isinstance(oid_by_ref, dict):
256
+ raise ValueError("Invalid recovery reference map") # noqa: TRY004 — malformed persisted JSON is a recoverable data error.
257
+ prefix = PRIVATE_REF_PREFIX + record_by_name["operation_id"] + "/" if name == "private_ref_by_name" else LOCAL_PREFIX
258
+ for reference, oid in oid_by_ref.items():
259
+ _validate_record_reference(reference, prefix)
260
+ validate_oid(oid)
261
+ if set(record_by_name["old_oid_by_ref"]) != set(record_by_name["new_oid_by_ref"]) or record_by_name["head_ref"] not in record_by_name["old_oid_by_ref"]:
262
+ raise ValueError("Recovery reference sets do not describe one captured repository")
263
+ return RewriteRecoveryRecord(**record_by_name)
264
+
265
+
266
+ def observe_recovery(record: RewriteRecoveryRecord, context: RepositoryContext) -> Generator[GitCommand | FileTask, object, RecoveryObservation]:
267
+ """Inspect actual refs, HEAD and index instead of trusting a pending journal state."""
268
+ if record.context_identity != context_identity(context):
269
+ raise ValueError("Recovery record belongs to another repository")
270
+ authority = yield FileTask(lambda: reference_signature(context))
271
+ locations = yield GitCommand(list(GIT_STORAGE_ARGUMENTS), profile=READ_PROFILE)
272
+ if not git_storage_matches(context, locations):
273
+ raise ValueError("Repository storage changed; reopen the repository before recovery")
274
+ output = yield GitCommand(["for-each-ref", "--format=" + RECOVERY_REF_FORMAT, LOCAL_PREFIX, PRIVATE_REF_PREFIX + record.operation_id + "/"])
275
+ ref_oid_by_name = {}
276
+ private_ref_by_name = {}
277
+ detail = ""
278
+ for line in output.splitlines():
279
+ name, oid, symbolic = line.split(b"\0")
280
+ name = os.fsdecode(name)
281
+ if symbolic:
282
+ detail = "A reference became symbolic; inspect references before recovery"
283
+ if name.startswith(LOCAL_PREFIX):
284
+ ref_oid_by_name[name] = validate_oid(oid.decode("ascii"))
285
+ else:
286
+ private_ref_by_name[name] = validate_oid(oid.decode("ascii"))
287
+ head = (yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))).removesuffix(b"\n")
288
+ head_ref = os.fsdecode(head) if head else None
289
+ worktrees = parse_worktree_listing((yield GitCommand(["worktree", "list", "--porcelain", "-z"])))
290
+ index_filename = os.path.join(context.git_dir, "index")
291
+ identity = yield FileTask(lambda: digest_text(index_digest(index_filename)))
292
+ actual_lock_identity = yield FileTask(lambda: lock_identity(index_filename + ".lock"))
293
+ if head_ref != record.head_ref:
294
+ detail = "HEAD changed outside the operation; inspect the active branch before recovery"
295
+ if actual_lock_identity is not None and actual_lock_identity != record.owned_lock_identity:
296
+ detail = "An index lock belongs to another writer; wait for that writer before recovery"
297
+ elif actual_lock_identity is not None:
298
+ lock_digest = yield FileTask(lambda: digest_text(index_digest(index_filename + ".lock")))
299
+ if lock_digest != record.owned_lock_digest:
300
+ detail = "Index lock content changed outside the operation; inspect it before recovery"
301
+ restorable = (not detail and record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED)
302
+ and ref_oid_by_name == record.new_oid_by_ref and identity == record.original_index_identity and identity != record.candidate_index_identity)
303
+ if restorable:
304
+ worktree_dir_by_branch = other_worktree_dir_by_branch(context, worktrees)
305
+ for name in sorted(record.old_oid_by_ref):
306
+ if record.old_oid_by_ref[name] != record.new_oid_by_ref[name] and name in worktree_dir_by_branch:
307
+ detail = (f"{name} is checked out in another worktree: {worktree_dir_by_branch[name]}. Restoring it would change that checkout's branch without its index. "
308
+ "Release that branch in the other worktree, then refresh recovery. No branches were restored.")
309
+ restorable = False
310
+ break
311
+ if (yield FileTask(lambda: reference_signature(context))) != authority:
312
+ raise ValueError("References, HEAD or worktrees changed during recovery inspection; refresh recovery")
313
+ return RecoveryObservation(ref_oid_by_name, identity, restorable, detail, head_ref, actual_lock_identity, private_ref_by_name, authority, worktrees)
314
+
315
+
316
+ def coherent_recovery_state(record: RewriteRecoveryRecord, observation: RecoveryObservation) -> RewritePublicationState | None:
317
+ """Identify already consistent public data that needs no branch restoration."""
318
+ if observation.detail:
319
+ return None
320
+ if observation.ref_oid_by_name == record.old_oid_by_ref and observation.index_identity == record.original_index_identity:
321
+ return RewritePublicationState.RESTORED
322
+ if observation.ref_oid_by_name == record.new_oid_by_ref and observation.index_identity == record.candidate_index_identity:
323
+ return RewritePublicationState.COMPLETE
324
+ return None
325
+
326
+
327
+ def validate_recovery_observation(record: RewriteRecoveryRecord, observation: RecoveryObservation, operation: OperationIntent,
328
+ repository_operation: RepositoryOperationService) -> None:
329
+ """Reject changed evidence immediately before cleanup or a terminal journal write."""
330
+ validate_recovery_authority(record, operation, repository_operation)
331
+ if read_recovery_record(record.operation_dir, operation.context) != record or reference_signature(operation.context) != observation.authority:
332
+ raise ValueError("Recovery evidence changed; refresh recovery before continuing")
333
+ index_filename = os.path.join(operation.context.git_dir, "index")
334
+ actual_lock = lock_identity(index_filename + ".lock")
335
+ if digest_text(index_digest(index_filename)) != observation.index_identity or actual_lock != observation.lock_identity:
336
+ raise ValueError("Index or lock changed during recovery; retain recovery data")
337
+ if actual_lock is not None and (actual_lock != record.owned_lock_identity or digest_text(index_digest(index_filename + ".lock")) != record.owned_lock_digest):
338
+ raise ValueError("Index lock content changed; retain it for inspection")
339
+
340
+
341
+ def discover_recovery(storage_root_dir: str, context: RepositoryContext) -> tuple[RecoveryDiscovery, ...]:
342
+ """List unfinished operations without deleting malformed or unknown recovery evidence."""
343
+ storage_root_dir = validate_storage_root(storage_root_dir, context)
344
+ if not os.path.exists(storage_root_dir):
345
+ return ()
346
+ discoveries = []
347
+ with os.scandir(storage_root_dir) as entries:
348
+ for entry in entries:
349
+ try:
350
+ if not entry.is_dir(follow_symlinks=False):
351
+ raise ValueError("Unexpected entry in recovery storage; inspect it before continuing")
352
+ record = read_recovery_record(entry.path, context)
353
+ if record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED):
354
+ discoveries.append(RecoveryDiscovery(entry.path, record))
355
+ except RecoveryContextMismatch:
356
+ continue
357
+ except (OSError, ValueError, TypeError, KeyError, AttributeError) as error:
358
+ discoveries.append(RecoveryDiscovery(entry.path, None, "Cannot inspect history recovery: " + str(error)))
359
+ return tuple(sorted(discoveries, key=lambda discovery: discovery.operation_dir))
360
+
361
+
362
+ def validate_recovery_authority(record: RewriteRecoveryRecord, operation: OperationIntent, repository_operation: RepositoryOperationService) -> None:
363
+ """Require the same executable, context and full reference scope for every recovery write."""
364
+ repository_operation.validate(operation)
365
+ if (record.context_identity != context_identity(operation.context) or record.git_filename != operation.git_filename
366
+ or operation.action != OperationAction.REF_WRITE or operation.capability != WindowCapability.MANAGE
367
+ or operation.scope != WriteScope.capture(operation.context, (LOCAL_PREFIX + "*",))):
368
+ raise ValueError("History recovery authority changed; reopen the repository before continuing")
369
+
370
+
371
+ def release_recovery_lock(record: RewriteRecoveryRecord, context: RepositoryContext) -> None:
372
+ """Release an interrupted writer's exact regular lock, never a replacement entry."""
373
+ from gitpane.worktree.mutation import release_owned_index_lock
374
+
375
+ filename = os.path.join(context.git_dir, "index.lock")
376
+ actual_identity = lock_identity(filename)
377
+ if actual_identity is None:
378
+ return
379
+ if actual_identity != record.owned_lock_identity:
380
+ raise ValueError("Index lock belongs to another writer; wait before retrying recovery")
381
+ if digest_text(index_digest(filename)) != record.owned_lock_digest:
382
+ raise ValueError("Index lock content changed; retain it for inspection")
383
+ release_owned_index_lock(filename, None, record.owned_lock_identity[:FILE_OBJECT_IDENTITY_FIELDS])
384
+ sync_directory(context.git_dir)
385
+
386
+
387
+ def finish_coherent_recovery(record: RewriteRecoveryRecord, observation: RecoveryObservation, state: RewritePublicationState,
388
+ operation: OperationIntent, repository_operation: RepositoryOperationService, *, detail: str = "") -> Generator[GitCommand | FileTask, object, RewriteRecoveryRecord]:
389
+ """Close recovery only while fresh public state, journal and owned resources remain coherent."""
390
+ from gitpane.history_rewrite.publish import cleanup_private_refs
391
+
392
+ if coherent_recovery_state(record, observation) != state:
393
+ raise ValueError("Repository is not coherent; retain recovery data")
394
+
395
+ def validate() -> None:
396
+ """Use the latest complete observation at each write boundary."""
397
+ validate_recovery_observation(record, observation, operation, repository_operation)
398
+
399
+ yield from cleanup_private_refs(record, observation, before_start=validate)
400
+ observation = yield from observe_recovery(record, operation.context)
401
+ if coherent_recovery_state(record, observation) != state or observation.private_ref_by_name:
402
+ raise ValueError("Repository changed during recovery cleanup; retain recovery data")
403
+
404
+ def release_lock() -> None:
405
+ """Revalidate before releasing only the interrupted operation's lock."""
406
+ validate()
407
+ release_recovery_lock(record, operation.context)
408
+
409
+ yield FileTask(release_lock)
410
+ observation = yield from observe_recovery(record, operation.context)
411
+ if coherent_recovery_state(record, observation) != state or observation.private_ref_by_name or observation.lock_identity is not None:
412
+ raise ValueError("Repository changed before recovery finished; retain recovery data")
413
+
414
+ def finish() -> RewriteRecoveryRecord:
415
+ """Record completion only while the final public state still matches."""
416
+ validate()
417
+ return transition_record(record, state, detail=detail)
418
+
419
+ return (yield FileTask(finish))
420
+
421
+
422
+ def recover_rewrite(record: RewriteRecoveryRecord, operation: OperationIntent, repository_operation: RepositoryOperationService,
423
+ *, restore: bool = False, expected_observation: RecoveryObservation | None = None) -> Generator[GitCommand | FileTask, object, RewritePublicationOutcome]:
424
+ """Reobserve pending work, restoring incomplete publication only when its caller authorizes it."""
425
+ from gitpane.history_rewrite.publish import RewritePublicationOutcome, compensate_rewrite
426
+
427
+ trusted = False
428
+ try:
429
+ fresh_record = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
430
+ if fresh_record != record:
431
+ raise ValueError("Recovery record changed; refresh recovery before continuing")
432
+ trusted = True
433
+ validate_recovery_authority(record, operation, repository_operation)
434
+ observation = yield from observe_recovery(record, operation.context)
435
+ if expected_observation is not None and observation != expected_observation:
436
+ raise ValueError("Recovery state changed since observation; refresh recovery")
437
+ if observation.detail:
438
+ raise ValueError(observation.detail)
439
+ state = coherent_recovery_state(record, observation)
440
+ if state is None and observation.restorable and restore:
441
+ return (yield from compensate_rewrite(record, observation, operation, repository_operation))
442
+ if state is None:
443
+ detail = "Branches were published but the index is original. Review recovery and confirm Restore previous branches."
444
+ if not observation.restorable:
445
+ changed_refs = []
446
+ for name in sorted(set(observation.ref_oid_by_name) | set(record.old_oid_by_ref)):
447
+ observed_oid = observation.ref_oid_by_name.get(name, "absent")
448
+ if observed_oid not in (record.old_oid_by_ref.get(name), record.new_oid_by_ref.get(name)):
449
+ changed_refs.append(f"{name}={observed_oid}")
450
+ detail = "References or index differ from a complete before/after state; inspect recovery without overwriting external changes."
451
+ if changed_refs:
452
+ detail += " Changed references: " + ", ".join(changed_refs)
453
+ raise ValueError(detail)
454
+ record = yield from finish_coherent_recovery(record, observation, state, operation, repository_operation)
455
+ status = OperationStatus.SUCCESS if state == RewritePublicationState.COMPLETE else OperationStatus.FAILED
456
+ return RewritePublicationOutcome(operation.operation_id, status, record=record)
457
+ except (OSError, ValueError, RuntimeError, TypeError, KeyError) as error:
458
+ if trusted:
459
+ return (yield from retain_recovery_failure(record, operation, error))
460
+ diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
461
+ return RewritePublicationOutcome(operation.operation_id, OperationStatus.UNKNOWN, diagnostic=diagnostic, record=record)
462
+
463
+
464
+ def retain_recovery_failure(record: RewriteRecoveryRecord, operation: OperationIntent, error: Exception) -> Generator[FileTask, object, RewritePublicationOutcome]:
465
+ """Persist failure against the caller's latest journal without replacing an external edit."""
466
+ from gitpane.history_rewrite.publish import RewritePublicationOutcome
467
+
468
+ detail = str(error)
469
+ try:
470
+ def retain_failure() -> RewriteRecoveryRecord:
471
+ """Change only the exact journal last written or inspected by this operation."""
472
+ current = read_recovery_record(record.operation_dir, operation.context)
473
+ if current != record:
474
+ return current
475
+ return transition_record(record, RewritePublicationState.NEEDS_RECOVERY, detail=detail)
476
+
477
+ record = yield FileTask(retain_failure)
478
+ except (OSError, ValueError, RuntimeError, TypeError, KeyError):
479
+ detail += "; cannot persist recovery status, retain the existing record for inspection"
480
+ diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], detail).prepare()
481
+ return RewritePublicationOutcome(operation.operation_id, OperationStatus.UNKNOWN, diagnostic=diagnostic, record=record)