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,856 @@
1
+ """Resolve a series of conflicts through owned external tools or explicit keep or delete choices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import queue
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field, replace
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING
16
+
17
+ from PySide6.QtCore import QObject, Qt, QTimer, Signal
18
+
19
+ from gitpane.config import MERGETOOL_START_TIMEOUT_MS, MERGETOOL_TERMINATE_TIMEOUT_MS, TOOL_RESULT_POLL_MS, WINDOWS_EXCEPTION_STATUS_START
20
+ from gitpane.operation.operation_status import OperationStatus
21
+ from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OperationOutcome
22
+ from gitpane.process.process_group import NativeProcessGroup
23
+ from gitpane.sequencer.conflict import NO_CONFLICT_SELECTED_TEXT, ConflictResolutionIntent, conflicts_from_snapshot, has_deleted_side
24
+ from gitpane.sequencer.merge_recovery import RECOVERY_OUTPUT_BASENAME, MergeRecoveryStore
25
+ from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
26
+ from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
27
+ from gitpane.tool.mergetool_recipe import MERGETOOL_DEFAULT_COMMAND, parse_mergetool_command
28
+ from gitpane.worktree.command_sequence import FileTask
29
+ from gitpane.worktree.worktree_file import FILE_OBJECT_IDENTITY_FIELDS, FILE_STABLE_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, FileImage, stat_identity
30
+ from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, Generator
34
+
35
+ from gitpane.operation.repository_operation import OperationIntent
36
+ from gitpane.sequencer.conflict import ConflictService
37
+ from gitpane.tool.mergetool_recipe import MergetoolRecipe
38
+ from gitpane.worktree.mutation import MutationService
39
+ from gitpane.worktree.worktree_snapshot import ConflictSide, WorktreeSnapshot
40
+
41
+ AWAITING_MERGE_STATES = ("awaiting-base", "awaiting-result", "awaiting-deletion")
42
+ # The only run states _poll has to look at over time: a starting tool has a startup deadline, and a pending confirmation becomes
43
+ # stale when its authority is lost without any repository operation change. No worker event announces either, so only these keep the timer running.
44
+ MERGETOOL_WATCHED_STATE_SET = frozenset(("starting", *AWAITING_MERGE_STATES))
45
+ # Said when a merge series is asked while another series already resolves conflicts in the same shared repository.
46
+ MERGE_SERIES_RUNNING_TEXT = "The merge tool is already resolving conflicts in this repository. Wait for it to finish."
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class MergetoolRequest:
51
+ """Retain the exact session, index stages, executable recipe and selected path."""
52
+
53
+ snapshot: WorktreeSnapshot
54
+ path: bytes
55
+ sides: tuple[ConflictSide, ...]
56
+ operation: OperationIntent
57
+ recipe: MergetoolRecipe
58
+ allow_missing_base: bool = False
59
+
60
+
61
+ class MergetoolStatus(Enum):
62
+ """How one merge tool run ended for its conflict, written as its value in the --log line of the result."""
63
+
64
+ SAVED = "saved"
65
+ """The saved file was staged as the resolution of the conflict."""
66
+ UNRESOLVED = "unresolved"
67
+ """No saved result was recognized, or the tool could not run: the conflict stays as it was."""
68
+ STALE = "stale"
69
+ """The repository or the conflict changed under the tool, so its result was not staged."""
70
+ UNKNOWN = "unknown"
71
+ """The final reading of the repository failed, so how the run ended is not known."""
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class MergetoolResult:
76
+ """Distinguish saved, unresolved and refused results from native process completion."""
77
+
78
+ request: MergetoolRequest
79
+ status: MergetoolStatus
80
+ diagnostic: str = ""
81
+ recovery_filename: str = ""
82
+ return_code: int | None = None
83
+ snapshot: WorktreeSnapshot | None = None
84
+ settings_available: bool = False
85
+
86
+
87
+ @dataclass
88
+ class MergetoolRun:
89
+ """Keep child and private data ownership independent of the originating widget.
90
+
91
+ ``activation`` is the startup value of the click that launches this tool, which lets its window come to the front;
92
+ a tool started without a click has none.
93
+ """
94
+
95
+ request: MergetoolRequest
96
+ is_current: Callable[[], bool]
97
+ state: str = "preparing"
98
+ before: FileImage | None = None
99
+ output: FileImage | None = None
100
+ resolution: ConflictResolutionIntent | None = None
101
+ process: subprocess.Popen | None = None
102
+ group: NativeProcessGroup | None = None
103
+ worker: threading.Thread | None = None
104
+ stop_requested: threading.Event = field(default_factory=threading.Event)
105
+ return_code: int | None = None
106
+ deadline: float | None = None
107
+ forced: bool = False
108
+ uncertain: bool = False
109
+ invalidated: bool = False
110
+ activation: StartupActivation = NO_STARTUP_ACTIVATION
111
+
112
+
113
+ @dataclass
114
+ class MergetoolSeries:
115
+ """Retain the original ordered paths while each tool gets a newly verified snapshot.
116
+
117
+ ``activation`` holds the startup value of the click that started the series until its first tool takes it: the
118
+ following tools start when the previous one closes, without a click, so they get none. A keep or delete answer is a
119
+ click too: it gives the series a new value for the tool of the next file.
120
+ """
121
+
122
+ request: MergetoolRequest
123
+ paths: tuple[bytes, ...]
124
+ is_current: Callable[[], bool]
125
+ position: int = 0
126
+ stop_requested: bool = False
127
+ current_id: str = ""
128
+ results: list[MergetoolResult] = field(default_factory=list)
129
+ skipped_paths: list[bytes] = field(default_factory=list)
130
+ snapshot: WorktreeSnapshot | None = None
131
+ diagnostic: str = ""
132
+ invalidated: bool = False
133
+ activation: StartupActivation = NO_STARTUP_ACTIVATION
134
+
135
+
136
+ class MergetoolUnavailable(ValueError):
137
+ """Identify executable errors separately from unsupported conflict content."""
138
+
139
+
140
+ class TemporaryMergeStore:
141
+ """Keep request-owned directories, immutable inputs and initially absent outputs."""
142
+
143
+ def __init__(self, recovery_store: MergeRecoveryStore | None = None) -> None:
144
+ """Record filesystem identities independently from request-supplied paths."""
145
+ self.recovery_store = recovery_store if recovery_store is not None else MergeRecoveryStore()
146
+ self.directory_by_request: dict[str, tuple[str, tuple[int, ...]]] = {}
147
+ self.input_by_request: dict[str, dict[str, FileImage]] = {}
148
+ self.parent_by_request: dict[str, tuple[tuple[str, tuple[int, ...]], ...]] = {}
149
+
150
+ def create(self, request: MergetoolRequest) -> None:
151
+ """Create private storage outside the worktree without precreating MERGED."""
152
+ directory = self.recovery_store.create_directory(request.snapshot.context)
153
+ os.chmod(directory, PRIVATE_DIRECTORY_MODE)
154
+ request_id = request.operation.operation_id
155
+ self.directory_by_request[request_id] = (directory, stat_identity(os.lstat(directory)))
156
+ self.input_by_request[request_id] = {}
157
+ parents = []
158
+ current_dir = directory
159
+ while True:
160
+ parents.append((current_dir, stat_identity(os.lstat(current_dir))[:FILE_OBJECT_IDENTITY_FIELDS]))
161
+ parent_dir = os.path.dirname(current_dir)
162
+ if parent_dir == current_dir:
163
+ break
164
+ current_dir = parent_dir
165
+ self.parent_by_request[request_id] = tuple(parents)
166
+
167
+ def filename(self, request: MergetoolRequest, role: str) -> str:
168
+ """Return an inventoried role path with no raw repository path interpolation."""
169
+ if role not in (*CONFLICT_ROLE_BY_STAGE.values(), RECOVERY_OUTPUT_BASENAME):
170
+ raise ValueError("Unknown merge file role")
171
+ directory, _identity = self.directory_by_request[request.operation.operation_id]
172
+ return os.path.join(directory, role)
173
+
174
+ def validate_directory(self, request: MergetoolRequest) -> None:
175
+ """Refuse replacement or redirection of the owned directory and its ancestors."""
176
+ directory, expected = self.directory_by_request[request.operation.operation_id]
177
+ metadata = os.lstat(directory)
178
+ if stat_identity(metadata)[:FILE_OBJECT_IDENTITY_FIELDS] != expected[:FILE_OBJECT_IDENTITY_FIELDS]:
179
+ raise ValueError("The private merge directory was replaced.")
180
+ current_dir = directory
181
+ for parent_dir, identity in self.parent_by_request[request.operation.operation_id]:
182
+ if stat_identity(os.lstat(parent_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != identity:
183
+ raise ValueError("A private merge ancestor was replaced.")
184
+ while True:
185
+ if os.path.islink(current_dir) or os.path.isjunction(current_dir) or not stat.S_ISDIR(os.lstat(current_dir).st_mode):
186
+ raise ValueError("The private merge path was redirected.")
187
+ parent_dir = os.path.dirname(current_dir)
188
+ if parent_dir == current_dir:
189
+ break
190
+ current_dir = parent_dir
191
+
192
+ def read_file(self, request: MergetoolRequest, filename: str) -> FileImage:
193
+ """Accept a regular single-link file only while its handle and path identities agree."""
194
+ self.validate_directory(request)
195
+ metadata = os.lstat(filename)
196
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or os.path.islink(filename) or os.path.isjunction(filename):
197
+ raise ValueError("The private merge result is not an owned regular file.")
198
+ with open(filename, "rb") as stream:
199
+ if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
200
+ raise ValueError("The private merge file changed while reading.")
201
+ content = stream.read()
202
+ if stat_identity(os.lstat(filename)) != stat_identity(metadata):
203
+ raise ValueError("The private merge file changed while reading.")
204
+ return FileImage(True, metadata.st_mode, content, identity=stat_identity(metadata))
205
+
206
+ def freeze_input(self, request: MergetoolRequest, filename: str) -> None:
207
+ """Remove write permission and retain exact input identities for later verification."""
208
+ os.chmod(filename, stat.S_IRUSR)
209
+ self.input_by_request[request.operation.operation_id][filename] = self.read_file(request, filename)
210
+
211
+ def capture_output(self, request: MergetoolRequest) -> FileImage | None:
212
+ """Recognize saved empty or identical bytes only after the process has really exited."""
213
+ self.validate_directory(request)
214
+ for filename, image in self.input_by_request[request.operation.operation_id].items():
215
+ if self.read_file(request, filename) != image:
216
+ raise ValueError("An immutable merge input was changed.")
217
+ filename = self.filename(request, RECOVERY_OUTPUT_BASENAME)
218
+ return self.read_file(request, filename) if os.path.lexists(filename) else None
219
+
220
+ def validate_output(self, request: MergetoolRequest, expected: FileImage) -> None:
221
+ """Require the same saved file identity and content after any confirmation wait."""
222
+ if self.capture_output(request) != expected:
223
+ raise ValueError("The saved merge result changed; it has not been published.")
224
+
225
+ def cleanup(self, request: MergetoolRequest, output: FileImage | None) -> None:
226
+ """Delete only named files with recorded identities, retaining unexpected content."""
227
+ request_id = request.operation.operation_id
228
+ if request_id not in self.directory_by_request:
229
+ return
230
+ self.validate_directory(request)
231
+ image_by_filename = dict(self.input_by_request[request_id])
232
+ if output is not None:
233
+ image_by_filename[self.filename(request, RECOVERY_OUTPUT_BASENAME)] = output
234
+ for filename, image in image_by_filename.items():
235
+ if self.read_file(request, filename) != image:
236
+ raise ValueError("A private file changed; cleanup was refused.")
237
+ for filename in image_by_filename:
238
+ os.chmod(filename, PRIVATE_FILE_MODE)
239
+ os.unlink(filename)
240
+ directory, _identity = self.directory_by_request[request_id]
241
+ os.rmdir(directory)
242
+ self.directory_by_request.pop(request_id)
243
+ self.input_by_request.pop(request_id)
244
+ self.parent_by_request.pop(request_id)
245
+
246
+
247
+ class MergetoolService(QObject):
248
+ """Reserve each selected conflict through its tool or file choice and final Git reread."""
249
+
250
+ started = Signal(object)
251
+ finished = Signal(object)
252
+ confirmation_requested = Signal(object, str)
253
+ confirmation_closed = Signal(str)
254
+ idle = Signal()
255
+ series_changed = Signal(object)
256
+ series_finished = Signal(object)
257
+ event_ready = Signal()
258
+
259
+ def __init__(self, conflict: ConflictService, mutation: MutationService, parent: QObject | None = None,
260
+ *, process_factory: Callable[..., subprocess.Popen] = subprocess.Popen, temporary_store: TemporaryMergeStore | None = None,
261
+ activation: Callable[[], StartupActivation] = no_startup_activation, platform: str = sys.platform,
262
+ distribution_family: DistributionFamily = DistributionFamily.UNKNOWN, distribution_release: DistributionRelease = DistributionRelease.OTHER) -> None:
263
+ """Inject the real transaction and process creator without hiding their ownership.
264
+
265
+ A merge refused for a missing tool says how to install KDiff3 on ``platform``, and under Linux on the distribution
266
+ ``distribution_family`` and ``distribution_release`` that the wiring read from the session once; all are received,
267
+ so each system's message can be checked from any of them.
268
+
269
+ The activation source tells the desktop that the user asked for a merge tool (an X11 startup identifier or a
270
+ Wayland activation token), so its window may come to the front. It is read on the window's thread at the click
271
+ that starts a series and at the agreement on a missing base, never for a tool started without a click. Without
272
+ a desktop, as in the tests, there is none.
273
+ """
274
+ super().__init__(parent)
275
+ self.conflict = conflict
276
+ self.mutation = mutation
277
+ self.activation = activation
278
+ self.platform = platform
279
+ self.distribution_family = distribution_family
280
+ self.distribution_release = distribution_release
281
+ self.worktree_service = mutation.worktree_service
282
+ self.repository_operation = mutation.repository_operation
283
+ self.process_factory = process_factory
284
+ self.temporary_store = temporary_store if temporary_store is not None else TemporaryMergeStore()
285
+ self.active_by_id: dict[str, MergetoolRun] = {}
286
+ self.result_by_id: dict[str, MergetoolResult] = {}
287
+ self.series_by_id: dict[str, MergetoolSeries] = {}
288
+ self.series_id_by_request: dict[str, str] = {}
289
+ self.event_queue: queue.SimpleQueue[tuple[str, str, object]] = queue.SimpleQueue()
290
+ self.closed = False
291
+ self.timer = QTimer(self)
292
+ self.timer.setInterval(TOOL_RESULT_POLL_MS)
293
+ self.timer.timeout.connect(self._poll)
294
+ self.event_ready.connect(self._poll, Qt.ConnectionType.QueuedConnection)
295
+ self.repository_operation.changed.connect(self._revalidate_runs)
296
+
297
+ def owns_repository(self, common_git_dir: str) -> bool:
298
+ """Include between-file reads so a series cannot race a rebase continuation."""
299
+ return (any(run.request.operation.common_git_dir == common_git_dir for run in self.active_by_id.values())
300
+ or any(series.request.operation.common_git_dir == common_git_dir for series in self.series_by_id.values()))
301
+
302
+ def merge_series(self, request: MergetoolRequest, paths: tuple[bytes, ...], is_current: Callable[[], bool]) -> str:
303
+ """Capture paths once and refuse another series against the same shared repository.
304
+
305
+ A refused series says why, so the user knows whether to wait, reopen the window or select a file: GitPane is
306
+ closing; no conflicted file is selected; the window is stale, with the reason it lost its authority (never
307
+ another running operation, since waiting for it would not help); or another series already works on the same
308
+ shared repository. Between two files a series holds no reservation, so only this service can name it.
309
+
310
+ Called by the user's click: an accepted series reads the click's startup value here, on the window's thread,
311
+ and keeps it for its first tool.
312
+ """
313
+ series_id = request.operation.operation_id
314
+ if self.closed:
315
+ self._refuse_series(request, GITPANE_CLOSING_TEXT)
316
+ return series_id
317
+ if not paths:
318
+ self._refuse_series(request, NO_CONFLICT_SELECTED_TEXT)
319
+ return series_id
320
+ if not is_current():
321
+ self._refuse_series(request, self.repository_operation.authority_cause(request.operation))
322
+ return series_id
323
+ for series in self.series_by_id.values():
324
+ if series.request.operation.common_git_dir == request.operation.common_git_dir:
325
+ self._refuse_series(request, MERGE_SERIES_RUNNING_TEXT)
326
+ return series_id
327
+ series = MergetoolSeries(request, tuple(sorted(set(paths))), is_current, activation=self.activation())
328
+ self.series_by_id[series_id] = series
329
+ self._next_series_path(series)
330
+ return series_id
331
+
332
+ def _refuse_series(self, request: MergetoolRequest, cause: str) -> None:
333
+ """Return a visible refusal with its real cause, without touching another series's reservation or state.
334
+
335
+ The result is published on the next event-loop turn, like any other result, so the caller has returned the
336
+ series identifier before the conflicts window reads the refusal.
337
+ """
338
+ result = MergetoolResult(request, MergetoolStatus.UNRESOLVED, cause)
339
+ self.result_by_id[request.operation.operation_id] = result
340
+ QTimer.singleShot(0, self, lambda: self.finished.emit(result))
341
+
342
+ def invalidate_session(self, session_id: str | None) -> None:
343
+ """Permanently revoke old publication rights while preserving children and saved output."""
344
+ for series in tuple(self.series_by_id.values()):
345
+ if series.request.operation.session_id == session_id:
346
+ series.invalidated = True
347
+ self.stop_series(series.request.operation.operation_id)
348
+ for run in tuple(self.active_by_id.values()):
349
+ if run.request.operation.session_id == session_id:
350
+ run.invalidated = True
351
+ if run.state in AWAITING_MERGE_STATES:
352
+ self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)
353
+
354
+ def stop_series(self, series_id: str) -> None:
355
+ """Prevent future launches without revoking the current process's publication rights."""
356
+ series = self.series_by_id.get(series_id)
357
+ if series is not None:
358
+ series.stop_requested = True
359
+ self.series_changed.emit(series)
360
+
361
+ def _next_series_path(self, series: MergetoolSeries) -> None:
362
+ """Choose the next path from a complete read of the repository, including paths already resolved externally.
363
+
364
+ The first path reads the repository. Each following path starts from the terminal read the previous file has
365
+ just finished, when that read still covers exactly the original inventory: its conversion settings are then
366
+ compared directly with the original ones. When the inventory changed, for instance a result equal to HEAD
367
+ leaves the list of changed files, the repository is read again and its conversion is checked on the original
368
+ inventory, because settings read on two different sets of paths cannot be compared. Either way the next tool
369
+ only starts after its own preparation has asked Git to verify the state it relies on.
370
+
371
+ The first tool the series starts takes the startup value of the click that started it; the series then forgets
372
+ it, so a tool started when the previous one closes cannot take the focus without a click.
373
+ """
374
+ request = series.request
375
+ if series.stop_requested or series.invalidated or self.closed or not series.is_current() or series.position >= len(series.paths):
376
+ self._finish_series(series)
377
+ return
378
+ series.current_id = ""
379
+ self.series_changed.emit(series)
380
+
381
+ def captured(snapshot: object, error: Exception | None) -> None:
382
+ """Advance only from a fresh read and never reinterpret the original path selection."""
383
+ if error is not None or snapshot is None or not snapshot.fresh:
384
+ series.diagnostic = "Could not read current merge conflicts. Rescan before retrying."
385
+ if error is not None:
386
+ series.diagnostic += " " + self._safe_error(error)
387
+ self._finish_series(series)
388
+ return
389
+ series.snapshot = snapshot
390
+ if series.stop_requested or series.invalidated or self.closed or not series.is_current():
391
+ self._finish_series(series)
392
+ return
393
+ if snapshot.head_oid != request.snapshot.head_oid:
394
+ series.diagnostic = "Repository configuration or HEAD changed. Rescan before retrying."
395
+ self._finish_series(series)
396
+ return
397
+ path = series.paths[series.position]
398
+ series.position += 1
399
+ entry = next((entry for entry in conflicts_from_snapshot(snapshot) if entry.path == path), None)
400
+ if entry is None:
401
+ series.skipped_paths.append(path)
402
+ self._next_series_path(series)
403
+ return
404
+ original = next((entry for entry in conflicts_from_snapshot(request.snapshot) if entry.path == path), None)
405
+ if original is None or entry.sides != original.sides:
406
+ series.diagnostic = "The selected conflict versions changed. Rescan before retrying."
407
+ self._finish_series(series)
408
+ return
409
+ owner = replace(request.operation, operation_id=uuid.uuid4().hex)
410
+ current = replace(request, snapshot=snapshot, path=path, sides=entry.sides, operation=owner)
411
+ series.current_id = owner.operation_id
412
+ self.series_id_by_request[owner.operation_id] = request.operation.operation_id
413
+ self.series_changed.emit(series)
414
+ activation = series.activation
415
+ series.activation = NO_STARTUP_ACTIVATION
416
+ self.merge(current, series.is_current, activation=activation)
417
+
418
+ def capture() -> Generator:
419
+ """Check attributes against the original inventory without changing the new snapshot."""
420
+ snapshot = yield from self.worktree_service.capture(request.snapshot.context, request.snapshot.git_filename, request.snapshot.generation)
421
+ yield from self.worktree_service.verify_conversion(request.snapshot)
422
+ return snapshot
423
+
424
+ if series.results:
425
+ terminal = series.snapshot
426
+ original = request.snapshot
427
+ if (terminal is None or not terminal.fresh or terminal.key != original.key or terminal.context != original.context
428
+ or terminal.git_filename != original.git_filename):
429
+ series.diagnostic = "Could not read current merge conflicts. Rescan before retrying."
430
+ self._finish_series(series)
431
+ return
432
+ if tuple(terminal.fingerprint_by_path) == tuple(original.fingerprint_by_path):
433
+ if terminal.conversion != original.conversion:
434
+ series.diagnostic = "Repository configuration or attributes changed. Rescan before retrying."
435
+ self._finish_series(series)
436
+ return
437
+ captured(terminal, None)
438
+ return
439
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, capture(), captured)
440
+
441
+ def _finish_series(self, series: MergetoolSeries) -> None:
442
+ """Publish the series observation without counting external resolutions as our results."""
443
+ self.series_by_id.pop(series.request.operation.operation_id, None)
444
+ self.series_finished.emit(series)
445
+ self._update_poll_timer()
446
+ if not self.active_by_id and not self.series_by_id:
447
+ self.idle.emit()
448
+
449
+ def _series_result(self, result: MergetoolResult) -> None:
450
+ """Require saved success and terminal Git proof before considering the next editor."""
451
+ series_id = self.series_id_by_request.pop(result.request.operation.operation_id, None)
452
+ series = self.series_by_id.get(series_id)
453
+ if series is None:
454
+ return
455
+ series.results.append(result)
456
+ series.snapshot = result.snapshot
457
+ if (result.status != MergetoolStatus.SAVED or result.snapshot is None or not result.snapshot.fresh
458
+ or any(entry.path == result.request.path for entry in conflicts_from_snapshot(result.snapshot))):
459
+ series.diagnostic = result.diagnostic
460
+ if result.recovery_filename:
461
+ series.diagnostic += " Saved result retained at: " + result.recovery_filename
462
+ self._finish_series(series)
463
+ return
464
+ self._next_series_path(series)
465
+
466
+ def merge(self, request: MergetoolRequest, is_current: Callable[[], bool], *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> str:
467
+ """Reserve each captured file before choosing a deletion prompt or an external merge.
468
+
469
+ ``activation`` is the startup value of the click that asked for this file, handed to its tool; a file started
470
+ without a click gets none.
471
+
472
+ A refused merge says why: GitPane is closing; the window is stale, with the reason it lost its authority (never
473
+ another running operation, since waiting for it would not help); or the repository could not be reserved, with
474
+ the reason the repository operation service finds, which names another operation only when one really holds it.
475
+ """
476
+ request_id = request.operation.operation_id
477
+ cause = ""
478
+ if self.closed:
479
+ cause = GITPANE_CLOSING_TEXT
480
+ elif not is_current():
481
+ cause = self.repository_operation.authority_cause(request.operation)
482
+ elif not self.repository_operation.reserve(request.operation):
483
+ cause = self.repository_operation.refusal_cause(request.operation)
484
+ if cause:
485
+ result = MergetoolResult(request, MergetoolStatus.UNRESOLVED, cause)
486
+ def refused() -> None:
487
+ """Report the refusal before ending its owning series."""
488
+ self.finished.emit(result)
489
+ self._series_result(result)
490
+
491
+ QTimer.singleShot(0, self, refused)
492
+ return request_id
493
+ run = MergetoolRun(request, is_current, activation=activation)
494
+ self.active_by_id[request_id] = run
495
+ self._prepare(run)
496
+ self._update_poll_timer()
497
+ return request_id
498
+
499
+ def _prepare(self, run: MergetoolRun) -> None:
500
+ """Validate missing-side choices before private extraction and native startup."""
501
+ request = run.request
502
+ request_id = request.operation.operation_id
503
+
504
+ def prepare() -> str:
505
+ """Inspect the conflict first so a deleted side does not require an installed text merge tool."""
506
+ if (request.operation.key != request.snapshot.key or request.operation.context != request.snapshot.context
507
+ or request.operation.git_filename != request.snapshot.git_filename):
508
+ raise ValueError("The merge request does not belong to its repository owner.")
509
+ entry = next((entry for entry in conflicts_from_snapshot(request.snapshot) if entry.path == request.path), None)
510
+ if entry is None or entry.sides != request.sides:
511
+ raise ValueError("The selected conflict versions changed. Rescan before retrying.")
512
+ if has_deleted_side(entry.sides):
513
+ return "deleted-side"
514
+ try:
515
+ request.recipe.validate(platform=self.platform, family=self.distribution_family, release=self.distribution_release)
516
+ except ValueError as error:
517
+ raise MergetoolUnavailable(str(error)) from error
518
+ if entry.kind not in ("Text conflict", "Add/add (no base)"):
519
+ raise ValueError("Use manual conflict resolution for this file type or missing side.")
520
+ missing_base = entry.kind == "Add/add (no base)"
521
+ if missing_base and request.allow_missing_base:
522
+ parse_mergetool_command(request.recipe.command, None, "LOCAL", "REMOTE", RECOVERY_OUTPUT_BASENAME, allow_missing_base=True)
523
+ return "missing-base" if missing_base and not request.allow_missing_base else ""
524
+
525
+ def sequence() -> Generator:
526
+ """Keep both file and Git work out of synchronous widget callbacks."""
527
+ reason = yield FileTask(prepare)
528
+ if reason == "deleted-side":
529
+ yield from self.worktree_service.verify(request.snapshot, (request.path,))
530
+ resolution = yield FileTask(lambda: ConflictResolutionIntent.capture(request.snapshot, request.path, request.operation, delete=True))
531
+ return reason, resolution
532
+ if reason:
533
+ return reason, None
534
+ return "", (yield from self.conflict.prepare_merge(request, self.temporary_store, self.mutation))
535
+
536
+ def prepared(value: object, error: Exception | None) -> None:
537
+ """Start the owned child only while the captured session is still valid."""
538
+ if error is not None:
539
+ self._finish(run, MergetoolStatus.UNRESOLVED, self._safe_error(error), settings_available=isinstance(error, MergetoolUnavailable))
540
+ return
541
+ if not self._current(run):
542
+ self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.")
543
+ return
544
+ reason, result = value
545
+ if reason == "deleted-side":
546
+ run.resolution = result
547
+ run.state = "awaiting-deletion"
548
+ self._update_poll_timer()
549
+ self.confirmation_requested.emit(request, reason)
550
+ return
551
+ if reason == "missing-base":
552
+ run.state = "awaiting-base"
553
+ self._update_poll_timer()
554
+ self.confirmation_requested.emit(request, "missing-base")
555
+ return
556
+ run.before = result
557
+ run.state = "starting"
558
+ run.deadline = time.monotonic() + MERGETOOL_START_TIMEOUT_MS / 1000
559
+ run.worker = threading.Thread(target=self._run_process, args=(run,), daemon=True)
560
+ run.worker.start()
561
+ self._update_poll_timer()
562
+
563
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), prepared, operation_id=request_id)
564
+
565
+ def confirm(self, request_id: str, accepted: bool) -> None:
566
+ """Require explicit agreement while rechecking saved output and original authority.
567
+
568
+ Called when the user answers the question: an agreement on a missing base starts the tool, so the startup value
569
+ of that answer is read here, on the window's thread, for it.
570
+ """
571
+ run = self.active_by_id.get(request_id)
572
+ if run is not None and run.state == "awaiting-deletion":
573
+ if not accepted:
574
+ self.confirm_deletion(request_id, None)
575
+ return
576
+ if run is None or run.state not in ("awaiting-base", "awaiting-result"):
577
+ return
578
+ if not accepted or not self._current(run):
579
+ self._finish(run, MergetoolStatus.UNRESOLVED, "The conflict was kept unresolved.", retain=run.output is not None)
580
+ return
581
+ if run.state == "awaiting-base":
582
+ run.request = replace(run.request, allow_missing_base=True)
583
+ run.activation = self.activation()
584
+ run.state = "preparing"
585
+ self._prepare(run)
586
+ return
587
+ self._publish(run)
588
+
589
+ def confirm_deletion(self, request_id: str, delete: bool | None) -> None:
590
+ """Stage the exact keep/delete choice under the existing owner, or stop the series on cancellation.
591
+
592
+ Called when the user answers the question: when a series has files left, the tool of the next one starts once the
593
+ choice is staged, so the startup value of that answer is read here, on the window's thread, for it.
594
+ """
595
+ run = self.active_by_id.get(request_id)
596
+ if run is None or run.state != "awaiting-deletion" or run.resolution is None:
597
+ return
598
+ if delete is None or not self._current(run):
599
+ self._finish(run, MergetoolStatus.UNRESOLVED, "The conflict was kept unresolved. The remaining selected files were not processed.")
600
+ return
601
+ if not delete and not run.resolution.image.exists:
602
+ self._finish(run, MergetoolStatus.UNRESOLVED, "The working file is absent and cannot be kept. Rescan before retrying.")
603
+ return
604
+ series = self.series_by_id.get(self.series_id_by_request.get(request_id))
605
+ if series is not None and series.position < len(series.paths):
606
+ series.activation = self.activation()
607
+ run.state = "publishing"
608
+ resolution = replace(run.resolution, delete=delete)
609
+
610
+ def completed(outcome: object, error: Exception | None) -> None:
611
+ """Continue only after successful publication; retain the transaction's recovery diagnostic on failure."""
612
+ if error is not None:
613
+ self._finish(run, MergetoolStatus.STALE, self._safe_error(error))
614
+ return
615
+ if outcome.status != "success":
616
+ self._finish(run, MergetoolStatus.STALE, outcome.diagnostic.cause if outcome.diagnostic is not None else "Git could not stage the chosen conflict resolution.")
617
+ return
618
+ self._finish(run, MergetoolStatus.SAVED)
619
+
620
+ self.worktree_service.run(resolution.snapshot.context, resolution.snapshot.git_filename, self.mutation.resolve_conflict_sequence(resolution), completed,
621
+ operation_id=request_id)
622
+
623
+ def _current(self, run: MergetoolRun) -> bool:
624
+ """Recheck both application authority and exact repository ownership before publication."""
625
+ registry = self.repository_operation
626
+ return (not run.invalidated and not self.closed and not registry.closed and run.is_current() and registry.active_by_id.get(run.request.operation.operation_id) is run.request.operation
627
+ and (registry.allows is None or registry.allows(run.request.operation)))
628
+
629
+ @staticmethod
630
+ def _safe_error(error: Exception) -> str:
631
+ """Avoid including arbitrary child output or command arguments in merge diagnostics."""
632
+ from gitpane.worktree.command_sequence import GitCommandError
633
+
634
+ return "Git could not validate or stage the merge result. Rescan before retrying." if isinstance(error, GitCommandError) else str(error)
635
+
636
+ def _run_process(self, run: MergetoolRun) -> None:
637
+ """Wait for the supervisor and every descendant without blocking the Qt event loop.
638
+
639
+ The supervisor gets GitPane's environment without any inherited startup value, plus the one of the click that
640
+ launched this tool, and hands that environment on to the tool.
641
+ """
642
+ request = run.request
643
+ request_id = request.operation.operation_id
644
+ try:
645
+ store = self.temporary_store
646
+ store.validate_directory(request)
647
+ role_set = set()
648
+ for side in request.sides:
649
+ role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
650
+ arguments = parse_mergetool_command(request.recipe.command, store.filename(request, "BASE") if "BASE" in role_set else None,
651
+ store.filename(request, "LOCAL"), store.filename(request, "REMOTE"), store.filename(request, RECOVERY_OUTPUT_BASENAME),
652
+ allow_missing_base=request.allow_missing_base)
653
+ if os.path.lexists(store.filename(request, RECOVERY_OUTPUT_BASENAME)):
654
+ raise ValueError("The private merge output was created before launch.")
655
+ run.group = NativeProcessGroup()
656
+ program_filename, arguments = run.group.prepare(request.recipe.program_filename, arguments)
657
+ directory, _identity = store.directory_by_request[request_id]
658
+ # Tool streams may contain whole file versions; redaction cannot safely classify arbitrary blob text.
659
+ run.process = self.process_factory([program_filename, *arguments], cwd=directory, shell=False, stdin=subprocess.DEVNULL,
660
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=run.activation.environment(os.environ),
661
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
662
+ self._queue_event((request_id, "started", None))
663
+ self._wait_process(run)
664
+ except (OSError, ValueError) as error:
665
+ if run.process is not None:
666
+ run.uncertain = True
667
+ self._wait_process(run)
668
+ return
669
+ if run.group is not None:
670
+ run.group.close()
671
+ self._queue_event((request_id, "error", type(error).__name__))
672
+
673
+ def _wait_process(self, run: MergetoolRun) -> None:
674
+ """Keep polling uncertain OS proof instead of abandoning a still-owned native child."""
675
+ request_id = run.request.operation.operation_id
676
+ stop_sent = False
677
+ while True:
678
+ if run.stop_requested.is_set() and not stop_sent:
679
+ try:
680
+ run.group.stop()
681
+ stop_sent = True
682
+ except OSError:
683
+ run.uncertain = True
684
+ try:
685
+ return_code = run.process.poll()
686
+ if return_code is not None and run.group.is_empty():
687
+ run.process.wait(timeout=MERGETOOL_TERMINATE_TIMEOUT_MS / 1000)
688
+ run.return_code = run.group.result_return_code(return_code)
689
+ failed = run.group.launch_failed()
690
+ run.group.close()
691
+ self._queue_event((request_id, "exited", failed))
692
+ return
693
+ except (OSError, ValueError):
694
+ if not run.uncertain:
695
+ self._queue_event((request_id, "unknown", None))
696
+ run.uncertain = True
697
+ time.sleep(TOOL_RESULT_POLL_MS / 1000)
698
+
699
+ def _queue_event(self, event: tuple[str, str, object]) -> None:
700
+ """Let a worker thread drop one event and wake the Qt thread, the only one that touches the runs."""
701
+ self.event_queue.put(event)
702
+ self.event_ready.emit()
703
+
704
+ def _update_poll_timer(self) -> None:
705
+ """Run the timer only while a run waits in a state that needs checking over time, never while a tool window is open."""
706
+ watched = any(run.state in MERGETOOL_WATCHED_STATE_SET for run in self.active_by_id.values())
707
+ if watched and not self.timer.isActive():
708
+ self.timer.start()
709
+ elif not watched and self.timer.isActive():
710
+ self.timer.stop()
711
+
712
+ def _revalidate_runs(self) -> None:
713
+ """Permanently invalidate every run that lost its authority, and end a pending confirmation that did.
714
+
715
+ Nothing is polled while a tool window is open: authority is rechecked each time a repository operation starts or ends,
716
+ when the tool exits and during publication. A change of the merge tool or Git preference invalidates the run for good
717
+ through invalidate_session, even if the preference is restored afterwards.
718
+ """
719
+ for run in tuple(self.active_by_id.values()):
720
+ if self._current(run):
721
+ continue
722
+ run.invalidated = True
723
+ if run.state in AWAITING_MERGE_STATES:
724
+ self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)
725
+
726
+ def _poll(self) -> None:
727
+ """Deliver worker events on Qt while retaining timed-out or unknown process ownership.
728
+
729
+ Worker events wake it through event_ready; the timer only adds the checks over time of the watched states.
730
+ """
731
+ while True:
732
+ try:
733
+ request_id, kind, value = self.event_queue.get_nowait()
734
+ except queue.Empty:
735
+ break
736
+ run = self.active_by_id.get(request_id)
737
+ if run is None:
738
+ continue
739
+ if kind == "started":
740
+ run.state = "running"
741
+ self.started.emit(run.request)
742
+ elif kind == "exited":
743
+ self._process_finished(run, bool(value))
744
+ elif kind == "error":
745
+ self._finish(run, MergetoolStatus.UNRESOLVED, "The external merge tool could not start (" + str(value) + ").", settings_available=True)
746
+ elif kind == "unknown":
747
+ run.state = "unknown"
748
+ for run in tuple(self.active_by_id.values()):
749
+ if run.state == "starting" and run.deadline is not None and time.monotonic() >= run.deadline:
750
+ run.forced = True
751
+ run.stop_requested.set()
752
+ self._revalidate_runs()
753
+ self._update_poll_timer()
754
+
755
+ def _process_finished(self, run: MergetoolRun, launch_failed: bool) -> None:
756
+ """Inspect a request-owned result only after the OS proves actual process completion."""
757
+ run.state = "inspecting"
758
+
759
+ def sequence() -> Generator:
760
+ """Read regular saved bytes on a file worker."""
761
+ return (yield FileTask(lambda: self.temporary_store.capture_output(run.request)))
762
+
763
+ def inspected(output: object, error: Exception | None) -> None:
764
+ """Never equate a successful close without a produced file to a resolution."""
765
+ run.output = output
766
+ if error is not None:
767
+ self._finish(run, MergetoolStatus.STALE, self._safe_error(error), retain=True)
768
+ elif not self._current(run):
769
+ self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=output is not None)
770
+ elif output is None or launch_failed or run.forced or self._crashed(run.return_code):
771
+ self._finish(run, MergetoolStatus.UNRESOLVED, "No successful saved merge result was recognized. Use Mark conflict as solved after checking the working file.", retain=output is not None)
772
+ elif run.return_code != 0 or run.uncertain or run.request.recipe.command != MERGETOOL_DEFAULT_COMMAND or self._has_markers(output):
773
+ run.state = "awaiting-result"
774
+ self._update_poll_timer()
775
+ self.confirmation_requested.emit(run.request, "saved-result")
776
+ else:
777
+ self._publish(run)
778
+
779
+ request = run.request
780
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), inspected, operation_id=request.operation.operation_id)
781
+
782
+ @staticmethod
783
+ def _crashed(return_code: int | None) -> bool:
784
+ """Distinguish signalled exits and Windows exception statuses from normal refusal codes."""
785
+ return return_code is None or return_code < 0 or os.name == "nt" and return_code >= WINDOWS_EXCEPTION_STATUS_START
786
+
787
+ @staticmethod
788
+ def _has_markers(output: FileImage) -> bool:
789
+ """Treat merge-like markers as ambiguity while allowing explicit saved-result approval."""
790
+ content = output.read()
791
+ return b"<<<<<<<" in content and b"=======" in content and b">>>>>>>" in content
792
+
793
+ def _publish(self, run: MergetoolRun) -> None:
794
+ """Keep metadata reads lightweight while the index transaction supervises its writes."""
795
+ run.state = "publishing"
796
+ request = run.request
797
+
798
+ def completed(outcome: object, error: Exception | None) -> None:
799
+ """Preserve saved bytes on any refusal, including Git filter or lock failures."""
800
+ if error is not None or outcome.status != "success":
801
+ self._finish(run, MergetoolStatus.STALE, self._safe_error(error) if error else "Git could not stage the saved working result.", retain=True)
802
+ return
803
+ self._finish(run, MergetoolStatus.SAVED)
804
+
805
+ sequence = self.conflict.publish_merge_result(request, run.before, run.output, self.temporary_store, self.mutation, lambda: self._current(run))
806
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence, completed, operation_id=request.operation.operation_id)
807
+
808
+ def _finish(self, run: MergetoolRun, status: MergetoolStatus, diagnostic: str = "", *, retain: bool = False, settings_available: bool = False) -> None:
809
+ """Reread terminal Git state and finish file work before releasing the sole owner."""
810
+ run.state = "finishing"
811
+ request, store = run.request, self.temporary_store
812
+ request_id = request.operation.operation_id
813
+ self.confirmation_closed.emit(request_id)
814
+ recovery_filename = store.filename(request, RECOVERY_OUTPUT_BASENAME) if retain and request_id in store.directory_by_request else ""
815
+ recovery_diagnostic = ""
816
+
817
+ def sequence() -> Generator:
818
+ """Keep cleanup and the final observation within the reservation's lifetime."""
819
+ nonlocal recovery_diagnostic
820
+ if recovery_filename:
821
+ try:
822
+ yield FileTask(lambda: store.validate_directory(request))
823
+ yield FileTask(lambda: store.recovery_store.retain(request.snapshot.context, os.path.dirname(recovery_filename), request.path))
824
+ except (OSError, ValueError) as error:
825
+ recovery_diagnostic = " Recovery information could not be recorded: " + self._safe_error(error)
826
+ if not retain:
827
+ yield FileTask(lambda: store.cleanup(request, run.output))
828
+ return (yield from self.worktree_service.capture(request.snapshot.context, request.snapshot.git_filename, request.snapshot.generation))
829
+
830
+ def complete(snapshot: object, error: Exception | None) -> None:
831
+ """Retain the result before notifying views or allowing another repository operation."""
832
+ final_status = MergetoolStatus.UNKNOWN if error is not None else status
833
+ final_diagnostic = diagnostic or (self._safe_error(error) if error else "")
834
+ final_diagnostic += recovery_diagnostic
835
+ result = MergetoolResult(request, final_status, final_diagnostic, recovery_filename, run.return_code, snapshot, settings_available)
836
+ self.result_by_id[request_id] = result
837
+ self.active_by_id.pop(request_id, None)
838
+ self.repository_operation.reconcile(OperationOutcome(request_id, OperationStatus.SUCCESS if final_status == MergetoolStatus.SAVED else OperationStatus.REFUSED))
839
+ self.finished.emit(result)
840
+ self._series_result(result)
841
+ self._update_poll_timer()
842
+ if not self.active_by_id and not self.series_by_id:
843
+ self.idle.emit()
844
+
845
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, sequence(), complete, operation_id=request_id)
846
+
847
+ def close(self) -> None:
848
+ """Reject new launches and stop supervised descendants while retaining their resources."""
849
+ self.closed = True
850
+ for series_id in tuple(self.series_by_id):
851
+ self.stop_series(series_id)
852
+ for run in tuple(self.active_by_id.values()):
853
+ run.forced = True
854
+ run.stop_requested.set()
855
+ if run.state in AWAITING_MERGE_STATES:
856
+ self._finish(run, MergetoolStatus.STALE, "The original repository session is no longer current.", retain=run.output is not None)