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,1435 @@
1
+ """Share native replay observations between current worktree views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import string
7
+ import tempfile
8
+ import uuid
9
+ from dataclasses import dataclass, replace
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PySide6.QtCore import QObject, Signal
13
+
14
+ from gitpane.commit.commit_metadata import read_path_metadata
15
+ from gitpane.config import GIT_COMMIT_TIMEOUT_MS, GIT_PATH_SEPARATOR, SUBMODULE_RECURSION_OFF_ARGUMENTS, ProcessPriority
16
+ from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE, SUCCESS_EXIT_CODE
17
+ from gitpane.operation.diagnostic import Diagnostic
18
+ from gitpane.operation.launch_signature import (
19
+ capture_write_launch_signature,
20
+ changed_launch_paths,
21
+ exclude_covered_launch_paths,
22
+ prepare_write_launch_signature,
23
+ sign_launch_paths,
24
+ verify_launch_signature,
25
+ )
26
+ from gitpane.operation.operation_status import OperationStatus, operation_status
27
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, OperationStepResult, WriteScope
28
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
29
+ from gitpane.reference.reference_edit import GITLINK_MODE, read_commit_metadata, reference_signature, reference_signature_paths, validate_oid
30
+ from gitpane.sequencer import replay_legacy
31
+ from gitpane.sequencer.commit_message_rule import AUTOMATIC_COMMENT_PREFIX, DEFAULT_COMMENT_PREFIX
32
+ from gitpane.sequencer.rebase_state import REBASE_BASENAMES
33
+ from gitpane.sequencer.replay_state import (
34
+ LIVE_INDEX_BASENAME,
35
+ MERGE_MESSAGE_BASENAME,
36
+ REPLAY_BASENAMES,
37
+ REPLAY_SEQUENCER_BASENAMES,
38
+ REPLAY_STEP_UNATTRIBUTED_CONFLICT,
39
+ SEQUENCER_BASENAME,
40
+ TRAILING_EMPTY_PIECE_COUNT,
41
+ ReplayAction,
42
+ ReplayCommitMode,
43
+ ReplayEffect,
44
+ ReplayKind,
45
+ ReplayLaunch,
46
+ ReplayOwnership,
47
+ ReplaySnapshot,
48
+ capture_replay,
49
+ capture_replay_passive,
50
+ join_replay_launch,
51
+ read_replay_metadata,
52
+ replay_action_diagnostic,
53
+ unreadable_replay_state,
54
+ )
55
+ from gitpane.submodule.submodule_loss import gitlink_paths, submodule_loss_refusal
56
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch, observation_priority
57
+ from gitpane.worktree.worktree_snapshot import WorktreeKey, read_index
58
+
59
+ if TYPE_CHECKING:
60
+ from collections.abc import Callable, Generator
61
+
62
+ from gitpane.history.repository_value import RepositoryContext
63
+ from gitpane.operation.diagnostic import PreparedDiagnostic
64
+ from gitpane.operation.launch_signature import LaunchSignature, WriteLaunchSignature
65
+ from gitpane.operation.repository_operation import RepositoryOperationService
66
+ from gitpane.sequencer.replay_state import ReplayMetadata
67
+ from gitpane.worktree.worktree_service import WorktreeService
68
+ from gitpane.worktree.worktree_snapshot import PassiveObservationSource
69
+
70
+ FIRST_PARENT_NUMBER = 1
71
+ MERGE_PARENT_MINIMUM = 2
72
+ # The folders in which Git keeps a paused rebase and the instructions of a cherry-pick or revert series.
73
+ NATIVE_REBASE_DIR_BASENAMES = ("rebase-merge", "rebase-apply")
74
+ # One grouped show describes several commits: NUL between the fields of one commit, and the 0x01 marker at the end of
75
+ # each, which Git follows with the line end it writes after every commit. The message comes last so that it may hold
76
+ # any other byte.
77
+ REPLAY_SOURCE_FORMAT = "%H%x00%P%x00%an%x00%aI%x00%cn%x00%cI%x00%B%x01"
78
+ REPLAY_RECORD_END = b"\x01\n"
79
+ REPLAY_FIELD_SEPARATOR = b"\0"
80
+ RECORD_OID_POSITION = 0
81
+ SOURCE_FIELD_COUNT = 7
82
+ SOURCE_PARENTS_POSITION = 1
83
+ SOURCE_AUTHOR_POSITION = 2
84
+ SOURCE_AUTHOR_DATE_POSITION = 3
85
+ SOURCE_COMMITTER_POSITION = 4
86
+ SOURCE_COMMIT_DATE_POSITION = 5
87
+ SOURCE_MESSAGE_POSITION = 6
88
+ # The line end Git writes after the description of one commit, and after one answer of rev-parse.
89
+ COMMIT_TERMINATOR = b"\n"
90
+ # How a path of a replayed commit is split and checked before Git applies it. Windows reads both slashes, ignores dots
91
+ # and spaces at the end of a name, and may open Git's folder under its short name.
92
+ # Paths are read from Git as bytes; this form is drawn from the one text separator rather than written again.
93
+ GIT_PATH_SEPARATOR_BYTES = GIT_PATH_SEPARATOR.encode("ascii")
94
+ WINDOWS_PATH_SEPARATOR = b"\\"
95
+ PATH_SEPARATORS = (GIT_PATH_SEPARATOR_BYTES, WINDOWS_PATH_SEPARATOR)
96
+ DRIVE_LETTER_POSITION = 0
97
+ DRIVE_SEPARATOR_POSITION = 1
98
+ DRIVE_SEPARATOR = b":"
99
+ RELATIVE_COMPONENT_SET = frozenset((b".", b".."))
100
+ WINDOWS_IGNORED_TRAILING_CHARACTERS = b". "
101
+ GIT_DIRECTORY_COMPONENT_SET = frozenset((b".git", b"git~1"))
102
+ REPLAY_PATH_OUTSIDE_CAUSE = "The selected commit changes a path outside the working tree. It was not applied."
103
+ REPLAY_PATH_GIT_DIRECTORY_CAUSE = "The selected commit changes a path inside the .git folder. It was not applied."
104
+ REPLAY_CHANGES_UNREADABLE_CAUSE = "The changes of the selected commit could not be read. Refresh before applying it."
105
+ # One record of a raw, NUL-framed diff listing (diff-tree, or diff --cached) is its metadata then its path: a colon,
106
+ # both modes, both objects and the status, separated by spaces.
107
+ RAW_DIFF_PREFIX = b":"
108
+ RAW_DIFF_FIELD_COUNT = 5
109
+ RAW_DIFF_OLD_MODE_POSITION = 0
110
+ RAW_DIFF_NEW_MODE_POSITION = 1
111
+ RAW_DIFF_PIECES_PER_RECORD = 2
112
+ # The mode Git writes for the missing side of an added or removed entry. A change whose two sides are only a submodule
113
+ # or nothing moves, adds or removes a submodule entry without Git touching the submodule's folder.
114
+ RAW_DIFF_ABSENT_MODE = b"000000"
115
+ SUBMODULE_ENTRY_MODE_SET = frozenset((GITLINK_MODE, RAW_DIFF_ABSENT_MODE))
116
+ # Git's pathspec magic for a path kept out of a command, read literally whatever characters it holds.
117
+ LITERAL_EXCLUSION_PATHSPEC_PREFIX = ":(exclude,literal)"
118
+ # A command stopped by its time limit or by the user: the process status then names the result on its own.
119
+ STOPPED_PROCESS_STATUSES = (ProcessStatus.TIMEOUT, ProcessStatus.IDLE_TIMEOUT, ProcessStatus.CANCELLED)
120
+ PAUSE_CHANGED_CAUSE = "The commit operation, HEAD or the index changed. Refresh before trying again."
121
+ CONTINUE_UNVERIFIED_CAUSE = "Continue finished without a verified commit. Inspect the current operation."
122
+ ABORT_UNVERIFIED_CAUSE = "Abort did not bring back the checkout the operation started from. Inspect the current state."
123
+ # rev-list --parents writes each commit followed by its parents; a commit of the chain Continue creates has exactly one.
124
+ CHAIN_COMMIT_FIELD_COUNT = 2
125
+ CHAIN_PARENT_POSITION = 1
126
+ # The background write that removes the anchor references earlier versions left. It opens no console: its only trace
127
+ # is the process log.
128
+ LEGACY_ANCHOR_RETIREMENT_OPERATION = "Retire earlier replay references"
129
+ LEGACY_ANCHOR_RETIREMENT_REFUSED_CAUSE = "A cherry-pick or revert is in progress. The earlier replay references were kept."
130
+
131
+
132
+ def native_pause_paths(context: RepositoryContext) -> tuple[list[str], list[str]]:
133
+ """Name every file Git keeps for a paused cherry-pick, revert or rebase in one worktree, then the folders holding them.
134
+
135
+ The names are the ones the native readers already open, joined to the worktree's own Git folder; nothing is read.
136
+ """
137
+ filenames = []
138
+ for basename in REPLAY_BASENAMES:
139
+ filenames.append(os.path.join(context.git_dir, basename))
140
+ sequencer_dir = os.path.join(context.git_dir, SEQUENCER_BASENAME)
141
+ for basename in REPLAY_SEQUENCER_BASENAMES:
142
+ filenames.append(os.path.join(sequencer_dir, basename))
143
+ watched_dirs = [sequencer_dir]
144
+ for rebase_dir_basename in NATIVE_REBASE_DIR_BASENAMES:
145
+ state_dir = os.path.join(context.git_dir, rebase_dir_basename)
146
+ watched_dirs.append(state_dir)
147
+ for basename in REBASE_BASENAMES:
148
+ filenames.append(os.path.join(state_dir, basename))
149
+ return filenames, watched_dirs
150
+
151
+
152
+ def replay_path_refusal(path: bytes) -> str:
153
+ """Name why a path a replayed commit changes must not be handed to Git, or answer "" for an ordinary path of the working tree.
154
+
155
+ A path is refused when it is empty or absolute, when one of its components is empty, "." or "..", or when one of
156
+ them names Git's own folder in any spelling Windows opens as that folder: any case, trailing dots and spaces, or
157
+ its short name. Both slashes count as separators, since Windows reads either.
158
+ """
159
+ drive_form = path[DRIVE_LETTER_POSITION:DRIVE_LETTER_POSITION + 1].isalpha() and path[DRIVE_SEPARATOR_POSITION:].startswith(DRIVE_SEPARATOR)
160
+ if not path or path.startswith(PATH_SEPARATORS) or drive_form:
161
+ return REPLAY_PATH_OUTSIDE_CAUSE
162
+ for component in path.replace(WINDOWS_PATH_SEPARATOR, GIT_PATH_SEPARATOR_BYTES).split(GIT_PATH_SEPARATOR_BYTES):
163
+ if not component or component in RELATIVE_COMPONENT_SET:
164
+ return REPLAY_PATH_OUTSIDE_CAUSE
165
+ folded = component.rstrip(WINDOWS_IGNORED_TRAILING_CHARACTERS).lower()
166
+ if not folded:
167
+ return REPLAY_PATH_OUTSIDE_CAUSE
168
+ if folded in GIT_DIRECTORY_COMPONENT_SET:
169
+ return REPLAY_PATH_GIT_DIRECTORY_CAUSE
170
+ return ""
171
+
172
+
173
+ def replay_change_refusal(output: bytes) -> str:
174
+ """Read the raw, NUL-framed diff-tree listing of the changes a replay applies and name the first reason to refuse it, or answer "".
175
+
176
+ Every path replay_path_refusal refuses is refused, the path of a submodule included, and a listing that does not
177
+ split into whole records is refused as unreadable. What a change does to the folder of a submodule is checked
178
+ separately, by replaced_submodule_paths.
179
+ """
180
+ try:
181
+ records = _split_raw_diff_records(output)
182
+ except ValueError as error:
183
+ return str(error)
184
+ for _fields, path in records:
185
+ refusal = replay_path_refusal(path)
186
+ if refusal:
187
+ return refusal
188
+ return ""
189
+
190
+
191
+ def replaced_submodule_paths(output: bytes, index_gitlink_paths: list[bytes]) -> list[bytes]:
192
+ """Name the submodules of the index whose folder the replayed changes of a raw, NUL-framed listing would remove or write into, in index order.
193
+
194
+ Git, launched without descending into submodules, handles a change that only moves, adds or removes a submodule
195
+ entry without touching the submodule's folder. Any other change on the path of a submodule of the index (turned
196
+ into a file or a link, or a file changed there), inside it (a folder of files taking its place) or on a folder
197
+ that holds it makes Git remove that folder or write into it. Direction does not matter, so a Revert that turns a
198
+ submodule back into a file is found like a Cherry-pick. Paths are compared the way Windows opens them. A listing
199
+ that does not split into whole records raises ValueError.
200
+ """
201
+ records = _split_raw_diff_records(output)
202
+ folded_records = []
203
+ for fields, path in records:
204
+ folded_records.append((fields, _folded_path(path)))
205
+ replaced_paths = []
206
+ for gitlink_path in index_gitlink_paths:
207
+ folded_gitlink_path = _folded_path(gitlink_path)
208
+ for fields, folded_path in folded_records:
209
+ if _change_replaces_submodule(fields, folded_path, folded_gitlink_path):
210
+ replaced_paths.append(gitlink_path)
211
+ break
212
+ return replaced_paths
213
+
214
+
215
+ def _change_replaces_submodule(fields: list[bytes], folded_path: bytes, folded_gitlink_path: bytes) -> bool:
216
+ """Tell whether one change removes the folder of a submodule or writes into it, rather than only moving, adding or removing its entry."""
217
+ if folded_path == folded_gitlink_path:
218
+ return not {fields[RAW_DIFF_OLD_MODE_POSITION], fields[RAW_DIFF_NEW_MODE_POSITION]} <= SUBMODULE_ENTRY_MODE_SET
219
+ return folded_path.startswith(folded_gitlink_path + GIT_PATH_SEPARATOR_BYTES) or folded_gitlink_path.startswith(folded_path + GIT_PATH_SEPARATOR_BYTES)
220
+
221
+
222
+ def _folded_path(path: bytes) -> bytes:
223
+ """Spell a path the way Windows opens it, so two spellings of one folder compare equal: either slash, any case, trailing dots and spaces dropped."""
224
+ components = []
225
+ for component in path.replace(WINDOWS_PATH_SEPARATOR, GIT_PATH_SEPARATOR_BYTES).split(GIT_PATH_SEPARATOR_BYTES):
226
+ text = component.rstrip(WINDOWS_IGNORED_TRAILING_CHARACTERS).decode("utf-8", "surrogateescape")
227
+ components.append(text.casefold().encode("utf-8", "surrogateescape"))
228
+ return GIT_PATH_SEPARATOR_BYTES.join(components)
229
+
230
+
231
+ def _split_raw_diff_records(output: bytes) -> list[tuple[list[bytes], bytes]]:
232
+ """Split a raw, NUL-framed diff listing into the metadata fields and the path of each record, in Git's order.
233
+
234
+ A listing that does not split into whole records, or a record whose metadata is not the colon then five fields,
235
+ is refused as unreadable rather than read partly.
236
+ """
237
+ pieces = output.split(b"\0")
238
+ if pieces[-1] or len(pieces) % RAW_DIFF_PIECES_PER_RECORD != TRAILING_EMPTY_PIECE_COUNT:
239
+ raise ValueError(REPLAY_CHANGES_UNREADABLE_CAUSE)
240
+ records = []
241
+ for position in range(0, len(pieces) - TRAILING_EMPTY_PIECE_COUNT, RAW_DIFF_PIECES_PER_RECORD):
242
+ metadata, path = pieces[position], pieces[position + 1]
243
+ fields = metadata.removeprefix(RAW_DIFF_PREFIX).split(b" ")
244
+ if not metadata.startswith(RAW_DIFF_PREFIX) or len(fields) != RAW_DIFF_FIELD_COUNT:
245
+ raise ValueError(REPLAY_CHANGES_UNREADABLE_CAUSE)
246
+ records.append((fields, path))
247
+ return records
248
+
249
+
250
+ def staged_gitlink_paths(raw_output: bytes) -> list[bytes]:
251
+ """Name the submodule entries a "diff --cached --raw -z --no-renames" listing shows as staged, in the order Git lists them.
252
+
253
+ An entry counts when either side of its change is a submodule: added, moved to another commit, removed, or
254
+ turned from or into a file. A listing that does not split into whole records raises ValueError.
255
+ """
256
+ paths = []
257
+ for fields, path in _split_raw_diff_records(raw_output):
258
+ if GITLINK_MODE in (fields[RAW_DIFF_OLD_MODE_POSITION], fields[RAW_DIFF_NEW_MODE_POSITION]):
259
+ paths.append(path)
260
+ return paths
261
+
262
+
263
+ def reset_keeping_gitlinks_arguments(gitlink_paths: list[bytes]) -> list[str]:
264
+ """Name the reset that sets the index back to HEAD, except for the given submodule entries, which stay staged.
265
+
266
+ Each submodule path is kept out by an exact, literal exclusion. GitPane hands every command to Git with literal
267
+ pathspecs, which would read the exclusion as a file name, so this reset reads pathspec magic again: its only
268
+ pathspecs are the exclusions built here. A path that is not UTF-8 raises ValueError before anything runs.
269
+ """
270
+ if not gitlink_paths:
271
+ return ["reset", "--quiet"]
272
+ arguments = ["--no-literal-pathspecs", "reset", "--quiet", "--"]
273
+ for path in gitlink_paths:
274
+ arguments.append(LITERAL_EXCLUSION_PATHSPEC_PREFIX + path.decode("utf-8"))
275
+ return arguments
276
+
277
+
278
+ def native_replay_message(message: str, source_oid: str, add_reference: bool, comment_prefix: str = "#") -> str:
279
+ """Remove only Git's trailing conflict notes and retain every authored message line."""
280
+ if comment_prefix == "auto":
281
+ before, separator, _notes = message.rpartition(" Conflicts:\n")
282
+ _body, _newline, comment_prefix = before.rpartition("\n")
283
+ if not separator or len(comment_prefix) != 1:
284
+ raise ValueError("The automatic native conflict comment prefix could not be verified. The message was preserved.")
285
+ body, separator, notes = message.rpartition("\n" + comment_prefix + " Conflicts:\n")
286
+ if separator and all(not line.strip() or line.startswith(comment_prefix + "\t") for line in notes.splitlines()):
287
+ message = body
288
+ message = message.rstrip("\n") + "\n"
289
+ trailer = "(cherry picked from commit " + source_oid + ")"
290
+ if add_reference and trailer not in message.splitlines():
291
+ message = message.rstrip("\n") + "\n\n" + trailer + "\n"
292
+ return message
293
+
294
+
295
+ def native_message_comment_prefix(message: str) -> str:
296
+ """Choose a temporary Git comment prefix that cannot remove an authored message line."""
297
+ lines = message.splitlines()
298
+ for character in string.punctuation + string.ascii_letters + string.digits:
299
+ if not any(line.startswith(character) for line in lines):
300
+ return character
301
+ raise ValueError("The native message needs an unused comment prefix. Its original contents were preserved.")
302
+
303
+
304
+ def publish_replay_message(context: RepositoryContext, metadata: ReplayMetadata, message: str, validate: Callable[[], None]) -> ReplayMetadata:
305
+ """Compare native bytes immediately before replacing the message with an owned prepared file."""
306
+ if metadata.content_by_basename.get(MERGE_MESSAGE_BASENAME) == message:
307
+ return metadata
308
+ descriptor, candidate_filename = tempfile.mkstemp(prefix="gitpane-replay-message-", dir=context.git_dir)
309
+ try:
310
+ with os.fdopen(descriptor, "wb") as stream:
311
+ stream.write(message.encode("utf-8"))
312
+ stream.flush()
313
+ os.fsync(stream.fileno())
314
+ validate()
315
+ if read_replay_metadata(context.git_dir) != metadata:
316
+ raise ValueError("The native commit message changed. Its external contents were preserved; refresh before continuing.")
317
+ os.replace(candidate_filename, os.path.join(context.git_dir, MERGE_MESSAGE_BASENAME))
318
+ return read_replay_metadata(context.git_dir)
319
+ finally:
320
+ if os.path.exists(candidate_filename):
321
+ os.unlink(candidate_filename)
322
+
323
+
324
+ class ReplayRecordAmbiguity(ValueError):
325
+ """A grouped commit description holds its record separator inside a message, so its records cannot be told apart.
326
+
327
+ Only this answer may be asked again one question at a time; any other malformed answer is a refusal.
328
+ """
329
+
330
+
331
+ def _split_replay_commit_records(output: bytes, field_count: int, expected_oids: list[str]) -> dict[str, tuple[bytes, ...]]:
332
+ """Split one grouped show answer into the raw fields of each asked commit, bound to its object ID, never to its position.
333
+
334
+ Each record ends with the 0x01 marker of the format followed by the line end Git writes after every commit; only
335
+ that protocol ending is removed, never a line end of a message. The first field of each record must be one of the
336
+ asked object IDs, each exactly once, and every record must hold all of its fields; the message, last, keeps any
337
+ further NUL byte. More records than asked means a message holds the separator: that answer is ambiguous and
338
+ refused as such, to be asked again one question at a time.
339
+ """
340
+ expected_oid_set = set(expected_oids)
341
+ if not expected_oids or len(expected_oid_set) != len(expected_oids):
342
+ raise ValueError("A grouped commit description must name distinct commits.")
343
+ if not output.endswith(REPLAY_RECORD_END):
344
+ raise ValueError("Git gave back an incomplete commit description.")
345
+ records = output.removesuffix(REPLAY_RECORD_END).split(REPLAY_RECORD_END)
346
+ if len(records) > len(expected_oids):
347
+ raise ReplayRecordAmbiguity("A commit message holds the record separator of the grouped description.")
348
+ if len(records) != len(expected_oids):
349
+ raise ValueError("Git did not describe every asked commit.")
350
+ fields_by_oid = {}
351
+ for record in records:
352
+ fields = record.split(REPLAY_FIELD_SEPARATOR, field_count - 1)
353
+ if len(fields) != field_count:
354
+ raise ValueError("Git gave back an incomplete commit description.")
355
+ oid = validate_oid(fields[RECORD_OID_POSITION].decode("ascii"))
356
+ if oid not in expected_oid_set or oid in fields_by_oid:
357
+ raise ValueError("Git described a commit that was not asked for.")
358
+ fields_by_oid[oid] = tuple(fields)
359
+ return fields_by_oid
360
+
361
+
362
+ def _decode_parent_oids(field: bytes) -> tuple[str, ...]:
363
+ """Read the space-separated parents of one commit description, in Git's order, refusing anything but full object IDs."""
364
+ parent_oids = []
365
+ for parent in field.decode("ascii").split(" ") if field else ():
366
+ parent_oids.append(validate_oid(parent))
367
+ return tuple(parent_oids)
368
+
369
+
370
+ def replay_executable_signature(git_filename: str) -> tuple:
371
+ """Identify the Git executable a replay launches by its normalized path and the metadata of that file.
372
+
373
+ A capability of Git is proven for exactly this file: another path, or the same file rewritten in place (its Windows
374
+ change time moves even when size and modification time are put back), is another executable. A file that cannot be
375
+ found identifies nothing and is refused.
376
+ """
377
+ filename = os.path.normcase(os.path.abspath(git_filename))
378
+ identity = read_path_metadata(filename)
379
+ if identity is None:
380
+ raise ValueError("The Git executable could not be identified. Check the configured Git.")
381
+ return (filename, identity)
382
+
383
+
384
+ @dataclass(frozen=True)
385
+ class ReplaySourceRecord:
386
+ """The six displayed fields of one commit, in the order the form has always received them, and its parents in Git's order."""
387
+
388
+ fields: tuple[str, ...]
389
+ parent_oids: tuple[str, ...]
390
+
391
+
392
+ def parse_replay_source_records(output: bytes, expected_oids: list[str]) -> dict[str, ReplaySourceRecord]:
393
+ """Read a grouped show written with REPLAY_SOURCE_FORMAT into one record per asked commit, bound to its object ID.
394
+
395
+ Text is decoded the way the form always decoded it, unreadable bytes replaced, and the message keeps the line end
396
+ Git writes after the description of a single commit, so each field reads exactly as it did when every commit had a
397
+ show of its own.
398
+ """
399
+ record_by_oid = {}
400
+ for oid, fields in _split_replay_commit_records(output, SOURCE_FIELD_COUNT, expected_oids).items():
401
+ texts = []
402
+ for position in (SOURCE_AUTHOR_POSITION, SOURCE_AUTHOR_DATE_POSITION, SOURCE_COMMITTER_POSITION, SOURCE_COMMIT_DATE_POSITION):
403
+ texts.append(fields[position].decode("utf-8", "replace"))
404
+ message = (fields[SOURCE_MESSAGE_POSITION] + COMMIT_TERMINATOR).decode("utf-8", "replace")
405
+ record_by_oid[oid] = ReplaySourceRecord((oid, *texts, message), _decode_parent_oids(fields[SOURCE_PARENTS_POSITION]))
406
+ return record_by_oid
407
+
408
+
409
+ def read_replay_source_records(oids: list[str], *, read_parents: bool = True) -> Generator[GitCommand, object, dict[str, ReplaySourceRecord]]:
410
+ """Describe the given commits, each named once, in one show, the answers bound to their object IDs.
411
+
412
+ An answer made ambiguous by a message holding the separator is asked again commit by commit through the historical
413
+ metadata read, plus the parent list when `read_parents` asks for it; without it the parents of that fallback stay
414
+ empty, and the caller must not read them. A failure of Git is never retried.
415
+ """
416
+ for oid in oids:
417
+ validate_oid(oid)
418
+ output = yield GitCommand(["show", "--no-patch", "--format=" + REPLAY_SOURCE_FORMAT, *oids, "--"], profile=READ_PROFILE)
419
+ try:
420
+ return parse_replay_source_records(output, oids)
421
+ except ReplayRecordAmbiguity:
422
+ record_by_oid = {}
423
+ for oid in oids:
424
+ fields = yield from read_commit_metadata(oid)
425
+ parent_oids = ()
426
+ if read_parents:
427
+ parent_output = yield GitCommand(["show", "--no-patch", "--format=%P", oid, "--"])
428
+ if not parent_output.endswith(COMMIT_TERMINATOR):
429
+ raise ValueError("Git could not read complete metadata for the selected commit") from None
430
+ parent_oids = _decode_parent_oids(parent_output.removesuffix(COMMIT_TERMINATOR))
431
+ record_by_oid[oid] = ReplaySourceRecord(tuple(fields), parent_oids)
432
+ return record_by_oid
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class ReplayParent:
437
+ """Identify a parent by its native position and full commit identity."""
438
+
439
+ number: int
440
+ oid: str
441
+ subject: str
442
+ author: str
443
+ commit_date: str
444
+
445
+
446
+ @dataclass(frozen=True)
447
+ class ReplaySource:
448
+ """Bind displayed metadata and ordered parents to one resolved source."""
449
+
450
+ oid: str
451
+ fields: tuple[str, ...]
452
+ parents: tuple[ReplayParent, ...]
453
+
454
+
455
+ @dataclass(frozen=True)
456
+ class ReplayIntent:
457
+ """Keep the selected source distinct from the checkout receiving its changes."""
458
+
459
+ operation: OperationIntent
460
+ kind: ReplayKind
461
+ source_oid: str
462
+ original_head_oid: str | None
463
+ original_symbolic_ref: str | None
464
+ create_commit: bool = True
465
+ add_reference: bool = False
466
+ mainline_parent_number: int | None = None
467
+ mainline_parent_oid: str | None = None
468
+
469
+
470
+ @dataclass
471
+ class NativeCommandRun:
472
+ """What became of the one native command an operation hands to Git: whether it started, how it ended and why it failed."""
473
+
474
+ started: bool = False
475
+ process_status: ProcessStatus | None = None
476
+ return_code: int | None = None
477
+ diagnostic: PreparedDiagnostic | None = None
478
+
479
+ def record_failure(self, operation: OperationIntent, error: GitCommandError | ValueError | OSError) -> None:
480
+ """Keep Git's own diagnostic of a failed command, or turn a refusal before Git into the first diagnostic of this run."""
481
+ if isinstance(error, GitCommandError):
482
+ self.diagnostic, self.process_status, self.return_code = error.diagnostic, error.process_status, error.return_code
483
+ return
484
+ if self.diagnostic is None:
485
+ self.diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
486
+
487
+
488
+ def commit_chain_from(output: bytes, base_oid: str, head_oid: str) -> bool:
489
+ """Tell whether a "rev-list --parents --reverse base..HEAD" listing is one line of new commits on `base_oid` ending at `head_oid`.
490
+
491
+ Each commit must have exactly one parent, the commit listed before it, the first one standing on `base_oid`.
492
+ """
493
+ parent_oid = base_oid
494
+ for line in output.decode("ascii").splitlines():
495
+ fields = line.split()
496
+ if len(fields) != CHAIN_COMMIT_FIELD_COUNT or fields[CHAIN_PARENT_POSITION] != parent_oid:
497
+ return False
498
+ parent_oid = fields[RECORD_OID_POSITION]
499
+ return parent_oid == head_oid and parent_oid != base_oid
500
+
501
+
502
+ def empty_result_effect(kind: ReplayKind, changes: bytes) -> str:
503
+ """Name why a replay that brings nothing created no commit: an empty source, a Revert without effect, or changes already present.
504
+
505
+ `changes` is the listing of what the source changes against the parent it is replayed from; an empty listing is
506
+ an empty source whatever the operation. The answer is the word the step writes in its free-text effect.
507
+ """
508
+ if not changes:
509
+ return ReplayEffect.INITIALLY_EMPTY.value
510
+ if kind == ReplayKind.REVERT:
511
+ return ReplayEffect.REVERT_NO_EFFECT.value
512
+ return ReplayEffect.ALREADY_PRESENT.value
513
+
514
+
515
+ def pause_origin(state: ReplaySnapshot) -> tuple[str | None, str | None]:
516
+ """Name the HEAD and the branch Git's Abort brings a pause back to.
517
+
518
+ A pause GitPane launched keeps the checkout of its launch; a series keeps its starting HEAD in Git's sequencer; a
519
+ single instruction never moves HEAD while it waits, so its current HEAD is its origin.
520
+ """
521
+ if state.launch is not None:
522
+ return state.launch.original_head_oid, state.launch.original_symbolic_ref
523
+ if state.identity is not None and state.identity.original_oid:
524
+ return state.identity.original_oid, state.symbolic_ref
525
+ return state.head_oid, state.symbolic_ref
526
+
527
+
528
+ def continue_message(state: ReplaySnapshot, metadata: ReplayMetadata, comment_output: bytes) -> str:
529
+ """Prepare the message Git's Continue commits: Git's conflict notes removed, every authored line kept, the reference added when asked.
530
+
531
+ The comment character is the one the configuration sets last. When Git picks it per message and this message holds
532
+ no conflict notes any more (a Continue tried before already removed them), nothing is left to remove with it.
533
+ """
534
+ message = metadata.content_by_basename.get(MERGE_MESSAGE_BASENAME, "")
535
+ comment_prefix = DEFAULT_COMMENT_PREFIX
536
+ for line in comment_output.decode("utf-8").splitlines():
537
+ _name, _separator, comment_prefix = line.partition(" ")
538
+ if comment_prefix == AUTOMATIC_COMMENT_PREFIX and " Conflicts:\n" not in message:
539
+ comment_prefix = DEFAULT_COMMENT_PREFIX
540
+ launch = state.launch
541
+ source_oid = launch.source_oid if launch is not None else next(iter(state.instruction_oids))
542
+ add_reference = launch is not None and launch.add_reference and state.kind == ReplayKind.CHERRY_PICK
543
+ message = native_replay_message(message, source_oid, add_reference, comment_prefix)
544
+ if not message.strip():
545
+ raise ValueError("The prepared commit message is empty. Write it before continuing.")
546
+ return message
547
+
548
+
549
+ def publish_continue_message(context: RepositoryContext, baseline: WriteLaunchSignature, metadata: ReplayMetadata, message: str) -> WriteLaunchSignature:
550
+ """Write the prepared message over Git's MERGE_MSG, then take the look the Continue is compared with when it launches.
551
+
552
+ Runs on the file worker. Between the first look and the new one, only that message may differ: the references,
553
+ the index and every other pause file must be exactly as the checks found them.
554
+ """
555
+ publish_replay_message(context, metadata, message, lambda: verify_launch_signature(baseline.metadata, PAUSE_CHANGED_CAUSE))
556
+ pause_filenames, pause_dirs = native_pause_paths(context)
557
+ prepared = capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths, baseline.index_filename,
558
+ baseline.index_content, extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs)
559
+ message_filename = os.path.join(context.git_dir, MERGE_MESSAGE_BASENAME)
560
+ for filename in changed_launch_paths(baseline.metadata, prepared.metadata):
561
+ if filename != message_filename:
562
+ raise ValueError(PAUSE_CHANGED_CAUSE)
563
+ if prepared.reference_state != baseline.reference_state:
564
+ raise ValueError(PAUSE_CHANGED_CAUSE)
565
+ return prepared
566
+
567
+
568
+ class ReplayService(QObject):
569
+ """Run Cherry-pick and Revert on Git's own commands and share one observation of their pause per worktree."""
570
+
571
+ changed = Signal(object)
572
+
573
+ def __init__(self, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None) -> None:
574
+ """Inject the shared scheduler so file reads drain with existing Git work."""
575
+ super().__init__(parent)
576
+ self.worktree_service = worktree_service
577
+ self.state_by_key: dict[WorktreeKey, ReplaySnapshot] = {}
578
+ self.generation_by_key: dict[WorktreeKey, int] = {}
579
+ # Each caller keeps the queue priority it asked with, so a shared read waits at the level of its most urgent caller.
580
+ self.observers_by_key: dict[WorktreeKey, list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]] = {}
581
+ # A caller waiting for the read after the running one, with the generation known when it asked.
582
+ self.waiting_observers_by_key: dict[WorktreeKey, list[tuple[int, Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]] = {}
583
+ # The generation of the last state an operation published for each worktree, as opposed to a state a read published.
584
+ self.published_generation_by_key: dict[WorktreeKey, int] = {}
585
+ self.closed = False
586
+ self.repository_operation = repository_operation
587
+ self.outcome_by_id: dict[str, OperationOutcome] = {}
588
+ self.submitted_id_set: set[str] = set()
589
+ # The pause GitPane launched in each worktree during this run, kept in memory only and dropped once an
590
+ # operation or a read finds no pause left. A restart forgets it: the pause is then read as an external one.
591
+ self.launch_by_key: dict[WorktreeKey, ReplayLaunch] = {}
592
+ # What each Git executable this service launched has proven about a source named twice to rev-list --no-walk:
593
+ # True when it keeps one instruction, False when it keeps two. A capability of Git, never a fact of a repository.
594
+ self.no_walk_support_by_executable: dict[tuple, bool] = {}
595
+
596
+ def read_source(self, context: RepositoryContext, git_filename: str, revision: str, *, callback: Callable[[ReplaySource | None, Exception | None], None],
597
+ resolved: bool = False) -> None:
598
+ """Resolve a local revision and read its real parents without changing HEAD.
599
+
600
+ `resolved` says `revision` is the full object ID of a commit a successful preparation has just resolved and
601
+ checked, so it is not resolved again; any revision typed or chosen by the user keeps the default and is resolved.
602
+ """
603
+ self.worktree_service.run(context, git_filename, self._read_source(revision, resolved=resolved), callback)
604
+
605
+ def _read_source(self, revision: str, *, resolved: bool = False) -> Generator:
606
+ """Read the source and all its parents in two shows, building the parents in the source's own parent order.
607
+
608
+ The metadata of a parent named twice is asked once, while the list and numbering of the parents stay those of
609
+ the source. A root source asks nothing for its parents.
610
+ """
611
+ if resolved:
612
+ oid = validate_oid(revision)
613
+ else:
614
+ resolved_output = yield GitCommand(["rev-parse", "--verify", "--end-of-options", revision + "^{commit}"])
615
+ oid = validate_oid(resolved_output.decode().strip())
616
+ source = (yield from read_replay_source_records([oid]))[oid]
617
+ requested_oids = []
618
+ for parent_oid in source.parent_oids:
619
+ if parent_oid not in requested_oids:
620
+ requested_oids.append(parent_oid)
621
+ parent_record_by_oid = (yield from read_replay_source_records(requested_oids, read_parents=False)) if requested_oids else {}
622
+ parents = []
623
+ for number, parent_oid in enumerate(source.parent_oids, start=FIRST_PARENT_NUMBER):
624
+ actual_oid, author, _author_date, _committer, commit_date, message = parent_record_by_oid[parent_oid].fields
625
+ subject, _separator, _body = message.partition("\n")
626
+ parents.append(ReplayParent(number, actual_oid, subject, author, commit_date))
627
+ return ReplaySource(oid, source.fields, tuple(parents))
628
+
629
+ def prepare(self, intent: ReplayIntent, *, is_current: Callable[[], bool], callback: Callable[[ReplayIntent | None, Exception | None], None]) -> None:
630
+ """Resolve the source and inspect destination identity without mutating Git."""
631
+ self.worktree_service.run(intent.operation.context, intent.operation.git_filename, self._validate_intent(intent, is_current), callback)
632
+
633
+ def _check_start_request(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> None:
634
+ """Refuse a launch request that no longer has a live service, a checkout, a known HEAD, a caller or a valid source."""
635
+ operation = intent.operation
636
+ if self.closed or operation.context.is_bare or not intent.original_head_oid or not is_current():
637
+ raise ValueError("The destination checkout is unavailable or changed. Refresh before applying a commit.")
638
+ if not intent.kind.is_replay or operation.action != OperationAction.REPLAY_START:
639
+ raise ValueError("This commit operation is unavailable.")
640
+ validate_oid(intent.source_oid)
641
+
642
+ def _validate_intent(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> Generator:
643
+ """Refuse changed checkout identity and incompatible native operations before the form is confirmed."""
644
+ operation = intent.operation
645
+ self._check_start_request(intent, is_current)
646
+ # The first grouped read of the capture also resolves the source and the Git directory, and refuses the launch
647
+ # when either is not exactly the one this intent names.
648
+ state = yield from capture_replay(operation.context, operation.generation, source_oid=intent.source_oid, verify_git_dir=True)
649
+ if not state.fresh or state.kind != ReplayKind.ABSENT:
650
+ raise ValueError("A native commit operation already exists or cannot be read. Refresh its state.")
651
+ if state.head_oid != intent.original_head_oid or state.symbolic_ref != intent.original_symbolic_ref or not is_current():
652
+ raise ValueError("HEAD or its branch changed. Refresh before applying a commit.")
653
+ return intent
654
+
655
+ def _verify_no_walk(self, context: RepositoryContext, git_filename: str, source_oid: str) -> Generator:
656
+ """Prove, once per Git executable, that a source named twice gives one instruction, and answer that executable's identity.
657
+
658
+ A launch without a commit names its source twice so that Git keeps one abortable instruction, which holds only
659
+ when rev-list deduplicates the two. The answer is kept for exactly the executable identity found before and
660
+ after the probe; a failed, interrupted or unreadable probe proves nothing and is not kept. An executable known
661
+ to keep both instructions is refused without asking again. The caller compares the identity again at launch.
662
+ """
663
+ signature = yield FileTask(lambda: replay_executable_signature(git_filename))
664
+ supported = self.no_walk_support_by_executable.get(signature)
665
+ if supported is None:
666
+ validate_oid(source_oid)
667
+ output = yield GitCommand(["rev-list", "--no-walk=unsorted", source_oid, source_oid, "--"])
668
+ after = yield FileTask(lambda: replay_executable_signature(git_filename))
669
+ if after != signature:
670
+ raise ValueError("The Git executable changed while it was checked. Refresh before applying a commit.")
671
+ if not output.endswith(COMMIT_TERMINATOR):
672
+ raise ValueError("Git gave back an incomplete instruction list.")
673
+ answers = output.removesuffix(COMMIT_TERMINATOR).decode("ascii").split("\n")
674
+ if answers == [source_oid]:
675
+ supported = True
676
+ elif answers == [source_oid, source_oid]:
677
+ supported = False
678
+ else:
679
+ raise ValueError("Git gave back an unexpected instruction list.")
680
+ self.no_walk_support_by_executable[signature] = supported
681
+ if not supported:
682
+ raise ValueError("This Git version cannot safely select one replay instruction.")
683
+ return signature
684
+
685
+ def _validate_launch_authority(self, operation: OperationIntent, is_current: Callable[[], bool], *, session_policy: bool = True) -> None:
686
+ """Refuse a launch whose replay service, write owner, exact reservation or caller is no longer current.
687
+
688
+ Called on the Qt thread, both before a launch preparation leaves for the file worker and in the launch check.
689
+ With session_policy the application's permission for this action is asked again. The paths that compare their
690
+ own native state on purpose (a Skip or the end without a commit after a launch, the steps after a prepared message) do
691
+ not ask it: the native state captured with their authority predates the pause or message GitPane itself wrote.
692
+ """
693
+ owner = self.repository_operation
694
+ if self.closed or owner is None or owner.closed or owner.active_by_id.get(operation.operation_id) is not operation:
695
+ raise ValueError("This replay no longer owns the repository. Refresh before trying again.")
696
+ if not is_current():
697
+ raise ValueError("The caller changed before launch. Refresh first.")
698
+ if session_policy and owner.allows is not None and not owner.allows(operation):
699
+ raise ValueError("This action is no longer authorized. Refresh before writing.")
700
+
701
+ def _policy_launch_signature(self, operation: OperationIntent) -> LaunchSignature:
702
+ """Freeze the native pause files the write policy reads, in this worktree and in every related one.
703
+
704
+ It is taken before the policy check on the Qt thread, never after: a signature taken once the policy has run
705
+ could already describe a state that policy never saw. Only metadata is read. The launch check compares it again,
706
+ without the paths the launch signature itself covers, so a pause started in another worktree while the command
707
+ waited in the queue is still refused.
708
+ """
709
+ context_by_key = {}
710
+ for context in (operation.context, *operation.reserved_contexts, *self.repository_operation.context_by_key.values()):
711
+ context_by_key[WorktreeKey.from_context(context)] = context
712
+ filenames = []
713
+ watched_dirs = []
714
+ for context in context_by_key.values():
715
+ if context.is_bare:
716
+ continue
717
+ pause_filenames, pause_dirs = native_pause_paths(context)
718
+ filenames.extend(pause_filenames)
719
+ watched_dirs.extend(pause_dirs)
720
+ return sign_launch_paths(filenames, watched_dirs, ())
721
+
722
+ def start(self, intent: ReplayIntent, *, is_current: Callable[[], bool]) -> str:
723
+ """Submit one captured operation once, retaining ownership until observation finishes."""
724
+ if self.repository_operation is None or self.closed:
725
+ raise ValueError("The commit operation service is unavailable.")
726
+ operation_id = intent.operation.operation_id
727
+ if operation_id in self.submitted_id_set:
728
+ return operation_id
729
+ self.submitted_id_set.add(operation_id)
730
+ return self.repository_operation.submit(intent.operation, self._start(intent, is_current))
731
+
732
+ def _start(self, intent: ReplayIntent, is_current: Callable[[], bool]) -> Generator:
733
+ """Apply one commit with Git's own cherry-pick or revert, the way a terminal does, and publish what Git left.
734
+
735
+ Only quick checks run before Git: HEAD, its branch and the index unchanged since the form was confirmed, no
736
+ native operation and no conflict in progress, nothing staged, and no changed path outside the working tree or
737
+ inside its .git folder. Git runs with submodule recursion turned off, so a change that only moves, adds or
738
+ removes a submodule entry never writes in the submodule's folder. A change that removes that folder or writes
739
+ into it (a submodule turned into a file, a link or a folder of files, or the reverse through a Revert) is only
740
+ let through when nothing in the folder, nested submodules included, would be lost; otherwise it is refused and
741
+ the files at stake are named. Git's answer is
742
+ then read once and ended the way the form asked: a new commit on the original HEAD, a pause Git keeps for the
743
+ user, a result that brings nothing (ended without a commit, with its reason), changes left unstaged when no
744
+ commit was asked for, or a refusal that wrote nothing. Going back from a pause is left to Git's own Abort.
745
+ """
746
+ # No file of the working tree is signed, listed or copied here. Git itself refuses to overwrite a local change
747
+ # the commit would touch, and then stops before writing anything. Its rename detection may carry a change onto
748
+ # a renamed path: that is still Git's own result, on a path it found inside the working tree, never outside it
749
+ # or inside .git, so it is left to Git and not checked again afterwards.
750
+ operation = intent.operation
751
+ context = operation.context
752
+ run = NativeCommandRun()
753
+ state = None
754
+ status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
755
+ changes = b""
756
+ index_filename = os.path.join(context.git_dir, LIVE_INDEX_BASENAME)
757
+ pause_filenames, pause_dirs = native_pause_paths(context)
758
+ try:
759
+ self._check_start_request(intent, is_current)
760
+ changed_cause = "HEAD, the index or a native operation changed before launch. Refresh before applying a commit."
761
+ # The index is read on the same worker that signs it, so the signature describes exactly the bytes kept here.
762
+ baseline = yield FileTask(lambda: capture_write_launch_signature(context, changed_cause, reference_signature, reference_signature_paths, index_filename,
763
+ read_index(index_filename), extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs))
764
+ before = yield from capture_replay(context, operation.generation, source_oid=intent.source_oid, verify_git_dir=True)
765
+ if before.unmerged_paths:
766
+ raise ValueError("The index has unresolved conflicts. Resolve them before applying a commit.")
767
+ if not before.fresh or before.kind != ReplayKind.ABSENT:
768
+ raise ValueError("A native commit operation already exists or cannot be read. Refresh its state.")
769
+ if before.head_oid != intent.original_head_oid or before.symbolic_ref != intent.original_symbolic_ref or not is_current():
770
+ raise ValueError("HEAD or its branch changed. Refresh before applying a commit.")
771
+ # Without rename detection both sides of a staged rename are named, and a staged deletion is never hidden behind one.
772
+ parent_output, staged, index_entries = yield ReadBatch((GitCommand(["show", "--no-patch", "--format=%P", intent.source_oid, "--"]),
773
+ GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"]),
774
+ GitCommand(["ls-files", "--stage", "-z"])))
775
+ compared_oid = self._validated_compared_parent(intent, parent_output)
776
+ if staged:
777
+ raise ValueError("The index contains staged changes. Commit or unstage them before applying a commit.")
778
+ # The two options make Git compare the objects itself, so no diff program or text converter of the user runs.
779
+ if compared_oid is None:
780
+ changes = yield GitCommand(["diff-tree", "-r", "-z", "--root", "--no-commit-id", "--no-renames", "--no-ext-diff", "--no-textconv", intent.source_oid, "--"])
781
+ else:
782
+ changes = yield GitCommand(["diff-tree", "-r", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", compared_oid, intent.source_oid, "--"])
783
+ refusal = replay_change_refusal(changes)
784
+ if refusal:
785
+ raise ValueError(refusal)
786
+ # The submodules are read from the current index rather than from the replayed commit, so a folder that
787
+ # replaces a submodule on this branch only is found too.
788
+ replaced_paths = replaced_submodule_paths(changes, gitlink_paths(index_entries))
789
+ executable_signature = None
790
+ if not intent.create_commit:
791
+ # Only the launch that names its source twice relies on Git keeping one instruction for it.
792
+ executable_signature = yield from self._verify_no_walk(context, operation.git_filename, intent.source_oid)
793
+ # Checked as late as a Git read can run, just before the launch checks that happen on the Qt thread.
794
+ for submodule_path in replaced_paths:
795
+ loss = yield from submodule_loss_refusal(context, submodule_path)
796
+ if loss:
797
+ raise ValueError(loss)
798
+ signature = yield FileTask(lambda: prepare_write_launch_signature(context, baseline, changed_cause, reference_signature))
799
+ # The policy reads native files on the Qt thread; the signature taken just before it lets the launch check
800
+ # prove those files, in every related worktree, still describe the state the policy accepted.
801
+ policy = self._policy_launch_signature(operation)
802
+ self.repository_operation.validate(operation)
803
+ self._validate_launch_authority(operation, is_current)
804
+ related_policy = exclude_covered_launch_paths(policy, signature.metadata)
805
+
806
+ def validate_launch() -> None:
807
+ """Compare only current authority, the metadata of the checked Git files and related pauses, and the Git executable, when the queued command launches."""
808
+ self._validate_launch_authority(operation, is_current)
809
+ verify_launch_signature(related_policy, changed_cause)
810
+ verify_launch_signature(signature.metadata, changed_cause)
811
+ if executable_signature is not None and replay_executable_signature(operation.git_filename) != executable_signature:
812
+ raise ValueError("The Git executable changed before launch. Refresh before applying a commit.")
813
+ run.started = True
814
+
815
+ arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, intent.kind.value]
816
+ if not intent.create_commit:
817
+ arguments.append("--no-commit")
818
+ arguments.append("--no-edit")
819
+ if intent.create_commit and intent.add_reference and intent.kind == ReplayKind.CHERRY_PICK:
820
+ arguments.append("-x")
821
+ if intent.mainline_parent_number is not None:
822
+ arguments.extend(["-m", str(intent.mainline_parent_number)])
823
+ arguments.append(intent.source_oid)
824
+ if not intent.create_commit:
825
+ # Named once, a replay without a commit leaves Git no operation its own Abort can end. Named twice, Git
826
+ # keeps the one instruction the two names give in its sequencer, which Abort ends like any series.
827
+ arguments.append(intent.source_oid)
828
+ yield GitCommand(arguments, profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
829
+ before_start=validate_launch, suppress_editor=True)
830
+ run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
831
+ except (GitCommandError, ValueError, OSError) as error:
832
+ run.record_failure(operation, error)
833
+ try:
834
+ after_look = None
835
+ if run.started:
836
+ # The look is taken before the reading of what Git left, so a step that ends that result compares the
837
+ # very state this reading accepted.
838
+ after_look = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths,
839
+ index_filename, read_index(index_filename), extra_filenames=pause_filenames,
840
+ extra_watched_dirs=pause_dirs))
841
+ state = yield from capture_replay(context, operation.generation, lone_message_is_trace=run.started)
842
+ if run.started:
843
+ state, status, effect, run = yield from self._launch_result(intent, after_look, state, run, changes, is_current)
844
+ except (GitCommandError, ValueError, OSError) as error:
845
+ status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
846
+ run.record_failure(operation, error)
847
+ state = unreadable_replay_state(state or ReplaySnapshot(operation.key, operation.generation), run.diagnostic.cause)
848
+ if status in (OperationStatus.SUCCESS, OperationStatus.PAUSED) and (status == OperationStatus.SUCCESS or state.unmerged_paths and run.return_code == GIT_CONFLICT_RETURN_CODE):
849
+ # A conflict is an expected stop that waits for the user, not a failure: Git's exit status and its message
850
+ # are already in the console, so no failure cause is attached to the pause.
851
+ run.diagnostic = None
852
+ elif run.started and run.diagnostic is None:
853
+ run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [],
854
+ "Git finished without a verified new commit. Refresh to inspect the current checkout.").prepare()
855
+ state = self._publish_result(state)
856
+ step = OperationStepResult(operation.operation, status, intent, state, run.process_status, effect, run.diagnostic, run.return_code)
857
+ outcome = OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
858
+ self.outcome_by_id[operation.operation_id] = outcome
859
+ return outcome
860
+
861
+ def _validated_compared_parent(self, intent: ReplayIntent, parent_output: bytes) -> str | None:
862
+ """Check the parent choice of the form against the real parents of the source, and name the parent its changes are read against.
863
+
864
+ A merge needs the parent the form selected, still at the same place; any other source must not name one. The
865
+ answer is None for a source without parent, whose changes are read against the empty tree.
866
+ """
867
+ parent_oids = []
868
+ for parent_oid in parent_output.decode("ascii").split():
869
+ parent_oids.append(validate_oid(parent_oid))
870
+ if len(parent_oids) >= MERGE_PARENT_MINIMUM:
871
+ parent_number = intent.mainline_parent_number
872
+ if parent_number is None or not FIRST_PARENT_NUMBER <= parent_number <= len(parent_oids):
873
+ raise ValueError("Select a parent of this merge before applying it.")
874
+ parent_index = parent_number - FIRST_PARENT_NUMBER
875
+ if parent_oids[parent_index] != intent.mainline_parent_oid:
876
+ raise ValueError("The selected merge parent changed. Select its current parent again.")
877
+ return parent_oids[parent_index]
878
+ if intent.mainline_parent_number is not None or intent.mainline_parent_oid is not None:
879
+ raise ValueError("This source is not a merge and cannot use a mainline parent.")
880
+ return next(iter(parent_oids), None)
881
+
882
+ def _launch_result(self, intent: ReplayIntent, after_look: WriteLaunchSignature, state: ReplaySnapshot, run: NativeCommandRun, changes: bytes,
883
+ is_current: Callable[[], bool]) -> Generator:
884
+ """Read what Git left after a launch and end what GitPane ends itself; answer the state, the status, the effect and the last run.
885
+
886
+ A new commit whose only parent is the original HEAD, on the same branch, is a success. A pause Git keeps on a
887
+ conflict is remembered as GitPane's own for this run of the program and waits for the user. A commit that
888
+ would bring nothing ends without one: Git's own Skip when Git keeps its pause, its Quit when it kept only its
889
+ message, then the reason. A launch without a commit ends with its changes unstaged. A failure that left HEAD
890
+ and the branch where they were is Git's own refusal. Anything else is unknown.
891
+ """
892
+ operation = intent.operation
893
+ if state.fresh and state.kind != ReplayKind.ABSENT:
894
+ self.launch_by_key[state.key] = ReplayLaunch(intent.kind, intent.source_oid, intent.create_commit, intent.add_reference,
895
+ intent.original_head_oid, intent.original_symbolic_ref)
896
+ owned = join_replay_launch(state, self.launch_by_key.get(state.key))
897
+ origin = (intent.original_head_oid, intent.original_symbolic_ref)
898
+ origin_kept = (state.head_oid, state.symbolic_ref) == origin and not state.unmerged_paths
899
+ # A cherry-pick without a commit, and a revert with nothing to commit, leave only Git's prepared message behind.
900
+ message_only = state.kind == ReplayKind.UNKNOWN and state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT and not state.unmerged_paths
901
+ own_pause = owned.ownership == ReplayOwnership.OWNED
902
+ finished = run.process_status == ProcessStatus.SUCCESS and run.return_code == SUCCESS_EXIT_CODE
903
+ stopped_empty = run.process_status == ProcessStatus.FAILED and run.return_code == GIT_CONFLICT_RETURN_CODE
904
+ effect_when_empty = empty_result_effect(intent.kind, changes)
905
+ # Git always leaves its message, and for a Revert its marker, after a replay without a commit; a clean exit
906
+ # that left no trace at all proves nothing about what was applied.
907
+ if origin_kept and not intent.create_commit and finished and (own_pause or message_only):
908
+ return (yield from self._finish_without_commit(operation, after_look, owned, origin, intent.kind, effect_when_empty, is_current, check_policy=False))
909
+ if origin_kept and intent.create_commit and stopped_empty and (own_pause or message_only):
910
+ staged = yield GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"])
911
+ if not staged and own_pause:
912
+ return (yield from self._skip(operation, after_look, owned, effect_when_empty, is_current, check_policy=False))
913
+ if not staged:
914
+ return (yield from self._finish_without_commit(operation, after_look, owned, origin, intent.kind, effect_when_empty, is_current, check_policy=False))
915
+ if not state.fresh:
916
+ return state, OperationStatus.UNKNOWN, "unknown", run
917
+ if state.kind != ReplayKind.ABSENT:
918
+ return state, operation_status(run.process_status) if run.process_status in STOPPED_PROCESS_STATUSES else OperationStatus.PAUSED, "unknown", run
919
+ if state.unmerged_paths or state.symbolic_ref != intent.original_symbolic_ref:
920
+ return state, OperationStatus.UNKNOWN, "unknown", run
921
+ if finished and state.head_oid and state.head_oid != intent.original_head_oid:
922
+ parent_output = yield GitCommand(["show", "--no-patch", "--format=%P", state.head_oid, "--"])
923
+ if parent_output.split() == [intent.original_head_oid.encode("ascii")]:
924
+ return state, OperationStatus.SUCCESS, "changed", run
925
+ return state, OperationStatus.UNKNOWN, "unknown", run
926
+ if run.process_status == ProcessStatus.FAILED and state.head_oid == intent.original_head_oid:
927
+ return state, OperationStatus.FAILED, "unknown", run
928
+ return state, OperationStatus.UNKNOWN, "unknown", run
929
+
930
+ def _skip(self, operation: OperationIntent, baseline: WriteLaunchSignature, paused: ReplaySnapshot, effect: str, is_current: Callable[[], bool], *,
931
+ check_policy: bool) -> Generator:
932
+ """End a checked pause whose instruction brings nothing with Git's own Skip; answer the state, the status, the effect and the run.
933
+
934
+ The Skip succeeds once no pause is left on the same HEAD and branch, without a new commit. When the pause is
935
+ one instruction of a longer series, Git goes on with the next ones as it does in a terminal, and the result is
936
+ read the way a Continue's is.
937
+ """
938
+ run = NativeCommandRun()
939
+ try:
940
+ yield from self._launch_pause_command(operation, baseline, [paused.kind.value, "--skip"], is_current, run, check_policy=check_policy)
941
+ except (GitCommandError, ValueError, OSError) as error:
942
+ run.record_failure(operation, error)
943
+ after = yield from self._read_after(operation, paused, run)
944
+ if not run.started:
945
+ return after, OperationStatus.UNKNOWN, "unknown", run
946
+ if (run.process_status == ProcessStatus.SUCCESS and after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths
947
+ and (after.head_oid, after.symbolic_ref) == (paused.head_oid, paused.symbolic_ref)):
948
+ return after, OperationStatus.SUCCESS, effect, run
949
+ status = yield from self._continue_status(paused, after, run)
950
+ return after, status, status.value, run
951
+
952
+ def _finish_without_commit(self, operation: OperationIntent, baseline: WriteLaunchSignature, state: ReplaySnapshot, origin: tuple[str | None, str | None],
953
+ kind: ReplayKind, effect_when_empty: str, is_current: Callable[[], bool], *, check_policy: bool) -> Generator:
954
+ """End a replay without a commit: Git's own Quit when a pause or its message is left, then the index set back to HEAD.
955
+
956
+ The index held exactly HEAD when the replay was launched, since a staged change refuses the launch, so setting
957
+ it back to HEAD leaves the replay's changes in the working tree only, as Unstaged changes; a staged change made
958
+ during the pause is unstaged with them, and nothing is written in a working file. A moved submodule pointer is
959
+ the exception: it stays staged, the only form it has. The reset starts only when something is staged, and is
960
+ compared with a look taken after the Quit. Answers the state, the status, the effect ("changed", or
961
+ `effect_when_empty` when the replay brought nothing) and the run.
962
+ """
963
+ context = operation.context
964
+ run = NativeCommandRun()
965
+ # What may be left is Git's pause of this very kind, its prepared message alone, or nothing at all.
966
+ message_only = state.kind == ReplayKind.UNKNOWN and state.pending_step == REPLAY_STEP_UNATTRIBUTED_CONFLICT
967
+ if not is_current() or state.unmerged_paths or (state.head_oid, state.symbolic_ref) != origin or state.kind not in (ReplayKind.ABSENT, kind) and not message_only:
968
+ run.record_failure(operation, ValueError(PAUSE_CHANGED_CAUSE))
969
+ return state, OperationStatus.UNKNOWN, "unknown", run
970
+ index_filename = baseline.index_filename
971
+ try:
972
+ if state.kind != ReplayKind.ABSENT:
973
+ yield from self._launch_pause_command(operation, baseline, [kind.value, "--quit"], is_current, run, check_policy=check_policy)
974
+ staged = yield GitCommand(["diff", "--cached", "--raw", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", "HEAD", "--"])
975
+ if staged:
976
+ # A submodule pointer has no unstaged form while the submodule is not checked out on the new commit,
977
+ # which GitPane never does: setting it back to HEAD would lose it without a trace. So the reset keeps
978
+ # every staged submodule entry out and sets only the rest back to HEAD.
979
+ reset_arguments = reset_keeping_gitlinks_arguments(staged_gitlink_paths(staged))
980
+ pause_filenames, pause_dirs = native_pause_paths(context)
981
+ reset_look = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths,
982
+ index_filename, read_index(index_filename), extra_filenames=pause_filenames,
983
+ extra_watched_dirs=pause_dirs))
984
+ if reset_look.reference_state != baseline.reference_state or reset_look.index_content != baseline.index_content:
985
+ raise ValueError(PAUSE_CHANGED_CAUSE)
986
+ # The Quit changed Git's pause files and nothing else, so the reset is compared with the look taken after it.
987
+ yield from self._launch_pause_command(operation, reset_look, reset_arguments, is_current, run, check_policy=False)
988
+ except (GitCommandError, ValueError, OSError) as error:
989
+ run.record_failure(operation, error)
990
+ after = yield from self._read_after(operation, state, run)
991
+ return after, OperationStatus.UNKNOWN, "unknown", run
992
+ after = yield from self._read_after(operation, state, run)
993
+ if after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths and (after.head_oid, after.symbolic_ref) == origin:
994
+ return after, OperationStatus.SUCCESS, "changed" if staged else effect_when_empty, run
995
+ run.record_failure(operation, ValueError("The operation without a commit did not end on the checkout it started from. Inspect the current state."))
996
+ return after, OperationStatus.UNKNOWN, "unknown", run
997
+
998
+ def _read_after(self, operation: OperationIntent, fallback: ReplaySnapshot, run: NativeCommandRun) -> Generator:
999
+ """Read what a native command left, or answer the last known state marked unreadable when Git cannot answer that read."""
1000
+ try:
1001
+ return (yield from capture_replay(operation.context, operation.generation))
1002
+ except GitCommandError as error:
1003
+ run.record_failure(operation, error)
1004
+ return unreadable_replay_state(fallback, error.diagnostic.cause)
1005
+
1006
+ def continue_replay(self, operation: OperationIntent, state: ReplaySnapshot, *, is_current: Callable[[], bool]) -> str:
1007
+ """Reserve one explicit Continue of the pause the user acted on, which Git's own Continue then ends."""
1008
+ if self.repository_operation is None or self.closed:
1009
+ raise ValueError("The commit operation service is unavailable.")
1010
+ if operation.operation_id in self.submitted_id_set:
1011
+ return operation.operation_id
1012
+ self.submitted_id_set.add(operation.operation_id)
1013
+ return self.repository_operation.submit(operation, self._continue(operation, state, is_current))
1014
+
1015
+ def abort(self, operation: OperationIntent, state: ReplaySnapshot, *, is_current: Callable[[], bool]) -> str:
1016
+ """Reserve one explicit Abort of the pause the user acted on, which Git's own Abort then ends."""
1017
+ if self.repository_operation is None or self.closed:
1018
+ raise ValueError("The commit operation service is unavailable.")
1019
+ if operation.operation_id in self.submitted_id_set:
1020
+ return operation.operation_id
1021
+ self.submitted_id_set.add(operation.operation_id)
1022
+ return self.repository_operation.submit(operation, self._abort(operation, state, is_current))
1023
+
1024
+ def _same_pause(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
1025
+ """Take the first look a command on a pause relies on, then read the pause once and require it to be the one the user acted on.
1026
+
1027
+ The look covers the references, the index and Git's pause files of this worktree. The read must show a fresh
1028
+ Cherry-pick or Revert with the same native identity, HEAD, branch, owner and commit mode. Answers the look and
1029
+ the state read.
1030
+ """
1031
+ context = operation.context
1032
+ index_filename = os.path.join(context.git_dir, LIVE_INDEX_BASENAME)
1033
+ pause_filenames, pause_dirs = native_pause_paths(context)
1034
+ # The index is read on the same worker that signs it, so the look describes exactly the bytes kept here.
1035
+ baseline = yield FileTask(lambda: capture_write_launch_signature(context, PAUSE_CHANGED_CAUSE, reference_signature, reference_signature_paths, index_filename,
1036
+ read_index(index_filename), extra_filenames=pause_filenames, extra_watched_dirs=pause_dirs))
1037
+ state = join_replay_launch((yield from capture_replay(context, operation.generation)), self.launch_by_key.get(operation.key))
1038
+ if (not is_current() or previous.key != operation.key or state.key != operation.key or not state.fresh or not state.kind.is_replay
1039
+ or state.identity != previous.identity or state.head_oid != previous.head_oid or state.symbolic_ref != previous.symbolic_ref
1040
+ or state.ownership != previous.ownership or state.mode != previous.mode):
1041
+ raise ValueError(PAUSE_CHANGED_CAUSE)
1042
+ return baseline, state
1043
+
1044
+ def _launch_pause_command(self, operation: OperationIntent, baseline: WriteLaunchSignature, arguments: list[str], is_current: Callable[[], bool],
1045
+ run: NativeCommandRun, *, check_policy: bool, prepared_message: tuple[ReplayMetadata, str] | None = None) -> Generator:
1046
+ """Hand one native write to the launcher once nothing moved since the first look, with submodule recursion turned off.
1047
+
1048
+ With `check_policy` the write policy is asked on the Qt thread the way a launch asks it, and the pause files of
1049
+ related worktrees are compared again when the command starts. A step that follows GitPane's own command (the
1050
+ Skip or the end without a commit of a launch) compares the pause GitPane itself left, so it skips that policy. A
1051
+ prepared message is written over Git's MERGE_MSG only once the policy accepted the pause as the user saw it;
1052
+ from then on the new message is part of what is compared. `run` records whether Git started and how it ended.
1053
+ """
1054
+ context = operation.context
1055
+ policy = None
1056
+ if check_policy:
1057
+ policy = self._policy_launch_signature(operation)
1058
+ self.repository_operation.validate(operation)
1059
+ self._validate_launch_authority(operation, is_current, session_policy=check_policy)
1060
+ # The policy compares the pause files with the ones captured at the click, so it is not asked again once
1061
+ # GitPane rewrote the message itself.
1062
+ session_policy = check_policy and prepared_message is None
1063
+ if prepared_message is not None:
1064
+ metadata, message = prepared_message
1065
+ baseline = yield FileTask(lambda: publish_continue_message(context, baseline, metadata, message))
1066
+ signature = yield FileTask(lambda: prepare_write_launch_signature(context, baseline, PAUSE_CHANGED_CAUSE, reference_signature))
1067
+ related_policy = exclude_covered_launch_paths(policy, signature.metadata) if policy is not None else None
1068
+ self._validate_launch_authority(operation, is_current, session_policy=session_policy)
1069
+
1070
+ def validate_launch() -> None:
1071
+ """Compare only current authority and the metadata of the checked files and related pauses when the queued command launches."""
1072
+ self._validate_launch_authority(operation, is_current, session_policy=session_policy)
1073
+ if related_policy is not None:
1074
+ verify_launch_signature(related_policy, PAUSE_CHANGED_CAUSE)
1075
+ verify_launch_signature(signature.metadata, PAUSE_CHANGED_CAUSE)
1076
+ run.started = True
1077
+
1078
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *arguments], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
1079
+ before_start=validate_launch, suppress_editor=True)
1080
+ run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
1081
+
1082
+ def _finish_pause_operation(self, operation: OperationIntent, previous: ReplaySnapshot, state: ReplaySnapshot, status: OperationStatus, effect: str,
1083
+ run: NativeCommandRun) -> OperationOutcome:
1084
+ """Publish the state an operation on a pause ends with and record its outcome, the diagnostic of a failure shown with that state."""
1085
+ if run.diagnostic is not None:
1086
+ state = replace(state, diagnostic=run.diagnostic.cause)
1087
+ state = self._publish_result(state)
1088
+ step = OperationStepResult(operation.operation, status, previous, state, run.process_status, effect, run.diagnostic, run.return_code)
1089
+ outcome = OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
1090
+ self.outcome_by_id[operation.operation_id] = outcome
1091
+ return outcome
1092
+
1093
+ def _continue(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
1094
+ """End a pause the way the mode chosen at its start asks, with Git's own commands.
1095
+
1096
+ In commit mode Git's own Continue commits the staged resolution with the prepared message and no editor; a
1097
+ resolution that brings nothing is ended by Git's own Skip instead, without a commit. A pause GitPane launched
1098
+ without a commit ends without one, its changes left unstaged. Only quick checks run first: the same pause, HEAD
1099
+ and branch the user acted on, and no conflict left. A staged change unrelated to the replay is not refused: Git
1100
+ commits the whole index, as it does in a terminal. Git's conflict notes leave the message; Git then runs with an
1101
+ unused comment character, so no line the author wrote is taken for a comment.
1102
+ """
1103
+ context = operation.context
1104
+ run = NativeCommandRun()
1105
+ state = previous
1106
+ status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
1107
+ ended = None
1108
+ try:
1109
+ if operation.action != OperationAction.REPLAY_CONTINUE:
1110
+ raise ValueError(PAUSE_CHANGED_CAUSE)
1111
+ baseline, state = yield from self._same_pause(operation, previous, is_current)
1112
+ refusal = replay_action_diagnostic(state, ReplayAction.CONTINUE, abort_available=True)
1113
+ if refusal:
1114
+ raise ValueError(refusal)
1115
+ if state.mode == ReplayCommitMode.NO_COMMIT:
1116
+ ended = yield from self._finish_without_commit(operation, baseline, state, pause_origin(state), state.kind, ReplayEffect.RESOLVED_NO_CHANGE.value, is_current,
1117
+ check_policy=True)
1118
+ else:
1119
+ staged = yield GitCommand(["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"])
1120
+ if not staged:
1121
+ # The resolution brings nothing to this instruction, so Git would need an empty commit: its own Skip
1122
+ # ends the instruction without one.
1123
+ ended = yield from self._skip(operation, baseline, state, ReplayEffect.RESOLVED_NO_CHANGE.value, is_current, check_policy=True)
1124
+ else:
1125
+ metadata = yield FileTask(lambda: read_replay_metadata(context.git_dir))
1126
+ comment_output = yield GitCommand(["config", "--get-regexp", r"^core\.comment(char|string)$"], accepted_codes=(0, 1))
1127
+ message = continue_message(state, metadata, comment_output)
1128
+ arguments = ["-c", "core.commentChar=" + native_message_comment_prefix(message), state.kind.value, "--continue"]
1129
+ yield from self._launch_pause_command(operation, baseline, arguments, is_current, run, check_policy=True, prepared_message=(metadata, message))
1130
+ except (GitCommandError, ValueError, OSError) as error:
1131
+ run.record_failure(operation, error)
1132
+ if ended is not None:
1133
+ state, status, effect, run = ended
1134
+ if status == OperationStatus.UNKNOWN and not run.started:
1135
+ # No command was handed to Git, so the pause is exactly as the user left it: the Continue was refused,
1136
+ # whichever step refused it.
1137
+ status, effect = OperationStatus.REFUSED, OperationStatus.REFUSED.value
1138
+ if status not in (OperationStatus.SUCCESS, OperationStatus.REFUSED) and run.diagnostic is None:
1139
+ run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], CONTINUE_UNVERIFIED_CAUSE).prepare()
1140
+ return self._finish_pause_operation(operation, previous, state, status, effect, run)
1141
+ try:
1142
+ after = yield from capture_replay(context, operation.generation)
1143
+ if run.started:
1144
+ status = yield from self._continue_status(previous, after, run)
1145
+ effect = status.value
1146
+ state = after
1147
+ except (GitCommandError, ValueError, OSError) as error:
1148
+ status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
1149
+ run.record_failure(operation, error)
1150
+ state = unreadable_replay_state(state, run.diagnostic.cause)
1151
+ if status not in (OperationStatus.SUCCESS, OperationStatus.REFUSED) and run.diagnostic is None:
1152
+ run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], CONTINUE_UNVERIFIED_CAUSE).prepare()
1153
+ return self._finish_pause_operation(operation, previous, state, status, effect, run)
1154
+
1155
+ def _continue_status(self, previous: ReplaySnapshot, after: ReplaySnapshot, run: NativeCommandRun) -> Generator:
1156
+ """Read what Git's Continue left: new commits standing on the paused HEAD, the next pause of a series, or an uncertain state.
1157
+
1158
+ Success needs Git's exit status 0, no pause left, the same branch and a line of new commits whose first one has
1159
+ the paused HEAD as its only parent.
1160
+ """
1161
+ if not after.fresh:
1162
+ return OperationStatus.UNKNOWN
1163
+ if after.kind != ReplayKind.ABSENT or after.unmerged_paths:
1164
+ return operation_status(run.process_status) if run.process_status in STOPPED_PROCESS_STATUSES else OperationStatus.PAUSED
1165
+ if run.process_status != ProcessStatus.SUCCESS or after.symbolic_ref != previous.symbolic_ref or not after.head_oid or not previous.head_oid:
1166
+ return OperationStatus.UNKNOWN
1167
+ chain = yield GitCommand(["rev-list", "--first-parent", "--parents", "--reverse", previous.head_oid + "..HEAD", "--"])
1168
+ return OperationStatus.SUCCESS if commit_chain_from(chain, previous.head_oid, after.head_oid) else OperationStatus.UNKNOWN
1169
+
1170
+ def _abort(self, operation: OperationIntent, previous: ReplaySnapshot, is_current: Callable[[], bool]) -> Generator:
1171
+ """End a pause with Git's own Abort, which brings back the checkout the operation started from.
1172
+
1173
+ Git itself refuses to overwrite a local change it would lose, and keeps changes it does not need to touch. The
1174
+ result is accepted once no pause is left and HEAD and its branch are back where the operation started; anything
1175
+ else is reported with Git's own diagnostic.
1176
+ """
1177
+ context = operation.context
1178
+ run = NativeCommandRun()
1179
+ state = previous
1180
+ status = OperationStatus.REFUSED
1181
+ try:
1182
+ if operation.action != OperationAction.REPLAY_ABORT:
1183
+ raise ValueError(PAUSE_CHANGED_CAUSE)
1184
+ baseline, state = yield from self._same_pause(operation, previous, is_current)
1185
+ refusal = replay_action_diagnostic(state, ReplayAction.ABORT, abort_available=True)
1186
+ if refusal:
1187
+ raise ValueError(refusal)
1188
+ yield from self._launch_pause_command(operation, baseline, [state.kind.value, "--abort"], is_current, run, check_policy=True)
1189
+ except (GitCommandError, ValueError, OSError) as error:
1190
+ run.record_failure(operation, error)
1191
+ try:
1192
+ after = yield from capture_replay(context, operation.generation)
1193
+ if run.started:
1194
+ origin_oid, origin_ref = pause_origin(state)
1195
+ restored = after.fresh and after.kind == ReplayKind.ABSENT and not after.unmerged_paths and after.head_oid == origin_oid and after.symbolic_ref == origin_ref
1196
+ status = OperationStatus.ABORTED if restored and run.process_status == ProcessStatus.SUCCESS else OperationStatus.UNKNOWN
1197
+ state = after
1198
+ except (GitCommandError, ValueError, OSError) as error:
1199
+ status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
1200
+ run.record_failure(operation, error)
1201
+ state = unreadable_replay_state(state, run.diagnostic.cause)
1202
+ if status == OperationStatus.UNKNOWN and run.diagnostic is None:
1203
+ run.diagnostic = Diagnostic(operation.operation, context.repository_dir, operation.git_filename, [], ABORT_UNVERIFIED_CAUSE).prepare()
1204
+ return self._finish_pause_operation(operation, previous, state, status, status.value, run)
1205
+
1206
+ def retire_legacy_anchors(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[tuple[str, ...]], None],
1207
+ session_id: str | None = None, generation: int = 0) -> None:
1208
+ """Remove, in the background, the anchor references earlier versions left in this repository, and tell which of their backups stay.
1209
+
1210
+ The folder of earlier journals is read first on the file worker; when it holds no working tree subfolder,
1211
+ nothing else runs. Otherwise the anchors are listed once, and the ones legacy_anchor_deletions chooses are
1212
+ removed by one reserved write, refused while a Cherry-pick or Revert is in progress. No file of that folder is
1213
+ ever changed. `callback` is called once, after everything ended, with the journal folders an earlier version
1214
+ left in the middle of a restoration whose anchor this repository still holds, wherever the repository was when
1215
+ they were written. A failure removes nothing and is written to the process log only.
1216
+ """
1217
+ answered = []
1218
+
1219
+ def answer(kept_dirs: tuple[str, ...]) -> None:
1220
+ """Call the caller back once, whichever path ends the retirement first."""
1221
+ if answered:
1222
+ return
1223
+ answered.append(True)
1224
+ callback(kept_dirs)
1225
+
1226
+ if self.closed or context.is_bare:
1227
+ answer(())
1228
+ return
1229
+ store_dir = replay_legacy.legacy_replay_store_dir()
1230
+
1231
+ def found(result: tuple[tuple[tuple[str, str], ...], tuple[str, ...]] | None, error: Exception | None) -> None:
1232
+ """Submit the removal of the chosen anchors, or answer at once when there is nothing to remove or nobody left to ask."""
1233
+ if error is not None or result is None:
1234
+ answer(())
1235
+ return
1236
+ deletions, kept_dirs = result
1237
+ if not deletions or self.closed or self.repository_operation is None or not is_current():
1238
+ answer(kept_dirs)
1239
+ return
1240
+ refs = []
1241
+ for ref, _oid in deletions:
1242
+ refs.append(ref)
1243
+ operation = OperationIntent.capture(uuid.uuid4().hex, context, git_filename, session_id=session_id, generation=generation, operation=LEGACY_ANCHOR_RETIREMENT_OPERATION,
1244
+ action=OperationAction.REF_WRITE, scope=WriteScope.capture(context, tuple(refs)))
1245
+ self.repository_operation.submit(operation, self._retire_legacy_anchors(operation, deletions, is_current, lambda: answer(kept_dirs)))
1246
+ if operation.operation_id in self.repository_operation.outcome_by_id:
1247
+ # Refused or ended before its first process: a refused sequence never runs, so it cannot answer by itself.
1248
+ answer(kept_dirs)
1249
+
1250
+ self.worktree_service.run(context, git_filename, self._find_legacy_anchors(store_dir, context.common_git_dir), found, priority=ProcessPriority.BACKGROUND)
1251
+
1252
+ def _find_legacy_anchors(self, store_dir: str, common_git_dir: str) -> Generator:
1253
+ """Read the earlier journals, then list the anchors only when an earlier version left a working tree subfolder; answer the deletions and the kept folders.
1254
+
1255
+ The same listing decides both answers: a kept anchor always names its folder, and a restoration recorded for
1256
+ this repository is named even when its anchor is gone.
1257
+ """
1258
+ in_use, records = yield FileTask(lambda: (replay_legacy.legacy_replay_store_in_use(store_dir), replay_legacy.read_legacy_replay_records(store_dir)))
1259
+ if not in_use:
1260
+ return (), ()
1261
+ listing = yield GitCommand(["for-each-ref", "--format=" + replay_legacy.LEGACY_ANCHOR_LISTING_FORMAT, replay_legacy.LEGACY_ANCHOR_REF_PREFIX])
1262
+ target_oid_by_ref = replay_legacy.parse_legacy_anchor_listing(listing)
1263
+ return (yield FileTask(lambda: (replay_legacy.legacy_anchor_deletions(target_oid_by_ref, records), replay_legacy.kept_legacy_backup_dirs(records, target_oid_by_ref, common_git_dir))))
1264
+
1265
+ def _retire_legacy_anchors(self, operation: OperationIntent, deletions: tuple[tuple[str, str], ...], is_current: Callable[[], bool],
1266
+ answer: Callable[[], None]) -> Generator:
1267
+ """Remove the chosen anchors in one update-ref transaction, each only while it still points to its listed object.
1268
+
1269
+ The replay state is read again under the reservation first, and the removal is refused while a Cherry-pick or
1270
+ Revert is in progress. Only references are written: no index, no working file and no submodule is touched.
1271
+ """
1272
+ run = NativeCommandRun()
1273
+ status = OperationStatus.REFUSED
1274
+ try:
1275
+ state = yield from capture_replay(operation.context, operation.generation)
1276
+ if not state.fresh or state.kind != ReplayKind.ABSENT or state.unmerged_paths:
1277
+ raise ValueError(LEGACY_ANCHOR_RETIREMENT_REFUSED_CAUSE)
1278
+ self.repository_operation.validate(operation)
1279
+ self._validate_launch_authority(operation, is_current)
1280
+
1281
+ def validate_launch() -> None:
1282
+ """Compare only current authority when the queued removal launches."""
1283
+ self._validate_launch_authority(operation, is_current)
1284
+ run.started = True
1285
+
1286
+ yield GitCommand(["update-ref", "--no-deref", "--stdin", "-z"], stdin=replay_legacy.legacy_anchor_deletion_input(deletions), profile=LOCAL_WRITE_PROFILE,
1287
+ before_start=validate_launch)
1288
+ run.process_status, run.return_code = ProcessStatus.SUCCESS, SUCCESS_EXIT_CODE
1289
+ status = OperationStatus.SUCCESS
1290
+ except (GitCommandError, ValueError, OSError) as error:
1291
+ run.record_failure(operation, error)
1292
+ status = OperationStatus.UNKNOWN if run.started else OperationStatus.REFUSED
1293
+ answer()
1294
+ step = OperationStepResult(operation.operation, status, process_status=run.process_status, effect="changed" if status == OperationStatus.SUCCESS else "unknown",
1295
+ diagnostic=run.diagnostic, return_code=run.return_code)
1296
+ return OperationOutcome(operation.operation_id, status, (step,), run.diagnostic)
1297
+
1298
+ def close(self) -> None:
1299
+ """Stop publishing while scheduled filesystem and process work drains."""
1300
+ self.closed = True
1301
+
1302
+ def _publish_result(self, state: ReplaySnapshot) -> ReplaySnapshot:
1303
+ """Publish the reconciled state an operation ends with, under a generation newer than any read still running.
1304
+
1305
+ The generation is remembered as an operation's publication: a read that was running meanwhile answers its callers
1306
+ with this state instead of its own result, and the replay controller may show it without reading it again.
1307
+ """
1308
+ generation = max(state.generation, self.generation_by_key.get(state.key, 0)) + 1
1309
+ state = replace(self._joined_launch(state), generation=generation)
1310
+ self.generation_by_key[state.key] = generation
1311
+ self.published_generation_by_key[state.key] = generation
1312
+ self.state_by_key[state.key] = state
1313
+ self.changed.emit(state)
1314
+ return state
1315
+
1316
+ def _joined_launch(self, state: ReplaySnapshot) -> ReplaySnapshot:
1317
+ """Mark a state as GitPane's own pause when it is the one launched here, and forget that launch once a fresh read shows no pause left."""
1318
+ if state.fresh and state.kind == ReplayKind.ABSENT:
1319
+ self.launch_by_key.pop(state.key, None)
1320
+ return state
1321
+ return join_replay_launch(state, self.launch_by_key.get(state.key))
1322
+
1323
+ def observe(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[ReplaySnapshot], None],
1324
+ priority: ProcessPriority = ProcessPriority.USER) -> None:
1325
+ """Share one worktree read between concurrent callers and reject replaced requester sessions.
1326
+
1327
+ The priority is the caller's place in the process queue: a click by default, background for an automatic look.
1328
+ """
1329
+ if self.closed:
1330
+ return
1331
+ key = WorktreeKey.from_context(context)
1332
+ if key in self.observers_by_key:
1333
+ # The read already running may have started before the change this caller wants to see (a native pause that appeared in between).
1334
+ # Answering from it could report a state that no longer exists, so late callers share one read started after it ends,
1335
+ # unless an operation publishes its verified result first: that result is newer than the request and answers it.
1336
+ self.waiting_observers_by_key.setdefault(key, []).append((self.generation_by_key.get(key, 0), is_current, callback, priority))
1337
+ return
1338
+ self._start_read(context, git_filename, [(is_current, callback, priority)])
1339
+
1340
+ def observe_passive(self, context: RepositoryContext, git_filename: str, source: PassiveObservationSource, *, is_current: Callable[[], bool],
1341
+ callback: Callable[[ReplaySnapshot], None], priority: ProcessPriority = ProcessPriority.USER) -> None:
1342
+ """Follow a cherry-pick or revert state for the banner with what the automatic refresh has just read; only that refresh calls it.
1343
+
1344
+ A request that has to wait for a read already running is answered by a complete read through Git, at the same priority.
1345
+ """
1346
+ if self.closed:
1347
+ return
1348
+ if WorktreeKey.from_context(context) in self.observers_by_key:
1349
+ # The facts in hand may be older than the change this caller waits for, and a waiting request is never kept
1350
+ # with them: it becomes a request through Git.
1351
+ self.observe(context, git_filename, is_current=is_current, callback=callback, priority=priority)
1352
+ return
1353
+ self._start_read(context, git_filename, [(is_current, callback, priority)], source)
1354
+
1355
+ def _start_read(self, context: RepositoryContext, git_filename: str, observers: list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]],
1356
+ source: PassiveObservationSource | None = None) -> None:
1357
+ """Run one native state read for these callers, then one more for the callers that arrived while it ran.
1358
+
1359
+ An operation that publishes its verified result while this read runs answers every caller who asked before that
1360
+ publication, this read's callers included, with the published state; this read's own result or error is then
1361
+ dropped. Only callers who asked after the publication need a new read, and they share one.
1362
+ """
1363
+ key = WorktreeKey.from_context(context)
1364
+ self.observers_by_key[key] = observers
1365
+ generation = self.generation_by_key.get(key, 0) + 1
1366
+ self.generation_by_key[key] = generation
1367
+
1368
+ def complete(result: ReplaySnapshot | None, error: Exception | None) -> None:
1369
+ """Publish this read first, so starting the follow-up read cannot mark it as superseded."""
1370
+ finished_observers = self.observers_by_key.pop(key, [])
1371
+ next_observers = []
1372
+ published_generation = self.published_generation_by_key.get(key, 0)
1373
+ for asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
1374
+ if published_generation > generation and asked_generation < published_generation:
1375
+ finished_observers.append((current, observer_callback, observer_priority))
1376
+ else:
1377
+ next_observers.append((current, observer_callback, observer_priority))
1378
+ try:
1379
+ # Callers of a superseded read that no publication answers still need an answer, so they join the follow-up read.
1380
+ next_observers = publish(finished_observers, result, error) + next_observers
1381
+ finally:
1382
+ current_observers = []
1383
+ for current, observer_callback, observer_priority in next_observers:
1384
+ if current():
1385
+ current_observers.append((current, observer_callback, observer_priority))
1386
+ running_observers = self.observers_by_key.get(key)
1387
+ if running_observers is not None:
1388
+ # A callback of this read already started the next one, after these callers asked, so it answers them too.
1389
+ running_observers.extend(current_observers)
1390
+ elif current_observers and not self.closed:
1391
+ # The follow-up read answers requests made after the previous read started: the facts in hand then no longer
1392
+ # hold, so it reads everything through Git.
1393
+ self._start_read(context, git_filename, current_observers)
1394
+
1395
+ def publish(observers: list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]], result: ReplaySnapshot | None,
1396
+ error: Exception | None) -> list[tuple[Callable[[], bool], Callable[[ReplaySnapshot], None], ProcessPriority]]:
1397
+ """Keep errors and external disappearance explicit without claiming success; return the callers left without an answer."""
1398
+ callbacks = []
1399
+ for current, observer_callback, _observer_priority in observers:
1400
+ if current():
1401
+ callbacks.append(observer_callback)
1402
+ if self.closed or not callbacks:
1403
+ return []
1404
+ if self.generation_by_key.get(key) != generation:
1405
+ latest = self.state_by_key.get(key)
1406
+ if latest is not None and latest.generation == self.generation_by_key.get(key) == self.published_generation_by_key.get(key):
1407
+ # An operation published while this read was pending: its result is newer than every request here and
1408
+ # answers it as published, unknown included, while this read's own result or error is dropped.
1409
+ for observer_callback in callbacks:
1410
+ observer_callback(latest)
1411
+ return []
1412
+ return observers
1413
+ previous = self.state_by_key.get(key)
1414
+ if error is not None or result is None:
1415
+ result = unreadable_replay_state(previous or ReplaySnapshot(key, generation), "Cannot read the native replay state. Refresh to try again.", generation=generation)
1416
+ else:
1417
+ result = self._joined_launch(result)
1418
+ if previous is not None and previous.identity is not None:
1419
+ if result.kind == ReplayKind.ABSENT:
1420
+ result = replace(result, diagnostic="Native commit operation disappeared externally. Refresh to inspect the result.", pending_step="disappeared")
1421
+ elif previous.identity != result.identity or previous.head_oid != result.head_oid:
1422
+ result = replace(result, diagnostic="Native commit operation changed externally. " + result.diagnostic)
1423
+ self.state_by_key[key] = result
1424
+ try:
1425
+ # The callers who asked for this read hear its answer before it is announced to everyone: a request that
1426
+ # asked for it (an automatic Continue waiting for its qualifying read) is answered by this very read
1427
+ # before anything reacting to the announcement acts on the same state.
1428
+ for observer_callback in callbacks:
1429
+ observer_callback(result)
1430
+ finally:
1431
+ self.changed.emit(result)
1432
+ return []
1433
+
1434
+ reading = capture_replay(context, generation) if source is None else capture_replay_passive(context, generation, source)
1435
+ self.worktree_service.run(context, git_filename, reading, complete, priority=observation_priority(observers))