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,739 @@
1
+ """Coordinate explicitly confirmed removal of historical paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass, field, replace
8
+ from enum import Enum
9
+ from threading import Event
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PySide6.QtCore import QObject, QTimer, Signal
13
+ from shiboken6 import isValid
14
+
15
+ from gitpane.config import LOCAL_PREFIX, ConsoleOperation
16
+ from gitpane.controller.refresh_controller import RefreshScope
17
+ from gitpane.history.file_history import WindowCapability
18
+ from gitpane.history_rewrite.prepare import discard_preparation, prepare_rewrite
19
+ from gitpane.history_rewrite.publish import RewriteProgress, RewritePublicationOutcome, compensate_rewrite, publish_rewrite, rewrite_operation
20
+ from gitpane.history_rewrite.recovery import RecoveryDiscovery, RewritePublicationState, coherent_recovery_state, context_identity, discover_recovery, observe_recovery, read_recovery_record, recover_rewrite
21
+ from gitpane.history_rewrite.value import RewriteCapture, RewriteTarget, RewriteTargetKind
22
+ from gitpane.operation.console_line import REPOSITORY_HEADER_LABEL, ConsoleHeader, operation_end_text
23
+ from gitpane.operation.diagnostic import Diagnostic
24
+ from gitpane.operation.operation_status import OperationStatus
25
+ from gitpane.operation.operation_trace import OperationTrace, publish_after_trace
26
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, OperationOutcome, WriteScope
27
+ from gitpane.system.path_identity import canonical_path
28
+ from gitpane.ui.console.operation_dialog import OperationDialog
29
+ from gitpane.ui.history_rewrite.preview import HistoryRewritePreview, PreviewState
30
+ from gitpane.ui.history_rewrite.question import HistoryRewriteQuestion
31
+ from gitpane.ui.history_rewrite.recovery_dialog import HistoryRewriteRecoveryDialog, RecoveryText, RestoreBranchesQuestion, RestoreText, can_restore
32
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
33
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
34
+ from gitpane.worktree.file_action_target import FileActionTargetKind
35
+ from gitpane.worktree.file_menu_action import FileAction
36
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
37
+
38
+ PREVIEW_STORAGE_SUFFIX = "-previews"
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Generator
42
+
43
+ from gitpane.controller.console_registry import ConsoleRegistry
44
+ from gitpane.controller.operation_policy import OperationPolicy
45
+ from gitpane.controller.reference_read_controller import ReferenceReadController
46
+ from gitpane.controller.refresh_controller import RefreshController
47
+ from gitpane.controller.session_registry import RepositorySession, SessionRegistry
48
+ from gitpane.history.repository import GitRepository
49
+ from gitpane.history_rewrite.recovery import RecoveryObservation, RewriteRecoveryRecord
50
+ from gitpane.history_rewrite.value import RewritePreparation
51
+ from gitpane.operation.diagnostic import DiagnosticLog
52
+ from gitpane.operation.repository_operation import RepositoryOperationService
53
+ from gitpane.preference.preference_store import PreferenceStore
54
+ from gitpane.worktree.file_action_target import FileActionSelection
55
+ from gitpane.worktree.staging import StagingSession
56
+ from gitpane.worktree.worktree_service import WorktreeService
57
+
58
+
59
+ class HistoryRewriteText(Enum):
60
+ """Describe unavailable historical actions without inventing repository facts."""
61
+
62
+ UNAVAILABLE = "This window can no longer remove history. Reopen the repository and select the path again."
63
+ STALE_SELECTION = "This file selection changed. Select the path again before removing its history."
64
+ WORKTREE_REQUIRED = "Open a working repository before removing a path from history."
65
+ READING = "Wait for the repository references to finish loading."
66
+ RESTORED = "Previous branches were restored. The original index and working files were retained."
67
+ RECOVERY = "The repository update needs recovery. Keep the recovery data at {directory}."
68
+ REFRESH_FAILED = "The repository update finished, but its views could not be fully refreshed. Refresh the repository to inspect its current state."
69
+
70
+
71
+ @dataclass
72
+ class RewriteRequest:
73
+ """Bind one private calculation to its requesting session and explicit consent."""
74
+
75
+ session: RepositorySession
76
+ operation: OperationIntent
77
+ target: RewriteTarget
78
+ branch: str | None
79
+ cancelled: Event = field(default_factory=Event)
80
+ running: bool = False
81
+ preparation: RewritePreparation | None = None
82
+ submitted: bool = False
83
+
84
+
85
+ @dataclass
86
+ class RewriteRefresh:
87
+ """Keep a reconciled result pending until its current repository readers have settled."""
88
+
89
+ outcome: OperationOutcome
90
+ generation_by_session: dict[str, int]
91
+
92
+
93
+ @dataclass
94
+ class RecoveryRequest:
95
+ """Bind visible recovery facts to the exact session that may approve their restoration."""
96
+
97
+ session: RepositorySession
98
+ operation: OperationIntent
99
+ dialog: HistoryRewriteRecoveryDialog
100
+ record: RewriteRecoveryRecord | None = None
101
+ observation: RecoveryObservation | None = None
102
+ question: RestoreBranchesQuestion | None = None
103
+ running: bool = False
104
+ submitted: bool = False
105
+ resolving: bool = False
106
+
107
+
108
+ class HistoryRewriteController(QObject):
109
+ """Coordinate the question, private preview and confirmed publication lifecycle."""
110
+
111
+ recovery_requested = Signal(object)
112
+
113
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy,
114
+ reference_read_controller: ReferenceReadController, console_registry_controller: ConsoleRegistry, refresh_controller: RefreshController,
115
+ repository: GitRepository, worktree_service: WorktreeService, repository_operation: RepositoryOperationService,
116
+ storage_root_dir: str, preference: PreferenceStore, diagnostic_log: DiagnosticLog | None = None, parent: QObject | None = None) -> None:
117
+ """Receive existing authorities and services without owning a second Git runner."""
118
+ super().__init__(parent)
119
+ self.session_registry_controller = session_registry_controller
120
+ self.operation_policy_controller = operation_policy_controller
121
+ self.reference_read_controller = reference_read_controller
122
+ self.console_registry_controller = console_registry_controller
123
+ self.refresh_controller = refresh_controller
124
+ self.repository = repository
125
+ self.worktree_service = worktree_service
126
+ self.repository_operation = repository_operation
127
+ self.storage_root_dir = storage_root_dir
128
+ self.preference = preference
129
+ self.diagnostic_log = diagnostic_log
130
+ self.request_by_session: dict[str, RewriteRequest] = {}
131
+ self.question_by_session: dict[str, HistoryRewriteQuestion] = {}
132
+ self.preview_by_session: dict[str, HistoryRewritePreview] = {}
133
+ self.publication_by_id: dict[str, RewriteRequest] = {}
134
+ self.outcome_by_id: dict[str, OperationOutcome] = {}
135
+ self.finishing_id_set: set[str] = set()
136
+ self.refresh_by_id: dict[str, RewriteRefresh] = {}
137
+ self.recovery_by_session: dict[str, RecoveryRequest] = {}
138
+ self.recovery_publication_by_id: dict[str, RecoveryRequest] = {}
139
+ self.recovery_discovery_session_set: set[str] = set()
140
+ self.pending_recoveries_by_session: dict[str, list[RecoveryDiscovery]] = {}
141
+
142
+ def present_recovery(self, record: RewriteRecoveryRecord) -> None:
143
+ """Rediscover interrupted work in its live owning session after the normal refresh starts."""
144
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
145
+ if not session.is_closed and context_identity(session.repository_context) == record.context_identity:
146
+ QTimer.singleShot(0, self, lambda identity=session_id: self.check_pending_recovery(identity))
147
+ return
148
+
149
+ def check_pending_recovery(self, session_id: str) -> None:
150
+ """Find durable incomplete records at repository access, including unreadable journals."""
151
+ registry = self.session_registry_controller
152
+ session = registry.session_by_id.get(session_id)
153
+ window = registry.window_by_session.get(session_id)
154
+ if (session is None or session.is_closed or session.repository_context.is_bare or window is None or not isValid(window)
155
+ or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE)):
156
+ return
157
+ if session_id in self.recovery_by_session:
158
+ return
159
+ if session_id in self.recovery_discovery_session_set:
160
+ return
161
+ self.recovery_discovery_session_set.add(session_id)
162
+ context = session.repository_context
163
+ git_filename = self.repository.git_filename
164
+
165
+ def sequence() -> Generator:
166
+ """Read private journal directories on the shared file worker."""
167
+ return (yield FileTask(lambda: discover_recovery(self.storage_root_dir, context)))
168
+
169
+ def complete(discoveries: tuple[RecoveryDiscovery, ...] | None, error: Exception | None) -> None:
170
+ """Ignore a discovery completed after navigation or closure of its original session."""
171
+ self.recovery_discovery_session_set.discard(session_id)
172
+ if (registry.session_by_id.get(session_id) is not session or session.is_closed or session.repository_context != context
173
+ or self.repository.git_filename != git_filename or not isValid(window)):
174
+ return
175
+ if error is not None:
176
+ discoveries = (RecoveryDiscovery(self.storage_root_dir, None, str(error)),)
177
+ self.pending_recoveries_by_session[session_id] = list(discoveries or ())
178
+ self._show_next_recovery(session_id)
179
+
180
+ self.worktree_service.run(context, git_filename, sequence(), complete)
181
+
182
+ def _show_next_recovery(self, session_id: str) -> None:
183
+ """Present one durable record at a time without deleting dismissed evidence."""
184
+ discoveries = self.pending_recoveries_by_session.get(session_id)
185
+ registry = self.session_registry_controller
186
+ session = registry.session_by_id.get(session_id)
187
+ window = registry.window_by_session.get(session_id)
188
+ if not discoveries or session is None or session.is_closed or window is None or not isValid(window) or session_id in self.recovery_by_session:
189
+ return
190
+ discovery = discoveries.pop(0)
191
+ dialog = HistoryRewriteRecoveryDialog(session.repository_context.repository_dir, discovery, window)
192
+ request = RecoveryRequest(session, self._operation(session_id), dialog, discovery.record)
193
+ self.recovery_by_session[session_id] = request
194
+ dialog.refresh_requested.connect(lambda: self.refresh_recovery_status(session_id))
195
+ dialog.restore_requested.connect(lambda: self.request_restore(session_id))
196
+
197
+ def closed(_result: int) -> None:
198
+ """Dismiss only the window; an accepted operation continues under its reservation."""
199
+ if self.recovery_by_session.get(session_id) is request:
200
+ self.recovery_by_session.pop(session_id, None)
201
+ if request.question is not None and isValid(request.question):
202
+ request.question.reject()
203
+ dialog.deleteLater()
204
+ QTimer.singleShot(0, self, lambda: self._show_next_recovery(session_id))
205
+
206
+ dialog.finished.connect(closed)
207
+ dialog.show_modal()
208
+ self.refresh_recovery_status(session_id)
209
+
210
+ def _recovery_current(self, session_id: str, request: RecoveryRequest) -> bool:
211
+ """Require the original live session, repository, executable and form authority."""
212
+ return (self.recovery_by_session.get(session_id) is request and isValid(request.dialog)
213
+ and self.session_registry_controller.session_by_id.get(session_id) is request.session and not request.session.is_closed
214
+ and request.session.repository_context == request.operation.context and self.repository.git_filename == request.operation.git_filename
215
+ and self.operation_policy_controller.operation_allowed(request.operation))
216
+
217
+ def refresh_recovery_status(self, session_id: str) -> None:
218
+ """Observe interrupted work, resolving only already consistent public state under reservation."""
219
+ request = self.recovery_by_session.get(session_id)
220
+ if request is None or request.running or request.submitted or not isValid(request.dialog):
221
+ return
222
+ if request.question is not None and isValid(request.question):
223
+ request.question.reject()
224
+ request.question = None
225
+ registry = self.session_registry_controller
226
+ if (registry.session_by_id.get(session_id) is not request.session or request.session.is_closed
227
+ or request.session.repository_context != request.operation.context or self.repository.git_filename != request.operation.git_filename):
228
+ request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
229
+ return
230
+ request.operation = replace(self._operation(session_id), operation=RestoreText.RESTORE.value,
231
+ scope=WriteScope.capture(request.operation.context, (LOCAL_PREFIX + "*",)))
232
+ request.observation = None
233
+ request.running = True
234
+ request.dialog.set_loading()
235
+ operation = request.operation
236
+
237
+ def sequence() -> Generator:
238
+ """Never substitute an in-memory journal when the durable record became unreadable."""
239
+ record = yield FileTask(lambda: read_recovery_record(request.dialog.operation_dir, operation.context))
240
+ if record.git_filename != operation.git_filename:
241
+ raise ValueError(RecoveryText.EXECUTABLE_CHANGED.value)
242
+ observation = yield from observe_recovery(record, operation.context)
243
+ return record, observation
244
+
245
+ def complete(value: tuple | None, error: Exception | None) -> None:
246
+ """Install only observations still attached to the original live authority."""
247
+ request.running = False
248
+ if self.recovery_by_session.get(session_id) is not request or not isValid(request.dialog):
249
+ return
250
+ if not self._recovery_current(session_id, request):
251
+ request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
252
+ return
253
+ if error is not None or value is None:
254
+ request.record = None
255
+ request.dialog.set_observation(None, None, str(error) if error is not None else RecoveryText.MISSING.value)
256
+ return
257
+ request.record, request.observation = value
258
+ request.dialog.set_observation(request.record, request.observation)
259
+ if (request.record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED)
260
+ and coherent_recovery_state(request.record, request.observation) is not None):
261
+ self._resolve_recovery(session_id, request)
262
+
263
+ self.worktree_service.run(operation.context, operation.git_filename, sequence(), complete)
264
+
265
+ def _resolve_recovery(self, session_id: str, request: RecoveryRequest) -> None:
266
+ """Submit one cleanup of coherent state without authorizing any public branch restoration."""
267
+ record, observation = request.record, request.observation
268
+ operation = request.operation
269
+ request.submitted = True
270
+ request.resolving = True
271
+ request.dialog.set_loading()
272
+ self.recovery_publication_by_id[operation.operation_id] = request
273
+
274
+ def sequence() -> Generator:
275
+ """Bind cleanup to the displayed journal, public state and still-live session."""
276
+ current = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
277
+ if current != record or not self._recovery_current(session_id, request):
278
+ raise ValueError(RecoveryText.UNAVAILABLE.value)
279
+ return (yield from recover_rewrite(current, operation, self.repository_operation, restore=False, expected_observation=observation))
280
+
281
+ self.repository_operation.submit(operation, sequence())
282
+
283
+ def request_restore(self, session_id: str) -> RestoreBranchesQuestion | None:
284
+ """Open a separate confirmation for precisely the currently displayed observation."""
285
+ request = self.recovery_by_session.get(session_id)
286
+ if request is None or request.running or request.submitted or not can_restore(request.record, request.observation):
287
+ return None
288
+ if not self._recovery_current(session_id, request):
289
+ request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
290
+ return None
291
+ if request.question is not None and isValid(request.question) and request.question.isVisible():
292
+ return request.question
293
+ question = RestoreBranchesQuestion(request.operation.context.repository_dir, request.record, request.observation, request.dialog)
294
+ request.question = question
295
+ question.restore_requested.connect(lambda: self.confirm_restore(session_id, question))
296
+ question.show_modal()
297
+ return question
298
+
299
+ def confirm_restore(self, session_id: str, question: RestoreBranchesQuestion) -> str | None:
300
+ """Consume explicit consent once, then revalidate its exact record and observed state."""
301
+ request = self.recovery_by_session.get(session_id)
302
+ if (request is None or request.running or request.submitted or request.question is not question or not isValid(question)
303
+ or question.result() != question.DialogCode.Accepted or not can_restore(request.record, request.observation)):
304
+ return None
305
+ request.question = None
306
+ if not self._recovery_current(session_id, request):
307
+ request.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
308
+ return None
309
+ record, observation = request.record, request.observation
310
+ operation = replace(request.operation, operation_id=uuid.uuid4().hex)
311
+ request.operation = operation
312
+ request.submitted = True
313
+ request.dialog.set_loading()
314
+ self.recovery_publication_by_id[operation.operation_id] = request
315
+ self._open_output(request)
316
+
317
+ def sequence() -> Generator:
318
+ """Refuse altered journals before the domain performs its fresh collective comparison."""
319
+ fresh_record = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
320
+ if fresh_record != record or not self._recovery_current(session_id, request):
321
+ raise ValueError(RecoveryText.UNAVAILABLE.value)
322
+ return (yield from compensate_rewrite(fresh_record, observation, operation, self.repository_operation))
323
+
324
+ self.repository_operation.submit(operation, sequence())
325
+ return operation.operation_id
326
+
327
+ def _recovery_finished(self, outcome: OperationOutcome, request: RecoveryRequest) -> None:
328
+ """Display verified restoration or preserve the failed attempt as an inspectable diagnostic."""
329
+ request.submitted = False
330
+ self.outcome_by_id[outcome.operation_id] = outcome
331
+ session_id = request.operation.session_id
332
+ if request.resolving:
333
+ request.resolving = False
334
+ if not self._recovery_current(session_id, request):
335
+ return
336
+ if outcome.diagnostic is not None:
337
+ request.dialog.set_error(outcome.diagnostic.cause)
338
+ return
339
+ # No public refs or index were changed, so a diagnostic reread is sufficient.
340
+ self.refresh_recovery_status(session_id)
341
+ return
342
+ # The domain reports the original rewrite as failed after compensation; this separately requested restoration succeeded.
343
+ restored = isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None and outcome.record.state == RewritePublicationState.RESTORED
344
+ self._refresh_result(request.operation, replace(outcome, status=OperationStatus.SUCCESS) if restored else outcome)
345
+ if self.recovery_by_session.get(session_id) is request and isValid(request.dialog):
346
+ if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None:
347
+ request.record = outcome.record
348
+ if outcome.diagnostic is not None:
349
+ request.dialog.set_error(RecoveryText.UNAVAILABLE.value + "\n" + outcome.diagnostic.cause)
350
+ else:
351
+ self.refresh_recovery_status(session_id)
352
+
353
+ def _operation(self, session_id: str) -> OperationIntent:
354
+ """Capture current form authority through the common operation policy."""
355
+ registry = self.session_registry_controller
356
+ session = registry.session_by_id[session_id]
357
+ return OperationIntent.capture(uuid.uuid4().hex, session.repository_context, self.repository.git_filename,
358
+ session_id=session_id, generation=registry.generation_by_session[session_id], action=OperationAction.REF_WRITE,
359
+ operation=FileAction.REMOVE_FROM_HISTORY.label, from_form=True)
360
+
361
+ def remove_history_reason(self, session_id: str, selection: FileActionSelection) -> str:
362
+ """Check menu authority without launching Git or treating display data as write proof."""
363
+ registry = self.session_registry_controller
364
+ window = registry.window_by_session.get(session_id)
365
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or window is None or not isValid(window):
366
+ return HistoryRewriteText.UNAVAILABLE.value
367
+ if not window.file_action_is_current(selection):
368
+ return HistoryRewriteText.STALE_SELECTION.value
369
+ if window.context.is_bare:
370
+ return HistoryRewriteText.WORKTREE_REQUIRED.value
371
+ if self.reference_read_controller.reference_snapshot_by_session.get(session_id) is None:
372
+ return HistoryRewriteText.READING.value
373
+ operation = self._operation(session_id)
374
+ return self.operation_policy_controller.operation_refusal(operation) or self.repository_operation.availability_diagnostic(operation)
375
+
376
+ def request(self, session_id: str, selection: FileActionSelection) -> HistoryRewriteQuestion | None:
377
+ """Open one initial question for the exact still-current historical file or folder."""
378
+ reason = self.remove_history_reason(session_id, selection)
379
+ if reason:
380
+ window = self.session_registry_controller.window_by_session.get(session_id)
381
+ if window is not None and isValid(window):
382
+ window.statusBar().showMessage(reason)
383
+ return None
384
+ if session_id in self.request_by_session:
385
+ return self.question_by_session.get(session_id)
386
+ registry = self.session_registry_controller
387
+ snapshot = self.reference_read_controller.reference_snapshot_by_session[session_id]
388
+ kind = RewriteTargetKind.FOLDER if selection.kind == FileActionTargetKind.FOLDER else RewriteTargetKind.FILE
389
+ target = RewriteTarget(selection.path(), kind)
390
+ request = RewriteRequest(registry.session_by_id[session_id], self._operation(session_id), target, snapshot.head_ref)
391
+ self.request_by_session[session_id] = request
392
+ question = HistoryRewriteQuestion(target, request.branch, registry.window_by_session[session_id])
393
+ self.question_by_session[session_id] = question
394
+ question.build_requested.connect(lambda _target: self.build_preview(session_id, request))
395
+ question.abort_requested.connect(lambda: self.abort_preview(session_id, request))
396
+ request.session.closed.connect(lambda: self.abort_preview(session_id))
397
+ question.show_modal()
398
+ return question
399
+
400
+ def _current(self, session_id: str, request: RewriteRequest) -> bool:
401
+ """Reject results after cancellation, replacement, loss of authority or a new generation."""
402
+ window = self.session_registry_controller.window_by_session.get(session_id)
403
+ return (self.request_by_session.get(session_id) is request and not request.cancelled.is_set()
404
+ and window is not None and isValid(window) and not window.is_closed
405
+ and self.session_registry_controller.session_by_id.get(session_id) is request.session
406
+ and not request.session.is_closed and request.session.repository_context == request.operation.context
407
+ and self.repository.git_filename == request.operation.git_filename
408
+ and self.operation_policy_controller.operation_allowed(request.operation))
409
+
410
+ def build_preview(self, session_id: str, request: RewriteRequest) -> None:
411
+ """Start one private calculation after explicit Build preview consent."""
412
+ if request.running or request.preparation is not None or request.cancelled.is_set():
413
+ return
414
+ registry = self.session_registry_controller
415
+ preview = self.preview_by_session.get(session_id)
416
+ if preview is None or not isValid(preview) or preview.state == PreviewState.CLOSED:
417
+ family = self.refresh_controller.submodule_snapshot_by_session.get(session_id)
418
+ parent_dir = ""
419
+ if family is not None:
420
+ for entry in family.entries:
421
+ if entry.repository_dir == request.operation.context.repository_dir:
422
+ parent_dir = entry.parent_dir or ""
423
+ break
424
+ preview = HistoryRewritePreview(request.target, request.branch or "", request.operation.context.repository_dir,
425
+ registry.window_by_session[session_id], active_submodule=bool(parent_dir), parent_repository_dir=parent_dir)
426
+ self.preview_by_session[session_id] = preview
427
+ preview.abort_requested.connect(lambda: self.abort_preview(session_id))
428
+ preview.recompute_requested.connect(lambda: self.recompute(session_id))
429
+ preview.confirm_requested.connect(lambda preparation: self.confirm(session_id, preparation))
430
+ preview.show_modal()
431
+ if not self._current(session_id, request):
432
+ preview.mark_stale()
433
+ return
434
+ request.running = True
435
+ # _current has just checked that the operation's Git is the repository's chosen Git. Its version is the line the Git check at start or in
436
+ # Settings read for that Git; it stays empty while that check has not answered or after it failed, and the preparation then reads it itself
437
+ # before the export.
438
+ capture = RewriteCapture(request.operation.context, request.operation.git_filename, request.branch, generation=request.operation.generation,
439
+ git_version=self.repository.git_version)
440
+
441
+ def prepare() -> Generator:
442
+ """Read the active repository's parent identity before showing its mandatory warning."""
443
+ parent_output = yield GitCommand(["rev-parse", "--show-superproject-working-tree"])
444
+ if isValid(preview) and self._current(session_id, request):
445
+ preview.parent_repository_dir = os.fsdecode(parent_output.removesuffix(b"\n"))
446
+ preview.active_submodule = bool(preview.parent_repository_dir)
447
+ # Unpublished scratch directories must never be mistaken for durable interrupted publications.
448
+ preview_storage_dir = self.storage_root_dir + PREVIEW_STORAGE_SUFFIX
449
+ return (yield from prepare_rewrite(capture, request.target, preview_storage_dir, cancelled=request.cancelled.is_set))
450
+
451
+ def complete(preparation: RewritePreparation | None, error: Exception | None) -> None:
452
+ """Publish only into the live original preview after every private process has drained."""
453
+ request.running = False
454
+ request.preparation = preparation
455
+ if request.cancelled.is_set() or not isValid(preview) or request.session.is_closed:
456
+ self._discard(session_id, request, close=True)
457
+ return
458
+ if not self._current(session_id, request):
459
+ preview.mark_stale()
460
+ preview.recompute_button.setEnabled(True)
461
+ return
462
+ if error is not None:
463
+ preview.set_error(str(error))
464
+ return
465
+ if preparation is None:
466
+ preview.set_error(HistoryRewriteText.READING.value)
467
+ return
468
+ preview.set_preparation(preparation)
469
+
470
+ self.worktree_service.run(capture.context, capture.git_filename, prepare(), complete, operation_id=request.operation.operation_id)
471
+
472
+ def abort_preview(self, session_id: str, request: RewriteRequest | None = None) -> None:
473
+ """Cancel only this private calculation, then close after its processes and cleanup finish."""
474
+ request = request or self.request_by_session.get(session_id)
475
+ if request is None or self.request_by_session.get(session_id) is not request or request.submitted:
476
+ return
477
+ request.cancelled.set()
478
+ if request.running:
479
+ runner = self.worktree_service.runner
480
+ requests = list(runner.pending_requests)
481
+ for active in tuple(runner.active_by_id.values()):
482
+ requests.append(active.request)
483
+ for process in requests:
484
+ if process.operation_id == request.operation.operation_id:
485
+ runner.cancel(process.request_id)
486
+ return
487
+ self._discard(session_id, request, close=True)
488
+
489
+ def _discard(self, session_id: str, request: RewriteRequest, *, close: bool) -> None:
490
+ """Remove only owned private storage outside the GUI thread before releasing the form."""
491
+ def sequence() -> Generator:
492
+ """Wait for ownership-checked private cleanup on the shared file worker."""
493
+ if request.preparation is not None:
494
+ yield FileTask(lambda: discard_preparation(request.preparation))
495
+
496
+ def complete(_value: object, error: Exception | None) -> None:
497
+ """Close the original form without interfering with a newer request."""
498
+ request.running = False
499
+ if self.request_by_session.get(session_id) is not request:
500
+ return
501
+ preview = self.preview_by_session.get(session_id)
502
+ if error is not None:
503
+ window = self.session_registry_controller.window_by_session.get(session_id)
504
+ if window is not None and isValid(window):
505
+ window.statusBar().showMessage(str(error))
506
+ self.request_by_session.pop(session_id, None)
507
+ question = self.question_by_session.pop(session_id, None)
508
+ if question is not None and isValid(question):
509
+ question.close()
510
+ question.deleteLater()
511
+ if close and preview is not None and isValid(preview):
512
+ preview.finish_abort()
513
+
514
+ request.running = True
515
+ self.worktree_service.run(request.operation.context, request.operation.git_filename, sequence(), complete)
516
+
517
+ def recompute(self, session_id: str) -> None:
518
+ """Create fresh form authority after draining the previous private proposal."""
519
+ previous = self.request_by_session.get(session_id)
520
+ if previous is None or previous.running or previous.submitted or not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
521
+ return
522
+ previous.cancelled.set()
523
+
524
+ def sequence() -> Generator:
525
+ """Discard the superseded token before calculating another proposal."""
526
+ if previous.preparation is not None:
527
+ yield FileTask(lambda: discard_preparation(previous.preparation))
528
+
529
+ def complete(_value: object, error: Exception | None) -> None:
530
+ """Start anew only while the same repository session still owns the form."""
531
+ previous.running = False
532
+ if self.request_by_session.get(session_id) is not previous or previous.session.is_closed:
533
+ return
534
+ preview = self.preview_by_session.get(session_id)
535
+ if preview is None or not isValid(preview) or preview.state != PreviewState.PREPARING:
536
+ self.abort_preview(session_id)
537
+ return
538
+ if error is not None:
539
+ preview.set_error(str(error))
540
+ return
541
+ if (self.session_registry_controller.session_by_id.get(session_id) is not previous.session
542
+ or previous.session.repository_context != previous.operation.context):
543
+ preview.mark_stale(HistoryRewriteText.UNAVAILABLE.value)
544
+ return
545
+ snapshot = self.reference_read_controller.reference_snapshot_by_session.get(session_id)
546
+ request = RewriteRequest(previous.session, self._operation(session_id), previous.target, snapshot.head_ref if snapshot is not None else previous.branch)
547
+ self.request_by_session[session_id] = request
548
+ self.build_preview(session_id, request)
549
+
550
+ previous.running = True
551
+ self.worktree_service.run(previous.operation.context, previous.operation.git_filename, sequence(), complete)
552
+
553
+ def confirm(self, session_id: str, preparation: RewritePreparation) -> str | None:
554
+ """Consume exactly one fresh proposal and let the shared service reserve its publication."""
555
+ request = self.request_by_session.get(session_id)
556
+ preview = self.preview_by_session.get(session_id)
557
+ if (request is None or request.submitted or request.running or request.preparation is not preparation
558
+ or preview is None or not isValid(preview) or preview.state != PreviewState.EXECUTING):
559
+ return None
560
+ if not self._current(session_id, request):
561
+ preview.mark_stale(self.operation_policy_controller.operation_refusal(request.operation))
562
+ return None
563
+ request.submitted = True
564
+ operation = rewrite_operation(preparation, uuid.uuid4().hex, session_id=session_id)
565
+ request.operation = operation
566
+ self.publication_by_id[operation.operation_id] = request
567
+ self._open_output(request)
568
+ self.repository_operation.submit(operation, publish_rewrite(preparation, operation, self.repository_operation, self.storage_root_dir,
569
+ progress=lambda phase: self._show_progress(operation.operation_id, phase)))
570
+ return operation.operation_id
571
+
572
+ def _open_output(self, request: RewriteRequest | RecoveryRequest) -> None:
573
+ """Keep the common console registered so repository closure waits for reconciliation."""
574
+ operation = request.operation
575
+ trace = OperationTrace(operation.operation_id, self.repository.runner, self.diagnostic_log, native_commands_only=True,
576
+ header=ConsoleHeader(REPOSITORY_HEADER_LABEL, operation.context.repository_dir))
577
+ window = self.session_registry_controller.window_by_session[operation.session_id]
578
+ output = OperationDialog(trace, operation.operation, window, preference=self.preference, console_operation=ConsoleOperation.HISTORY_REWRITE)
579
+ self.console_registry_controller.reference_output_by_id[operation.operation_id] = output
580
+
581
+ def closed(_result: int) -> None:
582
+ """Release only this console's subscription once it can actually close."""
583
+ self.console_registry_controller.reference_output_by_id.pop(operation.operation_id, None)
584
+ trace.close()
585
+
586
+ output.finished.connect(closed)
587
+ output.abort_requested.connect(lambda: self.finish_safely(operation.operation_id))
588
+ open_window_modal(output, keeps_window_buttons=True)
589
+
590
+ def _show_progress(self, operation_id: str, phase: RewriteProgress) -> None:
591
+ """Report actual service steps without inventing a completion percentage."""
592
+ output = self.console_registry_controller.reference_output_by_id.get(operation_id)
593
+ if output is None or not isValid(output) or not output.running:
594
+ return
595
+ output.trace.append_event(operation_id, "status", phase.value)
596
+ output.status_label.setText((RewriteProgress.FINISHING if operation_id in self.finishing_id_set else phase).value)
597
+
598
+ def finish_safely(self, operation_id: str) -> None:
599
+ """Keep an accepted publication alive until refs and index have been reconciled."""
600
+ if operation_id not in self.publication_by_id and operation_id not in self.recovery_publication_by_id:
601
+ return
602
+ self.finishing_id_set.add(operation_id)
603
+ self._show_progress(operation_id, RewriteProgress.FINISHING)
604
+
605
+ def operation_finished(self, outcome: OperationOutcome) -> None:
606
+ """Present only the service's reconciled outcome, preserving incomplete recovery evidence."""
607
+ recovery = self.recovery_publication_by_id.pop(outcome.operation_id, None)
608
+ if recovery is not None:
609
+ self._recovery_finished(outcome, recovery)
610
+ return
611
+ request = self.publication_by_id.pop(outcome.operation_id, None)
612
+ if request is None:
613
+ return
614
+ self.outcome_by_id[outcome.operation_id] = outcome
615
+ session_id = request.operation.session_id
616
+ preview = self.preview_by_session.get(session_id)
617
+ if outcome.status == OperationStatus.REFUSED:
618
+ request.submitted = False
619
+ if preview is not None and isValid(preview):
620
+ preview.mark_stale(outcome.diagnostic.cause if outcome.diagnostic is not None else "")
621
+ else:
622
+ if preview is not None and isValid(preview):
623
+ preview.finish_publication()
624
+ self._discard(session_id, request, close=False)
625
+ if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None and outcome.record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED):
626
+ self.recovery_requested.emit(outcome.record)
627
+ if outcome.status == OperationStatus.REFUSED:
628
+ self._finish_output(outcome)
629
+ return
630
+ replacement_by_oid = request.preparation.replacement_by_oid if outcome.status == OperationStatus.SUCCESS else None
631
+ self._refresh_result(request.operation, outcome, replacement_by_oid)
632
+
633
+ def _refresh_result(self, operation: OperationIntent, outcome: OperationOutcome, replacement_by_oid: dict[str, str | None] | None = None) -> None:
634
+ """Keep publication and restoration consoles pending until the shared readers settle."""
635
+ generation_by_session = {}
636
+ for identity, session in self.session_registry_controller.session_by_id.items():
637
+ if (not session.is_closed and canonical_path(session.repository_context.common_git_dir) == operation.common_git_dir
638
+ and self.operation_policy_controller.allows(identity, WindowCapability.MANAGE)):
639
+ generation_by_session[identity] = session.generation + 1
640
+ self.refresh_by_id[outcome.operation_id] = RewriteRefresh(outcome, generation_by_session)
641
+ self._show_progress(outcome.operation_id, RewriteProgress.REFRESHING)
642
+ self.refresh_controller.refresh_after_reference_operation(operation, outcome, RefreshScope.FULL, replacement_by_oid=replacement_by_oid)
643
+ self.readers_changed()
644
+
645
+ def observe_refresh(self, session_id: str) -> None:
646
+ """Revoke a visible proposal immediately when its repository starts a new read generation."""
647
+ recovery = self.recovery_by_session.get(session_id)
648
+ if recovery is not None and not recovery.submitted and isValid(recovery.dialog) and not self._recovery_current(session_id, recovery):
649
+ recovery.observation = None
650
+ recovery.dialog.set_error(HistoryRewriteText.UNAVAILABLE.value)
651
+ if recovery.question is not None and isValid(recovery.question):
652
+ recovery.question.reject()
653
+ recovery.question = None
654
+ request = self.request_by_session.get(session_id)
655
+ preview = self.preview_by_session.get(session_id)
656
+ if request is None or request.submitted or preview is None or not isValid(preview):
657
+ return
658
+ if not self._current(session_id, request):
659
+ preview.mark_stale()
660
+ preview.recompute_button.setEnabled(not request.running)
661
+
662
+ def observe_worktree(self, staging: StagingSession) -> None:
663
+ """Invalidate old index proposals when an existing worktree reader observes a change."""
664
+ snapshot = staging.snapshot
665
+ if snapshot is not None and snapshot.fresh:
666
+ for session_id, request in tuple(self.request_by_session.items()):
667
+ preparation = request.preparation
668
+ if request.submitted or preparation is None or snapshot.key != request.operation.key:
669
+ continue
670
+ capture = preparation.capture
671
+ if (snapshot.index_content != capture.index_identity or snapshot.head_oid != capture.local_ref_by_name.get(capture.head_ref)
672
+ or snapshot.probe is not None and snapshot.probe.branch_head != capture.head_ref):
673
+ preview = self.preview_by_session.get(session_id)
674
+ if preview is not None and isValid(preview):
675
+ preview.mark_stale()
676
+ self.readers_changed()
677
+
678
+ def readers_changed(self) -> None:
679
+ """Check pending results after the current signal has finished updating reader state."""
680
+ if self.refresh_by_id:
681
+ QTimer.singleShot(0, self, self._finish_refresh)
682
+
683
+ def _finish_refresh(self) -> None:
684
+ """End consoles only after refreshed graphs, worktree reads and their processes have drained."""
685
+ if (self.repository.runner.active_by_id or self.repository.runner.pending_requests or self.worktree_service.file_task_by_id
686
+ or self.worktree_service.continuation_by_id):
687
+ return
688
+ registry = self.session_registry_controller
689
+ for operation_id, pending in tuple(self.refresh_by_id.items()):
690
+ waiting = False
691
+ failed = False
692
+ for session_id, generation in pending.generation_by_session.items():
693
+ session = registry.session_by_id.get(session_id)
694
+ if session is None or session.is_closed:
695
+ continue
696
+ if session.generation < generation or session.refresh_active or session.refresh_queued is not None:
697
+ waiting = True
698
+ break
699
+ staging = self.refresh_controller.staging_session_registry_controller.staging_by_key.get(WorktreeKey.from_context(session.repository_context))
700
+ if staging is not None and staging.refresh_active:
701
+ waiting = True
702
+ break
703
+ if (session.snapshot is None or not session.snapshot.complete or session.snapshot.error
704
+ or staging is not None and (staging.snapshot is None or not staging.snapshot.fresh)):
705
+ failed = True
706
+ if waiting:
707
+ continue
708
+ self.refresh_by_id.pop(operation_id, None)
709
+ outcome = pending.outcome
710
+ if failed and outcome.status == OperationStatus.SUCCESS:
711
+ diagnostic = Diagnostic(FileAction.REMOVE_FROM_HISTORY.label, "", self.repository.git_filename, [], HistoryRewriteText.REFRESH_FAILED.value).prepare()
712
+ outcome = replace(outcome, status=OperationStatus.UNKNOWN, diagnostic=diagnostic)
713
+ self._finish_output(outcome)
714
+
715
+ def _finish_output(self, outcome: OperationOutcome) -> None:
716
+ """Append the terminal result after the trace has consumed the final native output."""
717
+ self.finishing_id_set.discard(outcome.operation_id)
718
+ output = self.console_registry_controller.reference_output_by_id.get(outcome.operation_id)
719
+ if output is None or not isValid(output):
720
+ return
721
+
722
+ def publish() -> None:
723
+ """End a still-live console with the exact observed status and diagnostic."""
724
+ if not isValid(output):
725
+ return
726
+ if outcome.diagnostic is not None:
727
+ output.trace.append_diagnostic(outcome.operation_id, "status", outcome.diagnostic, outcome.status)
728
+ if isinstance(outcome, RewritePublicationOutcome) and outcome.record is not None:
729
+ if outcome.record.state == RewritePublicationState.RESTORED:
730
+ output.trace.append_event(outcome.operation_id, "status", HistoryRewriteText.RESTORED.value)
731
+ elif outcome.record.state != RewritePublicationState.COMPLETE:
732
+ output.trace.append_event(outcome.operation_id, "status", HistoryRewriteText.RECOVERY.value.format(directory=outcome.record.operation_dir))
733
+ output.trace.append_end_line(outcome.operation_id, "status", outcome.status.value,
734
+ operation_end_text(output.windowTitle(), outcome.status), outcome.status,
735
+ failure_reported=outcome.diagnostic is not None)
736
+ output.show_result(outcome.status, failure_reported=outcome.diagnostic is not None,
737
+ failure_text=outcome.diagnostic.cause if outcome.diagnostic is not None else "")
738
+
739
+ publish_after_trace(publish, self)