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,1539 @@
1
+ """Publish privately prepared Git indexes under an owned lock with recovery records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import uuid
10
+ from dataclasses import asdict, dataclass, replace
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QObject, QTimer, Signal
15
+
16
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS, MUTATION_RECORD_LIMIT_BYTES
17
+ from gitpane.history.repository_value import FileStatus
18
+ from gitpane.operation.diagnostic import Diagnostic
19
+ from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OWNED_REPOSITORY_TEXT, OperationAction, OperationIntent, RepositoryOperationService, WriteScope, native_rebase_capture
20
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
21
+ from gitpane.system.path_identity import canonical_path
22
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
23
+ from gitpane.worktree.staged_reset import may_carry_split_index
24
+ from gitpane.worktree.worktree_file import (
25
+ FILE_MODE_IDENTITY_FIELD,
26
+ FILE_OBJECT_IDENTITY_FIELDS,
27
+ FILE_STABLE_IDENTITY_FIELDS,
28
+ MISSING_PARENT_IDENTITY,
29
+ PRIVATE_DIRECTORY_MODE,
30
+ PRIVATE_FILE_MODE,
31
+ FileImage,
32
+ capture_image,
33
+ clean_created_parents,
34
+ clean_owned,
35
+ create_missing_parents,
36
+ matches_after_announced_mode,
37
+ materialize_blob,
38
+ owned_inventory,
39
+ prepare_publication_image,
40
+ private_file_dir,
41
+ publication_image,
42
+ publication_permission,
43
+ publish_image_mode,
44
+ respell_single_entry,
45
+ stat_identity,
46
+ validate_destination,
47
+ validate_image,
48
+ write_image,
49
+ )
50
+ from gitpane.worktree.worktree_snapshot import SUBMODULE_NO_COMMIT_CHANGE, StagingSide, WorktreeKey, file_fingerprint, fingerprint, read_index, verify_local_state
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Callable, Generator
54
+
55
+ from gitpane.history.repository_value import RepositoryContext
56
+ from gitpane.operation.diagnostic import PreparedDiagnostic
57
+ from gitpane.process.process_runner import ProcessRunner
58
+ from gitpane.sequencer.conflict import ConflictResolutionIntent
59
+ from gitpane.worktree.partial_patch import RawLineSelection, RawPatch
60
+ from gitpane.worktree.staged_reset import ResetCandidate
61
+ from gitpane.worktree.worktree_service import WorktreeService
62
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
63
+
64
+ JOURNAL_SCHEMA_VERSION = 1
65
+
66
+
67
+ class JournalKind(Enum):
68
+ """The kinds of change a recovery journal describes; the index journal of a staging change carries no kind at all."""
69
+
70
+ RESET = "reset"
71
+ """A Reset of whole files, with one entry per file it replaces."""
72
+ FILE = "file"
73
+ """A change of one working file, such as a Reset of selected lines or a historical restore."""
74
+
75
+
76
+ class JournalState(Enum):
77
+ """The moments of a journaled change, written as their value in record.json and read back at recovery."""
78
+
79
+ PREPARING = "preparing"
80
+ """The private copies are being written; nothing visible has changed."""
81
+ PREPARED = "prepared"
82
+ """The candidates and backups are complete; nothing visible has changed yet."""
83
+ PUBLISHING = "publishing"
84
+ """The visible replacements are about to start."""
85
+ FILE_PUBLISHED = "file_published"
86
+ """At least one working file holds its new content."""
87
+ INDEX_PUBLISHED = "index_published"
88
+ """The index holds its new content."""
89
+ COMPLETE = "complete"
90
+ """Every replacement finished and was cleaned up."""
91
+ ABANDONED = "abandoned"
92
+ """The change stopped before anything visible changed."""
93
+ RECOVERED_PRE = "recovered_pre"
94
+ """A recovery found or restored the state from before the change."""
95
+ RECOVERED_POST = "recovered_post"
96
+ """A recovery found the change fully applied."""
97
+ ROLLED_BACK = "rolled_back"
98
+ """The change put the working file back after a failure."""
99
+ RESTORED = "restored"
100
+ """A recovery put this file of a Reset back to its content from before the change."""
101
+
102
+
103
+ # The journal states a recovery leaves alone: the change ended, either way.
104
+ FINISHED_JOURNAL_STATE_SET = frozenset((JournalState.COMPLETE, JournalState.ABANDONED, JournalState.RECOVERED_PRE, JournalState.RECOVERED_POST, JournalState.ROLLED_BACK))
105
+ # Causes of a journal this version cannot read, shown after "Cannot inspect interrupted operation: ".
106
+ UNKNOWN_JOURNAL_STATE_CAUSE = "Unknown recovery record state"
107
+ UNKNOWN_JOURNAL_KIND_CAUSE = "Unknown recovery record kind"
108
+
109
+
110
+ def read_journal_state(record_by_name: dict[str, object]) -> JournalState:
111
+ """Read the state a recovery journal records, refusing a word this version does not know, an empty one or none.
112
+
113
+ The refusal is a plain sentence the user reads, never Python's own message about the enum.
114
+ """
115
+ try:
116
+ return JournalState(record_by_name.get("state"))
117
+ except ValueError as error:
118
+ raise ValueError(UNKNOWN_JOURNAL_STATE_CAUSE) from error
119
+
120
+
121
+ def read_journal_kind(record_by_name: dict[str, object]) -> JournalKind | None:
122
+ """Read the kind of change a recovery journal records, or None when it records none, as the index journal of a staging change does.
123
+
124
+ A kind this version does not know is refused with a plain sentence the user reads.
125
+ """
126
+ if "kind" not in record_by_name:
127
+ return None
128
+ try:
129
+ return JournalKind(record_by_name["kind"])
130
+ except ValueError as error:
131
+ raise ValueError(UNKNOWN_JOURNAL_KIND_CAUSE) from error
132
+
133
+ # The operation named by a diagnostic about restoring interrupted changes; the staging view keeps it shown as a state.
134
+ RECOVER_CHANGES_OPERATION = "Recover changes"
135
+ # Said when the snapshot's repository folder no longer resolves to the Git directory it was read from, for example after a move.
136
+ REPOSITORY_FOLDER_CHANGED_TEXT = "The repository folder changed since it was read. Refresh before writing."
137
+ # Said when an identifier already prepared is asked again for another repository, Git program, action or scope.
138
+ PREPARED_ACTION_CHANGED_TEXT = "This action no longer matches what was prepared. Refresh and try again."
139
+ # Introduces the folder where an interrupted change keeps its files, so the user can inspect them.
140
+ RECOVERY_FILES_LABEL = "Recovery files: "
141
+ # Ends a sentence of a cause; a cause that already ends with it needs no other one before the next sentence.
142
+ SENTENCE_END = "."
143
+
144
+
145
+ def recovery_files_cause(cause: str, operation_dir: str) -> str:
146
+ """Follow a failure cause with the folder that keeps the recovery files, as the next sentence of the same text.
147
+
148
+ Many refusal texts already end their sentence, so the full stop is added only when the cause has none.
149
+ """
150
+ separator = " " if cause.endswith(SENTENCE_END) else SENTENCE_END + " "
151
+ return cause + separator + RECOVERY_FILES_LABEL + operation_dir
152
+
153
+
154
+ def reset_recovery_authority(context: RepositoryContext) -> dict[str, str]:
155
+ """Bind durable recovery to native refs and pause identity without including Reset's own effects."""
156
+ from gitpane.reference.reference_edit import reference_signature
157
+
158
+ observations = []
159
+ for observation in native_rebase_capture(context):
160
+ observations.append(asdict(observation) if observation is not None else None)
161
+ return {
162
+ "references": fingerprint(json.dumps(reference_signature(context), default=bytes.hex).encode("utf-8")),
163
+ "rebase": fingerprint(json.dumps(observations, sort_keys=True).encode("utf-8")),
164
+ }
165
+
166
+
167
+ def require_unchanged_authority(context: RepositoryContext, expected: dict[str, str]) -> None:
168
+ """Refuse when references, configuration files or the native rebase moved since the expected authority was read.
169
+
170
+ The authority is read from local files only, so bracketing Git's verification and the live replacements with it
171
+ costs no Git process, and a reference moved after that verification cannot pass for the verified state.
172
+ """
173
+ if reset_recovery_authority(context) != expected:
174
+ raise ValueError("HEAD, references or the native rebase changed before publication; refresh the changes")
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class MutationOutcome:
179
+ """Report the proven index result even when its original view has closed."""
180
+
181
+ operation_id: str
182
+ key: WorktreeKey
183
+ status: str
184
+ completed_paths: tuple[bytes, ...]
185
+ failed_paths: tuple[bytes, ...]
186
+ diagnostic: PreparedDiagnostic | None = None
187
+ recovery_dir: str | None = None
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class PreparedLineReset:
192
+ """Own one exact calculated candidate and backup until publication or cancellation."""
193
+
194
+ operation_id: str
195
+ model: RawPatch
196
+ selection: RawLineSelection
197
+ before: FileImage
198
+ after: FileImage
199
+ operation_dir: str
200
+ private_dir: str
201
+ candidate_filename: str
202
+ backup_filename: str
203
+ record_by_name: dict[str, object]
204
+
205
+
206
+ def announced_candidate_mode(candidate_identity: tuple[int, ...] | None, after: FileImage) -> int | None:
207
+ """Predict the mode a candidate reports after publish_image_mode, recorded before the change so recovery recognizes it.
208
+
209
+ The prediction is the POSIX result: file type kept, permission bits replaced. Where a system reports permissions
210
+ differently, the prediction simply does not match and the recorded identity stays the only accepted one.
211
+ """
212
+ permission = publication_permission(after)
213
+ if candidate_identity is None or permission is None:
214
+ return None
215
+ return stat.S_IFMT(candidate_identity[FILE_MODE_IDENTITY_FIELD]) | permission
216
+
217
+
218
+ def write_record(operation_dir: str, record_by_name: dict[str, object]) -> None:
219
+ """Flush a private recovery record before making its described step visible."""
220
+ filename = os.path.join(operation_dir, "record.json")
221
+ temporary_filename = os.path.join(operation_dir, "record.next")
222
+ with open(temporary_filename, "w", encoding="utf-8") as stream:
223
+ os.chmod(temporary_filename, PRIVATE_FILE_MODE)
224
+ json.dump(record_by_name, stream, sort_keys=True)
225
+ stream.flush()
226
+ os.fsync(stream.fileno())
227
+ os.replace(temporary_filename, filename)
228
+
229
+
230
+ def fill_owned_lock(descriptor: int, content: bytes) -> None:
231
+ """Write a candidate into the lock file this operation created, and make it durable before anyone checks it."""
232
+ with os.fdopen(descriptor, "wb") as stream:
233
+ stream.write(content)
234
+ stream.flush()
235
+ os.fsync(stream.fileno())
236
+
237
+
238
+ def release_owned_index_lock(lock_filename: str, descriptor: int | None, identity: tuple[int, int] | None) -> None:
239
+ """Close a publication handle and remove its lock only while its recorded ownership still agrees."""
240
+ if descriptor is not None:
241
+ os.close(descriptor)
242
+ if identity is None:
243
+ return
244
+ try:
245
+ metadata = os.lstat(lock_filename)
246
+ except FileNotFoundError:
247
+ return
248
+ if not stat.S_ISREG(metadata.st_mode) or stat_identity(metadata)[:FILE_OBJECT_IDENTITY_FIELDS] != identity:
249
+ raise ValueError("The publication lock changed; cleanup refused")
250
+ os.unlink(lock_filename)
251
+
252
+
253
+ class MutationService(QObject):
254
+ """Serialize index changes by worktree and keep all candidate files outside working trees."""
255
+
256
+ finished = Signal(object)
257
+ reread_requested = Signal(object)
258
+
259
+ def __init__(self, runner: ProcessRunner, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None) -> None:
260
+ """Inject command execution and reads rather than constructing hidden services."""
261
+ super().__init__(parent)
262
+ self.runner = runner
263
+ self.worktree_service = worktree_service
264
+ self.repository_operation = repository_operation if repository_operation is not None else RepositoryOperationService(worktree_service, self)
265
+ self.operation_id_by_key: dict[WorktreeKey, str] = {}
266
+ self.intent_by_id: dict[str, OperationIntent] = {}
267
+ self.active_key_set: set[WorktreeKey] = set()
268
+ self.active_rule_filename_set: set[str] = set()
269
+ self.configuration_busy = False
270
+ self.outcome_by_id: dict[str, MutationOutcome] = {}
271
+ self.prepared_reset_by_id: dict[str, PreparedLineReset] = {}
272
+ self.prepared_file_reset_by_id: dict[str, ResetCandidate] = {}
273
+ self.live_reset_id_set: set[str] = set()
274
+
275
+ def acquire(self, snapshot: WorktreeSnapshot, operation_id: str, *, action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None,
276
+ operation: str | None = None) -> bool:
277
+ """Reserve the worktree and its shared repository before any candidate or live write."""
278
+ intent, _reason = self._acquisition_check(snapshot.context, snapshot.git_filename, operation_id, snapshot.generation, action, scope, snapshot.key, operation)
279
+ return self._reserve_acquisition(intent)
280
+
281
+ def acquire_context(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int = 0, *,
282
+ action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None) -> bool:
283
+ """Protect preparation that must capture a snapshot after acquiring repository ownership."""
284
+ intent, _reason = self._acquisition_check(context, git_filename, operation_id, generation, action, scope)
285
+ return self._reserve_acquisition(intent)
286
+
287
+ def acquire_refusal_cause(self, snapshot: WorktreeSnapshot, operation_id: str, *, action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None,
288
+ operation: str | None = None) -> str:
289
+ """Say why ``acquire`` refused this write, with the same arguments, in the same event-loop turn as the refusal.
290
+
291
+ Another operation is named only when a write really holds the repository, so the user waits only when waiting helps.
292
+ """
293
+ return self._acquisition_refusal_cause(snapshot.context, snapshot.git_filename, operation_id, snapshot.generation, action, scope, snapshot.key, operation)
294
+
295
+ def acquire_context_refusal_cause(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int = 0, *,
296
+ action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None) -> str:
297
+ """Say why ``acquire_context`` refused this write, with the same arguments, in the same event-loop turn as the refusal."""
298
+ return self._acquisition_refusal_cause(context, git_filename, operation_id, generation, action, scope)
299
+
300
+ def _acquisition_check(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int, action: OperationAction, scope: WriteScope | None,
301
+ read_key: WorktreeKey | None = None, operation: str | None = None) -> tuple[OperationIntent | None, str]:
302
+ """Run the checks of an acquisition that belong to this service, without reserving anything.
303
+
304
+ Answers the intent to reserve, or no intent and the reason that prevents it. Both the acquisition and the
305
+ cause of its refusal use this one list of checks, so the cause cannot drift from what was really refused.
306
+ """
307
+ key = WorktreeKey.from_context(context)
308
+ if read_key is not None and read_key != key:
309
+ return None, REPOSITORY_FOLDER_CHANGED_TEXT
310
+ # A global Git setting being saved or another change of the same worktree really holds the repository.
311
+ if self.configuration_busy or key in self.active_key_set:
312
+ return None, OWNED_REPOSITORY_TEXT
313
+ intent = OperationIntent.capture(operation_id, context, git_filename, generation=generation, action=action,
314
+ **({"scope": scope} if scope is not None else {}), **({"operation": operation} if operation is not None else {}))
315
+ previous = self.intent_by_id.get(operation_id)
316
+ if previous is None:
317
+ return intent, ""
318
+ if previous.context != context or previous.git_filename != git_filename or previous.action != action or previous.scope != intent.scope or previous.operation != intent.operation:
319
+ return None, PREPARED_ACTION_CHANGED_TEXT
320
+ return previous, ""
321
+
322
+ def _reserve_acquisition(self, intent: OperationIntent | None) -> bool:
323
+ """Reserve an intent the acquisition checks accepted and record this service's ownership of its worktree."""
324
+ if intent is None or not self.repository_operation.reserve(intent):
325
+ return False
326
+ self.intent_by_id[intent.operation_id] = intent
327
+ self.operation_id_by_key[intent.key] = intent.operation_id
328
+ self.active_key_set.add(intent.key)
329
+ return True
330
+
331
+ def _acquisition_refusal_cause(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int, action: OperationAction,
332
+ scope: WriteScope | None, read_key: WorktreeKey | None = None, operation: str | None = None) -> str:
333
+ """Give the cause of a refused acquisition: closing first, then this service's checks, then the repository operation service's reason."""
334
+ if self.repository_operation.closed:
335
+ return GITPANE_CLOSING_TEXT
336
+ intent, reason = self._acquisition_check(context, git_filename, operation_id, generation, action, scope, read_key, operation)
337
+ if reason:
338
+ return reason
339
+ return self.repository_operation.refusal_cause(intent)
340
+
341
+ def validate_write(self, key: WorktreeKey) -> None:
342
+ """Recheck the native operation and exact owner before publishing local content."""
343
+ operation_id = self.operation_id_by_key.get(key)
344
+ intent = self.repository_operation.active_by_id.get(operation_id)
345
+ if intent is None:
346
+ raise ValueError("This action no longer owns the worktree")
347
+ self.repository_operation.validate(intent)
348
+
349
+ def release(self, key: WorktreeKey, operation_id: str) -> None:
350
+ """Release only matching ownership after its processes and recovery work finish."""
351
+ if self.operation_id_by_key.get(key) != operation_id:
352
+ return
353
+ self.operation_id_by_key.pop(key)
354
+ self.active_key_set.discard(key)
355
+ self.repository_operation.release(operation_id)
356
+
357
+ def prepare_reset_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], callback: Callable[[ResetCandidate | None, Exception | None], None]) -> str:
358
+ """Compute a whole-file result, releasing the reservation while confirmation is open.
359
+
360
+ A refused reservation hands the callback the real cause of the refusal.
361
+ """
362
+ operation_id = uuid.uuid4().hex
363
+ if not self.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
364
+ cause = self.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
365
+ QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
366
+ return operation_id
367
+ self.live_reset_id_set.add(operation_id)
368
+
369
+ def complete(prepared: ResetCandidate | None, error: Exception | None) -> None:
370
+ """Retain only successful candidates and always release calculation ownership."""
371
+ self.release(snapshot.key, operation_id)
372
+ self.live_reset_id_set.discard(operation_id)
373
+ if error is None:
374
+ self.prepared_file_reset_by_id[operation_id] = prepared
375
+ else:
376
+ self.reread_requested.emit(snapshot.key)
377
+ callback(prepared, error)
378
+
379
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare_file_reset(operation_id, snapshot, entries), complete, operation_id=operation_id)
380
+ return operation_id
381
+
382
+ def cancel_reset_file(self, prepared: ResetCandidate) -> None:
383
+ """Invalidate a declined confirmation and clean only its owned unpublished files."""
384
+ if self.prepared_file_reset_by_id.pop(prepared.operation_id, None) is not prepared:
385
+ return
386
+ prepared.record_by_name["state"] = JournalState.ABANDONED.value
387
+ write_record(prepared.operation_dir, prepared.record_by_name)
388
+ self._clean_reset_content(prepared.record_by_name)
389
+ self.reread_requested.emit(prepared.snapshot.key)
390
+
391
+ def reset_file(self, prepared: ResetCandidate) -> str:
392
+ """Reacquire the worktree and publish only the exact confirmed file/index candidate.
393
+
394
+ A refused reservation abandons the candidate and reports the real cause of the refusal.
395
+ """
396
+ if self.prepared_file_reset_by_id.pop(prepared.operation_id, None) is not prepared:
397
+ raise ValueError("This reset confirmation is no longer current")
398
+ snapshot = prepared.snapshot
399
+ if not self.acquire(snapshot, prepared.operation_id, action=OperationAction.WORKTREE_EDIT):
400
+ cause = self.acquire_refusal_cause(snapshot, prepared.operation_id, action=OperationAction.WORKTREE_EDIT)
401
+ prepared.record_by_name["state"] = JournalState.ABANDONED.value
402
+ write_record(prepared.operation_dir, prepared.record_by_name)
403
+ diagnostic = Diagnostic("Reset", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
404
+ outcome = MutationOutcome(prepared.operation_id, snapshot.key, "refused", (), tuple(change.path for change in prepared.changes), diagnostic)
405
+ QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
406
+ return prepared.operation_id
407
+ self.live_reset_id_set.add(prepared.operation_id)
408
+
409
+ def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
410
+ """Keep terminal results after view closure and request a complete reread."""
411
+ self.release(snapshot.key, prepared.operation_id)
412
+ self.live_reset_id_set.discard(prepared.operation_id)
413
+ if error is not None:
414
+ outcome = self._reset_failure(prepared, error)
415
+ self._publish_outcome(outcome)
416
+
417
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._publish_reset(prepared), complete, operation_id=prepared.operation_id)
418
+ return prepared.operation_id
419
+
420
+ def _prepare_file_reset(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], *, resolution: ConflictResolutionIntent | None = None) -> Generator:
421
+ """Prepare every file and durable backup before any live path may change."""
422
+ from gitpane.worktree.staged_reset import ResetCandidate, calculate_reset
423
+
424
+ recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
425
+ if recovery:
426
+ raise ValueError(recovery)
427
+ paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
428
+ recovery_authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
429
+ yield from self._check_reset_preparation(snapshot, paths, resolution)
430
+ root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
431
+ os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
432
+ operation_dir = os.path.join(root_dir, operation_id)
433
+ os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
434
+ record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.RESET.value, "operation_id": operation_id,
435
+ "index_filename": snapshot.index_filename, "before": snapshot.index_fingerprint, "after": None, "state": JournalState.PREPARING.value, "files": [],
436
+ "git_filename": snapshot.git_filename, "recovery_authority": recovery_authority,
437
+ "operation_dir": operation_dir, "operation_identity": stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS]}
438
+ write_record(operation_dir, record_by_name)
439
+ try:
440
+ side = next(iter(entries)).side
441
+ operation = "Reset"
442
+ explanation = "Remove staged changes from the index and working files. Independent unstaged changes will be preserved." if side == StagingSide.STAGED else "Replace unstaged changes with the index version. Staged content will be preserved."
443
+ if resolution is None:
444
+ changes, before_entries, after_entries, candidate = yield from calculate_reset(snapshot, entries, operation_dir)
445
+ else:
446
+ from gitpane.sequencer.conflict import prepare_conflict_deletion
447
+
448
+ # Conflict deletion needs the same durable file/index publication and recovery as a staged reset.
449
+ changes, before_entries, after_entries, candidate = yield from prepare_conflict_deletion(resolution, operation_dir)
450
+ side = StagingSide.STAGED
451
+ operation = "Delete conflicted file"
452
+ explanation = "Delete the working file and stage its deletion as the conflict resolution."
453
+ owned_files: list[dict[str, object]] = []
454
+ normalized_changes = []
455
+ for number, change in enumerate(changes):
456
+ private_dir = private_file_dir(snapshot.context, change.path, operation_dir, allow_missing_parents=True)
457
+ backup_filename = os.path.join(private_dir, str(number) + "-before")
458
+ candidate_filename = os.path.join(private_dir, str(number) + "-after")
459
+ if change.before.exists:
460
+ yield FileTask(lambda change=change, backup_filename=backup_filename: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, change.before.read())))
461
+ after = yield FileTask(lambda change=change, candidate_filename=candidate_filename: prepare_publication_image(candidate_filename, change.after, change.before))
462
+ change = replace(change, after=after)
463
+ normalized_changes.append(change)
464
+ candidate_identity = stat_identity(os.lstat(candidate_filename)) if change.after.exists else None
465
+ candidate_published_mode = announced_candidate_mode(candidate_identity, change.after)
466
+ owned_files.append({"path_hex": change.path.hex(), "before": change.before.digest(), "after": change.after.digest(),
467
+ "before_mode": change.before.mode, "after_mode": change.after.mode, "before_absent": not change.before.exists, "after_absent": not change.after.exists,
468
+ "before_identity": change.before.identity, "parents": change.before.parents, "private_dir": private_dir,
469
+ "private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS], "backup_filename": backup_filename,
470
+ "backup_identity": stat_identity(os.lstat(backup_filename)) if change.before.exists else None,
471
+ "candidate_filename": candidate_filename, "candidate_identity": candidate_identity, "candidate_published_mode": candidate_published_mode,
472
+ "state": JournalState.PREPARED.value, "published_identity": None, "original_path_hex": (change.original_path or change.path).hex()})
473
+ changes = tuple(normalized_changes)
474
+ backup_index_filename = os.path.join(operation_dir, "before-index")
475
+ if snapshot.index_content is not None:
476
+ yield FileTask(lambda: write_image(backup_index_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, snapshot.index_content)))
477
+ record_by_name.update({"after": fingerprint(candidate), "state": JournalState.PREPARED.value, "files": owned_files,
478
+ "owned_files": owned_inventory(operation_dir), "side": side.value})
479
+ yield from self._check_reset_preparation(snapshot, paths, resolution)
480
+ for change in changes:
481
+ yield FileTask(lambda change=change: validate_image(snapshot.context, change.path, change.before))
482
+ write_record(operation_dir, record_by_name)
483
+ return ResetCandidate(operation_id, snapshot, side, changes, before_entries, after_entries, candidate, operation_dir, record_by_name, explanation, operation)
484
+ except (OSError, ValueError, RuntimeError):
485
+ record_by_name["state"] = JournalState.ABANDONED.value
486
+ write_record(operation_dir, record_by_name)
487
+ raise
488
+
489
+ def _check_reset_preparation(self, snapshot: WorktreeSnapshot, paths: tuple[bytes, ...], resolution: ConflictResolutionIntent | None) -> Generator:
490
+ """Refuse stale selected bytes at a preparation boundary.
491
+
492
+ A normal Reset only builds private files here, so the process-free check of the index bytes and selected paths
493
+ is enough; Git verifies HEAD, repository identity and conversion once, under the publication lock. A conflict
494
+ deletion asks Git to verify the index, HEAD, configuration, attributes and chosen path it was written against;
495
+ its complete capture stays at publication, under the lock, where it protects the file replacement.
496
+ """
497
+ if resolution is None:
498
+ yield FileTask(lambda: verify_local_state(snapshot, paths))
499
+ return
500
+ yield from self.worktree_service.verify(snapshot, paths)
501
+
502
+ @staticmethod
503
+ def _reset_verified_paths(prepared: ResetCandidate) -> tuple[bytes, ...]:
504
+ """Name every selected path the capture fingerprinted, both sides of a rename included.
505
+
506
+ A destination the capture never listed has no fingerprint to compare; its before image still proves it.
507
+ """
508
+ paths: list[bytes] = []
509
+ for change in prepared.changes:
510
+ for path in (change.path, change.original_path):
511
+ if path is not None and path in prepared.snapshot.fingerprint_by_path and path not in paths:
512
+ paths.append(path)
513
+ return tuple(paths)
514
+
515
+ def _clean_reset_content(self, record_by_name: dict[str, object]) -> None:
516
+ """Remove confirmed owned backup and candidate files without following directory substitutions.
517
+
518
+ A candidate may also carry the final permissions its record announced: publication changes them just before
519
+ recording the new identity, and an interruption between the two must not make GitPane refuse its own file.
520
+ Any other difference, including another mode, still refuses the cleanup.
521
+ """
522
+ operation_dir = record_by_name["operation_dir"]
523
+ if os.path.islink(operation_dir) or os.path.isjunction(operation_dir) or stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record_by_name["operation_identity"]):
524
+ raise ValueError("The operation directory changed; cleanup refused")
525
+ for record in record_by_name["files"]:
526
+ private_dir = record["private_dir"]
527
+ if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
528
+ raise ValueError("Private recovery directory changed; cleanup refused")
529
+ for name in ("backup", "candidate"):
530
+ filename = record[name + "_filename"]
531
+ if os.path.dirname(filename) != private_dir:
532
+ raise ValueError("Recovery filename is outside its owned directory")
533
+ if not os.path.lexists(filename):
534
+ continue
535
+ actual_identity = stat_identity(os.lstat(filename))
536
+ recorded_identity = tuple(record[name + "_identity"])
537
+ announced_mode = record.get("candidate_published_mode") if name == "candidate" else None
538
+ if actual_identity != recorded_identity and not matches_after_announced_mode(actual_identity, recorded_identity, announced_mode):
539
+ raise ValueError("Recovery content changed; cleanup refused")
540
+ os.unlink(filename)
541
+ if private_dir != operation_dir:
542
+ os.rmdir(private_dir)
543
+ clean_owned(operation_dir, record_by_name.get("owned_files", []))
544
+
545
+ def _publish_reset(self, prepared: ResetCandidate, *, validate: Callable[[], None] | None = None) -> Generator:
546
+ """Publish files then index, recording each real transition and retaining partial results.
547
+
548
+ Each candidate's final permissions were recorded at preparation, before this changes them: on POSIX the change
549
+ also gives the candidate a new identity, and a crash before the next record write must still let recovery
550
+ recognize and clean the candidate.
551
+
552
+ A normal Reset fills its owned lock with the candidate index first, asks Git to verify once, then proves with
553
+ local reads that no reference moved, before the first replacement and again after the last one. A caller that
554
+ supplies its own validation keeps the complete capture and HEAD read it was written against.
555
+ """
556
+ snapshot = prepared.snapshot
557
+ complete_check = validate is not None
558
+ validate_owner = validate or (lambda: self.validate_write(snapshot.key))
559
+ paths = tuple(change.path for change in prepared.changes)
560
+ lock_filename = snapshot.index_filename + ".lock"
561
+ lock_descriptor: int | None = None
562
+ lock_identity: tuple[int, int] | None = None
563
+ published_path_hexes: list[str] = []
564
+ created_dirs: list[tuple[str, tuple[int, int]]] = []
565
+ prepared.record_by_name["created_dirs"] = created_dirs
566
+ prepared.record_by_name["published_path_hexes"] = published_path_hexes
567
+ try:
568
+ lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
569
+ lock_identity = stat_identity(os.fstat(lock_descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
570
+ if complete_check:
571
+ current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename)
572
+ self._validate(snapshot, current, paths)
573
+ else:
574
+ if prepared.index_content is not None:
575
+ descriptor, lock_descriptor = lock_descriptor, None
576
+ fill_owned_lock(descriptor, prepared.index_content)
577
+ yield from self.worktree_service.verify(snapshot, self._reset_verified_paths(prepared))
578
+ for change in prepared.changes:
579
+ yield FileTask(lambda change=change: validate_image(snapshot.context, change.path, change.before))
580
+ for change, record in zip(prepared.changes, prepared.record_by_name["files"], strict=True):
581
+ yield FileTask(lambda change=change, record=record: self._validate_reset_storage(change.before, change.after, record))
582
+ if complete_check and prepared.index_content is not None:
583
+ descriptor, lock_descriptor = lock_descriptor, None
584
+ fill_owned_lock(descriptor, prepared.index_content)
585
+ if not complete_check:
586
+ yield FileTask(lambda: require_unchanged_authority(snapshot.context, prepared.record_by_name["recovery_authority"]))
587
+ prepared.record_by_name["state"] = JournalState.PUBLISHING.value
588
+ prepared.record_by_name["lock_identity"] = lock_identity
589
+ write_record(prepared.operation_dir, prepared.record_by_name)
590
+ for change, record in zip(prepared.changes, prepared.record_by_name["files"], strict=True):
591
+ expected = publication_image(change.before, created_dirs)
592
+ yield FileTask(lambda change=change, expected=expected: validate_image(snapshot.context, change.path, expected))
593
+ if change.after.exists:
594
+ create_missing_parents(snapshot.context, change.path, change.before, created_dirs, lambda: write_record(prepared.operation_dir, prepared.record_by_name))
595
+ filename, _parents = validate_destination(snapshot.context, change.path, allow_missing_parents=True)
596
+ candidate_filename = record["candidate_filename"]
597
+ validate_owner()
598
+ self._validate_reset_storage(change.before, change.after, record)
599
+ if change.after.exists:
600
+ publish_image_mode(candidate_filename, change.after)
601
+ record["candidate_identity"] = stat_identity(os.lstat(candidate_filename))
602
+ write_record(prepared.operation_dir, prepared.record_by_name)
603
+ validate_image(snapshot.context, change.path, publication_image(change.before, created_dirs))
604
+ os.replace(candidate_filename, filename)
605
+ if change.original_path is not None:
606
+ respell_single_entry(snapshot.context, change.original_path, change.path)
607
+ elif change.before.exists:
608
+ os.unlink(filename)
609
+ published_path_hexes.append(change.path.hex())
610
+ actual = capture_image(snapshot.context, change.path, allow_missing_parents=True)
611
+ record.update({"state": JournalState.FILE_PUBLISHED.value, "published_identity": actual.identity})
612
+ prepared.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
613
+ write_record(prepared.operation_dir, prepared.record_by_name)
614
+ if complete_check:
615
+ if read_index(snapshot.index_filename) != snapshot.index_content:
616
+ raise ValueError("The index changed before publication")
617
+ final_head = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
618
+ if (final_head.strip().decode("ascii") or None) != snapshot.head_oid:
619
+ raise ValueError("HEAD changed before publication")
620
+ else:
621
+ yield FileTask(lambda: require_unchanged_authority(snapshot.context, prepared.record_by_name["recovery_authority"]))
622
+ if read_index(snapshot.index_filename) != snapshot.index_content:
623
+ raise ValueError("The index changed before publication")
624
+ for change in prepared.changes:
625
+ if capture_image(snapshot.context, change.path, allow_missing_parents=True).digest() != change.after.digest():
626
+ raise ValueError("A working file changed during " + prepared.operation)
627
+ if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
628
+ lock_identity = None
629
+ raise ValueError("The index publication lock changed")
630
+ if prepared.side == StagingSide.STAGED:
631
+ if read_index(lock_filename) != prepared.index_content:
632
+ raise ValueError("The candidate index changed")
633
+ validate_owner()
634
+ os.replace(lock_filename, snapshot.index_filename)
635
+ lock_identity = None
636
+ prepared.record_by_name["index_published"] = True
637
+ prepared.record_by_name["state"] = JournalState.INDEX_PUBLISHED.value
638
+ write_record(prepared.operation_dir, prepared.record_by_name)
639
+ outcome = MutationOutcome(prepared.operation_id, snapshot.key, "success", paths, ())
640
+ except (OSError, ValueError, RuntimeError) as error:
641
+ outcome = self._reset_failure(prepared, error)
642
+ finally:
643
+ clean_created_parents(created_dirs)
644
+ try:
645
+ release_owned_index_lock(lock_filename, lock_descriptor, lock_identity)
646
+ except (OSError, ValueError, RuntimeError) as error:
647
+ outcome = self._reset_failure(prepared, error)
648
+ if outcome.diagnostic is None:
649
+ try:
650
+ # Backups remain available if releasing the live publication lock fails.
651
+ prepared.record_by_name["state"] = JournalState.COMPLETE.value
652
+ write_record(prepared.operation_dir, prepared.record_by_name)
653
+ self._clean_reset_content(prepared.record_by_name)
654
+ except (OSError, ValueError, RuntimeError) as error:
655
+ outcome = self._reset_failure(prepared, error)
656
+ return outcome
657
+
658
+ def _reset_failure(self, prepared: ResetCandidate, error: Exception) -> MutationOutcome:
659
+ """Retain known published paths even if cleanup or a later editor obscures their current bytes."""
660
+ snapshot = prepared.snapshot
661
+ published_paths = []
662
+ for path_hex in prepared.record_by_name.get("published_path_hexes", []):
663
+ published_paths.append(bytes.fromhex(path_hex))
664
+ index_published = bool(prepared.record_by_name.get("index_published"))
665
+ # A filesystem replacement can succeed before raising; keep both recorded facts and observed results.
666
+ for change in prepared.changes:
667
+ try:
668
+ actual = capture_image(snapshot.context, change.path)
669
+ if actual.digest() == change.after.digest() and actual.digest() != change.before.digest() and change.path not in published_paths:
670
+ published_paths.append(change.path)
671
+ except (OSError, ValueError):
672
+ pass
673
+ if prepared.side == StagingSide.STAGED and prepared.index_content != snapshot.index_content:
674
+ try:
675
+ index_published = index_published or read_index(snapshot.index_filename) == prepared.index_content
676
+ except (OSError, ValueError):
677
+ pass
678
+ published_path_hexes = []
679
+ for path in published_paths:
680
+ published_path_hexes.append(path.hex())
681
+ prepared.record_by_name["published_path_hexes"] = published_path_hexes
682
+ prepared.record_by_name["index_published"] = index_published
683
+ status = "unknown" if index_published else "partial" if published_paths else "refused"
684
+ journal_state = JournalState.INDEX_PUBLISHED if index_published else JournalState.FILE_PUBLISHED if published_paths else JournalState.ABANDONED
685
+ prepared.record_by_name["state"] = journal_state.value
686
+ try:
687
+ write_record(prepared.operation_dir, prepared.record_by_name)
688
+ except OSError:
689
+ pass
690
+ diagnostic = Diagnostic(prepared.operation, snapshot.context.repository_dir, snapshot.git_filename, [], recovery_files_cause(str(error), prepared.operation_dir)).prepare()
691
+ failed_paths = tuple(change.path for change in prepared.changes if change.path not in published_paths)
692
+ return MutationOutcome(prepared.operation_id, snapshot.key, status, tuple(published_paths), failed_paths, diagnostic, prepared.operation_dir)
693
+
694
+ def _validate_reset_storage(self, before: FileImage, after: FileImage, record: dict[str, object]) -> None:
695
+ """Prove both backup and candidate ownership before any member of a batch is replaced."""
696
+ private_dir = record["private_dir"]
697
+ if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
698
+ raise ValueError("The owned Reset directory changed")
699
+ for name, image in (("backup", before), ("candidate", after)):
700
+ filename = record[name + "_filename"]
701
+ if os.path.dirname(filename) != private_dir:
702
+ raise ValueError("The owned Reset file is outside its private directory")
703
+ if not image.exists:
704
+ if os.path.lexists(filename):
705
+ raise ValueError("An unexpected file occupies the owned Reset path")
706
+ continue
707
+ if stat_identity(os.lstat(filename)) != tuple(record[name + "_identity"]):
708
+ raise ValueError("The owned Reset " + name + " changed")
709
+ if name == "candidate" and stat.S_ISLNK(image.mode):
710
+ content = os.fsencode(os.readlink(filename))
711
+ else:
712
+ if os.path.islink(filename):
713
+ raise ValueError("The owned Reset backup is a symbolic link")
714
+ with open(filename, "rb") as stream:
715
+ content = stream.read()
716
+ if content != image.read():
717
+ raise ValueError("The owned Reset " + name + " changed")
718
+
719
+ def _recover_reset(self, context: RepositoryContext, operation_dir: str, record_by_name: dict[str, object], operation_id: str | None) -> str:
720
+ """Own recovery separately or borrow an explicit caller while retaining original native authority.
721
+
722
+ A recovery that cannot reserve the worktree gives the real cause of the refusal, then where its files are kept.
723
+ """
724
+ if not record_by_name.get("recovery_authority") or not record_by_name.get("git_filename"):
725
+ return recovery_files_cause("Interrupted Reset has no native authority proof; inspect its retained files before restoring manually.", operation_dir)
726
+ key = WorktreeKey.from_context(context)
727
+ recovery_id = uuid.uuid4().hex
728
+ borrowed = operation_id is not None
729
+ if borrowed:
730
+ owner = self.repository_operation.active_by_id.get(operation_id)
731
+ if owner is None or owner.key != key or self.operation_id_by_key.get(key) != operation_id:
732
+ return recovery_files_cause("Reset recovery does not own this worktree.", operation_dir)
733
+ else:
734
+ if not self.acquire_context(context, record_by_name["git_filename"], recovery_id, action=OperationAction.WORKTREE_EDIT):
735
+ cause = self.acquire_context_refusal_cause(context, record_by_name["git_filename"], recovery_id, action=OperationAction.WORKTREE_EDIT)
736
+ return recovery_files_cause(cause, operation_dir)
737
+ owner = self.repository_operation.active_by_id[recovery_id]
738
+ recovery_intent = replace(owner, action=OperationAction.WORKTREE_EDIT, scope=WriteScope.capture(context))
739
+ lock_filename = record_by_name["index_filename"] + ".lock"
740
+ lock_identity = None
741
+ restored_paths: list[bytes] = []
742
+ diagnostic = ""
743
+
744
+ def validate_recovery() -> None:
745
+ """Recheck the caller, recovery action and durable proof immediately before publication."""
746
+ if self.operation_id_by_key.get(key) != owner.operation_id:
747
+ raise ValueError("Reset recovery no longer owns this worktree")
748
+ self.repository_operation.validate(owner)
749
+ reason = self.repository_operation.policy_diagnostic(recovery_intent)
750
+ if reason:
751
+ raise ValueError(reason)
752
+ allows = self.repository_operation.allows
753
+ if allows is not None and not allows(recovery_intent):
754
+ raise ValueError("Working-file recovery is no longer authorized")
755
+ if reset_recovery_authority(context) != record_by_name["recovery_authority"]:
756
+ raise ValueError("HEAD, references or the native rebase changed; inspect retained files before restoring manually")
757
+ if lock_identity is not None and stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
758
+ raise ValueError("The recovery index lock changed; remaining backups retained")
759
+
760
+ try:
761
+ validate_recovery()
762
+ if os.path.lexists(lock_filename):
763
+ raise ValueError("An interrupted Reset has an index lock; process termination and lock ownership cannot be proven")
764
+ descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
765
+ lock_identity = stat_identity(os.fstat(descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
766
+ os.close(descriptor)
767
+ diagnostic = self._reconcile_reset(context, operation_dir, record_by_name, validate_recovery, restored_paths)
768
+ except (OSError, ValueError, TypeError, KeyError) as error:
769
+ diagnostic = recovery_files_cause(str(error), operation_dir)
770
+ finally:
771
+ try:
772
+ release_owned_index_lock(lock_filename, None, lock_identity)
773
+ except (OSError, ValueError) as error:
774
+ diagnostic = (diagnostic + "\n" if diagnostic else "") + recovery_files_cause(str(error), operation_dir)
775
+ if not borrowed:
776
+ self.release(key, recovery_id)
777
+ if restored_paths:
778
+ failure = Diagnostic(RECOVER_CHANGES_OPERATION, context.repository_dir, owner.git_filename, [], diagnostic).prepare() if diagnostic else None
779
+ restored_path_set = set(restored_paths)
780
+ failed_paths = []
781
+ if diagnostic:
782
+ for record in record_by_name["files"]:
783
+ path = bytes.fromhex(record["path_hex"])
784
+ if path not in restored_path_set:
785
+ failed_paths.append(path)
786
+ self._publish_outcome(MutationOutcome(recovery_id, key, "partial" if diagnostic else "rolled_back", tuple(restored_paths), tuple(failed_paths), failure,
787
+ operation_dir if diagnostic else None))
788
+ return diagnostic
789
+
790
+ def _reconcile_reset(self, context: RepositoryContext, operation_dir: str, record_by_name: dict[str, object],
791
+ validate_recovery: Callable[[], None], restored_paths: list[bytes]) -> str:
792
+ """Check every image under the owned index lock before restoring any interrupted file."""
793
+ if record_by_name.get("operation_dir") != operation_dir:
794
+ raise ValueError("Recovery operation directory does not match its record")
795
+ if stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record_by_name["operation_identity"]):
796
+ raise ValueError("Recovery operation directory changed")
797
+ index_filename = record_by_name["index_filename"]
798
+ current_index = fingerprint(read_index(index_filename))
799
+ before_index, after_index = record_by_name["before"], record_by_name["after"]
800
+ if current_index not in (before_index, after_index):
801
+ return recovery_files_cause("The index changed outside the interrupted Reset.", operation_dir)
802
+ records = record_by_name["files"]
803
+ states: list[str] = []
804
+ images: list[FileImage] = []
805
+ for record in records:
806
+ path = bytes.fromhex(record["path_hex"])
807
+ image = capture_image(context, path, allow_missing_parents=True)
808
+ parents = tuple((directory, tuple(identity)) for directory, identity in record["parents"])
809
+ actual_identity_by_directory = dict(image.parents)
810
+ created_dirs = []
811
+ for directory, identity in record_by_name.get("created_dirs", []):
812
+ if actual_identity_by_directory.get(directory) != MISSING_PARENT_IDENTITY:
813
+ created_dirs.append((directory, tuple(identity)))
814
+ parents = publication_image(FileImage(False, 0, parents=parents), created_dirs).parents
815
+ if image.parents != parents:
816
+ return recovery_files_cause("A working parent changed outside the interrupted Reset.", operation_dir)
817
+ before_identity = record.get("restored_identity") or record["before_identity"]
818
+ published_identity = record["published_identity"] or record["candidate_identity"]
819
+ is_before = image.digest() == record["before"] and (image.identity is None or before_identity is not None and image.identity == tuple(before_identity))
820
+ is_after = image.digest() == record["after"] and (image.identity is None or published_identity is not None and image.identity[:FILE_STABLE_IDENTITY_FIELDS] == tuple(published_identity)[:FILE_STABLE_IDENTITY_FIELDS])
821
+ if not is_before and not is_after:
822
+ return recovery_files_cause("Working file changed outside the interrupted Reset: " + os.fsdecode(path) + ".", operation_dir)
823
+ states.append("before" if is_before else "after")
824
+ images.append(image)
825
+ private_dir = record["private_dir"]
826
+ if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
827
+ raise ValueError("Private recovery directory changed")
828
+ backup_filename = record["backup_filename"]
829
+ if os.path.dirname(backup_filename) != private_dir:
830
+ raise ValueError("Recovery backup is outside its owned directory")
831
+ if not record["before_absent"]:
832
+ if stat_identity(os.lstat(backup_filename)) != tuple(record["backup_identity"]) or os.path.islink(backup_filename):
833
+ raise ValueError("Recovery backup changed")
834
+ with open(backup_filename, "rb") as stream:
835
+ backup = FileImage(True, record["before_mode"], stream.read())
836
+ if backup.digest() != record["before"]:
837
+ raise ValueError("Recovery backup has different bytes")
838
+ fully_published = bool(records) and all(state == "after" or record["before"] == record["after"] for state, record in zip(states, records, strict=True))
839
+ if current_index == after_index and (before_index != after_index or fully_published):
840
+ if not fully_published:
841
+ return recovery_files_cause("The index was published but working files no longer match the Reset result.", operation_dir)
842
+ validate_recovery()
843
+ record_by_name["state"] = JournalState.RECOVERED_POST.value
844
+ write_record(operation_dir, record_by_name)
845
+ self._clean_reset_content(record_by_name)
846
+ return ""
847
+ # Check all paths first so an external edit in the last file prevents restoration of the first.
848
+ for record, state, image in zip(records, states, images, strict=True):
849
+ if state == "before":
850
+ continue
851
+ if fingerprint(read_index(index_filename)) != current_index:
852
+ raise ValueError("The index changed during recovery; remaining backups retained")
853
+ path = bytes.fromhex(record["path_hex"])
854
+ validate_image(context, path, image)
855
+ filename, _parents = validate_destination(context, bytes.fromhex(record.get("original_path_hex", record["path_hex"])))
856
+ if record["before_absent"]:
857
+ validate_recovery()
858
+ os.unlink(filename)
859
+ else:
860
+ with open(record["backup_filename"], "rb") as stream:
861
+ before = FileImage(True, record["before_mode"], stream.read())
862
+ rollback_filename = record["candidate_filename"] + "-rollback"
863
+ write_image(rollback_filename, before)
864
+ record["restored_identity"] = stat_identity(os.lstat(rollback_filename))
865
+ write_record(operation_dir, record_by_name)
866
+ validate_image(context, path, image)
867
+ validate_recovery()
868
+ os.replace(rollback_filename, filename)
869
+ if "original_path_hex" in record:
870
+ respell_single_entry(context, path, bytes.fromhex(record["original_path_hex"]))
871
+ record["restored_identity"] = capture_image(context, path).identity
872
+ restored_paths.append(path)
873
+ record["state"] = JournalState.RESTORED.value
874
+ write_record(operation_dir, record_by_name)
875
+ validate_recovery()
876
+ record_by_name["state"] = JournalState.RECOVERED_PRE.value
877
+ clean_created_parents(record_by_name.get("created_dirs", []))
878
+ write_record(operation_dir, record_by_name)
879
+ self._clean_reset_content(record_by_name)
880
+ return ""
881
+
882
+ def stage_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
883
+ """Prepare complete selected working files without modifying their bytes."""
884
+ return self._submit(snapshot, entries, "stage")
885
+
886
+ def resolve_conflict(self, intent: ConflictResolutionIntent) -> str:
887
+ """Reserve one manual resolution until its candidate is published and reported.
888
+
889
+ A refused resolution says why: GitPane closing first, then another operation when a write already holds the
890
+ repository, otherwise the reason the repository operation service finds for refusing the reservation.
891
+ """
892
+ owner, snapshot = intent.operation, intent.snapshot
893
+ operation_id = owner.operation_id
894
+ # A global Git setting being saved, another change of the same worktree, or this same resolution still running
895
+ # all hold the repository for real. They are named here because refusal_cause skips an operation with this
896
+ # resolution's identifier and does not know about a setting save.
897
+ write_holds_repository = self.configuration_busy or snapshot.key in self.active_key_set or operation_id in self.repository_operation.active_by_id
898
+ if write_holds_repository or not self.repository_operation.reserve(owner):
899
+ if self.repository_operation.closed:
900
+ cause = GITPANE_CLOSING_TEXT
901
+ elif write_holds_repository:
902
+ cause = OWNED_REPOSITORY_TEXT
903
+ else:
904
+ cause = self.repository_operation.refusal_cause(owner)
905
+ diagnostic = Diagnostic(owner.operation, snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
906
+ outcome = MutationOutcome(operation_id, snapshot.key, "refused", (), (intent.path,), diagnostic)
907
+ QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
908
+ return operation_id
909
+ self.operation_id_by_key[snapshot.key] = operation_id
910
+ self.active_key_set.add(snapshot.key)
911
+
912
+ def complete(value: object, error: Exception | None) -> None:
913
+ """Release only this manual owner after native execution has finished."""
914
+ if self.repository_operation.active_by_id.get(operation_id) is owner:
915
+ self.release(snapshot.key, operation_id)
916
+ else:
917
+ self.operation_id_by_key.pop(snapshot.key, None)
918
+ self.active_key_set.discard(snapshot.key)
919
+ if error is not None:
920
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(owner.operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
921
+ value = MutationOutcome(operation_id, snapshot.key, "refused", (), (intent.path,), diagnostic)
922
+ self._publish_outcome(value)
923
+
924
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self.resolve_conflict_sequence(intent), complete, operation_id=operation_id)
925
+ return operation_id
926
+
927
+ def resolve_conflict_sequence(self, intent: ConflictResolutionIntent) -> Generator:
928
+ """Stage a result using the exact existing owner without reserving or releasing it."""
929
+ from gitpane.sequencer.conflict import has_deleted_side
930
+
931
+ self._validate_conflict_owner(intent)
932
+ snapshot = intent.snapshot
933
+ entries = tuple(entry for entry in snapshot.entries if entry.path == intent.path and entry.status == FileStatus.UNMERGED)
934
+ if len(entries) != 1 or next(iter(entries)).conflict_sides != intent.sides:
935
+ raise ValueError("The selected conflict stages changed; rescan merge conflicts")
936
+ if (not intent.delete and not intent.image.exists or any(side.mode == "160000" for side in intent.sides)
937
+ or intent.delete and intent.image.exists and not has_deleted_side(intent.sides)):
938
+ raise ValueError("Choose an existing file result or explicitly confirm its deletion; resolve submodules externally")
939
+ yield FileTask(lambda: validate_image(snapshot.context, intent.path, intent.image))
940
+ if intent.delete and intent.image.exists:
941
+ operation_id = intent.operation.operation_id
942
+ self.live_reset_id_set.add(operation_id)
943
+ try:
944
+ prepared = yield from self._prepare_file_reset(operation_id, snapshot, entries, resolution=intent)
945
+ self._validate_conflict_owner(intent)
946
+ return (yield from self._publish_reset(prepared, validate=lambda: self._validate_conflict_owner(intent)))
947
+ finally:
948
+ self.live_reset_id_set.discard(operation_id)
949
+ return (yield from self._prepare(uuid.uuid4().hex, snapshot, entries, "resolve conflict", resolution=intent))
950
+
951
+ def _validate_conflict_owner(self, intent: ConflictResolutionIntent) -> None:
952
+ """Reject forged ownership and stale session authority before any publication."""
953
+ owner, snapshot = intent.operation, intent.snapshot
954
+ service = self.repository_operation
955
+ service.validate(owner)
956
+ if (service.active_by_id.get(owner.operation_id) is not owner or service.closed or service.allows is not None and not service.allows(owner)
957
+ or owner.key != snapshot.key or owner.context != snapshot.context or owner.git_filename != snapshot.git_filename):
958
+ raise ValueError("The conflict resolution no longer owns this repository or session")
959
+
960
+ def unstage_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
961
+ """Restore selected index entries to HEAD while retaining every working byte."""
962
+ return self._submit(snapshot, entries, "unstage")
963
+
964
+ def stop_tracking(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
965
+ """Remove selected index entries with Git's native safety checks and preserve disk content."""
966
+ return self._submit(snapshot, entries, "stop tracking")
967
+
968
+ def stage_lines(self, model: RawPatch, selection: RawLineSelection) -> str:
969
+ """Publish selected canonical lines using the same index reservation and validation."""
970
+ return self._submit(model.snapshot, (model.entry,), "stage", model, selection)
971
+
972
+ def prepare_reset_lines(self, model: RawPatch, selection: RawLineSelection, callback: Callable[[PreparedLineReset | None, Exception | None], None]) -> str:
973
+ """Reserve the worktree and compute the exact selected-line result before publication.
974
+
975
+ A refused reservation hands the callback the real cause of the refusal.
976
+ """
977
+ operation_id = uuid.uuid4().hex
978
+ if not self.acquire(model.snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
979
+ cause = self.acquire_refusal_cause(model.snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
980
+ QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
981
+ return operation_id
982
+
983
+ def complete(prepared: PreparedLineReset | None, error: Exception | None) -> None:
984
+ """Keep successful candidates reserved while releasing failed computations."""
985
+ if error is not None:
986
+ self.release(model.snapshot.key, operation_id)
987
+ else:
988
+ self.prepared_reset_by_id[operation_id] = prepared
989
+ callback(prepared, error)
990
+
991
+ self.worktree_service.run(model.snapshot.context, model.snapshot.git_filename, self._prepare_reset(operation_id, model, selection), complete, operation_id=operation_id)
992
+ return operation_id
993
+
994
+ def cancel_reset_lines(self, prepared: PreparedLineReset) -> None:
995
+ """Discard only an owned unpublished candidate when its source view or selection expires."""
996
+ if self.prepared_reset_by_id.get(prepared.operation_id) is not prepared:
997
+ return
998
+ self.prepared_reset_by_id.pop(prepared.operation_id)
999
+ self.release(prepared.model.snapshot.key, prepared.operation_id)
1000
+ prepared.record_by_name["state"] = JournalState.ABANDONED.value
1001
+ write_record(prepared.operation_dir, prepared.record_by_name)
1002
+ self._clean_file_content(prepared)
1003
+
1004
+ def reset_lines(self, prepared: PreparedLineReset) -> str:
1005
+ """Publish the exact calculated candidate; a changed target requires a fresh selection."""
1006
+ if self.prepared_reset_by_id.pop(prepared.operation_id, None) is not prepared:
1007
+ raise ValueError("This reset selection is no longer current")
1008
+
1009
+ def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
1010
+ """Release the worktree after the last process and recovery action have finished."""
1011
+ self.release(prepared.model.snapshot.key, prepared.operation_id)
1012
+ if error is not None:
1013
+ outcome = self._line_reset_failure(prepared, error)
1014
+ self._publish_outcome(outcome)
1015
+
1016
+ self.worktree_service.run(prepared.model.snapshot.context, prepared.model.snapshot.git_filename, self._publish_file(prepared), complete, operation_id=prepared.operation_id)
1017
+ return prepared.operation_id
1018
+
1019
+ def _prepare_reset(self, operation_id: str, model: RawPatch, selection: RawLineSelection) -> Generator:
1020
+ """Materialize outside user content and record the exact before/after file images.
1021
+
1022
+ Nothing live changes here, so both boundaries check the index bytes and the selected file without Git; Git
1023
+ verifies once, under the publication lock.
1024
+ """
1025
+ from gitpane.worktree.partial_patch import reset_content
1026
+
1027
+ snapshot, path = model.snapshot, model.entry.path
1028
+ model.selected(selection)
1029
+ recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
1030
+ if recovery:
1031
+ raise ValueError(recovery)
1032
+ yield FileTask(lambda: verify_local_state(snapshot, (path,)))
1033
+ before = yield FileTask(lambda: capture_image(snapshot.context, path, allow_missing_parents=True))
1034
+ if before.exists and not stat.S_ISREG(before.mode):
1035
+ raise ValueError("Partial reset requires a regular working file")
1036
+ root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
1037
+ os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
1038
+ operation_dir = os.path.join(root_dir, operation_id)
1039
+ os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
1040
+ private_dir = private_file_dir(snapshot.context, path, operation_dir, allow_missing_parents=True)
1041
+ candidate_filename = os.path.join(private_dir, "after-file")
1042
+ backup_filename = os.path.join(private_dir, "before-file")
1043
+ record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.FILE.value, "operation_id": operation_id,
1044
+ "index_filename": snapshot.index_filename, "path_hex": path.hex(), "before": before.digest(), "before_mode": before.mode, "before_absent": not before.exists,
1045
+ "before_identity": before.identity, "after": None, "state": JournalState.PREPARING.value, "private_dir": private_dir,
1046
+ "private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS], "candidate_filename": candidate_filename, "backup_filename": backup_filename}
1047
+ write_record(operation_dir, record_by_name)
1048
+ try:
1049
+ if before.exists:
1050
+ yield FileTask(lambda: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, before.read())))
1051
+ attributes = yield GitCommand(["check-attr", "-z", "filter", "working-tree-encoding", "--stdin"], stdin=path + b"\0")
1052
+ attribute_by_name: dict[bytes, bytes] = {}
1053
+ fields = iter(attributes.removesuffix(b"\0").split(b"\0"))
1054
+ for _path in fields:
1055
+ name, value = next(fields), next(fields)
1056
+ attribute_by_name[name] = value
1057
+ encoding_value = attribute_by_name.get(b"working-tree-encoding", b"unspecified")
1058
+ encoding = "" if encoding_value in (b"unspecified", b"unset") else encoding_value.decode("ascii")
1059
+ filter_value = attribute_by_name.get(b"filter", b"unspecified")
1060
+ has_filter = filter_value not in (b"unspecified", b"unset")
1061
+ restored = FileImage(False, 0)
1062
+ if model.before_oid:
1063
+ restored = yield from materialize_blob(model.before_oid, path, model.mode, os.path.join(private_dir, "materialized-index"))
1064
+ materialized_working = FileImage(False, 0)
1065
+ if has_filter and model.after_oid:
1066
+ materialized_working = yield from materialize_blob(model.after_oid, path, model.mode, os.path.join(private_dir, "materialized-working"))
1067
+ content = yield from reset_content(model, selection, before, restored, materialized_working, encoding, has_filter)
1068
+ mode = before.mode if before.exists else stat.S_IFREG | (0o755 if model.mode == "100755" else 0o644)
1069
+ after = FileImage(content is not None, mode, content)
1070
+ after = yield FileTask(lambda: prepare_publication_image(candidate_filename, after, before))
1071
+ if after.exists:
1072
+ metadata = os.lstat(candidate_filename)
1073
+ after = replace(after, identity=stat_identity(metadata), parents=before.parents)
1074
+ else:
1075
+ after = FileImage(False, 0, parents=before.parents)
1076
+ yield FileTask(lambda: verify_local_state(snapshot, (path,)))
1077
+ yield FileTask(lambda: validate_image(snapshot.context, path, before))
1078
+ identity_by_basename: dict[str, tuple[int, ...]] = {}
1079
+ for basename in ("after-file", "before-file", "materialized-index", "materialized-working"):
1080
+ filename = os.path.join(private_dir, basename)
1081
+ if os.path.exists(filename):
1082
+ identity_by_basename[basename] = stat_identity(os.lstat(filename))
1083
+ record_by_name.update({"after": after.digest(), "after_identity": after.identity, "state": JournalState.PREPARED.value, "owned_identity_by_basename": identity_by_basename})
1084
+ write_record(operation_dir, record_by_name)
1085
+ return PreparedLineReset(operation_id, model, selection, before, after, operation_dir, private_dir, candidate_filename, backup_filename, record_by_name)
1086
+ except (OSError, ValueError, RuntimeError):
1087
+ record_by_name["state"] = JournalState.ABANDONED.value
1088
+ write_record(operation_dir, record_by_name)
1089
+ raise
1090
+
1091
+ def _publish_file(self, prepared: PreparedLineReset) -> Generator:
1092
+ """Replace only an unchanged target, preserving recovery bytes after any publication failure.
1093
+
1094
+ The index lock stays empty: it only excludes other index writers while the working file changes. Git verifies
1095
+ once under it, after the private candidate is proven, and local reference reads taken before and after that
1096
+ verification replace a separate HEAD read.
1097
+ """
1098
+ snapshot, path = prepared.model.snapshot, prepared.model.entry.path
1099
+ filename, _parents = validate_destination(snapshot.context, path, allow_missing_parents=True)
1100
+ lock_filename = snapshot.index_filename + ".lock"
1101
+ lock_descriptor: int | None = None
1102
+ lock_identity: tuple[int, int] | None = None
1103
+ published = False
1104
+ created_dirs: list[tuple[str, tuple[int, int]]] = []
1105
+ prepared.record_by_name["created_dirs"] = created_dirs
1106
+ try:
1107
+ lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
1108
+ metadata = os.fstat(lock_descriptor)
1109
+ lock_identity = (metadata.st_dev, metadata.st_ino)
1110
+ yield FileTask(lambda: validate_image(snapshot.context, path, prepared.before))
1111
+ private_identity = stat_identity(os.lstat(prepared.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS]
1112
+ if tuple(prepared.record_by_name["private_identity"]) != private_identity or os.path.islink(prepared.private_dir) or os.path.isjunction(prepared.private_dir):
1113
+ raise ValueError("Private preparation directory changed")
1114
+ if prepared.after.exists:
1115
+ metadata = os.lstat(prepared.candidate_filename)
1116
+ if stat_identity(metadata) != prepared.after.identity or not stat.S_ISREG(metadata.st_mode):
1117
+ raise ValueError("Private reset candidate changed")
1118
+ with open(prepared.candidate_filename, "rb") as stream:
1119
+ if stream.read() != prepared.after.read():
1120
+ raise ValueError("Private reset candidate changed")
1121
+ prepared.record_by_name["state"] = JournalState.PREPARED.value
1122
+ write_record(prepared.operation_dir, prepared.record_by_name)
1123
+ authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
1124
+ yield from self.worktree_service.verify(snapshot, (path,))
1125
+ yield FileTask(lambda: require_unchanged_authority(snapshot.context, authority))
1126
+ metadata = os.lstat(lock_filename)
1127
+ if not stat.S_ISREG(metadata.st_mode) or (metadata.st_dev, metadata.st_ino) != lock_identity or metadata.st_size:
1128
+ lock_identity = None
1129
+ raise ValueError("The publication lock was replaced externally")
1130
+ # Recheck after the journal flush because an external editor is not blocked by Git's index lock.
1131
+ validate_image(snapshot.context, path, prepared.before)
1132
+ if read_index(snapshot.index_filename) != snapshot.index_content:
1133
+ raise ValueError("The index changed after confirmation")
1134
+ self.validate_write(snapshot.key)
1135
+ if prepared.after.exists:
1136
+ create_missing_parents(snapshot.context, path, prepared.before, created_dirs, lambda: write_record(prepared.operation_dir, prepared.record_by_name))
1137
+ publish_image_mode(prepared.candidate_filename, prepared.after)
1138
+ identity = stat_identity(os.lstat(prepared.candidate_filename))
1139
+ prepared.record_by_name["after_identity"] = identity
1140
+ prepared.record_by_name["owned_identity_by_basename"][os.path.basename(prepared.candidate_filename)] = identity
1141
+ write_record(prepared.operation_dir, prepared.record_by_name)
1142
+ validate_image(snapshot.context, path, publication_image(prepared.before, created_dirs))
1143
+ os.replace(prepared.candidate_filename, filename)
1144
+ elif prepared.before.exists:
1145
+ os.unlink(filename)
1146
+ published = True
1147
+ prepared.record_by_name["working_file_published"] = True
1148
+ prepared.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
1149
+ write_record(prepared.operation_dir, prepared.record_by_name)
1150
+ actual = capture_image(snapshot.context, path)
1151
+ if actual.digest() != prepared.after.digest():
1152
+ raise ValueError("Working file changed immediately after publication")
1153
+ outcome = MutationOutcome(prepared.operation_id, snapshot.key, "success", (path,), ())
1154
+ except (OSError, ValueError, RuntimeError) as error:
1155
+ status = "refused"
1156
+ if not published:
1157
+ try:
1158
+ actual = capture_image(snapshot.context, path)
1159
+ published = actual.digest() == prepared.after.digest() and actual.digest() != prepared.before.digest()
1160
+ prepared.record_by_name["working_file_published"] = published
1161
+ except (OSError, ValueError):
1162
+ pass
1163
+ if published:
1164
+ status = "unknown"
1165
+ try:
1166
+ actual = capture_image(snapshot.context, path)
1167
+ expected_identity = prepared.record_by_name["after_identity"]
1168
+ # Some filesystems update ctime on rename; content, mode and inode still identify our publication.
1169
+ same_identity = not actual.exists and not prepared.after.exists
1170
+ if actual.exists and actual.identity is not None and expected_identity is not None:
1171
+ same_identity = actual.identity[:FILE_STABLE_IDENTITY_FIELDS] == tuple(expected_identity)[:FILE_STABLE_IDENTITY_FIELDS]
1172
+ if actual.digest() == prepared.after.digest() and actual.parents == publication_image(prepared.before, created_dirs).parents and same_identity:
1173
+ if prepared.before.exists:
1174
+ rollback_filename = os.path.join(prepared.private_dir, "rollback-file")
1175
+ write_image(rollback_filename, prepared.before)
1176
+ validate_image(snapshot.context, path, actual)
1177
+ os.replace(rollback_filename, filename)
1178
+ elif actual.exists:
1179
+ validate_image(snapshot.context, path, actual)
1180
+ os.unlink(filename)
1181
+ status = "rolled_back"
1182
+ prepared.record_by_name["working_file_published"] = False
1183
+ except (OSError, ValueError):
1184
+ pass
1185
+ journal_state = JournalState.ROLLED_BACK if status == "rolled_back" else JournalState.FILE_PUBLISHED if published else JournalState.ABANDONED
1186
+ prepared.record_by_name["state"] = journal_state.value
1187
+ try:
1188
+ write_record(prepared.operation_dir, prepared.record_by_name)
1189
+ except OSError:
1190
+ pass
1191
+ cause = recovery_files_cause(str(error), prepared.operation_dir) if published else str(error)
1192
+ diagnostic = Diagnostic("Reset selected lines", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
1193
+ outcome = MutationOutcome(prepared.operation_id, snapshot.key, status, (path,) if status == "unknown" else (), (path,), diagnostic, prepared.operation_dir)
1194
+ finally:
1195
+ clean_created_parents(created_dirs)
1196
+ try:
1197
+ release_owned_index_lock(lock_filename, lock_descriptor, lock_identity)
1198
+ except (OSError, ValueError, RuntimeError) as error:
1199
+ outcome = self._line_reset_failure(prepared, error)
1200
+ if outcome.diagnostic is None:
1201
+ try:
1202
+ # Lock cleanup is part of publication, so recovery files outlive it.
1203
+ prepared.record_by_name["state"] = JournalState.COMPLETE.value
1204
+ write_record(prepared.operation_dir, prepared.record_by_name)
1205
+ self._clean_file_content(prepared)
1206
+ except (OSError, ValueError, RuntimeError) as error:
1207
+ outcome = self._line_reset_failure(prepared, error)
1208
+ return outcome
1209
+
1210
+ def _line_reset_failure(self, prepared: PreparedLineReset, error: Exception) -> MutationOutcome:
1211
+ """Report cleanup faults from recorded publication facts without rolling back later local content."""
1212
+ snapshot, path = prepared.model.snapshot, prepared.model.entry.path
1213
+ published = bool(prepared.record_by_name.get("working_file_published"))
1214
+ # The word this process wrote is compared as text: reading it back as a member could raise inside this failure handler.
1215
+ rolled_back = prepared.record_by_name.get("state") == JournalState.ROLLED_BACK.value
1216
+ status = "unknown" if published else "rolled_back" if rolled_back else "refused"
1217
+ journal_state = JournalState.FILE_PUBLISHED if published else JournalState.ROLLED_BACK if rolled_back else JournalState.ABANDONED
1218
+ prepared.record_by_name["state"] = journal_state.value
1219
+ try:
1220
+ write_record(prepared.operation_dir, prepared.record_by_name)
1221
+ except OSError:
1222
+ pass
1223
+ diagnostic = Diagnostic("Reset selected lines", snapshot.context.repository_dir, snapshot.git_filename, [], recovery_files_cause(str(error), prepared.operation_dir)).prepare()
1224
+ return MutationOutcome(prepared.operation_id, snapshot.key, status, (path,) if published else (), (path,), diagnostic, prepared.operation_dir)
1225
+
1226
+ def _clean_file_content(self, prepared: PreparedLineReset) -> None:
1227
+ """Remove only files owned by a completed or cancelled operation."""
1228
+ actual = os.lstat(prepared.private_dir)
1229
+ if os.path.islink(prepared.private_dir) or os.path.isjunction(prepared.private_dir) or stat_identity(actual)[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(prepared.record_by_name["private_identity"]):
1230
+ raise ValueError("Private cleanup directory changed; recovery files retained")
1231
+ for basename, identity in prepared.record_by_name["owned_identity_by_basename"].items():
1232
+ filename = os.path.join(prepared.private_dir, basename)
1233
+ if not os.path.lexists(filename):
1234
+ continue
1235
+ if os.path.islink(filename) or stat_identity(os.lstat(filename)) != tuple(identity):
1236
+ raise ValueError("Owned recovery file changed; cleanup refused")
1237
+ os.unlink(filename)
1238
+ if prepared.private_dir != prepared.operation_dir:
1239
+ os.rmdir(prepared.private_dir)
1240
+
1241
+ def _submit(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation: str, model: RawPatch | None = None, selection: RawLineSelection | None = None) -> str:
1242
+ """Reserve one worktree until computation, publication and terminal reporting finish.
1243
+
1244
+ A refused reservation is reported with the real cause of the refusal.
1245
+ """
1246
+ operation_id = uuid.uuid4().hex
1247
+ paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
1248
+ action = OperationAction.UNSTAGE if operation == "unstage" else OperationAction.STAGE
1249
+ if not self.acquire(snapshot, operation_id, action=action):
1250
+ diagnostic = Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], self.acquire_refusal_cause(snapshot, operation_id, action=action)).prepare()
1251
+ outcome = MutationOutcome(operation_id, snapshot.key, "refused", (), paths, diagnostic)
1252
+ QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
1253
+ return operation_id
1254
+
1255
+ def complete(value: object, error: Exception | None) -> None:
1256
+ """Retain failures as terminal results, releasing the reservation only now."""
1257
+ self.release(snapshot.key, operation_id)
1258
+ if error is not None:
1259
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
1260
+ value = MutationOutcome(operation_id, snapshot.key, "refused", (), paths, diagnostic)
1261
+ self._publish_outcome(value)
1262
+
1263
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare(operation_id, snapshot, entries, operation, model, selection), complete, operation_id=operation_id)
1264
+ return operation_id
1265
+
1266
+ def _publish_outcome(self, outcome: MutationOutcome) -> None:
1267
+ """Preserve the result for reopening and request authoritative state after every issue."""
1268
+ self.outcome_by_id[outcome.operation_id] = outcome
1269
+ self.reread_requested.emit(outcome.key)
1270
+ self.finished.emit(outcome)
1271
+
1272
+ def recover(self, context: RepositoryContext, index_filename: str, *, operation_id: str | None = None) -> str:
1273
+ """Classify interrupted operations and restore proven Reset effects under explicit authority."""
1274
+ root_dir = os.path.join(context.git_dir, "gitpane-operation")
1275
+ if not os.path.exists(root_dir):
1276
+ return ""
1277
+ if os.path.islink(root_dir) or os.path.isjunction(root_dir) or not os.path.isdir(root_dir):
1278
+ return "The recovery directory is not a private directory"
1279
+ diagnostics: list[str] = []
1280
+ with os.scandir(root_dir) as iterator:
1281
+ operation_dirs = sorted(iterator, key=lambda entry: entry.name)
1282
+ for entry in operation_dirs:
1283
+ if entry.name in self.prepared_file_reset_by_id or entry.name in self.live_reset_id_set:
1284
+ continue
1285
+ if not entry.is_dir(follow_symlinks=False) or os.path.isjunction(entry.path) or len(entry.name) != len(uuid.uuid4().hex) or any(character not in "0123456789abcdef" for character in entry.name):
1286
+ diagnostics.append("Unrecognized recovery data requires inspection")
1287
+ continue
1288
+ record_filename = os.path.join(entry.path, "record.json")
1289
+ try:
1290
+ if os.path.islink(record_filename):
1291
+ raise ValueError("Linked recovery record")
1292
+ if os.stat(record_filename).st_size > MUTATION_RECORD_LIMIT_BYTES:
1293
+ raise ValueError("Recovery record exceeds the supported limit")
1294
+ with open(record_filename, encoding="utf-8") as stream:
1295
+ record_by_name = json.load(stream)
1296
+ if record_by_name.get("schema_version") != JOURNAL_SCHEMA_VERSION or record_by_name.get("index_filename") != index_filename:
1297
+ raise ValueError("Unrecognized recovery record")
1298
+ # Both words are read once here, so a word this version does not know, or an empty one, stops the
1299
+ # inspection of this journal and leaves it untouched for the user.
1300
+ state = read_journal_state(record_by_name)
1301
+ kind = read_journal_kind(record_by_name)
1302
+ if state in FINISHED_JOURNAL_STATE_SET:
1303
+ continue
1304
+ if kind == JournalKind.RESET:
1305
+ diagnostic = self._recover_reset(context, entry.path, record_by_name, operation_id)
1306
+ if diagnostic:
1307
+ diagnostics.append(diagnostic)
1308
+ continue
1309
+ if kind == JournalKind.FILE:
1310
+ path = bytes.fromhex(record_by_name["path_hex"])
1311
+ image = capture_image(context, path, allow_missing_parents=True)
1312
+ if image.digest() == record_by_name.get("before"):
1313
+ record_by_name["state"] = JournalState.RECOVERED_PRE.value
1314
+ elif image.digest() == record_by_name.get("after"):
1315
+ record_by_name["state"] = JournalState.RECOVERED_POST.value
1316
+ else:
1317
+ diagnostics.append(recovery_files_cause("Working file changed outside the interrupted operation: " + os.fsdecode(path) + ".", entry.path))
1318
+ continue
1319
+ write_record(entry.path, record_by_name)
1320
+ continue
1321
+ if os.path.lexists(index_filename + ".lock"):
1322
+ diagnostics.append("An interrupted operation has an index lock; ownership cannot be proven")
1323
+ continue
1324
+ current = fingerprint(read_index(index_filename))
1325
+ if current == record_by_name.get("before"):
1326
+ record_by_name["state"] = JournalState.RECOVERED_PRE.value
1327
+ elif current == record_by_name.get("after"):
1328
+ record_by_name["state"] = JournalState.RECOVERED_POST.value
1329
+ else:
1330
+ diagnostics.append("The index changed outside the interrupted operation; recovery data retained")
1331
+ continue
1332
+ write_record(entry.path, record_by_name)
1333
+ except (OSError, ValueError, TypeError, KeyError) as error:
1334
+ diagnostics.append("Cannot inspect interrupted operation: " + str(error))
1335
+ return "\n".join(diagnostics)
1336
+
1337
+ def _prepare(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation: str,
1338
+ model: RawPatch | None = None, selection: RawLineSelection | None = None,
1339
+ resolution: ConflictResolutionIntent | None = None) -> Generator[GitCommand, bytes, MutationOutcome]:
1340
+ """Compute outside the real index, recheck under exclusive lock, then publish once.
1341
+
1342
+ Every form, a conflict resolution included, writes its candidate into the owned lock and makes it durable
1343
+ before Git's single final verification, which reads HEAD itself. Local reads of references, configuration
1344
+ files and the native rebase are taken before that verification and compared again just before publication,
1345
+ so a change made after Git answered cannot pass for the verified state. A resolution also rechecks the
1346
+ identity of its accepted file and its exact owner at that last point.
1347
+ """
1348
+ if not snapshot.fresh or snapshot.context.is_bare or snapshot.context.worktree_dir is None:
1349
+ raise ValueError("A complete current working tree read is required")
1350
+ side = StagingSide.UNSTAGED if operation in ("stage", "stop tracking", "resolve conflict") else StagingSide.STAGED
1351
+ if not entries or any(entry not in snapshot.entries or entry.side != side for entry in entries):
1352
+ raise ValueError("Choose current " + side.label + " files")
1353
+ if operation == "stage":
1354
+ entries = tuple(entry for entry in entries if entry.stageable)
1355
+ if not entries:
1356
+ raise ValueError(SUBMODULE_NO_COMMIT_CHANGE)
1357
+ if resolution is None and any(entry.conflict_stages for entry in snapshot.entries):
1358
+ intent = self.repository_operation.active_by_id.get(operation_id)
1359
+ # Preparing unrelated files is allowed only under the captured native rebase authority.
1360
+ if any(entry.conflict_stages for entry in entries) or intent is None or not any(intent.rebase_capture or ()):
1361
+ raise ValueError("Resolve all conflicts before preparing changes")
1362
+ if any(entry.skip_worktree for entry in entries):
1363
+ raise ValueError("This file is marked skip-worktree; expand it before preparing changes")
1364
+ if operation == "stop tracking" and any(entry.old_mode not in ("100644", "100755", "120000") for entry in entries):
1365
+ raise ValueError("Stop tracking requires tracked ordinary files")
1366
+ paths = (resolution.path,) if resolution is not None else tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
1367
+ outcome_id = resolution.operation.operation_id if resolution is not None else operation_id
1368
+ recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
1369
+ if recovery:
1370
+ raise ValueError(recovery)
1371
+ # Only a private candidate is built before locking. Defer the complete Git
1372
+ # metadata check until publication, while rejecting already-stale bytes now.
1373
+ yield FileTask(lambda: verify_local_state(snapshot, paths))
1374
+ root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
1375
+ os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
1376
+ operation_dir = os.path.join(root_dir, operation_id)
1377
+ os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
1378
+ candidate_filename = os.path.join(operation_dir, "candidate-index")
1379
+ backup_filename = os.path.join(operation_dir, "before-index")
1380
+ record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "operation_id": operation_id, "index_filename": snapshot.index_filename,
1381
+ "before": snapshot.index_fingerprint, "after": None, "index_absent": snapshot.index_content is None, "state": JournalState.PREPARING.value}
1382
+ write_record(operation_dir, record_by_name)
1383
+ environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
1384
+ lock_filename = snapshot.index_filename + ".lock"
1385
+ lock_descriptor: int | None = None
1386
+ lock_identity: tuple[int, int] | None = None
1387
+ published = False
1388
+ try:
1389
+ if snapshot.index_content is not None:
1390
+ # Preserve the index timestamp: changing it can make Git trust a same-size racy stat entry.
1391
+ shutil.copy2(snapshot.index_filename, candidate_filename)
1392
+ os.chmod(candidate_filename, PRIVATE_FILE_MODE)
1393
+ if read_index(candidate_filename) != snapshot.index_content:
1394
+ raise ValueError("The index changed while preparing its private copy")
1395
+ with open(backup_filename, "xb") as stream:
1396
+ os.chmod(backup_filename, PRIVATE_FILE_MODE)
1397
+ stream.write(snapshot.index_content)
1398
+ stream.flush()
1399
+ os.fsync(stream.fileno())
1400
+ else:
1401
+ yield GitCommand(["read-tree", "--empty"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
1402
+ # Ordinary indexes avoid an unnecessary process; only a possible split index is flattened.
1403
+ if may_carry_split_index(snapshot.index_content):
1404
+ yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
1405
+ before_entries = snapshot.index_entries
1406
+ if before_entries is None:
1407
+ before_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
1408
+ expected_line_record: bytes | None = None
1409
+ if model is not None:
1410
+ from gitpane.worktree.partial_patch import stage_candidate
1411
+ expected_line_record = yield from stage_candidate(model, selection, environment_by_name)
1412
+ arguments = None
1413
+ elif operation in ("stage", "resolve conflict"):
1414
+ arguments = ["add", "--pathspec-from-file=-", "--pathspec-file-nul"]
1415
+ elif operation == "stop tracking":
1416
+ arguments = ["rm", "--cached", "--pathspec-from-file=-", "--pathspec-file-nul"]
1417
+ elif snapshot.head_oid is not None:
1418
+ arguments = ["restore", "--staged", "--source=" + snapshot.head_oid, "--pathspec-from-file=-", "--pathspec-file-nul"]
1419
+ else:
1420
+ arguments = ["update-index", "--force-remove", "-z", "--stdin"]
1421
+ if arguments is not None:
1422
+ # Every form of this command rewrites the candidate index, and add also runs the user's clean filters, which can outlive it.
1423
+ # It declares its own supervised profile: the merge tool publishes through a sequence started without one, whose metadata reads stay lightweight.
1424
+ yield GitCommand(arguments, stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name,
1425
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
1426
+ after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
1427
+ unchanged_records = []
1428
+ path_set = set(paths)
1429
+ for output in (before_entries, after_entries):
1430
+ if output and not output.endswith(b"\0"):
1431
+ raise ValueError("Incomplete candidate index entries")
1432
+ records = []
1433
+ for record in output.split(b"\0"):
1434
+ _metadata, separator, path = record.partition(b"\t")
1435
+ if record and not separator:
1436
+ raise ValueError("Malformed candidate index entry")
1437
+ if record and path not in path_set:
1438
+ records.append(record)
1439
+ unchanged_records.append(sorted(records))
1440
+ before_unchanged, after_unchanged = unchanged_records
1441
+ if before_unchanged != after_unchanged:
1442
+ raise ValueError("The candidate changed files outside the selection")
1443
+ if model is not None:
1444
+ # The listing that proves the neighbors unchanged also proves the staged lines: exactly the expected
1445
+ # record at the selected path, or no record at all after a total removal.
1446
+ line_records = []
1447
+ for record in after_entries.split(b"\0"):
1448
+ if record and record.partition(b"\t")[2] == model.entry.path:
1449
+ line_records.append(record + b"\0")
1450
+ if line_records != ([] if expected_line_record is None else [expected_line_record]):
1451
+ raise ValueError("The candidate index does not hold the selected lines")
1452
+ if resolution is not None:
1453
+ selected_records = []
1454
+ for record in after_entries.split(b"\0"):
1455
+ metadata, _separator, path = record.partition(b"\t")
1456
+ if path == resolution.path:
1457
+ selected_records.append(metadata)
1458
+ if resolution.delete:
1459
+ valid_result = not selected_records
1460
+ else:
1461
+ valid_result = len(selected_records) == 1 and next(iter(selected_records)).endswith(b" 0")
1462
+ if not valid_result:
1463
+ raise ValueError("The candidate did not resolve the selected conflict")
1464
+ candidate = read_index(candidate_filename)
1465
+ if candidate is None:
1466
+ raise ValueError("Git did not produce a candidate index")
1467
+ record_by_name["after"] = fingerprint(candidate)
1468
+ record_by_name["state"] = JournalState.PREPARED.value
1469
+ write_record(operation_dir, record_by_name)
1470
+ lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
1471
+ metadata = os.fstat(lock_descriptor)
1472
+ lock_identity = (metadata.st_dev, metadata.st_ino)
1473
+ # The durable prepared record already describes both possible index images.
1474
+ # Recovery classifies those bytes and any remaining lock without intermediate records.
1475
+ descriptor, lock_descriptor = lock_descriptor, None
1476
+ fill_owned_lock(descriptor, candidate)
1477
+ authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
1478
+ yield from self.worktree_service.verify(snapshot, paths)
1479
+ # Git's lock protects the index; an editor can still change working bytes independently.
1480
+ for path in paths:
1481
+ actual = yield FileTask(lambda path=path: file_fingerprint(snapshot.context, path))
1482
+ expected = snapshot.fingerprint_by_path[path]
1483
+ if expected.startswith(actual + ":"):
1484
+ from gitpane.tool.system_tool import worktree_filename
1485
+ submodule_head = yield GitCommand(["-C", worktree_filename(snapshot.context, path), "rev-parse", "--verify", "HEAD"])
1486
+ actual += ":" + submodule_head.strip().decode("ascii")
1487
+ if actual != expected:
1488
+ raise ValueError("Working file changed before publication; refresh the changes")
1489
+ if resolution is not None:
1490
+ # The accepted result is bound to its file identity and parents, which a byte fingerprint alone does not prove.
1491
+ yield FileTask(lambda: validate_image(snapshot.context, resolution.path, resolution.image))
1492
+ yield FileTask(lambda: require_unchanged_authority(snapshot.context, authority))
1493
+ if read_index(snapshot.index_filename) != snapshot.index_content:
1494
+ raise ValueError("The index changed before publication")
1495
+ metadata = os.lstat(lock_filename)
1496
+ if (metadata.st_dev, metadata.st_ino) != lock_identity or read_index(lock_filename) != candidate:
1497
+ lock_identity = None
1498
+ raise ValueError("The publication lock was replaced externally")
1499
+ if resolution is None:
1500
+ self.validate_write(snapshot.key)
1501
+ else:
1502
+ self._validate_conflict_owner(resolution)
1503
+ os.replace(lock_filename, snapshot.index_filename)
1504
+ published = True
1505
+ lock_identity = None
1506
+ record_by_name["state"] = JournalState.COMPLETE.value
1507
+ write_record(operation_dir, record_by_name)
1508
+ return MutationOutcome(outcome_id, snapshot.key, "success", paths, ())
1509
+ except (OSError, ValueError, RuntimeError) as error:
1510
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
1511
+ if operation == "stop tracking":
1512
+ diagnostic = replace(diagnostic, cause=diagnostic.cause + " Review the staged version before retrying; no files were removed from disk.")
1513
+ if not published:
1514
+ record_by_name["state"] = JournalState.ABANDONED.value
1515
+ try:
1516
+ write_record(operation_dir, record_by_name)
1517
+ except OSError:
1518
+ pass
1519
+ return MutationOutcome(outcome_id, snapshot.key, "unknown" if published else "refused", paths if published else (), () if published else paths, diagnostic, operation_dir)
1520
+ finally:
1521
+ if lock_descriptor is not None:
1522
+ os.close(lock_descriptor)
1523
+ if lock_identity is not None:
1524
+ try:
1525
+ metadata = os.lstat(lock_filename)
1526
+ if stat.S_ISREG(metadata.st_mode) and (metadata.st_dev, metadata.st_ino) == lock_identity:
1527
+ os.unlink(lock_filename)
1528
+ except FileNotFoundError:
1529
+ pass
1530
+
1531
+ def _validate(self, snapshot: WorktreeSnapshot, current: WorktreeSnapshot, paths: tuple[bytes, ...]) -> None:
1532
+ """Refuse stale index, HEAD, conversion settings or selected working bytes."""
1533
+ if snapshot.head_oid != current.head_oid or snapshot.index_content != current.index_content or snapshot.conversion != current.conversion:
1534
+ raise ValueError("Repository changed since selection; refresh the changes")
1535
+ if snapshot.key != current.key or canonical_path(snapshot.index_filename) != canonical_path(current.index_filename):
1536
+ raise ValueError("The selected index does not belong to this worktree")
1537
+ for path in paths:
1538
+ if snapshot.fingerprint_by_path.get(path) != current.fingerprint_by_path.get(path):
1539
+ raise ValueError("Working file changed since selection; refresh the changes")