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,750 @@
1
+ """Own asynchronous rebase observations independently of repository windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field, replace
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QObject, Signal
10
+
11
+ from gitpane.config import DETACHED_HEAD_NAME, GIT_COMMIT_TIMEOUT_MS, LINE_SEPARATOR, REFERENCE_ROOT_PREFIX, SUBMODULE_RECURSION_OFF_ARGUMENTS, ProcessPriority
12
+ from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE, validate_oid
13
+ from gitpane.operation.console_line import REBASE_LABEL
14
+ from gitpane.operation.diagnostic import Diagnostic
15
+ from gitpane.operation.launch_signature import capture_write_launch_signature, prepare_write_launch_signature, verify_launch_signature
16
+ from gitpane.operation.operation_status import OperationStatus
17
+ from gitpane.operation.repository_operation import OperationAction, OperationOutcome, OperationStepResult, native_rebase_capture
18
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
19
+ from gitpane.reference.reference_edit import reference_signature, reference_signature_paths
20
+ from gitpane.sequencer.rebase_state import REBASE_CONTINUABLE_STATE_SET, REBASE_STOPPED_STATE_SET, RebaseSnapshot, can_abort_rebase, capture_rebase, capture_rebase_passive, ended_outside_state
21
+ from gitpane.system.path_identity import canonical_path
22
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, observation_priority
23
+ from gitpane.worktree.worktree_service import STATUS_ARGUMENTS, read_worktree_probe
24
+ from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, parse_status, read_index
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable, Generator
28
+
29
+ from gitpane.history.repository_value import RepositoryContext
30
+ from gitpane.operation.diagnostic import PreparedDiagnostic
31
+ from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
32
+ from gitpane.tool.editor import EditorService
33
+ from gitpane.worktree.worktree_service import WorktreeService
34
+ from gitpane.worktree.worktree_snapshot import LocalEntry, PassiveObservationSource, WorktreeProbe
35
+
36
+ # The one read that resolves the rebase target, HEAD and the branch HEAD is on answers exactly these three lines.
37
+ INTENT_ANSWER_LINE_COUNT = 3
38
+ UNREADABLE_INTENT_CAUSE = "The rebase target or HEAD could not be read. Refresh before rebasing."
39
+ # Without it, a repository set with i18n.commitEncoding gets its rebase list written in that encoding, which the GitPane
40
+ # editor and the rebase state reader can no longer read as UTF-8. It only adds arguments to the rebase commands, no process.
41
+ UTF8_LOG_OUTPUT_ARGUMENTS = ("-c", "i18n.logOutputEncoding=UTF-8")
42
+ # The status that proves how a rebase ended leaves out untracked files, which Git leaves alone, and rename detection,
43
+ # which would only relabel a change that must not exist anyway. Every other option, submodules included, is the shared one.
44
+ FINAL_STATUS_OPTION_BY_SHARED_OPTION = {"--untracked-files=all": "--untracked-files=no", "--renames": "--no-renames"}
45
+ ABORT_REBASE_LABEL = "Abort rebase"
46
+ REBASE_ABORTED_TEXT = "Rebase aborted."
47
+ REBASE_ABORT_UNVERIFIED_CAUSE = "The original rebase HEAD, branch or index could not be verified. Refresh to inspect the result."
48
+ REBASE_ABORT_LEFT_CHANGES_CAUSE = "The index or tracked files still differ from the original commit after Abort. Refresh to inspect the result."
49
+ EDITOR_ABORT_CHANGED_CAUSE = "The rebase left after Abort in the GitPane editor is not the one this command started from. Refresh to inspect it."
50
+ # Git names the command that corrects its instructions in each refusal of them, and in nothing else: at launch and after
51
+ # a list edit ("You can fix this with 'git rebase --edit-todo' and then run 'git rebase --continue'."), and on Continue
52
+ # ("error: please fix this using 'git rebase --edit-todo'."). Texts of Git 2.53; GitPane runs Git in the C locale, so
53
+ # they are never translated.
54
+ INSTRUCTION_ERROR_MARKER = "'git rebase --edit-todo'"
55
+ # Start of each line Git writes for an error: the lines the editor shows above the list to correct.
56
+ GIT_ERROR_PREFIX = "error: "
57
+ # Git's exit status when it refuses the instructions. A stop on a conflict exits with the same status; the fresh read of
58
+ # the rebase tells the two apart.
59
+ INSTRUCTION_ERROR_RETURN_CODE = 1
60
+
61
+
62
+ def final_status_arguments() -> list[str]:
63
+ """Build the one-off status that proves how a rebase ended from the arguments every worktree read uses."""
64
+ arguments = []
65
+ for argument in STATUS_ARGUMENTS:
66
+ arguments.append(FINAL_STATUS_OPTION_BY_SHARED_OPTION.get(argument, argument))
67
+ return arguments
68
+
69
+
70
+ def read_rebase_final_status() -> Generator[GitCommand, bytes, tuple[WorktreeProbe, tuple[LocalEntry, ...]]]:
71
+ """Read HEAD, its branch, the conflicts and the tracked changes of the worktree from one status, once a rebase ended.
72
+
73
+ Both answers come from the very same output. A branch header Git cannot name costs the probe its usual second read.
74
+ """
75
+ output = yield GitCommand(final_status_arguments(), profile=READ_PROFILE)
76
+ probe = yield from read_worktree_probe(output)
77
+ return probe, parse_status(output)
78
+
79
+
80
+ def parent_differences(entries: tuple[LocalEntry, ...]) -> tuple[LocalEntry, ...]:
81
+ """Keep the final status entries where the index or a file of the parent repository differs.
82
+
83
+ A submodule whose working side alone differs, a gitlink on both sides with changed content or a folder on another
84
+ commit, is left out: a write of the parent never touches a submodule folder, so that difference is not the parent's.
85
+ A gitlink changed in the index, or a submodule replaced by a file, still counts.
86
+ """
87
+ differences = []
88
+ for entry in entries:
89
+ if entry.side == StagingSide.UNSTAGED and entry.is_gitlink and entry.old_mode == entry.new_mode:
90
+ continue
91
+ differences.append(entry)
92
+ return tuple(differences)
93
+
94
+
95
+ def prove_rebase_restored(state: RebaseSnapshot, expected_head_oid: str | None, expected_branch: str | None) -> Generator[GitCommand, bytes, RebaseSnapshot]:
96
+ """Prove from one status that an abandoned rebase left HEAD, its branch, the index and the tracked files as they were before it.
97
+
98
+ The answer is the state to publish, saying the rebase was aborted; a difference raises ValueError with its cause.
99
+ Untracked files are Git's to leave alone and are not listed. A submodule folder that alone differs does not count
100
+ either: an Abort never touches it, so its own changes and the commit it has checked out stay as they were.
101
+ """
102
+ probe, entries = yield from read_rebase_final_status()
103
+ state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
104
+ if probe.head_oid != expected_head_oid or probe.branch_head != expected_branch or probe.unmerged_records:
105
+ raise ValueError(REBASE_ABORT_UNVERIFIED_CAUSE)
106
+ if parent_differences(entries):
107
+ raise ValueError(REBASE_ABORT_LEFT_CHANGES_CAUSE)
108
+ return replace(state, diagnostic=REBASE_ABORTED_TEXT)
109
+
110
+
111
+ def _parse_intent_answer(output: bytes) -> tuple[str, str, str | None]:
112
+ """Split the grouped target and HEAD read into the peeled target, HEAD and its full branch name, None when detached.
113
+
114
+ Anything but two full object IDs followed by a full reference name or HEAD is refused; nothing is guessed.
115
+ """
116
+ lines = output.decode().removesuffix("\n").split("\n")
117
+ if len(lines) != INTENT_ANSWER_LINE_COUNT:
118
+ raise ValueError(UNREADABLE_INTENT_CAUSE)
119
+ target_line, head_line, name_line = lines
120
+ try:
121
+ target_oid, head_oid = validate_oid(target_line), validate_oid(head_line)
122
+ except ValueError as error:
123
+ raise ValueError(UNREADABLE_INTENT_CAUSE) from error
124
+ if name_line == DETACHED_HEAD_NAME:
125
+ return target_oid, head_oid, None
126
+ if not name_line.startswith(REFERENCE_ROOT_PREFIX):
127
+ raise ValueError(UNREADABLE_INTENT_CAUSE)
128
+ return target_oid, head_oid, name_line
129
+
130
+
131
+ def stopped_on_conflict(state: RebaseSnapshot, return_code: int | None) -> bool:
132
+ """Tell whether Git stopped the rebase on a conflict that waits for the user, an expected pause rather than a failure.
133
+
134
+ Git reports such a stop with its conflict exit status. Only a fresh read showing the rebase in conflict with named
135
+ unmerged paths turns it into a pause; any other failure (a refusing hook, a fatal error) stays one. The caller
136
+ checks that the rebase read is the one its command started or continued.
137
+ """
138
+ return return_code == GIT_CONFLICT_RETURN_CODE and state.fresh and state.state == "conflict" and bool(state.conflict_paths)
139
+
140
+
141
+ def list_error_lines(text: str) -> list[str]:
142
+ """Keep the error lines Git writes about the list itself, leaving out the line that only names the correction."""
143
+ lines = []
144
+ for line in text.split(LINE_SEPARATOR):
145
+ if line.startswith(GIT_ERROR_PREFIX) and INSTRUCTION_ERROR_MARKER not in line:
146
+ lines.append(line)
147
+ return lines
148
+
149
+
150
+ def instruction_error_text(state: RebaseSnapshot, return_code: int | None, stderr: str, error_before_edit: str = "") -> str:
151
+ """Answer the error lines of instructions Git refused and kept for a correction, or an empty text for any other end.
152
+
153
+ Git refuses a list it cannot run with its failure status, keeps the rebase waiting for a corrected list, and names
154
+ git rebase --edit-todo in its message. A stop on a conflict, an editor that failed, a rebase Git removed, a read
155
+ that is not fresh or any other exit status is no list to correct. The caller checks that the rebase read is the one
156
+ its command started or continued.
157
+
158
+ The text is the error lines of the last block of Git's messages, a block ending at the line that names the
159
+ correction; that line itself is left out, since the window already runs the command it advises. Before opening the editor on a list it refused, Git checks that list again and writes its errors once
160
+ more with no such line: when they are the ones error_before_edit already showed, they are left out, so only the
161
+ errors of the edited list remain.
162
+ """
163
+ if return_code != INSTRUCTION_ERROR_RETURN_CODE or not state.fresh or state.identity is None or stopped_on_conflict(state, return_code):
164
+ return ""
165
+ if INSTRUCTION_ERROR_MARKER not in stderr:
166
+ return ""
167
+ error_lines: list[str] = []
168
+ block_lines: list[str] = []
169
+ for line in stderr.split(LINE_SEPARATOR):
170
+ # Git writes the line naming the correction after the errors of the list, and the window already runs for the
171
+ # user the command it advises: that line closes the block without entering it.
172
+ if line.startswith(GIT_ERROR_PREFIX) and INSTRUCTION_ERROR_MARKER not in line:
173
+ block_lines.append(line)
174
+ if INSTRUCTION_ERROR_MARKER in line:
175
+ if block_lines:
176
+ error_lines = block_lines
177
+ block_lines = []
178
+ known_lines = list_error_lines(error_before_edit)
179
+ known_count = len(known_lines)
180
+ if known_lines and len(error_lines) > known_count and error_lines[:known_count] == known_lines:
181
+ error_lines = error_lines[known_count:]
182
+ return LINE_SEPARATOR.join(error_lines)
183
+
184
+
185
+ def _expected_branch(identity_head_name: str) -> str | None:
186
+ """Answer the branch a rebase started from, None for a detached HEAD."""
187
+ return identity_head_name if identity_head_name.startswith(REFERENCE_ROOT_PREFIX) else None
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class RebaseAbortResult:
192
+ """What one abandonment of a rebase ended with, before it is published: status, state to show, cause and the Abort command's exit."""
193
+
194
+ status: OperationStatus
195
+ state: RebaseSnapshot
196
+ diagnostic: PreparedDiagnostic | None = None
197
+ process_status: ProcessStatus | None = None
198
+ return_code: int | None = None
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class RebaseOutcome(OperationOutcome):
203
+ """The result of a rebase command, with the error lines of the instructions Git refused and kept for a correction, when it did."""
204
+
205
+ instruction_error: str = field(default="", kw_only=True)
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class RebaseIntent:
210
+ """Keep the graph target distinct from the loaded branch or detached HEAD."""
211
+
212
+ operation: OperationIntent
213
+ target_oid: str
214
+ source_head_oid: str | None
215
+ source_symbolic_ref: str | None
216
+
217
+
218
+ class RebaseService(QObject):
219
+ """Keep the latest native observation for each independent worktree."""
220
+
221
+ changed = Signal(object)
222
+
223
+ def __init__(self, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None, editor: EditorService | None = None) -> None:
224
+ """Inject the existing bounded command and filesystem scheduler."""
225
+ super().__init__(parent)
226
+ self.worktree_service = worktree_service
227
+ self.state_by_key: dict[WorktreeKey, RebaseSnapshot] = {}
228
+ self.generation_by_key: dict[WorktreeKey, int] = {}
229
+ # The generation of the last result a command published for each worktree, as opposed to a read.
230
+ self.published_generation_by_key: dict[WorktreeKey, int] = {}
231
+ # Each caller keeps the queue priority it asked with, so a shared read waits at the level of its most urgent caller.
232
+ self.observers_by_key: dict[WorktreeKey, list[tuple[Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]]] = {}
233
+ # Each waiting caller keeps the generation it asked at, so a command result can only answer callers it was published after.
234
+ self.waiting_observers_by_key: dict[WorktreeKey, list[tuple[int, Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]]] = {}
235
+ self.outcome_by_id: dict[str, OperationOutcome] = {}
236
+ self.repository_operation = repository_operation
237
+ self.editor = editor
238
+ self.closed = False
239
+
240
+ def close(self) -> None:
241
+ """Stop accepting observations while existing workers drain without publishing to closing views."""
242
+ self.closed = True
243
+
244
+ def _publish_result(self, state: RebaseSnapshot) -> RebaseSnapshot:
245
+ """Supersede every pending observation before publishing a reconciled command result."""
246
+ latest = self.state_by_key.get(state.key)
247
+ generation = max(state.generation, self.generation_by_key.get(state.key, 0), latest.generation if latest is not None else 0) + 1
248
+ self.generation_by_key[state.key] = generation
249
+ self.published_generation_by_key[state.key] = generation
250
+ state = replace(state, generation=generation)
251
+ self.state_by_key[state.key] = state
252
+ self.changed.emit(state)
253
+ return state
254
+
255
+ def prepare(self, intent: RebaseIntent, *, is_current: Callable[[], bool], callback: Callable[[RebaseIntent | None, Exception | None], None]) -> None:
256
+ """Resolve the selected commit and reject stale source identity before confirmation."""
257
+ self.worktree_service.run(intent.operation.context, intent.operation.git_filename, self._validate_intent(intent, is_current), callback)
258
+
259
+ def _validate_intent(self, intent: RebaseIntent, is_current: Callable[[], bool]) -> Generator:
260
+ """Read native target, worktree and HEAD facts without changing the checkout."""
261
+ operation = intent.operation
262
+ if operation.context.is_bare or not intent.source_head_oid or not is_current():
263
+ raise ValueError("The rebase source is unavailable or changed. Refresh before rebasing.")
264
+ validate_oid(intent.target_oid)
265
+ # One read resolves the target as a commit, HEAD and the branch HEAD is on; a full object ID never reads as an option.
266
+ answer = yield GitCommand(["rev-parse", intent.target_oid + "^{commit}", "HEAD", "--symbolic-full-name", "HEAD"])
267
+ target_oid, head_oid, head_ref = _parse_intent_answer(answer)
268
+ git_directory = yield GitCommand(["rev-parse", "--absolute-git-dir"])
269
+ if canonical_path(os.fsdecode(git_directory).strip()) != operation.key.git_dir:
270
+ raise ValueError("The rebase worktree changed. Refresh before rebasing.")
271
+ state = yield from capture_rebase(operation.context, operation.generation)
272
+ if not state.fresh or state.state != "absent":
273
+ raise ValueError("A native rebase already exists or cannot be read. Refresh its state.")
274
+ if head_oid != intent.source_head_oid or head_ref != intent.source_symbolic_ref or not is_current():
275
+ raise ValueError("HEAD or its branch changed. Refresh before rebasing.")
276
+ return replace(intent, target_oid=target_oid)
277
+
278
+ def start(self, intent: RebaseIntent, *, is_current: Callable[[], bool]) -> str:
279
+ """Submit exactly one native rebase through the shared writer and editor owners; every edit Git asks for waits for a GitPane window."""
280
+ if self.repository_operation is None or self.editor is None:
281
+ raise ValueError("The rebase services are unavailable.")
282
+ return self.repository_operation.submit(intent.operation, self._start(intent, is_current))
283
+
284
+ def edit_todo(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool], error_before_edit: str = "") -> str:
285
+ """Edit the captured native list in a GitPane window under a new writer reservation without continuing Git.
286
+
287
+ error_before_edit is the error Git gave for the list this edit corrects, so that a list still refused after the
288
+ edit reports only its own errors.
289
+ """
290
+ if self.repository_operation is None or self.editor is None or operation.action != OperationAction.REBASE_CONTINUE:
291
+ raise ValueError("The rebase instruction editor is unavailable.")
292
+ return self.repository_operation.submit(operation, self._edit_todo(operation, state, is_current, error_before_edit))
293
+
294
+ def continue_rebase(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool]) -> str:
295
+ """Capture the index at the explicit Continue request and reserve one native command."""
296
+ if self.repository_operation is None or self.editor is None or operation.action != OperationAction.REBASE_CONTINUE:
297
+ raise ValueError("The rebase services are unavailable.")
298
+ index_filename = os.path.join(operation.context.git_dir, "index")
299
+ index_content = read_index(index_filename)
300
+ return self.repository_operation.submit(operation, self._continue(operation, state, is_current, index_filename, index_content))
301
+
302
+ def abort(self, operation: OperationIntent, state: RebaseSnapshot, *, is_current: Callable[[], bool]) -> str:
303
+ """Reserve one native Abort while preserving the observed rebase, HEAD and requester."""
304
+ if self.repository_operation is None or operation.action != OperationAction.REBASE_ABORT:
305
+ raise ValueError("The native rebase abort service is unavailable.")
306
+
307
+ def current() -> bool:
308
+ """Accept unchanged refreshes while rejecting changed native state or editor ownership."""
309
+ latest = self.state_by_key.get(operation.key)
310
+ # A background read advances the observation counter even when Git has not changed.
311
+ # Bind Abort to native identity and HEAD so that read cannot invalidate the user's click.
312
+ if latest is not None and (latest.identity != state.identity or latest.head_oid != state.head_oid or not can_abort_rebase(latest)):
313
+ return False
314
+ if self.editor is not None:
315
+ for context in self.editor.context_by_id.values():
316
+ if not context.closed and context.worktree_key == operation.key:
317
+ return False
318
+ return is_current()
319
+
320
+ return self.repository_operation.submit(operation, self._abort(operation, state, current))
321
+
322
+ def _abort(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool]) -> Generator:
323
+ """Let Git restore its origin and report abandonment only after reading the resulting files and references."""
324
+ result = yield from self._restore_native_rebase(operation, previous, is_current, lambda: self.repository_operation.validate(operation))
325
+ return self._publish_abort(operation, previous, result)
326
+
327
+ def _publish_abort(self, operation: OperationIntent, before: RebaseSnapshot | None, result: RebaseAbortResult) -> OperationOutcome:
328
+ """Publish the state an abandonment ended with and keep its outcome, one Abort step."""
329
+ state = self._publish_result(result.state)
330
+ step = OperationStepResult(ABORT_REBASE_LABEL, result.status, before, state, result.process_status, result.status.value, result.diagnostic, result.return_code)
331
+ outcome = OperationOutcome(operation.operation_id, result.status, (step,), result.diagnostic)
332
+ self.outcome_by_id[operation.operation_id] = outcome
333
+ return outcome
334
+
335
+ def _restore_native_rebase(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool],
336
+ validate_reservation: Callable[[], None]) -> Generator[object, object, RebaseAbortResult]:
337
+ """Run git rebase --abort on the rebase previous describes, kept out of submodules, then prove from the files that everything is back.
338
+
339
+ The rebase must still be the one previous read, with the same HEAD, and validate_reservation must accept the
340
+ launch; nothing is published here.
341
+ """
342
+ diagnostic = process_status = return_code = None
343
+ native_started = False
344
+ state = previous
345
+ status = OperationStatus.REFUSED
346
+ try:
347
+ changed_cause = "HEAD or the Abort request changed before launch. Refresh first."
348
+ baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths))
349
+ state = yield from capture_rebase(operation.context, previous.generation)
350
+ if (not is_current() or not can_abort_rebase(previous) or not can_abort_rebase(state) or previous.key != operation.key or state.key != operation.key
351
+ or previous.identity is None or state.identity != previous.identity or state.head_oid != previous.head_oid):
352
+ raise ValueError("The native rebase or HEAD changed. Refresh before aborting.")
353
+ signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
354
+
355
+ def validate_launch() -> None:
356
+ """Reject changed native metadata, HEAD, session or activity after scheduler waiting."""
357
+ nonlocal native_started
358
+ validate_reservation()
359
+ if not is_current():
360
+ raise ValueError(changed_cause)
361
+ verify_launch_signature(signature.metadata, changed_cause)
362
+ native_started = True
363
+
364
+ # Without recursion, restoring the original commit leaves the files of every submodule as they are.
365
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "rebase", "--abort"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
366
+ before_start=validate_launch)
367
+ process_status, return_code = ProcessStatus.SUCCESS, 0
368
+ except (GitCommandError, ValueError, OSError) as error:
369
+ if isinstance(error, GitCommandError):
370
+ diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
371
+ else:
372
+ diagnostic = Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
373
+ try:
374
+ state = yield from capture_rebase(operation.context, previous.generation)
375
+ if native_started and return_code == 0 and state.fresh and state.state == "absent":
376
+ state = yield from prove_rebase_restored(state, previous.identity.original_oid, _expected_branch(previous.identity.head_name))
377
+ status = OperationStatus.ABORTED
378
+ elif native_started:
379
+ status = OperationStatus.FAILED if return_code != 0 else OperationStatus.UNKNOWN
380
+ if diagnostic is None:
381
+ diagnostic = Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [],
382
+ "Git exited but the native rebase restoration could not be verified. Refresh to inspect the result.").prepare()
383
+ elif state.state == "absent":
384
+ # The rebase ended elsewhere before this Abort could start: the refusal stays in the console, the views
385
+ # follow the ended rebase.
386
+ state = replace(ended_outside_state(state), native_observations=None)
387
+ if diagnostic is not None and not state.ended_outside:
388
+ # The published message is the command's, not the capture's own, so the state no longer describes a plain capture.
389
+ state = replace(state, diagnostic=diagnostic.cause, native_observations=None)
390
+ except (GitCommandError, ValueError, OSError) as error:
391
+ status = OperationStatus.UNKNOWN
392
+ if diagnostic is None:
393
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
394
+ state = replace(previous, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None)
395
+ return RebaseAbortResult(status, state, diagnostic, process_status, return_code)
396
+
397
+ def _abort_after_editor(self, operation: OperationIntent, previous: RebaseSnapshot | None, expected_head_oid: str | None, expected_branch: str | None) -> Generator:
398
+ """Cancel the whole rebase the user aborted in the GitPane editor, inside the reservation of the command Git answered.
399
+
400
+ Git exits as soon as the editor answers Abort. On the first list it has already removed its state, so running
401
+ git rebase --abort would only fail: the result is proven from the files alone. On any later edit Git waits
402
+ paused, and its own git rebase --abort brings everything back. Either way HEAD and its branch must be the ones
403
+ the rebase started from. Git's lines about the editor stay in the console; the outcome carries no failure cause.
404
+
405
+ The caller's own currency check is not asked again: it judges the action it was captured for (a start, a
406
+ continue, a list edit), which the rebase Git left paused no longer matches. The Abort is judged instead by the
407
+ reservation, still this command's, and by the application's rules for an Abort of the rebase read now.
408
+ """
409
+ try:
410
+ state = yield from capture_rebase(operation.context, operation.generation)
411
+ if state.fresh and state.state == "absent":
412
+ state = yield from prove_rebase_restored(state, expected_head_oid, expected_branch)
413
+ result = RebaseAbortResult(OperationStatus.ABORTED, state)
414
+ elif state.identity is None or state.identity.original_oid != expected_head_oid or _expected_branch(state.identity.head_name) != expected_branch:
415
+ raise ValueError(EDITOR_ABORT_CHANGED_CAUSE)
416
+ else:
417
+ # The reservation is still this command's, but Git changed the rebase it was captured on: the Abort is judged
418
+ # on the rebase Git left, read now, so a change made by anyone else before its launch still refuses it.
419
+ rebase_capture = yield FileTask(lambda: native_rebase_capture(operation.context))
420
+ follow_up = replace(operation, action=OperationAction.REBASE_ABORT, rebase_capture=rebase_capture)
421
+ result = yield from self._restore_native_rebase(operation, state, lambda: True, lambda: self.repository_operation.validate_follow_up(operation, follow_up))
422
+ except (GitCommandError, ValueError, OSError) as error:
423
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(ABORT_REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
424
+ fallback = previous or self.state_by_key.get(operation.key) or RebaseSnapshot(operation.key, operation.generation)
425
+ result = RebaseAbortResult(OperationStatus.UNKNOWN, replace(fallback, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None), diagnostic)
426
+ return self._publish_abort(operation, previous, result)
427
+
428
+ def _continue(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool], index_filename: str, index_content: bytes | None) -> Generator:
429
+ """Recheck the stopped commit before launch and prove completion from native effects."""
430
+ diagnostic = process_status = return_code = editor_context = None
431
+ native_started = aborted_in_editor = False
432
+ instruction_error = ""
433
+ state = previous
434
+ status = OperationStatus.REFUSED
435
+ try:
436
+ changed_cause = "HEAD or the index changed before Continue. Refresh first."
437
+ baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths, index_filename, index_content))
438
+ state = yield from capture_rebase(operation.context, operation.generation)
439
+ if (not is_current() or not previous.fresh or not state.fresh or previous.key != operation.key or state.identity is None
440
+ or state.identity != previous.identity or state.head_oid != previous.head_oid or state.current_step != previous.current_step
441
+ or state.state not in REBASE_CONTINUABLE_STATE_SET):
442
+ raise ValueError("The paused rebase or HEAD changed. Refresh before continuing.")
443
+ # This fresh capture, read inside the reservation, is the authority; a displayed or passive state never is.
444
+ if state.unmerged:
445
+ raise ValueError("Resolve and stage all conflicts before continuing the rebase.")
446
+ signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
447
+ editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
448
+
449
+ def validate_launch() -> None:
450
+ """Reject index, HEAD and native-state changes during scheduler waiting."""
451
+ nonlocal native_started
452
+ self.repository_operation.validate(operation)
453
+ if not is_current():
454
+ raise ValueError(changed_cause)
455
+ verify_launch_signature(signature.metadata, changed_cause)
456
+ native_started = True
457
+
458
+ # Without recursion, the remaining steps leave the files of every submodule as they are.
459
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "rebase", "--continue"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
460
+ before_start=validate_launch, editor_context=editor_context)
461
+ process_status, return_code = ProcessStatus.SUCCESS, 0
462
+ except (GitCommandError, ValueError, OSError) as error:
463
+ if isinstance(error, GitCommandError):
464
+ diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
465
+ aborted_in_editor = editor_context is not None and editor_context.abort_requested
466
+ else:
467
+ diagnostic = Diagnostic("Continue rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
468
+ if aborted_in_editor:
469
+ return (yield from self._abort_after_editor(operation, previous, previous.identity.original_oid, _expected_branch(previous.identity.head_name)))
470
+ try:
471
+ state = yield from capture_rebase(operation.context, operation.generation)
472
+ if native_started:
473
+ status = OperationStatus.PAUSED if state.state != "absent" and return_code == 0 else OperationStatus.FAILED
474
+ if state.state == "absent" and state.fresh and return_code == 0:
475
+ # The status ties the branch to HEAD itself, so the branch needs no read of its own.
476
+ probe, _entries = yield from read_rebase_final_status()
477
+ state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
478
+ if probe.branch_head != _expected_branch(previous.identity.head_name) or probe.unmerged_records or not probe.head_oid:
479
+ raise ValueError("The final rebase branch or index could not be verified. Refresh to inspect it.")
480
+ yield GitCommand(["merge-base", "--is-ancestor", previous.identity.onto_oid, probe.head_oid])
481
+ status = OperationStatus.SUCCESS
482
+ elif state.identity != previous.identity:
483
+ status = OperationStatus.UNKNOWN
484
+ elif stopped_on_conflict(state, return_code):
485
+ # The next commit conflicts: Git waits for the user again. Its message is already in the console, so
486
+ # no failure cause is attached to the pause.
487
+ status, diagnostic = OperationStatus.PAUSED, None
488
+ elif diagnostic is not None:
489
+ instruction_error = instruction_error_text(state, return_code, diagnostic.stderr)
490
+ if instruction_error:
491
+ # Git refused the remaining instructions and waits for a corrected list: a pause for the editor,
492
+ # whose error lines are already in the console.
493
+ status, diagnostic = OperationStatus.PAUSED, None
494
+ elif state.state == "absent":
495
+ # The rebase ended elsewhere before this Continue could start: the refusal stays in the console.
496
+ state = ended_outside_state(state)
497
+ except (GitCommandError, ValueError, OSError) as error:
498
+ status = OperationStatus.UNKNOWN
499
+ if diagnostic is None:
500
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Continue rebase", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
501
+ state = replace(previous, state="unknown", fresh=False, diagnostic=diagnostic.cause, native_observations=None)
502
+ state = self._publish_result(state)
503
+ step = OperationStepResult("Continue rebase", status, previous, state, process_status, status.value, diagnostic, return_code)
504
+ outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
505
+ self.outcome_by_id[operation.operation_id] = outcome
506
+ return outcome
507
+
508
+ def _edit_todo(self, operation: OperationIntent, previous: RebaseSnapshot, is_current: Callable[[], bool], error_before_edit: str) -> Generator:
509
+ """Revalidate native identity at launch and publish the resulting pause after editor exit.
510
+
511
+ An edited list Git refuses again leaves the rebase paused, with the errors Git wrote after the edit.
512
+ """
513
+ diagnostic = process_status = return_code = editor_context = None
514
+ aborted_in_editor = False
515
+ instruction_error = ""
516
+ status = OperationStatus.REFUSED
517
+ state = previous
518
+ try:
519
+ state = yield from capture_rebase(operation.context, operation.generation)
520
+ if (not is_current() or not state.fresh or not previous.fresh or state.key != previous.key or state.identity is None
521
+ or state.identity != previous.identity or state.identity.backend != "rebase-merge"
522
+ or state.state not in REBASE_STOPPED_STATE_SET):
523
+ raise ValueError("The native rebase changed. Refresh before editing its instructions.")
524
+ editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
525
+
526
+ def validate_launch() -> None:
527
+ """Reject a replaced capture or closed session after scheduler waiting."""
528
+ self.repository_operation.validate(operation)
529
+ if not is_current():
530
+ raise ValueError("The rebase instruction request is no longer current. Refresh first.")
531
+
532
+ # Saving the list replays nothing, but like every other write it starts with Git kept out of the submodules.
533
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "rebase", "--edit-todo"], profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
534
+ before_start=validate_launch, editor_context=editor_context)
535
+ process_status, return_code, status = ProcessStatus.SUCCESS, 0, OperationStatus.PAUSED
536
+ except (GitCommandError, ValueError, OSError) as error:
537
+ if isinstance(error, GitCommandError):
538
+ diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
539
+ status = OperationStatus.FAILED
540
+ aborted_in_editor = editor_context is not None and editor_context.abort_requested
541
+ else:
542
+ diagnostic = Diagnostic("Edit rebase instructions", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
543
+ if aborted_in_editor:
544
+ return (yield from self._abort_after_editor(operation, previous, previous.identity.original_oid, _expected_branch(previous.identity.head_name)))
545
+ try:
546
+ state = yield from capture_rebase(operation.context, operation.generation)
547
+ if not state.fresh or state.identity != previous.identity:
548
+ status = OperationStatus.UNKNOWN
549
+ elif status == OperationStatus.FAILED and diagnostic is not None:
550
+ instruction_error = instruction_error_text(state, return_code, diagnostic.stderr, error_before_edit)
551
+ if instruction_error:
552
+ # Git kept the rebase paused on the list it refused: a pause for the editor, whose error lines are already in the console.
553
+ status, diagnostic = OperationStatus.PAUSED, None
554
+ if state.state == "absent":
555
+ # Editing the instructions never ends a rebase: one that is gone was ended elsewhere.
556
+ state = ended_outside_state(state)
557
+ except (ValueError, OSError, GitCommandError):
558
+ state = replace(state, state="unknown", fresh=False, diagnostic="Cannot reread the native rebase after editing. Refresh to inspect it.", native_observations=None)
559
+ status = OperationStatus.UNKNOWN
560
+ state = self._publish_result(state)
561
+ step = OperationStepResult("Edit rebase instructions", status, operation, state, process_status, status.value, diagnostic, return_code)
562
+ outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
563
+ self.outcome_by_id[operation.operation_id] = outcome
564
+ return outcome
565
+
566
+ def _start(self, intent: RebaseIntent, is_current: Callable[[], bool]) -> Generator:
567
+ """Reconcile native state after every exit before releasing command ownership."""
568
+ operation = intent.operation
569
+ diagnostic = process_status = return_code = editor_context = None
570
+ native_started = aborted_in_editor = False
571
+ instruction_error = ""
572
+ state = None
573
+ status = OperationStatus.REFUSED
574
+ try:
575
+ changed_cause = "HEAD or repository identity changed before rebase launch. Refresh first."
576
+ baseline = yield FileTask(lambda: capture_write_launch_signature(operation.context, changed_cause, reference_signature, reference_signature_paths))
577
+ intent = yield from self._validate_intent(intent, is_current)
578
+ signature = yield FileTask(lambda: prepare_write_launch_signature(operation.context, baseline, changed_cause, reference_signature))
579
+ editor_context = self.editor.create_context(operation.operation_id, operation.context.git_dir, operation.key)
580
+
581
+ def validate_launch() -> None:
582
+ """Reject changes made while the checked command waited in the scheduler."""
583
+ nonlocal native_started
584
+ self.repository_operation.validate(operation)
585
+ if not is_current():
586
+ raise ValueError(changed_cause)
587
+ verify_launch_signature(signature.metadata, changed_cause)
588
+ native_started = True
589
+
590
+ # Without recursion, a step that moves a gitlink changes what the branch points to, never the files checked out in the submodule.
591
+ arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, *UTF8_LOG_OUTPUT_ARGUMENTS, "-c", "rebase.abbreviateCommands=false", "-c", "rebase.instructionFormat=%s", "rebase", "--interactive", "--no-autosquash", "--no-autostash",
592
+ "--no-rebase-merges", "--no-update-refs", "--no-fork-point", intent.target_oid]
593
+ yield GitCommand(arguments, profile=LOCAL_WRITE_PROFILE, timeout_ms=GIT_COMMIT_TIMEOUT_MS, idle_timeout_ms=GIT_COMMIT_TIMEOUT_MS,
594
+ before_start=validate_launch, editor_context=editor_context)
595
+ process_status, return_code = ProcessStatus.SUCCESS, 0
596
+ except (GitCommandError, ValueError, OSError) as error:
597
+ if isinstance(error, GitCommandError):
598
+ diagnostic, process_status, return_code = error.diagnostic, error.process_status, error.return_code
599
+ aborted_in_editor = editor_context is not None and editor_context.abort_requested
600
+ else:
601
+ diagnostic = Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
602
+ if aborted_in_editor:
603
+ return (yield from self._abort_after_editor(operation, None, intent.source_head_oid, intent.source_symbolic_ref))
604
+ try:
605
+ state = yield from capture_rebase(operation.context, operation.generation)
606
+ completed = False
607
+ if native_started and return_code == 0 and state.fresh and state.state == "absent":
608
+ # The status ties the branch to HEAD itself, so the branch needs no read of its own.
609
+ probe, _entries = yield from read_rebase_final_status()
610
+ state = replace(state, head_oid=probe.head_oid, original_branch=probe.branch_head or "")
611
+ completed = bool(probe.head_oid) and not probe.unmerged_records and probe.branch_head == intent.source_symbolic_ref
612
+ if completed:
613
+ yield GitCommand(["merge-base", "--is-ancestor", intent.target_oid, state.head_oid])
614
+ status = OperationStatus.SUCCESS
615
+ elif native_started:
616
+ status = OperationStatus.PAUSED if state.state != "absent" and return_code == 0 else OperationStatus.FAILED
617
+ started_here = state.identity is not None and state.identity.original_oid == intent.source_head_oid and state.identity.onto_oid == intent.target_oid
618
+ if started_here and stopped_on_conflict(state, return_code):
619
+ # The rebase this command started stopped on a conflict and waits for the user: an expected pause
620
+ # whose message is already in the console, not a failure.
621
+ status, diagnostic = OperationStatus.PAUSED, None
622
+ elif started_here and diagnostic is not None:
623
+ instruction_error = instruction_error_text(state, return_code, diagnostic.stderr)
624
+ if instruction_error:
625
+ # Git refused the instructions of the rebase this command started and keeps them for a
626
+ # correction: a pause for the editor, whose error lines are already in the console.
627
+ status, diagnostic = OperationStatus.PAUSED, None
628
+ if status == OperationStatus.FAILED and diagnostic is None:
629
+ # Git exited cleanly and removed its native directory, yet the branch or index does not match the launch: name the cause rather than a bare Failed.
630
+ diagnostic = Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [],
631
+ "Git exited but the final branch or index could not be verified. Refresh to inspect the result.").prepare()
632
+ except (GitCommandError, ValueError, OSError) as error:
633
+ status = OperationStatus.UNKNOWN if native_started else OperationStatus.REFUSED
634
+ if diagnostic is None:
635
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(REBASE_LABEL, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
636
+ state = replace(state or self.state_by_key.get(operation.key) or RebaseSnapshot(operation.key, operation.generation), state="unknown", fresh=False,
637
+ diagnostic=diagnostic.cause, native_observations=None)
638
+ state = self._publish_result(state)
639
+ step = OperationStepResult(REBASE_LABEL, status, intent, state, process_status, status.value, diagnostic, return_code)
640
+ outcome = RebaseOutcome(operation.operation_id, status, (step,), diagnostic, instruction_error=instruction_error)
641
+ self.outcome_by_id[operation.operation_id] = outcome
642
+ return outcome
643
+
644
+ def observe(self, context: RepositoryContext, git_filename: str, *, is_current: Callable[[], bool], callback: Callable[[RebaseSnapshot], None],
645
+ priority: ProcessPriority = ProcessPriority.USER) -> None:
646
+ """Publish only a current requester and the newest read for this worktree.
647
+
648
+ The priority is the caller's place in the process queue: a click by default, background for an automatic look.
649
+ """
650
+ key = WorktreeKey.from_context(context)
651
+ if self.closed:
652
+ return
653
+ if key in self.observers_by_key:
654
+ # The read already running may have started before the change this caller wants to see (a conflict staged in between).
655
+ # Answering from it could report a state that no longer exists, so late callers share one read started after it ends.
656
+ self.waiting_observers_by_key.setdefault(key, []).append((self.generation_by_key.get(key, 0), is_current, callback, priority))
657
+ return
658
+ self._start_read(context, git_filename, [(is_current, callback, priority)])
659
+
660
+ def observe_passive(self, context: RepositoryContext, git_filename: str, source: PassiveObservationSource, *, is_current: Callable[[], bool],
661
+ callback: Callable[[RebaseSnapshot], None], priority: ProcessPriority = ProcessPriority.USER) -> None:
662
+ """Follow the rebase state for the banner with what the automatic refresh has just read; only that refresh calls it.
663
+
664
+ A request that has to wait for a read already running is answered by a complete read through Git, at the same priority.
665
+ """
666
+ if self.closed:
667
+ return
668
+ if WorktreeKey.from_context(context) in self.observers_by_key:
669
+ # The facts in hand may be older than the change this caller waits for, and a waiting request is never kept
670
+ # with them: it becomes a request through Git.
671
+ self.observe(context, git_filename, is_current=is_current, callback=callback, priority=priority)
672
+ return
673
+ self._start_read(context, git_filename, [(is_current, callback, priority)], source)
674
+
675
+ def _start_read(self, context: RepositoryContext, git_filename: str, observers: list[tuple[Callable[[], bool], Callable[[RebaseSnapshot], None], ProcessPriority]],
676
+ source: PassiveObservationSource | None = None) -> None:
677
+ """Run one native state read for these callers, then one more for the callers that arrived while it ran."""
678
+ key = WorktreeKey.from_context(context)
679
+ self.observers_by_key[key] = observers
680
+ generation = self.generation_by_key.get(key, 0) + 1
681
+ self.generation_by_key[key] = generation
682
+
683
+ def complete(result: RebaseSnapshot | None, error: Exception | None) -> None:
684
+ """Publish this read first, so starting the follow-up read cannot mark it as superseded."""
685
+ running_observers = self.observers_by_key.get(key)
686
+ if self.generation_by_key.get(key) != generation and running_observers is not None:
687
+ # A command published its own verified result while this read ran. That result answers every caller that asked before
688
+ # the publication, so their refresh request ends here; a caller that asked after it needs a read newer than its request.
689
+ still_waiting_observers = []
690
+ for asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
691
+ if asked_generation < self.generation_by_key.get(key, 0):
692
+ running_observers.append((current, observer_callback, observer_priority))
693
+ else:
694
+ still_waiting_observers.append((asked_generation, current, observer_callback, observer_priority))
695
+ if still_waiting_observers:
696
+ self.waiting_observers_by_key[key] = still_waiting_observers
697
+ try:
698
+ publish(result, error)
699
+ finally:
700
+ waiting_observers = []
701
+ for _asked_generation, current, observer_callback, observer_priority in self.waiting_observers_by_key.pop(key, []):
702
+ if current():
703
+ waiting_observers.append((current, observer_callback, observer_priority))
704
+ next_observers = self.observers_by_key.get(key)
705
+ if next_observers is not None:
706
+ # A callback of this read already started the next one, after these callers asked, so it answers them too.
707
+ next_observers.extend(waiting_observers)
708
+ elif waiting_observers and not self.closed:
709
+ # The follow-up read answers requests made after the previous read started: the facts in hand then no longer
710
+ # hold, so it reads everything through Git.
711
+ self._start_read(context, git_filename, waiting_observers)
712
+
713
+ def publish(result: RebaseSnapshot | None, error: Exception | None) -> None:
714
+ """Retain known steps on read failure without reporting an invented completion."""
715
+ observers = self.observers_by_key.pop(key, [])
716
+ callbacks = []
717
+ for current, observer_callback, _observer_priority in observers:
718
+ if current():
719
+ callbacks.append(observer_callback)
720
+ if self.closed or not callbacks:
721
+ return
722
+ if self.generation_by_key.get(key) != generation:
723
+ latest = self.state_by_key.get(key)
724
+ if latest is not None and latest.generation == self.generation_by_key.get(key):
725
+ for observer_callback in callbacks:
726
+ observer_callback(latest)
727
+ return
728
+ previous = self.state_by_key.get(key)
729
+ owned = self.repository_operation is not None and any(operation.key == key for operation in self.repository_operation.active_by_id.values())
730
+ if error is not None or result is None:
731
+ cause = str(error) if isinstance(error, ValueError) and str(error).startswith("Native rebase") else "Cannot read the native rebase state. Refresh to try again."
732
+ result = replace(previous or RebaseSnapshot(key, generation), generation=generation, state="unknown", fresh=False, diagnostic=cause, native_observations=None)
733
+ elif result.state == "absent":
734
+ if previous is not None and previous.state == "absent" and previous.diagnostic == REBASE_ABORTED_TEXT:
735
+ result = replace(result, head_oid=previous.head_oid, diagnostic=previous.diagnostic)
736
+ elif previous is not None and previous.identity is not None and not owned:
737
+ # The rebase is gone and no command of GitPane ended it. The read is published as it is, with no
738
+ # rebase in it, so the next read has nothing left to compare with and the views stop showing one.
739
+ result = ended_outside_state(result)
740
+ if (not owned and previous is not None and previous.identity is not None and result.identity is not None
741
+ and (previous.identity != result.identity or previous.head_oid != result.head_oid or previous.current_step != result.current_step)):
742
+ result = replace(result, diagnostic="State changed outside GitPane. The current native rebase state has been read."
743
+ + ("\n" + result.diagnostic if result.diagnostic else ""))
744
+ self.state_by_key[key] = result
745
+ self.changed.emit(result)
746
+ for observer_callback in callbacks:
747
+ observer_callback(result)
748
+
749
+ self.worktree_service.run(context, git_filename, capture_rebase(context, generation) if source is None else capture_rebase_passive(context, generation, source),
750
+ complete, priority=observation_priority(observers))