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,506 @@
1
+ """Keep unresolved paths tied to complete working-tree observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import uuid
9
+ from dataclasses import dataclass, replace
10
+ from typing import TYPE_CHECKING, BinaryIO
11
+
12
+ from gitpane.history.repository_value import FileStatus
13
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
14
+ from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE, ConflictSide, LocalEntry, WorktreeKey, validate_conflict_index
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable, Generator
18
+
19
+ from gitpane.operation.repository_operation import OperationIntent
20
+ from gitpane.sequencer.mergetool import MergetoolRequest, TemporaryMergeStore
21
+ from gitpane.worktree.mutation import MutationService
22
+ from gitpane.worktree.worktree_file import FileImage
23
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
24
+
25
+ # The same refusals are given by every route that accepts a conflict result, so their wording lives in one place.
26
+ SUBMODULE_RESOLUTION_REFUSAL = "Resolve the submodule commit externally; this entry is not a file to merge."
27
+ UNSUPPORTED_RESULT_REFUSAL = "The working result is not a regular file or symbolic link"
28
+ # Said when a conflict action (merge tool, choosing a side, marking solved) is asked with no conflict selected; the merge
29
+ # tool service gives it too, for a caller that skipped this check.
30
+ NO_CONFLICT_SELECTED_TEXT = "Select a conflict before choosing its resolution."
31
+ # Git writes its conflict markers seven characters long unless the conflict-marker-size attribute says otherwise. That
32
+ # attribute is not read here, so a path configured with longer markers is not recognised as still conflicted.
33
+ CONFLICT_MARKER_LENGTH = 7
34
+ CONFLICT_MARKER_CHARACTERS = (b"<", b"|", b"=", b">")
35
+ # A marker is followed by its label after a space, or ends its line; any other character makes it ordinary text.
36
+ CONFLICT_MARKER_FOLLOWER_SET = frozenset((b" ", b"\t"))
37
+ # Git treats content holding a NUL byte as binary and never writes text markers into it.
38
+ BINARY_CONTENT_BYTE = b"\0"
39
+ FIRST_LINE_NUMBER = 1
40
+
41
+
42
+ def conflict_count_text(count: int) -> str:
43
+ """Describe the same unresolved count in the repository banner and conflict list."""
44
+ return f"{count} unresolved merge conflict" + ("s" if count != 1 else "")
45
+
46
+
47
+ def conflict_path_label(path: bytes) -> str:
48
+ """Expose control characters without changing the original path identity."""
49
+ parts = []
50
+ quoted_content_start, quoted_content_end = 1, -1
51
+ for character in path.decode("utf-8", "surrogateescape"):
52
+ if character == "\\":
53
+ parts.append("\\\\")
54
+ elif not character.isprintable():
55
+ parts.append(ascii(character)[quoted_content_start:quoted_content_end])
56
+ else:
57
+ parts.append(character)
58
+ return "".join(parts)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ConflictMarkerLine:
63
+ """Name one line of a working result that is still a conflict marker, numbered from 1 as an editor shows it."""
64
+
65
+ line_number: int
66
+ marker: str
67
+
68
+
69
+ def conflict_marker_of_line(line: bytes) -> str:
70
+ """Return the marker that opens a line, or an empty text when the line is ordinary text.
71
+
72
+ Seven `<`, `|`, `=` or `>` count only when followed by a space, a tab or the end of the line: a longer run such as
73
+ a Markdown underline of eight `=` is text, like a marker glued to its label.
74
+ """
75
+ for character in CONFLICT_MARKER_CHARACTERS:
76
+ marker = character * CONFLICT_MARKER_LENGTH
77
+ if not line.startswith(marker):
78
+ continue
79
+ follower = line[CONFLICT_MARKER_LENGTH:CONFLICT_MARKER_LENGTH + 1]
80
+ if not follower or follower in CONFLICT_MARKER_FOLLOWER_SET:
81
+ return marker.decode("ascii")
82
+ return ""
83
+
84
+
85
+ def conflict_marker_lines(content: bytes) -> tuple[ConflictMarkerLine, ...]:
86
+ """Find the conflict markers Git left in a working result, so a file still holding them is not staged as resolved without a warning.
87
+
88
+ Staging those lines by mistake commits broken code. Lines end with LF or CRLF. Binary content, recognised as Git
89
+ does by a NUL byte, is never scanned and gives no marker.
90
+ """
91
+ if BINARY_CONTENT_BYTE in content:
92
+ return ()
93
+ marker_lines = []
94
+ for line_number, line in enumerate(content.split(b"\n"), start=FIRST_LINE_NUMBER):
95
+ marker = conflict_marker_of_line(line.removesuffix(b"\r"))
96
+ if marker:
97
+ marker_lines.append(ConflictMarkerLine(line_number, marker))
98
+ return tuple(marker_lines)
99
+
100
+
101
+ def has_deleted_side(sides: tuple[ConflictSide, ...]) -> bool:
102
+ """Identify a file with a base and an absent merge side, excluding submodule entries."""
103
+ role_set = set()
104
+ for side in sides:
105
+ if side.mode == "160000":
106
+ return False
107
+ role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
108
+ return "BASE" in role_set and not {"LOCAL", "REMOTE"}.issubset(role_set)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ConflictEntry:
113
+ """Describe an unresolved path and its immutable Git object versions."""
114
+
115
+ path: bytes
116
+ sides: tuple[ConflictSide, ...]
117
+ kind: str = "Text conflict"
118
+ explanation: str = ""
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ConflictSelection:
123
+ """Bind exact selected paths to a worktree and capture generation."""
124
+
125
+ key: WorktreeKey
126
+ generation: int
127
+ paths: tuple[bytes, ...]
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ConflictResolutionIntent:
132
+ """Bind a confirmed working result to its exact unresolved index and operation owner."""
133
+
134
+ snapshot: WorktreeSnapshot
135
+ path: bytes
136
+ sides: tuple[ConflictSide, ...]
137
+ image: FileImage
138
+ operation: OperationIntent
139
+ delete: bool = False
140
+ # Conflict markers found in the accepted bytes; the confirmation warns about them before anything is staged.
141
+ marker_lines: tuple[ConflictMarkerLine, ...] = ()
142
+
143
+ @classmethod
144
+ def capture(cls, snapshot: WorktreeSnapshot, path: bytes, operation: OperationIntent, *, delete: bool = False) -> ConflictResolutionIntent:
145
+ """Capture the working result before confirmation without changing its bytes or index."""
146
+ from gitpane.worktree.worktree_file import capture_image
147
+ from gitpane.worktree.worktree_snapshot import verify_local_state
148
+
149
+ image = capture_image(snapshot.context, path, allow_missing_parents=True)
150
+ intent = cls.accept(snapshot, path, image, operation, delete=delete)
151
+ verify_local_state(snapshot, (path,))
152
+ return intent
153
+
154
+ @classmethod
155
+ def accept(cls, snapshot: WorktreeSnapshot, path: bytes, image: FileImage, operation: OperationIntent, *, delete: bool = False) -> ConflictResolutionIntent:
156
+ """Bind an already captured working result to its unresolved index stages, reading the file again only to prove it unchanged.
157
+
158
+ A caller that accepted exact bytes earlier builds its intent here, so a second capture can never silently adopt
159
+ bytes written in between.
160
+ """
161
+ from gitpane.worktree.worktree_file import validate_image
162
+
163
+ entries = conflicts_from_snapshot(snapshot)
164
+ entry = next((entry for entry in entries if entry.path == path), None)
165
+ if entry is None:
166
+ raise ValueError("The selected path is no longer unresolved; rescan merge conflicts")
167
+ if any(side.mode == "160000" for side in entry.sides):
168
+ raise ValueError(SUBMODULE_RESOLUTION_REFUSAL)
169
+ if delete and image.exists and not has_deleted_side(entry.sides):
170
+ raise ValueError("Delete file requires a conflict with a deleted side")
171
+ if image.exists and not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode)):
172
+ raise ValueError(UNSUPPORTED_RESULT_REFUSAL)
173
+ if not delete and (not image.exists or not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode))):
174
+ raise ValueError("The working result is missing or unsupported; a deletion requires explicit confirmation")
175
+ validate_image(snapshot.context, path, image)
176
+ return cls(snapshot, path, entry.sides, image, operation, delete)
177
+
178
+ def with_marker_lines(self) -> ConflictResolutionIntent:
179
+ """Attach the conflict markers still present in the accepted bytes, read from the captured image and never from the file again.
180
+
181
+ A deletion and a symbolic link have no file content to scan. Runs on a file worker, since the image may have to
182
+ be read from its private copy.
183
+ """
184
+ if self.delete or not self.image.exists or not stat.S_ISREG(self.image.mode):
185
+ return self
186
+ return replace(self, marker_lines=conflict_marker_lines(self.image.read()))
187
+
188
+
189
+ def capture_conflict_result(snapshot: WorktreeSnapshot, paths: tuple[bytes, ...], *,
190
+ expected_image_by_path: dict[bytes, FileImage] | None = None) -> tuple[WorktreeSnapshot, dict[bytes, FileImage]]:
191
+ """Accept the working bytes of selected unresolved paths as they are now, and answer the snapshot that expects them.
192
+
193
+ Only the fingerprints of the accepted paths change. HEAD, index bytes and entries, conflict sides, conversion
194
+ settings, generation and every other field stay the ones the snapshot was read with, so a later Git verification
195
+ still compares the repository against that earlier read: this is a local acceptance of chosen bytes, never a new
196
+ read by Git. With expected images, each path must still be exactly the published file, type, parents and bytes
197
+ included. Runs on a file worker.
198
+ """
199
+ from gitpane.worktree.worktree_file import capture_image, validate_image
200
+ from gitpane.worktree.worktree_snapshot import file_fingerprint, read_index
201
+
202
+ if not snapshot.fresh or snapshot.context.is_bare or snapshot.key != WorktreeKey.from_context(snapshot.context):
203
+ raise ValueError("A complete current conflict read is required; rescan merge conflicts")
204
+ if read_index(snapshot.index_filename) != snapshot.index_content:
205
+ raise ValueError("The index changed since selection; rescan merge conflicts")
206
+ entry_by_path: dict[bytes, ConflictEntry] = {}
207
+ for entry in conflicts_from_snapshot(snapshot):
208
+ entry_by_path[entry.path] = entry
209
+ fingerprint_by_path = dict(snapshot.fingerprint_by_path)
210
+ image_by_path: dict[bytes, FileImage] = {}
211
+ for path in paths:
212
+ entry = entry_by_path.get(path)
213
+ if entry is None:
214
+ raise ValueError("The selected path is no longer unresolved; rescan merge conflicts")
215
+ if any(side.mode == "160000" for side in entry.sides):
216
+ raise ValueError(SUBMODULE_RESOLUTION_REFUSAL)
217
+ image = capture_image(snapshot.context, path, allow_missing_parents=True)
218
+ if image.exists and not (stat.S_ISREG(image.mode) or stat.S_ISLNK(image.mode)):
219
+ raise ValueError(UNSUPPORTED_RESULT_REFUSAL)
220
+ if expected_image_by_path is not None:
221
+ expected = expected_image_by_path.get(path)
222
+ if expected is None or (image.exists, image.identity, image.parents, image.digest()) != (expected.exists, expected.identity, expected.parents, expected.digest()):
223
+ raise ValueError("The published working result changed; rescan merge conflicts")
224
+ accepted_fingerprint = file_fingerprint(snapshot.context, path)
225
+ # The fingerprint is read after the image; proving the image unchanged afterwards binds both to the same file.
226
+ validate_image(snapshot.context, path, image)
227
+ fingerprint_by_path[path] = accepted_fingerprint
228
+ image_by_path[path] = image
229
+ if read_index(snapshot.index_filename) != snapshot.index_content:
230
+ raise ValueError("The index changed since selection; rescan merge conflicts")
231
+ return replace(snapshot, fingerprint_by_path=fingerprint_by_path), image_by_path
232
+
233
+
234
+ # Version bodies are written straight into their private files as Git streams them; the parser keeps none of them.
235
+ RETAINED_VERSION_BODY_LIMIT_BYTES = 0
236
+
237
+
238
+ def open_version_files(filenames_by_oid: dict[str, list[str]]) -> dict[str, list[BinaryIO]]:
239
+ """Create every private version file exclusively; if one cannot be created, close and remove the ones already made."""
240
+ streams_by_oid: dict[str, list[BinaryIO]] = {}
241
+ try:
242
+ for oid, filenames in filenames_by_oid.items():
243
+ for filename in filenames:
244
+ # The writer spans asynchronous Git delivery; close_version_files owns its closing.
245
+ streams_by_oid.setdefault(oid, []).append(open(filename, "xb")) # noqa: SIM115
246
+ except OSError:
247
+ close_version_files(streams_by_oid)
248
+ remove_version_files(filenames_by_oid)
249
+ raise
250
+ return streams_by_oid
251
+
252
+
253
+ def close_version_files(streams_by_oid: dict[str, list[BinaryIO]]) -> None:
254
+ """Close every version file, even when one of them fails to close, then report the first failure."""
255
+ first_error: OSError | None = None
256
+ for streams in streams_by_oid.values():
257
+ for stream in streams:
258
+ try:
259
+ stream.close()
260
+ except OSError as error:
261
+ if first_error is None:
262
+ first_error = error
263
+ if first_error is not None:
264
+ raise first_error
265
+
266
+
267
+ def remove_version_files(filenames_by_oid: dict[str, list[str]]) -> None:
268
+ """Remove the private version files this preparation created, so a failed read leaves no partial tool input."""
269
+ for filenames in filenames_by_oid.values():
270
+ for filename in filenames:
271
+ if os.path.isfile(filename) and not os.path.islink(filename):
272
+ os.unlink(filename)
273
+
274
+
275
+ def prepare_conflict_deletion(intent: ConflictResolutionIntent, operation_dir: str) -> Generator:
276
+ """Remove only the selected index stages in a private copy before publishing a file deletion."""
277
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS
278
+ from gitpane.worktree.command_sequence import GitCommand
279
+ from gitpane.worktree.staged_reset import FileChange, may_carry_split_index
280
+ from gitpane.worktree.worktree_file import PRIVATE_FILE_MODE, FileImage
281
+ from gitpane.worktree.worktree_snapshot import read_index
282
+
283
+ snapshot = intent.snapshot
284
+ candidate_filename = os.path.join(operation_dir, "candidate-index")
285
+ shutil.copy2(snapshot.index_filename, candidate_filename)
286
+ os.chmod(candidate_filename, PRIVATE_FILE_MODE)
287
+ if read_index(candidate_filename) != snapshot.index_content:
288
+ raise ValueError("The index changed while preparing the deletion")
289
+ environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
290
+ # Only an index that may be split is flattened, in the private copy: an ordinary index saves a supervised write.
291
+ if may_carry_split_index(snapshot.index_content):
292
+ yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
293
+ yield GitCommand(["update-index", "--force-remove", "-z", "--stdin"], stdin=intent.path + b"\0", environment_by_name=environment_by_name,
294
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
295
+ after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
296
+ expected_records = []
297
+ for record in snapshot.index_entries.split(b"\0"):
298
+ _metadata, _separator, path = record.partition(b"\t")
299
+ if path != intent.path:
300
+ expected_records.append(record)
301
+ if sorted(after_entries.split(b"\0")) != sorted(expected_records):
302
+ raise ValueError("The deletion candidate changed other index entries")
303
+ candidate = read_index(candidate_filename)
304
+ if candidate is None:
305
+ raise ValueError("Git did not produce the deletion candidate index")
306
+ change = FileChange(intent.path, intent.image, FileImage(False, 0, parents=intent.image.parents))
307
+ return (change,), snapshot.index_entries, after_entries, candidate
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class ConflictState:
312
+ """Retain last known conflicts while distinguishing failed or stale reads."""
313
+
314
+ key: WorktreeKey
315
+ generation: int = 0
316
+ entries: tuple[ConflictEntry, ...] = ()
317
+ fresh: bool = False
318
+ diagnostic: str = "Conflict state is unknown; refresh the repository"
319
+
320
+
321
+ def conflicts_from_snapshot(snapshot: WorktreeSnapshot) -> tuple[ConflictEntry, ...]:
322
+ """Build conflicts from the status and index captured together, without new reads."""
323
+ if not snapshot.fresh or snapshot.index_entries is None:
324
+ raise ValueError("A complete current index read is required")
325
+ validate_conflict_index(snapshot.entries, snapshot.index_entries)
326
+ entries = []
327
+ for entry in snapshot.entries:
328
+ if entry.status == FileStatus.UNMERGED:
329
+ kind, explanation = classify_conflict(entry)
330
+ entries.append(ConflictEntry(entry.path, entry.conflict_sides, kind, explanation))
331
+ return tuple(sorted(entries, key=lambda entry: entry.path))
332
+
333
+
334
+ def snapshot_has_conflicts(snapshot: WorktreeSnapshot) -> bool:
335
+ """Tell whether a fresh worktree read still lists a conflict; a read that cannot say counts as still conflicted."""
336
+ try:
337
+ return bool(conflicts_from_snapshot(snapshot))
338
+ except ValueError:
339
+ return True
340
+
341
+
342
+ def classify_conflict(entry: LocalEntry) -> tuple[str, str]:
343
+ """Describe actual modes and absent roles without guessing associations between paths."""
344
+ role_set = set()
345
+ mode_set = set()
346
+ for side in entry.conflict_sides:
347
+ role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
348
+ mode_set.add(side.mode)
349
+ if "160000" in mode_set:
350
+ return "Submodule conflict", SUBMODULE_RESOLUTION_REFUSAL
351
+ if "120000" in mode_set:
352
+ return "Symbolic link", "The side objects contain link targets. Resolve the link explicitly without following it as a text file."
353
+ if role_set == {"BASE"}:
354
+ return "Both sides absent", "A rename conflict can leave this original path unresolved. Resolve each listed path independently; no destination is inferred."
355
+ if role_set == {"LOCAL"}:
356
+ return "Added locally (no base)", "Only LOCAL is present. This may be a rename destination; resolve this exact path independently."
357
+ if role_set == {"REMOTE"}:
358
+ return "Added remotely (no base)", "Only REMOTE is present. This may be a rename destination; resolve this exact path independently."
359
+ if "LOCAL" not in role_set:
360
+ return "Deleted locally", "LOCAL is absent; no empty file stands in for the deleted side."
361
+ if "REMOTE" not in role_set:
362
+ return "Deleted remotely", "REMOTE is absent; no empty file stands in for the deleted side."
363
+ if entry.conflict_binary:
364
+ return "Binary conflict", "An available version contains binary data or Git attributes require binary handling. Choose the result explicitly."
365
+ if "BASE" not in role_set:
366
+ return "Add/add (no base)", "Both added versions exist, but BASE is absent."
367
+ if entry.conflict_binary is None:
368
+ return "Unmerged file", "The index versions are known, but their contents have not been inspected."
369
+ return "Text conflict", "BASE, LOCAL and REMOTE refer to the exact versions stored in the index."
370
+
371
+
372
+ class ConflictService:
373
+ """Reconcile conflict observations without treating read failures as resolution."""
374
+
375
+ def __init__(self) -> None:
376
+ """Keep last accepted observations separate for each linked worktree."""
377
+ self.state_by_key: dict[WorktreeKey, ConflictState] = {}
378
+
379
+ def observe(self, key: WorktreeKey, snapshot: WorktreeSnapshot | None, diagnostic: str = "") -> ConflictState:
380
+ """Publish coherent sides or preserve the previous list with an unknown state."""
381
+ previous = self.state_by_key.get(key, ConflictState(key))
382
+ try:
383
+ if snapshot is None or snapshot.key != key:
384
+ raise ValueError(diagnostic or "Conflict state is unknown; refresh the repository")
385
+ entries = conflicts_from_snapshot(snapshot)
386
+ state = ConflictState(key, snapshot.generation, entries, True, "")
387
+ except ValueError as error:
388
+ state = ConflictState(key, previous.generation, previous.entries, False, diagnostic or str(error))
389
+ self.state_by_key[key] = state
390
+ return state
391
+
392
+ def prepare_merge(self, request: MergetoolRequest, store: TemporaryMergeStore, mutation: MutationService) -> Generator:
393
+ """Extract only captured index objects into immutable private input files, all versions in one Git read.
394
+
395
+ Each object Git streams is routed by its own identity to every role that names it, so the order of the
396
+ answers never matters and a version shared by two roles is read once. The strict framing must account for
397
+ every requested object before any file becomes a tool input; on any failure every role file is closed and
398
+ removed.
399
+ """
400
+ from gitpane.history.history_parse import CatFileParser
401
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
402
+ from gitpane.worktree.worktree_file import capture_image
403
+
404
+ snapshot = request.snapshot
405
+ yield from mutation.worktree_service.verify(snapshot, (request.path,))
406
+ image = yield FileTask(lambda: capture_image(snapshot.context, request.path))
407
+ if not image.exists or not stat.S_ISREG(image.mode):
408
+ raise ValueError("Use manual conflict resolution for an absent or non-regular working file.")
409
+ yield FileTask(lambda: store.create(request))
410
+ filenames_by_oid: dict[str, list[str]] = {}
411
+ for side in request.sides:
412
+ filenames_by_oid.setdefault(side.oid, []).append(store.filename(request, CONFLICT_ROLE_BY_STAGE[side.stage]))
413
+ streams_by_oid = yield FileTask(lambda: open_version_files(filenames_by_oid))
414
+ oids = sorted(filenames_by_oid)
415
+ parser = CatFileParser(oids, object_limit_bytes=RETAINED_VERSION_BODY_LIMIT_BYTES, expected_object_type="blob")
416
+
417
+ def route(body: bytes) -> None:
418
+ """Copy the bytes of the object being framed into every role file that names it."""
419
+ for stream in streams_by_oid[parser.oid]:
420
+ stream.write(body)
421
+
422
+ def consume(chunk: bytes) -> None:
423
+ """Feed Git's stream to the strict framing, which hands each body fragment to the router."""
424
+ parser.feed(chunk)
425
+
426
+ parser.body_callback = route
427
+ extracted = False
428
+ try:
429
+ yield GitCommand(["cat-file", "--batch"], stdin="".join(oid + "\n" for oid in oids).encode("ascii"), consume=consume, stdout_limit_bytes=None)
430
+ parser.finish()
431
+ extracted = True
432
+ finally:
433
+ yield FileTask(lambda: close_version_files(streams_by_oid))
434
+ if not extracted:
435
+ yield FileTask(lambda: remove_version_files(filenames_by_oid))
436
+
437
+ def freeze() -> None:
438
+ """Make every complete version read-only and remember its identity for later checks."""
439
+ for filenames in filenames_by_oid.values():
440
+ for filename in filenames:
441
+ store.freeze_input(request, filename)
442
+
443
+ yield FileTask(freeze)
444
+ yield from mutation.worktree_service.verify(snapshot, (request.path,))
445
+ return image
446
+
447
+ def publish_merge_result(self, request: MergetoolRequest, before: FileImage, output: FileImage, store: TemporaryMergeStore,
448
+ mutation: MutationService, is_current: Callable[[], bool]) -> Generator:
449
+ """Revalidate a saved result, publish its bytes, then stage under its existing owner.
450
+
451
+ The working file replacement keeps its own Git verification. The image it produced is then accepted locally,
452
+ its exact identity and bytes, and the staging verifies the index, HEAD and conversion afresh under its lock:
453
+ a complete capture between the two writes would prove nothing either of them does not prove already.
454
+ """
455
+ from gitpane.worktree.command_sequence import FileTask
456
+ from gitpane.worktree.worktree_file import FileImage, capture_image, prepare_publication_image, private_file_dir, publish_image_mode, validate_destination, validate_image
457
+ from gitpane.worktree.worktree_snapshot import verify_local_state
458
+
459
+ snapshot, path = request.snapshot, request.path
460
+ yield from mutation.worktree_service.verify(snapshot, (path,))
461
+
462
+ def publish() -> FileImage:
463
+ """Keep the private saved output intact even when publication or later staging fails."""
464
+ if not is_current():
465
+ raise ValueError("The original repository session is no longer current.")
466
+ store.validate_output(request, output)
467
+ validate_image(snapshot.context, path, before)
468
+ verify_local_state(snapshot, (path,))
469
+ if os.path.lexists(snapshot.index_filename + ".lock"):
470
+ raise ValueError("The Git index is locked by another operation; the saved result has been retained.")
471
+ output_dir, _identity = store.directory_by_request[request.operation.operation_id]
472
+ directory = private_file_dir(snapshot.context, path, output_dir)
473
+ filename = os.path.join(directory, "publish-" + uuid.uuid4().hex)
474
+ try:
475
+ after = prepare_publication_image(filename, FileImage(True, before.mode, output.read()), before)
476
+ store.validate_output(request, output)
477
+ validate_image(snapshot.context, path, before)
478
+ verify_local_state(snapshot, (path,))
479
+ if os.path.lexists(snapshot.index_filename + ".lock"):
480
+ raise ValueError("The Git index is locked by another operation; the saved result has been retained.")
481
+ if not is_current():
482
+ raise ValueError("The original repository session is no longer current.")
483
+ destination_filename, _parents = validate_destination(snapshot.context, path)
484
+ publish_image_mode(filename, after)
485
+ os.replace(filename, destination_filename)
486
+ published = capture_image(snapshot.context, path)
487
+ if published.digest() != after.digest():
488
+ raise ValueError("Working result changed after publication; the saved result has been retained.")
489
+ return published
490
+ finally:
491
+ if os.path.isfile(filename) and not os.path.islink(filename):
492
+ os.unlink(filename)
493
+ if directory != output_dir:
494
+ os.rmdir(directory)
495
+
496
+ published = yield FileTask(publish)
497
+
498
+ def accept() -> ConflictResolutionIntent:
499
+ """Bind the staging to the exact published image, without adopting any HEAD, index or conversion read now."""
500
+ accepted_snapshot, image_by_path = capture_conflict_result(snapshot, (path,), expected_image_by_path={path: published})
501
+ return ConflictResolutionIntent.accept(accepted_snapshot, path, image_by_path[path], request.operation)
502
+
503
+ intent = yield FileTask(accept)
504
+ if intent.image.read() != output.read() or not is_current():
505
+ raise ValueError("Working result or session changed after publication; rescan merge conflicts.")
506
+ return (yield from mutation.resolve_conflict_sequence(intent))
@@ -0,0 +1,163 @@
1
+ """Keep refused merge outputs discoverable without granting them publication rights."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import stat
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING
14
+
15
+ from PySide6.QtCore import QStandardPaths
16
+
17
+ from gitpane.config import MUTATION_RECORD_LIMIT_BYTES
18
+ from gitpane.system.qt_folder import writable_folder
19
+ from gitpane.worktree.worktree_file import FILE_OBJECT_IDENTITY_FIELDS, FILE_STABLE_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, stat_identity
20
+
21
+ if TYPE_CHECKING:
22
+ from gitpane.history.repository_value import RepositoryContext
23
+
24
+ RECOVERY_DIRECTORY_BASENAME = "gitpane-merge-recovery"
25
+ RECOVERY_RECORD_BASENAME = "record.json"
26
+ RECOVERY_OUTPUT_BASENAME = "MERGED"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MergeRecovery:
31
+ """Describe a retained file for manual recovery, never for automatic publication."""
32
+
33
+ path: bytes
34
+ filename: str
35
+ saved_at: str
36
+ sha256: str
37
+
38
+
39
+ def validate_recovery_directory(directory: str) -> None:
40
+ """Reject redirected ancestors before accessing private retained files."""
41
+ while True:
42
+ metadata = os.lstat(directory)
43
+ if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(directory) or os.path.isjunction(directory):
44
+ raise ValueError("The merge recovery directory was redirected.")
45
+ parent_dir = os.path.dirname(directory)
46
+ if parent_dir == directory:
47
+ return
48
+ directory = parent_dir
49
+
50
+
51
+ def read_recovery_file(filename: str, limit: int | None = None) -> bytes:
52
+ """Read one regular private file only while its open handle and path remain identical."""
53
+ validate_recovery_directory(os.path.dirname(filename))
54
+ return read_checked_leaf(filename, limit)
55
+
56
+
57
+ def read_checked_leaf(filename: str, limit: int | None = None) -> bytes:
58
+ """Read one regular private file whose containing folder a caller has already checked.
59
+
60
+ Callers that read many files of the same folder walk its ancestors once and then use this function, instead of
61
+ paying that walk again for every file. A caller with no such check must use the function above.
62
+ """
63
+ metadata = os.lstat(filename)
64
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or os.path.islink(filename) or os.path.isjunction(filename):
65
+ raise ValueError("The merge recovery file is not a regular private file.")
66
+ if limit is not None and metadata.st_size > limit:
67
+ raise ValueError("The merge recovery record is too large.")
68
+ with open(filename, "rb") as stream:
69
+ # Windows handle and path ctime have different meanings; retain the full path check after reading.
70
+ if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
71
+ raise ValueError("The merge recovery file changed while reading.")
72
+ # A read sized to the safety limit makes Python allocate that whole limit (up to 256 MiB for a replay backup) even for a
73
+ # file of a few bytes. One byte more than the checked size costs the real size only and still shows a file that grew.
74
+ content = stream.read(metadata.st_size + 1)
75
+ if stat_identity(os.lstat(filename)) != stat_identity(metadata) or len(content) != metadata.st_size:
76
+ raise ValueError("The merge recovery file changed while reading.")
77
+ return content
78
+
79
+
80
+ class MergeRecoveryStore:
81
+ """Store private results independently of repository moves and discover them after restart."""
82
+
83
+ def __init__(self, storage_dir: str | None = None) -> None:
84
+ """Normalize injected and Qt storage paths so ownership checks match tempfile directories."""
85
+ if storage_dir is None:
86
+ storage_dir = os.path.join(writable_folder(QStandardPaths.StandardLocation.AppLocalDataLocation, sys.platform), RECOVERY_DIRECTORY_BASENAME)
87
+ self.storage_dir = os.path.abspath(storage_dir)
88
+
89
+ def root_dir(self, context: RepositoryContext) -> str:
90
+ """Keep linked worktrees separate using their own Git administrative directory."""
91
+ # Git already resolved this absolute location; re-resolving a replaced ancestor would retarget recovery.
92
+ repository_key = os.path.normcase(os.path.abspath(context.git_dir))
93
+ return os.path.join(self.storage_dir, hashlib.sha256(os.fsencode(repository_key)).hexdigest())
94
+
95
+ def create_directory(self, context: RepositoryContext) -> str:
96
+ """Allocate durable private storage before launching an editor."""
97
+ root_dir = self.root_dir(context)
98
+ missing_dirs = []
99
+ existing_dir = root_dir
100
+ while not os.path.lexists(existing_dir):
101
+ missing_dirs.append(existing_dir)
102
+ existing_dir = os.path.dirname(existing_dir)
103
+ validate_recovery_directory(existing_dir)
104
+ for directory in reversed(missing_dirs):
105
+ try:
106
+ os.mkdir(directory, PRIVATE_DIRECTORY_MODE)
107
+ except FileExistsError:
108
+ pass
109
+ validate_recovery_directory(directory)
110
+ validate_recovery_directory(root_dir)
111
+ directory = tempfile.mkdtemp(prefix="merge-", dir=root_dir)
112
+ os.chmod(directory, PRIVATE_DIRECTORY_MODE)
113
+ return directory
114
+
115
+ def retain(self, context: RepositoryContext, directory: str, path: bytes) -> None:
116
+ """Flush minimal recovery metadata only after a saved result has been retained."""
117
+ if os.path.dirname(directory) != self.root_dir(context):
118
+ raise ValueError("The merge result is outside its repository recovery directory.")
119
+ content = read_recovery_file(os.path.join(directory, RECOVERY_OUTPUT_BASENAME))
120
+ record_by_name = {"path_hex": path.hex(), "saved_at": datetime.now(UTC).isoformat(timespec="seconds"),
121
+ "sha256": hashlib.sha256(content).hexdigest(),
122
+ "directory_identity": stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]}
123
+ filename = os.path.join(directory, RECOVERY_RECORD_BASENAME)
124
+ descriptor = os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
125
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
126
+ json.dump(record_by_name, stream)
127
+ stream.flush()
128
+ os.fsync(stream.fileno())
129
+
130
+ def discover(self, context: RepositoryContext) -> tuple[MergeRecovery, ...]:
131
+ """Read retained paths and verify bytes without modifying files or authorizing a replay."""
132
+ root_dir = self.root_dir(context)
133
+ if not os.path.lexists(root_dir):
134
+ return ()
135
+ validate_recovery_directory(root_dir)
136
+ results = []
137
+ directories = []
138
+ with os.scandir(root_dir) as entries:
139
+ for entry in entries:
140
+ if entry.is_dir(follow_symlinks=False):
141
+ directories.append(entry.path)
142
+ for directory in sorted(directories):
143
+ validate_recovery_directory(directory)
144
+ record_filename = os.path.join(directory, RECOVERY_RECORD_BASENAME)
145
+ if not os.path.lexists(record_filename):
146
+ continue
147
+ try:
148
+ record_by_name = json.loads(read_recovery_file(record_filename, MUTATION_RECORD_LIMIT_BYTES))
149
+ identity = stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]
150
+ if list(identity) != record_by_name["directory_identity"]:
151
+ raise ValueError("A retained merge directory was replaced.")
152
+ filename = os.path.join(directory, RECOVERY_OUTPUT_BASENAME)
153
+ content = read_recovery_file(filename)
154
+ if hashlib.sha256(content).hexdigest() != record_by_name["sha256"]:
155
+ raise ValueError("A retained merge result changed. Inspect the recovery directory manually: " + root_dir)
156
+ path = bytes.fromhex(record_by_name["path_hex"])
157
+ saved_at = record_by_name["saved_at"]
158
+ if not path or not isinstance(saved_at, str):
159
+ raise ValueError("A retained merge record is incomplete.")
160
+ results.append(MergeRecovery(path, filename, saved_at, record_by_name["sha256"]))
161
+ except (KeyError, TypeError, AttributeError) as error:
162
+ raise ValueError("A retained merge record is unreadable.") from error
163
+ return tuple(results)