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,110 @@
1
+ """Describe the steps of a Git command sequence: commands, read batches, file tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ from gitpane.config import GIT_READ_TIMEOUT_MS, WORKTREE_OUTPUT_LIMIT_BYTES, ProcessPriority
9
+ from gitpane.operation.diagnostic import Diagnostic
10
+ from gitpane.process.process_runner import READ_PROFILE
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ from gitpane.process.process_runner import ProcessResult
16
+ from gitpane.tool.editor import EditorExecutionContext
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class GitCommand:
21
+ """Describe a bounded command without exposing asynchronous plumbing to callers."""
22
+
23
+ arguments: list[str]
24
+ stdin: bytes | None = None
25
+ environment_by_name: dict[str, str] = field(default_factory=dict)
26
+ accepted_codes: tuple[int, ...] = (0,)
27
+ stdout_limit_bytes: int | None = WORKTREE_OUTPUT_LIMIT_BYTES
28
+ timeout_ms: int = GIT_READ_TIMEOUT_MS
29
+ idle_timeout_ms: int = GIT_READ_TIMEOUT_MS
30
+ consume: Callable[[bytes], None] | None = None
31
+ # None means a read: see command_profile.
32
+ profile: str | None = None
33
+ before_start: Callable[[], None] | None = None
34
+ editor_context: EditorExecutionContext | None = None
35
+ suppress_editor: bool = False
36
+ stdin_filename: str | None = None
37
+ # See ProcessRequest.opens_files_deleted_at_end.
38
+ opens_files_deleted_at_end: bool = False
39
+
40
+ def __post_init__(self) -> None:
41
+ """Reject competing input sources before the sequence reaches its runner."""
42
+ if self.stdin is not None and self.stdin_filename is not None:
43
+ raise ValueError("stdin and stdin_filename are mutually exclusive")
44
+
45
+
46
+ class GitCommandError(RuntimeError):
47
+ """Preserve a prepared command failure for both the view and optional logging."""
48
+
49
+ def __init__(self, result: ProcessResult) -> None:
50
+ """Filter command details once at the process boundary, naming the process so a console that showed its end does not repeat it."""
51
+ request = result.request
52
+ self.process_status = result.status
53
+ self.return_code = result.return_code
54
+ self.diagnostic = Diagnostic("Read or prepare changes", request.working_dir, request.program_filename, request.arguments, result.cause, result.stderr, result.return_code,
55
+ process_id=request.request_id).prepare()
56
+ super().__init__(self.diagnostic.cause)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ReadBatch:
61
+ """Read independent Git or filesystem facts concurrently, retaining their original result positions."""
62
+
63
+ commands: tuple[GitCommand | FileTask, ...]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class FileTask:
68
+ """Move potentially large filesystem reads off the Qt event loop."""
69
+
70
+ callback: Callable[[], object]
71
+
72
+
73
+ # A command that declares nothing is a read and does not need the supervised launcher, which costs an interpreter and
74
+ # a job per process. Every command that writes, or that can run a hook, a filter or the editor, declares LOCAL_WRITE_PROFILE
75
+ # itself, and the tests refuse any undeclared command that is not a read.
76
+ DEFAULT_COMMAND_PROFILE = READ_PROFILE
77
+
78
+
79
+ def command_profile(command: GitCommand) -> str:
80
+ """Decide the launch profile of one command: the one it declares, otherwise the read profile.
81
+
82
+ This is the only place where that decision is made, so a change of rule cannot forget one of the paths a command
83
+ can take to the process runner.
84
+ """
85
+ if command.profile is not None:
86
+ return command.profile
87
+ return DEFAULT_COMMAND_PROFILE
88
+
89
+
90
+ def resumed_priority(priority: ProcessPriority) -> ProcessPriority:
91
+ """Answer the level a sequence resumes at once one of its steps has ended.
92
+
93
+ A user action that has started is from then on the rest of an operation; the rest of an operation and background
94
+ work keep their own level, so background work never overtakes anything by lasting longer.
95
+ """
96
+ if priority == ProcessPriority.USER:
97
+ return ProcessPriority.CONTINUATION
98
+ return priority
99
+
100
+
101
+ def observation_priority(observers: list[tuple[Callable[[], bool], Callable, ProcessPriority]]) -> ProcessPriority:
102
+ """Answer the queue level of a read shared by these callers: the most urgent level among those still current.
103
+
104
+ A caller whose window was replaced meanwhile will not be answered, so its level does not hurry the read.
105
+ """
106
+ priority = ProcessPriority.BACKGROUND
107
+ for current, _callback, observer_priority in observers:
108
+ if observer_priority < priority and current():
109
+ priority = observer_priority
110
+ return priority
@@ -0,0 +1,61 @@
1
+ """Describe what a file menu of a repository window points at."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.history.repository_value import FilePanel, RevisionEntry
11
+
12
+ if TYPE_CHECKING:
13
+ from gitpane.history.repository_value import TreeEntry
14
+
15
+
16
+ class FileActionTargetKind(Enum):
17
+ """Distinguish a single historical entry from an entire directory."""
18
+
19
+ FILE = "file"
20
+ FOLDER = "folder"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FileActionSelection:
25
+ """Capture the clicked entry and panel generation before opening a menu."""
26
+
27
+ entry: RevisionEntry | TreeEntry | None
28
+ commit_oid: str
29
+ panel_kind: FilePanel
30
+ generation: int
31
+ kind: FileActionTargetKind = FileActionTargetKind.FILE
32
+ folder_path: bytes | None = None
33
+
34
+ def __post_init__(self) -> None:
35
+ """Reject ambiguous selections before any caller can treat a directory as a blob."""
36
+ if self.kind == FileActionTargetKind.FOLDER:
37
+ if self.entry is not None or not self.folder_path:
38
+ raise ValueError("A folder selection needs its raw path and no file entry.")
39
+ elif self.entry is None or self.folder_path is not None:
40
+ raise ValueError("A file selection needs its entry and no folder path.")
41
+
42
+ def basename(self) -> str:
43
+ """Return the literal selected side's basename without display decorations."""
44
+ _directory, _separator, basename = self.path().rpartition(b"/")
45
+ return os.fsdecode(basename)
46
+
47
+ def path(self) -> bytes:
48
+ """Return the raw folder or file path, using the former spelling for a deletion."""
49
+ if self.kind == FileActionTargetKind.FOLDER:
50
+ return self.folder_path
51
+ return self.entry.new_path or self.entry.old_path if isinstance(self.entry, RevisionEntry) else self.entry.path
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FileRestoreTarget:
56
+ """Identify a restoration source separately from the commit that owns the visible selection."""
57
+
58
+ commit_oid: str
59
+ label: str
60
+ message: str
61
+ unavailable: str = ""
@@ -0,0 +1,52 @@
1
+ """Name the actions the file menus offer, each with the label it shows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Self
7
+
8
+
9
+ class FileAction(Enum):
10
+ """An action of the menu on a file of Prepare changes or of the repository window, with the label of its entry.
11
+
12
+ The value is the name of the action; it is never written to a file.
13
+ """
14
+
15
+ STAGE = ("stage", "Stage selected")
16
+ """Prepare changes only: stage the selected files."""
17
+ RESET = ("reset", "Reset")
18
+ """Prepare changes only: reset the selected side of the files."""
19
+ DELETE = ("delete", "Delete file")
20
+ """Prepare changes only: move the files to the Trash."""
21
+ COPY_PATHS = ("copy_paths", "Copy path(s)")
22
+ """Prepare changes only: the title of the submenu that holds the two copies of the path."""
23
+ COPY_BASENAME = ("copy_basename", "Copy basename")
24
+ COPY_ABSOLUTE = ("copy_absolute", "Copy full abs filename")
25
+ FOLDER = ("folder", "Show in folder")
26
+ TEXT_EDITOR = ("text_editor", "Show in text editor")
27
+ HISTORY = ("history", "File history")
28
+ """Prepare changes only: the history of the working copy file."""
29
+ STOP_TRACKING = ("stop_tracking", "Stop tracking this file")
30
+ """Prepare changes only: stage the removal of the files from the index, keeping them on disk."""
31
+ RESET_TO = ("reset_to", "Reset to")
32
+ """Repository window only: the title of the submenu that restores the file from a parent commit."""
33
+ DISPLAY_HISTORY = ("display_history", "Display file history")
34
+ """Repository window only: the history of the file of the selected commit."""
35
+ COMPARE = ("compare", "Compare with external tool")
36
+ """Repository window only: the comparison of the file in the external diff tool, in the file menu and the tool menu."""
37
+ SAVE_AS = ("save_as", "Save selected as")
38
+ """Repository window only: save the file of the selected commit to a chosen place."""
39
+ REMOVE_FROM_HISTORY = ("remove_from_history", "Remove from history")
40
+ """Repository window only: review removing the selected path from local history."""
41
+
42
+ def __new__(cls, value: str, label: str) -> Self:
43
+ """Keep the name of the action as the value, and its menu label beside it."""
44
+ member = object.__new__(cls)
45
+ member._value_ = value
46
+ member.label = label
47
+ return member
48
+
49
+
50
+ # The actions that only open the working copy or its folder. Prepare changes offers them in both lists, Staged
51
+ # included, because they never change the index; the menu and the session both read this one definition.
52
+ READ_ONLY_FILE_ACTION_SET = frozenset((FileAction.FOLDER, FileAction.TEXT_EDITOR))
@@ -0,0 +1,361 @@
1
+ """Restore an explicitly chosen historical file without touching the live index or HEAD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import uuid
8
+ from dataclasses import dataclass, replace
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtCore import QObject, QTimer
12
+
13
+ from gitpane.history.file_history import WINDOW_CAPABILITY_BY_MODE, WindowCapability
14
+ from gitpane.history.history_parse import parse_commit, parse_tree_entries
15
+ from gitpane.history.repository_value import FileStatus, RevisionEntry, validate_oid
16
+ from gitpane.operation.diagnostic import Diagnostic
17
+ from gitpane.operation.repository_operation import OperationAction
18
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
19
+ from gitpane.worktree.mutation import JOURNAL_SCHEMA_VERSION, JournalKind, JournalState, MutationOutcome, recovery_files_cause, write_record
20
+ from gitpane.worktree.worktree_file import (
21
+ FILE_OBJECT_IDENTITY_FIELDS,
22
+ PRIVATE_DIRECTORY_MODE,
23
+ PRIVATE_FILE_MODE,
24
+ FileImage,
25
+ capture_image,
26
+ clean_created_parents,
27
+ create_missing_parents,
28
+ materialize_blob,
29
+ prepare_publication_image,
30
+ private_file_dir,
31
+ publish_image_mode,
32
+ stat_identity,
33
+ validate_destination,
34
+ validate_image,
35
+ write_image,
36
+ )
37
+ from gitpane.worktree.worktree_snapshot import WorktreeKey, read_index, verify_local_state
38
+
39
+ if TYPE_CHECKING:
40
+ from collections.abc import Callable, Generator
41
+
42
+ from gitpane.history.repository_value import RepositoryContext
43
+ from gitpane.worktree.file_action_target import FileActionSelection
44
+ from gitpane.worktree.mutation import MutationService
45
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
46
+
47
+
48
+ # The name of the action, also the title of every window and diagnostic that reports on it.
49
+ RESTORE_SELECTED_VERSION_TITLE = "Restore selected version"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class HistoricalRestoreRequest:
54
+ """Own one exact historical source and the local state accepted by its confirmation."""
55
+
56
+ operation_id: str
57
+ key: WorktreeKey
58
+ commit_oid: str
59
+ path: bytes
60
+ blob_oid: str
61
+ mode: str
62
+ selection: FileActionSelection
63
+ before: FileImage
64
+ after: FileImage
65
+ snapshot: WorktreeSnapshot
66
+ attributes: bytes
67
+ message: str
68
+ window_mode: str
69
+ validate: Callable[[], bool]
70
+ operation_dir: str
71
+ private_dir: str
72
+ candidate_filename: str
73
+ record_by_name: dict[str, object]
74
+
75
+
76
+ class HistoricalRestoreService(QObject):
77
+ """Retain preparations and writes independently of the lifetime of their initiating widgets."""
78
+
79
+ def __init__(self, mutation: MutationService, parent: QObject | None = None) -> None:
80
+ """Share worktree reservations, recovery and rereads with other local operations."""
81
+ super().__init__(parent)
82
+ self.mutation = mutation
83
+ self.prepared_by_id: dict[str, HistoricalRestoreRequest] = {}
84
+ self.outcome_by_id: dict[str, MutationOutcome] = {}
85
+
86
+ def prepare(self, context: RepositoryContext, git_filename: str, selection: FileActionSelection | None, message: str,
87
+ window_mode: str, validate: Callable[[], bool], callback: Callable[[HistoricalRestoreRequest | None, Exception | None], None], source_oid: str | None = None) -> str:
88
+ """Capture the viewed commit or a verified direct parent's file before replacing local content.
89
+
90
+ A refused reservation gives the real reason, worked out right away so it describes the state that refused it:
91
+ another operation is named only when a write really holds the repository.
92
+ """
93
+ operation_id = uuid.uuid4().hex
94
+ key = WorktreeKey.from_context(context)
95
+ try:
96
+ self._authorize(context, window_mode, validate)
97
+ if selection is None:
98
+ raise ValueError("Select a historical file first")
99
+ if not self.mutation.acquire_context(context, git_filename, operation_id, action=OperationAction.WORKTREE_EDIT):
100
+ raise ValueError(self.mutation.acquire_context_refusal_cause(context, git_filename, operation_id, action=OperationAction.WORKTREE_EDIT))
101
+ except ValueError as error:
102
+ QTimer.singleShot(0, lambda error=error: callback(None, error))
103
+ return operation_id
104
+ self.mutation.live_reset_id_set.add(operation_id)
105
+
106
+ def complete(request: HistoricalRestoreRequest | None, error: Exception | None) -> None:
107
+ """Release the worktree while the owned candidate awaits confirmation."""
108
+ self.mutation.release(key, operation_id)
109
+ if error is None:
110
+ self.prepared_by_id[operation_id] = request
111
+ else:
112
+ self.mutation.live_reset_id_set.discard(operation_id)
113
+ self.mutation.reread_requested.emit(key)
114
+ callback(request, error)
115
+
116
+ sequence = self._prepare(operation_id, context, git_filename, selection, message, window_mode, validate, source_oid)
117
+ self.mutation.worktree_service.run(context, git_filename, sequence, complete, operation_id=operation_id)
118
+ return operation_id
119
+
120
+ def cancel(self, request: HistoricalRestoreRequest) -> None:
121
+ """Invalidate declined or stale confirmation without touching the working file."""
122
+ if self.prepared_by_id.pop(request.operation_id, None) is not request:
123
+ return
124
+ self.mutation.live_reset_id_set.discard(request.operation_id)
125
+ request.record_by_name["state"] = JournalState.ABANDONED.value
126
+ write_record(request.operation_dir, request.record_by_name)
127
+ self._clean(request)
128
+
129
+ def restore(self, request: HistoricalRestoreRequest) -> str:
130
+ """Reacquire publication ownership and refuse any changed capability or capture.
131
+
132
+ A refused reservation writes nothing and gives the real reason, followed by the folder of its recovery files.
133
+ """
134
+ if self.prepared_by_id.pop(request.operation_id, None) is not request:
135
+ raise ValueError("This restoration confirmation is no longer current")
136
+ snapshot = request.snapshot
137
+
138
+ def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
139
+ """Publish retained terminal results only after all asynchronous work ends."""
140
+ self.mutation.release(request.key, request.operation_id)
141
+ self.mutation.live_reset_id_set.discard(request.operation_id)
142
+ if error is not None:
143
+ outcome = self._failure(request, error, bool(request.record_by_name.get("working_file_published")))
144
+ self.outcome_by_id[request.operation_id] = outcome
145
+ self.mutation._publish_outcome(outcome)
146
+
147
+ if not self.mutation.acquire(snapshot, request.operation_id, action=OperationAction.WORKTREE_EDIT):
148
+ self.mutation.live_reset_id_set.discard(request.operation_id)
149
+ cause = self.mutation.acquire_refusal_cause(snapshot, request.operation_id, action=OperationAction.WORKTREE_EDIT)
150
+ outcome = self._failure(request, ValueError(cause), False)
151
+ self.outcome_by_id[request.operation_id] = outcome
152
+ QTimer.singleShot(0, lambda: self.mutation._publish_outcome(outcome))
153
+ return request.operation_id
154
+ self.mutation.worktree_service.run(snapshot.context, snapshot.git_filename, self._publish(request), complete, operation_id=request.operation_id)
155
+ return request.operation_id
156
+
157
+ def _authorize(self, context: RepositoryContext, window_mode: str, validate: Callable[[], bool]) -> None:
158
+ """Enforce management permission again at the write service boundary."""
159
+ if WindowCapability.RESTORE not in WINDOW_CAPABILITY_BY_MODE.get(window_mode, ()) or context.is_bare or context.worktree_dir is None:
160
+ raise ValueError("Historical restoration requires a repository window with a working tree")
161
+ if not validate():
162
+ raise ValueError("The selected historical file is no longer current")
163
+
164
+ def _prepare(self, operation_id: str, context: RepositoryContext, git_filename: str, selection: FileActionSelection,
165
+ message: str, window_mode: str, validate: Callable[[], bool], source_oid: str | None = None) -> Generator:
166
+ """Resolve the explicit source tree while retaining the visible selection as the destination."""
167
+ selected_oid = validate_oid(selection.commit_oid)
168
+ commit_oid = validate_oid(source_oid) if source_oid is not None else selected_oid
169
+ if commit_oid != selected_oid:
170
+ raw_commit = yield GitCommand(["cat-file", "commit", selected_oid])
171
+ selected_commit = parse_commit(selected_oid, raw_commit, selected_oid)
172
+ if commit_oid not in selected_commit.parent_oids:
173
+ raise ValueError("The restoration source is not this commit or one of its direct parents")
174
+ path = selection.path()
175
+ validate_destination(context, path, allow_missing_parents=True)
176
+ source_path = path
177
+ if commit_oid != selected_oid and isinstance(selection.entry, RevisionEntry) and selection.entry.parent_oid == commit_oid and selection.entry.status == FileStatus.RENAMED:
178
+ source_path = selection.entry.old_path or path
179
+ output = yield GitCommand(["--literal-pathspecs", "ls-tree", "-z", commit_oid, "--", os.fsdecode(source_path)])
180
+ entries = parse_tree_entries(output)
181
+ if len(entries) != 1 or next(iter(entries)).path != source_path:
182
+ raise ValueError("This file is absent from the chosen source commit")
183
+ entry = next(iter(entries))
184
+ if entry.mode == "160000":
185
+ raise ValueError("A submodule points to a commit, not a file blob")
186
+ snapshot = yield from self.mutation.worktree_service.capture(context, git_filename)
187
+ recovery = self.mutation.recover(context, snapshot.index_filename, operation_id=operation_id)
188
+ if recovery:
189
+ raise ValueError(recovery)
190
+ attributes = yield GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=path + b"\0")
191
+ before = yield FileTask(lambda: capture_image(context, path, allow_missing_parents=True))
192
+ root_dir = os.path.join(context.git_dir, "gitpane-operation")
193
+ os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
194
+ operation_dir = os.path.join(root_dir, operation_id)
195
+ os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
196
+ record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.FILE.value, "operation_id": operation_id,
197
+ "index_filename": snapshot.index_filename, "path_hex": path.hex(), "before": before.digest(), "after": None, "state": JournalState.PREPARING.value}
198
+ write_record(operation_dir, record_by_name)
199
+ try:
200
+ private_dir = private_file_dir(context, path, operation_dir, allow_missing_parents=True)
201
+ content_filename = os.path.join(private_dir, "blob-content")
202
+ candidate_filename = os.path.join(private_dir, "candidate")
203
+ backup_filename = os.path.join(private_dir, "before")
204
+ symlink_output = yield GitCommand(["config", "--bool", "--get", "core.symlinks"], accepted_codes=(0, 1))
205
+ after = yield from materialize_blob(entry.oid, path, entry.mode, content_filename, symlink_output.strip() != b"false")
206
+ if stat.S_ISREG(after.mode) and stat.S_ISREG(before.mode):
207
+ executable_mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
208
+ permission_mode = stat.S_IMODE(before.mode) & ~executable_mask
209
+ if entry.mode == "100755":
210
+ permission_mode |= executable_mask
211
+ after = replace(after, mode=stat.S_IFREG | permission_mode)
212
+ if before.exists:
213
+ yield FileTask(lambda: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, before.read())))
214
+ after = yield FileTask(lambda: prepare_publication_image(candidate_filename, after, before))
215
+ owned_identity_by_basename = {}
216
+ for filename in (content_filename, candidate_filename, backup_filename):
217
+ if os.path.lexists(filename):
218
+ owned_identity_by_basename[os.path.basename(filename)] = stat_identity(os.lstat(filename))
219
+ record_by_name.update({"after": after.digest(), "state": JournalState.PREPARED.value, "private_dir": private_dir,
220
+ "private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS],
221
+ "owned_identity_by_basename": owned_identity_by_basename})
222
+ request = HistoricalRestoreRequest(operation_id, snapshot.key, commit_oid, path, entry.oid, entry.mode, selection, before, after,
223
+ snapshot, attributes, message, window_mode, validate, operation_dir, private_dir, candidate_filename, record_by_name)
224
+ yield from self._check_preparation(request)
225
+ write_record(operation_dir, record_by_name)
226
+ return request
227
+ except (OSError, ValueError, RuntimeError):
228
+ record_by_name["state"] = JournalState.ABANDONED.value
229
+ write_record(operation_dir, record_by_name)
230
+ raise
231
+
232
+ def _check_preparation(self, request: HistoricalRestoreRequest) -> Generator:
233
+ """Check without a Git process that the confirmation still matches the capture: authority, index bytes, target file and private storage.
234
+
235
+ These checks only prepare the confirmation. Publication has Git verify the capture again under the index lock.
236
+ """
237
+ snapshot = request.snapshot
238
+ self._authorize(snapshot.context, request.window_mode, request.validate)
239
+ yield FileTask(lambda: verify_local_state(snapshot, ()))
240
+ yield FileTask(lambda: validate_image(snapshot.context, request.path, request.before))
241
+ yield FileTask(lambda: self._validate_storage(request))
242
+ self._authorize(snapshot.context, request.window_mode, request.validate)
243
+
244
+ def _validate(self, request: HistoricalRestoreRequest) -> Generator:
245
+ """Have Git verify the capture, then compare the target's own conversion attributes and its exact local file capture.
246
+
247
+ Called under the owned index lock just before publication. The capture is verified without paths on purpose: the
248
+ historical target can be clean or absent from the read, so its content is proved by its own file capture and its
249
+ attributes by the explicit check below.
250
+ """
251
+ snapshot = request.snapshot
252
+ self._authorize(snapshot.context, request.window_mode, request.validate)
253
+ yield from self.mutation.worktree_service.verify(snapshot, ())
254
+ attributes = yield GitCommand(["check-attr", "--all", "-z", "--stdin"], stdin=request.path + b"\0")
255
+ if attributes != request.attributes:
256
+ raise ValueError("File conversion attributes changed after selection")
257
+ yield FileTask(lambda: validate_image(snapshot.context, request.path, request.before))
258
+ self._authorize(snapshot.context, request.window_mode, request.validate)
259
+
260
+ def _publish(self, request: HistoricalRestoreRequest) -> Generator:
261
+ """Replace only the working file under an owned index lock, then prove index and HEAD stayed unchanged."""
262
+ snapshot = request.snapshot
263
+ lock_filename = snapshot.index_filename + ".lock"
264
+ lock_identity = None
265
+ published = False
266
+ created_dirs: list[tuple[str, tuple[int, int]]] = []
267
+ request.record_by_name["created_dirs"] = created_dirs
268
+ try:
269
+ descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
270
+ try:
271
+ lock_identity = stat_identity(os.fstat(descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
272
+ finally:
273
+ os.close(descriptor)
274
+ yield from self._validate(request)
275
+ self._validate_storage(request)
276
+ if request.before.digest() == request.after.digest():
277
+ status = "unchanged"
278
+ else:
279
+ request.record_by_name["state"] = JournalState.PUBLISHING.value
280
+ write_record(request.operation_dir, request.record_by_name)
281
+ validate_image(snapshot.context, request.path, request.before)
282
+ expected = create_missing_parents(snapshot.context, request.path, request.before, created_dirs, lambda: write_record(request.operation_dir, request.record_by_name))
283
+ filename, _parents = validate_destination(snapshot.context, request.path)
284
+ validate_image(snapshot.context, request.path, expected)
285
+ self._authorize(snapshot.context, request.window_mode, request.validate)
286
+ if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
287
+ raise ValueError("The restoration index lock changed")
288
+ publish_image_mode(request.candidate_filename, request.after)
289
+ request.record_by_name["owned_identity_by_basename"][os.path.basename(request.candidate_filename)] = stat_identity(os.lstat(request.candidate_filename))
290
+ write_record(request.operation_dir, request.record_by_name)
291
+ validate_image(snapshot.context, request.path, expected)
292
+ self.mutation.validate_write(snapshot.key)
293
+ os.replace(request.candidate_filename, filename)
294
+ published = True
295
+ request.record_by_name["working_file_published"] = True
296
+ request.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
297
+ write_record(request.operation_dir, request.record_by_name)
298
+ status = "success"
299
+ final_head = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
300
+ if read_index(snapshot.index_filename) != snapshot.index_content or (final_head.strip().decode("ascii") or None) != snapshot.head_oid:
301
+ raise ValueError("HEAD or index changed during restoration; recovery files retained")
302
+ actual = yield FileTask(lambda: capture_image(snapshot.context, request.path))
303
+ if actual.digest() != request.after.digest():
304
+ raise ValueError("The working file changed during restoration; recovery files retained")
305
+ outcome = MutationOutcome(request.operation_id, request.key, status, (request.path,), ())
306
+ except (OSError, ValueError, RuntimeError) as error:
307
+ outcome = self._failure(request, error, published)
308
+ finally:
309
+ if not published:
310
+ clean_created_parents(created_dirs)
311
+ try:
312
+ if lock_identity is not None and os.path.lexists(lock_filename):
313
+ if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
314
+ raise ValueError("The restoration index lock changed; cleanup refused")
315
+ os.unlink(lock_filename)
316
+ except (OSError, ValueError, RuntimeError) as error:
317
+ outcome = self._failure(request, error, published)
318
+ if outcome.diagnostic is None:
319
+ try:
320
+ # Recovery content stays owned until the index lock has been released successfully.
321
+ request.record_by_name["state"] = JournalState.COMPLETE.value
322
+ write_record(request.operation_dir, request.record_by_name)
323
+ self._clean(request)
324
+ except (OSError, ValueError, RuntimeError) as error:
325
+ outcome = self._failure(request, error, published)
326
+ return outcome
327
+
328
+ def _validate_storage(self, request: HistoricalRestoreRequest) -> None:
329
+ """Refuse private directory or candidate substitutions before exposing any prepared content."""
330
+ record = request.record_by_name
331
+ if os.path.islink(request.private_dir) or os.path.isjunction(request.private_dir) or stat_identity(os.lstat(request.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != record["private_identity"]:
332
+ raise ValueError("Private restoration storage changed")
333
+ for basename, identity in record["owned_identity_by_basename"].items():
334
+ if stat_identity(os.lstat(os.path.join(request.private_dir, basename))) != identity:
335
+ raise ValueError("Private restoration content changed")
336
+
337
+ def _clean(self, request: HistoricalRestoreRequest) -> None:
338
+ """Remove only still-owned private artifacts after success or cancellation."""
339
+ record = request.record_by_name
340
+ if os.path.islink(request.private_dir) or os.path.isjunction(request.private_dir) or stat_identity(os.lstat(request.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != record["private_identity"]:
341
+ raise ValueError("Private restoration storage changed; cleanup refused")
342
+ for basename, identity in request.record_by_name["owned_identity_by_basename"].items():
343
+ filename = os.path.join(request.private_dir, basename)
344
+ if os.path.lexists(filename):
345
+ if stat_identity(os.lstat(filename)) != identity:
346
+ raise ValueError("Private restoration content changed; cleanup refused")
347
+ os.unlink(filename)
348
+ if request.private_dir != request.operation_dir:
349
+ os.rmdir(request.private_dir)
350
+
351
+ def _failure(self, request: HistoricalRestoreRequest, error: Exception, published: bool) -> MutationOutcome:
352
+ """Retain backups on any uncertain result without undoing a later external edit."""
353
+ request.record_by_name["state"] = (JournalState.FILE_PUBLISHED if published else JournalState.ABANDONED).value
354
+ try:
355
+ write_record(request.operation_dir, request.record_by_name)
356
+ except OSError:
357
+ pass
358
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(RESTORE_SELECTED_VERSION_TITLE, request.snapshot.context.repository_dir,
359
+ request.snapshot.git_filename, [], str(error)).prepare()
360
+ diagnostic = replace(diagnostic, cause=recovery_files_cause(diagnostic.cause, request.operation_dir))
361
+ return MutationOutcome(request.operation_id, request.key, "unknown" if published else "refused", (request.path,) if published else (), (request.path,), diagnostic, request.operation_dir)