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,730 @@
1
+ """Run native stash commands, keeping their observed effects with the authority they started from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import uuid
8
+ from dataclasses import dataclass, field, replace
9
+ from datetime import UTC, datetime
10
+ from typing import TYPE_CHECKING
11
+
12
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, GIT_PATH_SEPARATOR, SUBMODULE_RECURSION_OFF_ARGUMENTS
13
+ from gitpane.operation.diagnostic import Diagnostic
14
+ from gitpane.operation.operation_status import OperationStatus
15
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
16
+ from gitpane.system.path_identity import canonical_path
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable, Generator
20
+
21
+ from gitpane.history.repository_value import RepositoryContext
22
+ from gitpane.operation.diagnostic import PreparedDiagnostic
23
+ from gitpane.operation.launch_signature import LaunchSignature
24
+ from gitpane.operation.repository_operation import OperationIntent, OperationOutcome, RepositoryOperationService
25
+ from gitpane.reference.stash_list import StashEntry, StashList
26
+ from gitpane.worktree.worktree_service import WorktreeService
27
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
28
+
29
+ STASH_INCLUDE_UNTRACKED_KEY = "stash_include_untracked"
30
+ STASH_KEEP_INDEX_KEY = "stash_keep_index"
31
+ STASH_DEFAULT_BY_KEY = {STASH_INCLUDE_UNTRACKED_KEY: True, STASH_KEEP_INDEX_KEY: False}
32
+ STASH_REFERENCE = "refs/stash"
33
+ # Where the files backend keeps the stash reflog, relative to the shared Git directory Git already resolved.
34
+ STASH_REFLOG_DIR_PARTS = ("logs", "refs")
35
+ STASH_REFLOG_BASENAME = "stash"
36
+ STASH_CHANGED_CAUSE = "Stash changed during reading; refresh the repository"
37
+ NEW_CHANGE_CAUSE = "Working files, index or HEAD changed; reopen the form after refreshing"
38
+ CREATE_CHANGED_CAUSE = "Working files, index, HEAD or stash authority changed before launch; reopen the form"
39
+ POP_CHANGED_CAUSE = "Stash, working files, index or authority changed before launch; refresh the repository"
40
+ DROP_CHANGED_CAUSE = "The stash list changed since it was read; refresh the repository and try again"
41
+ DROP_AMBIGUOUS_CAUSE = "Several stashes hold this same stash commit; drop the right one from the left panel"
42
+ DROP_CONFLICT_CAUSE = "Merge conflicts remain in the working tree; the stash is kept"
43
+ DROP_OPERATION_LABEL = "Drop stash"
44
+
45
+
46
+ def stash_reflog_filename(context: RepositoryContext) -> str:
47
+ """Name the shared stash reflog of the files backend; a reftable store keeps it in its tables instead."""
48
+ return os.path.join(context.common_git_dir, *STASH_REFLOG_DIR_PARTS, STASH_REFLOG_BASENAME)
49
+
50
+
51
+ def capture_stash_signature(context: RepositoryContext, filenames: tuple[str, ...] = (), watched_dirs: tuple[str, ...] = (),
52
+ parent_dirs: tuple[str, ...] = ()) -> LaunchSignature:
53
+ """Take the metadata, without any content, of the shared stash storage plus the given paths.
54
+
55
+ The stash storage is every file and folder the reference signature covers (loose, packed and table references,
56
+ configuration, linked worktrees) and the stash reflog with each of its folders, present or absent, so a reflog
57
+ that appears, grows or is replaced changes the signature. Runs on a worker: it walks the reference folders.
58
+ """
59
+ from gitpane.operation.launch_signature import sign_launch_paths
60
+ from gitpane.reference.reference_edit import reference_signature_paths
61
+
62
+ reference_filenames, reference_dirs = reference_signature_paths(context)
63
+ reflog_filename = stash_reflog_filename(context)
64
+ log_refs_dir = os.path.dirname(reflog_filename)
65
+ all_filenames = [*reference_filenames, reflog_filename, *filenames]
66
+ all_watched_dirs = [*reference_dirs, os.path.dirname(log_refs_dir), log_refs_dir, *watched_dirs]
67
+ all_parent_dirs = [context.common_git_dir, context.git_dir, *parent_dirs]
68
+ return sign_launch_paths(all_filenames, all_watched_dirs, all_parent_dirs)
69
+
70
+
71
+ def refuse_new_changes(snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> None:
72
+ """Refuse a fresh status that shows a path the form's read did not: a new change or a new untracked file."""
73
+ for entry in entries:
74
+ for path in entry.paths:
75
+ if path not in snapshot.fingerprint_by_path:
76
+ raise ValueError(NEW_CHANGE_CAUSE)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class StashLaunchBaseline:
81
+ """The first look one stash write relies on, taken on a worker before its policy and content checks.
82
+
83
+ ``signature`` holds only metadata of the checked files and folders. The other fields are the exact contents a
84
+ worker compares again once every check has passed, just before the command is handed to the launcher. Nothing
85
+ here authorizes a write by itself.
86
+ """
87
+
88
+ signature: LaunchSignature
89
+ reference_state: tuple
90
+ working_state: tuple
91
+ reflog_content: bytes | None
92
+
93
+
94
+ def stash_launch_paths(context: RepositoryContext, paths: tuple[bytes, ...], untracked_dirs: tuple[str, ...], native_git_dirs: tuple[str, ...],
95
+ index_filename: str) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
96
+ """List the files, watched folders and containing folders one stash write is checked against, besides the stash storage.
97
+
98
+ Files: the index, every path the native stash reads, the linked worktree pointers and the pause markers of every
99
+ related Git directory, so a rebase, cherry-pick, revert or merge started elsewhere is noticed. Watched folders:
100
+ the worktree root, each folder holding a checked path and the untracked folders a creation found, where a new
101
+ entry would change what Git stashes. Containing folders are compared by identity only. Runs on a worker.
102
+ """
103
+ from gitpane.operation.launch_signature import LINKED_COMMON_DIR_BASENAME, WORKTREE_GIT_ENTRY_BASENAME
104
+ from gitpane.operation.repository_operation import PAUSE_PROBE_GIT_PATHS
105
+ from gitpane.reference.reference_edit import reference_git_dirs
106
+ from gitpane.tool.system_tool import worktree_filename
107
+
108
+ worktree_dir = context.worktree_dir
109
+ if worktree_dir is None:
110
+ raise ValueError("A stash requires a working tree")
111
+ filenames = [index_filename, os.path.join(context.git_dir, LINKED_COMMON_DIR_BASENAME)]
112
+ watched_dirs = [worktree_dir, *untracked_dirs]
113
+ git_entry = os.path.join(worktree_dir, WORKTREE_GIT_ENTRY_BASENAME)
114
+ parent_dirs = [os.path.dirname(index_filename), git_entry]
115
+ if os.path.isfile(git_entry):
116
+ filenames.append(git_entry)
117
+ root_dir = os.path.normcase(os.path.abspath(worktree_dir))
118
+ for path in paths:
119
+ filename = worktree_filename(context, path)
120
+ filenames.append(filename)
121
+ directory = os.path.dirname(filename)
122
+ while os.path.normcase(directory) != root_dir:
123
+ watched_dirs.append(directory)
124
+ parent_dir = os.path.dirname(directory)
125
+ if parent_dir == directory:
126
+ raise ValueError("A stashed path is outside the working tree")
127
+ directory = parent_dir
128
+ for git_dir in sorted({*native_git_dirs, *reference_git_dirs(context)}):
129
+ parent_dirs.append(git_dir)
130
+ for git_path in PAUSE_PROBE_GIT_PATHS:
131
+ filenames.append(os.path.join(git_dir, *git_path.split(GIT_PATH_SEPARATOR)))
132
+ return tuple(filenames), tuple(watched_dirs), tuple(parent_dirs)
133
+
134
+
135
+ def capture_stash_launch_baseline(context: RepositoryContext, index_filename: str, index_content: bytes | None, paths: tuple[bytes, ...],
136
+ untracked_dirs: tuple[str, ...], native_git_dirs: tuple[str, ...], changed_cause: str) -> StashLaunchBaseline:
137
+ """Take the first look of a stash write: the contents it will compare again, between two identical metadata looks.
138
+
139
+ The index must still be the one the write was asked with. Runs on a worker: it walks folders and reads contents.
140
+ """
141
+ from gitpane.operation.launch_signature import verify_launch_signature
142
+ from gitpane.reference.reference_edit import reference_signature, working_signature
143
+ from gitpane.worktree.worktree_snapshot import read_index
144
+
145
+ filenames, watched_dirs, parent_dirs = stash_launch_paths(context, paths, untracked_dirs, native_git_dirs, index_filename)
146
+ signature = capture_stash_signature(context, filenames, watched_dirs, parent_dirs)
147
+ if read_index(index_filename) != index_content:
148
+ raise ValueError(changed_cause)
149
+ reference_state = reference_signature(context)
150
+ working_state = working_signature(context, paths)
151
+ reflog_content = read_index(stash_reflog_filename(context))
152
+ verify_launch_signature(signature, changed_cause)
153
+ return StashLaunchBaseline(signature, reference_state, working_state, reflog_content)
154
+
155
+
156
+ def prepare_stash_launch(context: RepositoryContext, baseline: StashLaunchBaseline, index_filename: str, index_content: bytes | None, paths: tuple[bytes, ...],
157
+ untracked_signature: tuple[tuple[str, tuple[int, ...]], ...], changed_cause: str) -> LaunchSignature:
158
+ """Check, once every other check has passed, that nothing moved since the first look, and answer the signature the launch compares.
159
+
160
+ The contents are compared with the first look, between two metadata looks that must both match it: a change made
161
+ by another program meanwhile is refused, never adopted. Runs on a worker.
162
+ """
163
+ from gitpane.operation.launch_signature import verify_launch_signature
164
+ from gitpane.reference.reference_edit import reference_signature, working_signature
165
+ from gitpane.worktree.worktree_snapshot import read_index
166
+
167
+ verify_launch_signature(baseline.signature, changed_cause)
168
+ if (read_index(index_filename) != index_content or reference_signature(context) != baseline.reference_state
169
+ or working_signature(context, paths) != baseline.working_state or read_index(stash_reflog_filename(context)) != baseline.reflog_content):
170
+ raise ValueError(changed_cause)
171
+ untracked_dirs = []
172
+ for directory, _identity in untracked_signature:
173
+ untracked_dirs.append(directory)
174
+ if _directory_signature(tuple(untracked_dirs)) != untracked_signature:
175
+ raise ValueError(changed_cause)
176
+ verify_launch_signature(baseline.signature, changed_cause)
177
+ return baseline.signature
178
+
179
+
180
+ def _write_paths(snapshot: WorktreeSnapshot) -> tuple[bytes, ...]:
181
+ """Include clean tracked files because native stash also reads changes made while queued."""
182
+ path_set = set(snapshot.fingerprint_by_path)
183
+ for record in (snapshot.index_entries or b"").split(b"\0"):
184
+ if record:
185
+ _metadata, _separator, path = record.partition(b"\t")
186
+ path_set.add(path)
187
+ return tuple(sorted(path_set))
188
+
189
+
190
+ def _directory_signature(dirs: tuple[str, ...]) -> tuple[tuple[str, tuple[int, ...]], ...]:
191
+ """Detect new nested files through parent metadata without rereading ignored content."""
192
+ from gitpane.worktree.worktree_file import stat_identity
193
+
194
+ records = []
195
+ for directory in dirs:
196
+ metadata = os.lstat(directory)
197
+ if not stat.S_ISDIR(metadata.st_mode) or getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
198
+ raise ValueError("An untracked directory changed type; reopen the stash form")
199
+ records.append((directory, stat_identity(metadata)))
200
+ return tuple(records)
201
+
202
+
203
+ def _capture_untracked_directory_signature(context: RepositoryContext) -> Generator:
204
+ """Include initially empty directories while leaving Git-ignored trees unvisited."""
205
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
206
+ from gitpane.worktree.worktree_file import validate_destination
207
+
208
+ worktree_dir = context.worktree_dir
209
+ if worktree_dir is None:
210
+ raise ValueError("Creating a stash requires a working tree")
211
+ root_signature = yield FileTask(lambda: _directory_signature((worktree_dir,)))
212
+ untracked = yield GitCommand(["ls-files", "--others", "--directory", "--exclude-standard", "-z"])
213
+ ignored = yield GitCommand(["ls-files", "--others", "--ignored", "--directory", "--exclude-standard", "-z"])
214
+
215
+ def capture() -> tuple[tuple[str, tuple[int, ...]], ...]:
216
+ """Enumerate directory metadata in the filesystem worker before capturing file content."""
217
+ ignored_dir_set = set()
218
+ for path in ignored.split(b"\0"):
219
+ if path.endswith(b"/"):
220
+ filename, _parents = validate_destination(context, path.rstrip(b"/"))
221
+ ignored_dir_set.add(os.path.normcase(filename))
222
+ pending_dirs = []
223
+ for path in untracked.split(b"\0"):
224
+ if path.endswith(b"/"):
225
+ filename, _parents = validate_destination(context, path.rstrip(b"/"))
226
+ pending_dirs.append(filename)
227
+ directory_set = set()
228
+ while pending_dirs:
229
+ directory = pending_dirs.pop()
230
+ if directory in directory_set or os.path.normcase(directory) in ignored_dir_set:
231
+ continue
232
+ _directory_signature((directory,))
233
+ directory_set.add(directory)
234
+ with os.scandir(directory) as entries:
235
+ for entry in entries:
236
+ if entry.name.casefold() != ".git" and entry.is_dir(follow_symlinks=False):
237
+ pending_dirs.append(entry.path)
238
+ return root_signature + _directory_signature(tuple(sorted(directory_set)))
239
+
240
+ return (yield FileTask(capture))
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class StashOption:
245
+ """Keep creation options separate from the literal user message."""
246
+
247
+ include_untracked: bool = STASH_DEFAULT_BY_KEY[STASH_INCLUDE_UNTRACKED_KEY]
248
+ keep_index: bool = STASH_DEFAULT_BY_KEY[STASH_KEEP_INDEX_KEY]
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class StashIntent:
253
+ """Bind one submission to the exact worktree read presented by its form."""
254
+
255
+ operation: OperationIntent
256
+ snapshot: WorktreeSnapshot
257
+ message: str = ""
258
+ option: StashOption = StashOption()
259
+
260
+ @classmethod
261
+ def capture(cls, snapshot: WorktreeSnapshot, message: str = "", option: StashOption | None = None, *, session_id: str | None = None) -> StashIntent:
262
+ """Assign a fresh submission identity without changing its captured repository."""
263
+ from gitpane.operation.repository_operation import OperationAction, OperationIntent, WriteScope
264
+
265
+ operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
266
+ session_id=session_id, operation="Create stash", action=OperationAction.STASH,
267
+ scope=WriteScope.capture(snapshot.context, (STASH_REFERENCE,)))
268
+ return cls(operation, snapshot, message, option if option is not None else StashOption())
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class StashObservation:
273
+ """Preserve reflog entries even when two consecutive stashes share an object ID."""
274
+
275
+ oid: str | None
276
+ entries: tuple[bytes, ...]
277
+ unmerged_paths: tuple[bytes, ...] | None = field(default=None, compare=False)
278
+ retained_oid: str | None = field(default=None, compare=False)
279
+ retained: bool | None = field(default=None, compare=False)
280
+ observed_at: datetime | None = field(default=None, compare=False)
281
+
282
+ def retention_of(self, previous: StashObservation) -> StashObservation:
283
+ """Attach a dated observation of the original stash object without claiming future retention."""
284
+ retained = previous.oid is not None and previous.oid.encode("ascii") in self.entries
285
+ return replace(self, retained_oid=previous.oid, retained=retained, observed_at=datetime.now(UTC))
286
+
287
+ def added_after(self, previous: StashObservation) -> bool:
288
+ """Recognize a single prepended entry while retaining the older reflog exactly."""
289
+ first_entry_index, preceding_entries_index = 0, 1
290
+ return (self.oid is not None and len(self.entries) == len(previous.entries) + 1
291
+ and self.entries[preceding_entries_index:] == previous.entries and self.entries[first_entry_index].decode("ascii") == self.oid)
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class StashDropIntent:
296
+ """One request to drop one exact stash, named by the stash commit GitPane read for it.
297
+
298
+ Git drops a stash by its position only (``stash@{N}``), and every position moves when another stash is created
299
+ or dropped. The stash commit is what names the stash: the drop reads the list again and refuses when that commit
300
+ is no longer where it was read. ``index`` None means the position is unknown and the commit is looked for in the
301
+ whole list, as for the stash a Stash pop kept; it must then appear exactly once. ``require_resolved`` refuses the
302
+ drop while the index still holds an unresolved conflict.
303
+ """
304
+
305
+ operation: OperationIntent
306
+ oid: str
307
+ index: int | None = None
308
+ require_resolved: bool = False
309
+
310
+ @classmethod
311
+ def of_entry(cls, operation: OperationIntent, entry: StashEntry) -> StashDropIntent:
312
+ """Drop the stash the user chose in the left panel: it must still be at the position the list showed."""
313
+ return cls(_stash_write_operation(operation, DROP_OPERATION_LABEL), entry.oid, entry.index)
314
+
315
+ @classmethod
316
+ def of_retained(cls, operation: OperationIntent, oid: str) -> StashDropIntent:
317
+ """Drop the stash a Stash pop kept, wherever it now stands in the list, and only once no conflict remains."""
318
+ return cls(_stash_write_operation(operation, DROP_OPERATION_LABEL), oid, None, require_resolved=True)
319
+
320
+
321
+ def _stash_write_operation(operation: OperationIntent, label: str) -> OperationIntent:
322
+ """Give a captured write the stash authority and the stash reference as its only written reference."""
323
+ from gitpane.operation.repository_operation import OperationAction, WriteScope
324
+
325
+ return replace(operation, operation=label, action=OperationAction.STASH, scope=WriteScope.capture(operation.context, (STASH_REFERENCE,)))
326
+
327
+
328
+ def locate_stash(stash_list: StashList, intent: StashDropIntent) -> int:
329
+ """Answer the position of the stash a drop names, or refuse when it moved, vanished or cannot be told apart."""
330
+ if stash_list.cause:
331
+ raise ValueError(stash_list.cause)
332
+ positions = []
333
+ for entry in stash_list.entries:
334
+ if entry.oid == intent.oid:
335
+ positions.append(entry.index)
336
+ if intent.index is not None:
337
+ if intent.index not in positions:
338
+ raise ValueError(DROP_CHANGED_CAUSE)
339
+ return intent.index
340
+ if not positions:
341
+ raise ValueError(DROP_CHANGED_CAUSE)
342
+ if len(positions) > 1:
343
+ raise ValueError(DROP_AMBIGUOUS_CAUSE)
344
+ only_position_index = 0
345
+ return positions[only_position_index]
346
+
347
+
348
+ def stash_oids(stash_list: StashList | None) -> tuple[str, ...]:
349
+ """List the stash commits of a read list, newest first, to compare two reads without their dates or messages."""
350
+ if stash_list is None:
351
+ return ()
352
+ oids = []
353
+ for entry in stash_list.entries:
354
+ oids.append(entry.oid)
355
+ return tuple(oids)
356
+
357
+
358
+ def pop_stopped_on_conflict(outcome: OperationOutcome) -> bool:
359
+ """Tell whether a Stash pop stopped on merge conflicts, an expected pause that waits for the user rather than a failure.
360
+
361
+ Git applied what it could, left the conflicting files unmerged, kept the stash and ended with its conflict exit
362
+ status. Any other ending (a refusal, an error, a result that could not be read) stays a failure.
363
+ """
364
+ from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE
365
+
366
+ if outcome.status != OperationStatus.PARTIAL:
367
+ return False
368
+ for step in outcome.steps:
369
+ if isinstance(step.after, StashObservation) and step.after.unmerged_paths and step.return_code == GIT_CONFLICT_RETURN_CODE:
370
+ return True
371
+ return False
372
+
373
+
374
+ class StashService:
375
+ """Run native stash writes under the shared repository owner and always reread."""
376
+
377
+ def __init__(self, worktree_service: WorktreeService, repository_operation: RepositoryOperationService) -> None:
378
+ """Inject existing process execution and common repository reservations."""
379
+ self.worktree_service = worktree_service
380
+ self.repository_operation = repository_operation
381
+ self.outcome_by_id: dict[str, OperationOutcome] = {}
382
+ self.cancelled_id_set: set[str] = set()
383
+
384
+ def pop(self, operation: OperationIntent, *, is_current: Callable[[], bool] | None = None) -> str:
385
+ """Apply the latest stash once after acquiring the common repository reservation."""
386
+ from gitpane.operation.repository_operation import OperationAction, WriteScope
387
+
388
+ operation = replace(operation, action=OperationAction.STASH, scope=WriteScope.capture(operation.context, (STASH_REFERENCE,)))
389
+ return self.repository_operation.submit(operation, self._pop(operation, is_current))
390
+
391
+ def cancel_pop(self, operation_id: str) -> None:
392
+ """Stop current native work while allowing final observation to complete.
393
+
394
+ The Stash pop console no longer offers Abort (a pop is a local write that must finish), so the application
395
+ does not call this any more. It stays as the service-level way to stop a queued or running pop: the stash tests
396
+ use it to check that a stopped pop still rereads and reports the retained stash instead of losing it.
397
+ """
398
+ if operation_id in self.outcome_by_id:
399
+ return
400
+ self.cancelled_id_set.add(operation_id)
401
+ runner = self.worktree_service.runner
402
+ for active in tuple(runner.active_by_id.values()):
403
+ if active.request.operation_id == operation_id:
404
+ runner.cancel(active.request.request_id)
405
+ for request in tuple(runner.pending_requests):
406
+ if request.operation_id == operation_id:
407
+ runner.cancel(request.request_id)
408
+
409
+ def _require_authority(self, operation: OperationIntent, is_current: Callable[[], bool] | None, changed_cause: str) -> None:
410
+ """Refuse a write whose live authority is gone, reading nothing from disk beyond what the application policy checks.
411
+
412
+ The service must be open and still hold this very intention, with management capability; the application must
413
+ still allow it and consider it current, and a pop must not have been cancelled.
414
+ """
415
+ from gitpane.history.file_history import WindowCapability
416
+
417
+ owner = self.repository_operation
418
+ allows = owner.allows
419
+ if (owner.closed or owner.active_by_id.get(operation.operation_id) is not operation or operation.capability != WindowCapability.MANAGE
420
+ or operation.operation_id in self.cancelled_id_set or allows is not None and not allows(operation)
421
+ or is_current is not None and not is_current()):
422
+ raise ValueError(changed_cause)
423
+
424
+ def _related_git_dirs(self, operation: OperationIntent) -> tuple[str, ...]:
425
+ """Freeze, on the Qt thread, the Git directories whose pause state the write policy examines for this shared storage."""
426
+ git_dirs = [operation.context.git_dir]
427
+ for context in operation.reserved_contexts:
428
+ git_dirs.append(context.git_dir)
429
+ for context in tuple(self.repository_operation.context_by_key.values()):
430
+ if canonical_path(context.common_git_dir) == operation.common_git_dir:
431
+ git_dirs.append(context.git_dir)
432
+ return tuple(dict.fromkeys(git_dirs))
433
+
434
+ def _pop(self, operation: OperationIntent, is_current: Callable[[], bool] | None) -> Generator:
435
+ """Recheck the captured stash at native launch and reread even on a rejected command."""
436
+ from gitpane.operation.launch_signature import verify_launch_signature
437
+ from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
438
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
439
+
440
+ def read_failure_diagnostic(error: Exception) -> PreparedDiagnostic:
441
+ """Describe a failed read of the pop's result, naming the Git process it comes from when there is one.
442
+
443
+ A console that already ended that process with its own line then does not repeat the failure. The block
444
+ written to the log is the same as without the process, which is not part of its text.
445
+ """
446
+ return Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error),
447
+ process_id=error.diagnostic.process_id if isinstance(error, GitCommandError) else "").prepare()
448
+
449
+ before = after = snapshot = refreshed = diagnostic = process_status = return_code = None
450
+ native_started = False
451
+ status, effect = OperationStatus.REFUSED, "not observed"
452
+ try:
453
+ if operation.context.is_bare:
454
+ raise ValueError("This bare repository has no working tree for Stash pop")
455
+ before = yield from self.observe(operation.context)
456
+ # A pop is asked without a worktree read, so this complete capture is what its result is compared with.
457
+ snapshot = yield from self.worktree_service.capture(operation.context, operation.git_filename, operation.generation)
458
+ if before.oid is None:
459
+ raise ValueError("No stash to apply. Create a stash before using Stash pop.")
460
+ paths = _write_paths(snapshot)
461
+ git_dirs = self._related_git_dirs(operation)
462
+ baseline = yield FileTask(lambda: capture_stash_launch_baseline(operation.context, snapshot.index_filename, snapshot.index_content, paths, (), git_dirs,
463
+ POP_CHANGED_CAUSE))
464
+ self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
465
+ self.repository_operation.validate(operation)
466
+ if (yield from self.observe(operation.context)) != before:
467
+ raise ValueError("Stash changed before launch; refresh the repository")
468
+ signature = yield FileTask(lambda: prepare_stash_launch(operation.context, baseline, snapshot.index_filename, snapshot.index_content, paths, (),
469
+ POP_CHANGED_CAUSE))
470
+ self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
471
+
472
+ def validate_launch() -> None:
473
+ """Refuse a changed authority or any metadata change after queue waiting; no content is read here."""
474
+ nonlocal native_started
475
+ self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
476
+ verify_launch_signature(signature, POP_CHANGED_CAUSE)
477
+ native_started = True
478
+
479
+ # Stash invokes Git subcommands internally; no user pathspec is passed to this native command. Submodule
480
+ # recursion is turned off so the files of every submodule stay as they are, even with submodule.recurse set.
481
+ yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "--no-literal-pathspecs", "stash", "pop"], timeout_ms=GIT_MUTATION_TIMEOUT_MS,
482
+ idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
483
+ process_status, return_code = ProcessStatus.SUCCESS, 0
484
+ except (ValueError, OSError, GitCommandError) as error:
485
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
486
+ process_status = error.process_status if isinstance(error, GitCommandError) else None
487
+ return_code = error.return_code if isinstance(error, GitCommandError) else None
488
+ status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
489
+ try:
490
+ after = yield from self.observe(operation.context)
491
+ if before is not None:
492
+ after = after.retention_of(before)
493
+ if process_status == ProcessStatus.SUCCESS:
494
+ preceding_entries_index = 1
495
+ removed = before is not None and after.entries == before.entries[preceding_entries_index:]
496
+ status, effect = (OperationStatus.SUCCESS, "applied") if removed else (OperationStatus.UNKNOWN, "unknown")
497
+ if not removed:
498
+ diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [],
499
+ "Stash history changed unexpectedly; inspect the repository before trying again").prepare()
500
+ except (ValueError, OSError, GitCommandError) as error:
501
+ status, effect = OperationStatus.UNKNOWN, "unknown"
502
+ diagnostic = read_failure_diagnostic(error)
503
+ try:
504
+ if not operation.context.is_bare:
505
+ refreshed = yield from self.worktree_service.capture(operation.context, operation.git_filename, operation.generation)
506
+ if after is not None:
507
+ unmerged_paths = []
508
+ for entry in refreshed.entries:
509
+ if entry.conflict_stages:
510
+ unmerged_paths.append(entry.path)
511
+ after = replace(after, unmerged_paths=tuple(sorted(set(unmerged_paths))))
512
+ if status != OperationStatus.UNKNOWN and native_started:
513
+ changed = before != after or (snapshot is not None and (snapshot.head_oid != refreshed.head_oid or snapshot.index_entries != refreshed.index_entries
514
+ or snapshot.fingerprint_by_path != refreshed.fingerprint_by_path))
515
+ if after is not None and after.unmerged_paths or process_status != ProcessStatus.SUCCESS and changed:
516
+ status, effect = OperationStatus.PARTIAL, "partially applied"
517
+ elif process_status != ProcessStatus.SUCCESS:
518
+ effect = "unchanged"
519
+ if after is not None and after.unmerged_paths:
520
+ names = []
521
+ for path in after.unmerged_paths:
522
+ names.append(path.decode("utf-8", "replace"))
523
+ cause = "Unresolved files:\n" + "\n".join(names)
524
+ diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
525
+ if after is not None and after.retained:
526
+ cause = diagnostic.cause + "\n" if diagnostic is not None else ""
527
+ cause += "Stash retained: " + after.retained_oid + " (observed " + after.observed_at.isoformat() + ")"
528
+ diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
529
+ except (ValueError, OSError, GitCommandError) as error:
530
+ status = OperationStatus.UNKNOWN
531
+ diagnostic = read_failure_diagnostic(error)
532
+ step = OperationStepResult("Stash pop", status, before, after, process_status, effect, diagnostic, return_code)
533
+ refresh = OperationStepResult("Read working tree", OperationStatus.SUCCESS if refreshed is not None else OperationStatus.UNKNOWN, before=snapshot, after=refreshed)
534
+ outcome = OperationOutcome(operation.operation_id, status, (step, refresh), diagnostic)
535
+ self.outcome_by_id[operation.operation_id] = outcome
536
+ self.cancelled_id_set.discard(operation.operation_id)
537
+ return outcome
538
+
539
+ def drop(self, intent: StashDropIntent, *, is_current: Callable[[], bool] | None = None) -> str:
540
+ """Drop one exact stash once, under the common repository reservation, and report what the list shows afterwards."""
541
+ return self.repository_operation.submit(intent.operation, self._drop(intent, is_current))
542
+
543
+ def _drop(self, intent: StashDropIntent, is_current: Callable[[], bool] | None) -> Generator:
544
+ """Find the stash again by its commit, check nothing moved, run `git stash drop stash@{N}`, then read the list again.
545
+
546
+ The stash reflog is read as a file between two identical metadata looks at the stash storage, and the same
547
+ look is compared again just before Git starts: a stash created or dropped by another program meanwhile makes
548
+ the drop refuse instead of removing whatever now stands at that position. Only the stash reference and its
549
+ reflog are written; working files, the index and submodules are never read for writing nor touched.
550
+ """
551
+ from gitpane.operation.launch_signature import verify_launch_signature
552
+ from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
553
+ from gitpane.reference.stash_list import read_stash_list, stash_selector
554
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
555
+
556
+ operation = intent.operation
557
+ context = operation.context
558
+ before = after = diagnostic = process_status = return_code = None
559
+ native_started = False
560
+ status, effect = OperationStatus.REFUSED, "not observed"
561
+ position = None
562
+ try:
563
+ if context.is_bare:
564
+ raise ValueError("This bare repository has no stash to drop")
565
+ signature = yield FileTask(lambda: capture_stash_signature(context))
566
+ before = yield FileTask(lambda: read_stash_list(context))
567
+ position = locate_stash(before, intent)
568
+ if intent.require_resolved:
569
+ unmerged = yield GitCommand(["ls-files", "--unmerged", "-z"])
570
+ if unmerged:
571
+ raise ValueError(DROP_CONFLICT_CAUSE)
572
+ yield FileTask(lambda: verify_launch_signature(signature, DROP_CHANGED_CAUSE))
573
+ self._require_authority(operation, is_current, DROP_CHANGED_CAUSE)
574
+ self.repository_operation.validate(operation)
575
+
576
+ def validate_launch() -> None:
577
+ """Refuse a changed authority or any change of the stash storage after queue waiting; no content is read here."""
578
+ nonlocal native_started
579
+ self._require_authority(operation, is_current, DROP_CHANGED_CAUSE)
580
+ verify_launch_signature(signature, DROP_CHANGED_CAUSE)
581
+ native_started = True
582
+
583
+ yield GitCommand(["stash", "drop", stash_selector(position)], timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS,
584
+ before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
585
+ process_status, return_code = ProcessStatus.SUCCESS, 0
586
+ except (ValueError, OSError, GitCommandError) as error:
587
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DROP_OPERATION_LABEL, context.repository_dir, operation.git_filename, [], str(error)).prepare()
588
+ process_status = error.process_status if isinstance(error, GitCommandError) else None
589
+ return_code = error.return_code if isinstance(error, GitCommandError) else None
590
+ status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
591
+ if native_started:
592
+ try:
593
+ after = yield FileTask(lambda: read_stash_list(context))
594
+ expected = list(stash_oids(before))
595
+ del expected[position]
596
+ if not after.cause and stash_oids(after) == tuple(expected):
597
+ status, effect = OperationStatus.SUCCESS, "dropped"
598
+ elif process_status == ProcessStatus.SUCCESS or stash_oids(after) != stash_oids(before):
599
+ status, effect = OperationStatus.UNKNOWN, "unknown"
600
+ diagnostic = Diagnostic(DROP_OPERATION_LABEL, context.repository_dir, operation.git_filename, [],
601
+ "Stash list changed unexpectedly; inspect the repository before trying again").prepare()
602
+ else:
603
+ effect = "unchanged"
604
+ except (ValueError, OSError) as error:
605
+ status, effect = OperationStatus.UNKNOWN, "unknown"
606
+ diagnostic = Diagnostic("Read stash result", context.repository_dir, operation.git_filename, [], str(error)).prepare()
607
+ step = OperationStepResult(DROP_OPERATION_LABEL, status, before, after, process_status, effect, diagnostic, return_code)
608
+ outcome = OperationOutcome(operation.operation_id, status, (step,), diagnostic)
609
+ self.outcome_by_id[operation.operation_id] = outcome
610
+ return outcome
611
+
612
+ def observe(self, context: RepositoryContext) -> Generator:
613
+ """Read the stash reference and reflog of the context's shared storage, without copying messages or file contents to diagnostics."""
614
+ from gitpane.operation.launch_signature import verify_launch_signature
615
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
616
+
617
+ # Two identical metadata looks at the stash storage, before and after the Git reads, prove those reads saw one
618
+ # state; a second rev-parse could not see a reflog entry added with the same object ID, a packing or a new table.
619
+ signature = yield FileTask(lambda: capture_stash_signature(context))
620
+ # Exit code 1 with no output means the reference is absent; a timeout or a cancellation stays an error.
621
+ output = yield GitCommand(["rev-parse", "--verify", "--quiet", STASH_REFERENCE], accepted_codes=(0, 1))
622
+ oid_output = output.strip()
623
+ entries: tuple[bytes, ...] = ()
624
+ if oid_output:
625
+ # The reference and its reflog are separate facts: a reference can exist without a log, or point elsewhere
626
+ # than its latest entry, and both are kept as Git reports them.
627
+ reflog_output = yield GitCommand(["reflog", "show", "--format=%H", STASH_REFERENCE])
628
+ entries = tuple(reflog_output.splitlines())
629
+ yield FileTask(lambda: verify_launch_signature(signature, STASH_CHANGED_CAUSE))
630
+ return StashObservation(oid_output.decode("ascii") or None, entries)
631
+
632
+ def create(self, intent: StashIntent, *, is_current: Callable[[], bool] | None = None) -> str:
633
+ """Submit one native creation once without replaying an uncertain result."""
634
+ return self.repository_operation.submit(intent.operation, self._create(intent, is_current))
635
+
636
+ def _create(self, intent: StashIntent, is_current: Callable[[], bool] | None) -> Generator:
637
+ """Validate captured state, execute Git, then separate its status from observed effects."""
638
+ # Preferences import the option definitions; load execution dependencies only when running a stash.
639
+ from gitpane.operation.launch_signature import verify_launch_signature
640
+ from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
641
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
642
+ from gitpane.worktree.worktree_service import STATUS_ARGUMENTS
643
+ from gitpane.worktree.worktree_snapshot import parse_status
644
+
645
+ operation, snapshot = intent.operation, intent.snapshot
646
+ before = after = refreshed = diagnostic = process_status = return_code = None
647
+ native_started = False
648
+ status, effect = OperationStatus.REFUSED, "not observed"
649
+ try:
650
+ if (snapshot.context.is_bare or not snapshot.fresh or snapshot.key != operation.key or snapshot.context != operation.context
651
+ or snapshot.generation != operation.generation or snapshot.git_filename != operation.git_filename):
652
+ raise ValueError("The stash request does not match its captured working tree")
653
+ if "\0" in intent.message:
654
+ raise ValueError("The stash message cannot contain a NUL character")
655
+ if is_current is not None and not is_current():
656
+ raise ValueError("The stash request is no longer current; reopen the form")
657
+ before = yield from self.observe(snapshot.context)
658
+ untracked_directory_signature = ()
659
+ if intent.option.include_untracked:
660
+ untracked_directory_signature = yield from _capture_untracked_directory_signature(snapshot.context)
661
+ untracked_dirs = []
662
+ for directory, _identity in untracked_directory_signature:
663
+ untracked_dirs.append(directory)
664
+ paths = _write_paths(snapshot)
665
+ git_dirs = self._related_git_dirs(operation)
666
+ # The exact bytes of every path the native stash reads, clean tracked files included, are taken here and
667
+ # compared again after the checks below, between metadata looks that must not move: the checks then
668
+ # describe the state that will be stashed.
669
+ baseline = yield FileTask(lambda: capture_stash_launch_baseline(snapshot.context, snapshot.index_filename, snapshot.index_content, paths, tuple(untracked_dirs),
670
+ git_dirs, CREATE_CHANGED_CAUSE))
671
+ self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
672
+ self.repository_operation.validate(operation)
673
+ # verify compares the paths the form read (bytes, index, HEAD, identity, configuration and attributes); only
674
+ # the paths with a fingerprint are passed, a clean path listed by the index has none to compare with.
675
+ yield from self.worktree_service.verify(snapshot, tuple(snapshot.fingerprint_by_path))
676
+ # A fresh status shows what verify cannot: a change to a file that was clean, or a new untracked file.
677
+ status_output = yield GitCommand(list(STATUS_ARGUMENTS), profile=READ_PROFILE)
678
+ refuse_new_changes(snapshot, parse_status(status_output))
679
+ signature = yield FileTask(lambda: prepare_stash_launch(snapshot.context, baseline, snapshot.index_filename, snapshot.index_content, paths,
680
+ untracked_directory_signature, CREATE_CHANGED_CAUSE))
681
+ self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
682
+
683
+ def validate_launch() -> None:
684
+ """Refuse a changed authority or any metadata change after queue waiting; no content is read here."""
685
+ nonlocal native_started
686
+ self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
687
+ verify_launch_signature(signature, CREATE_CHANGED_CAUSE)
688
+ native_started = True
689
+
690
+ # The shared reader enables literal pathspecs, but stash internally checks out :/ for Keep index.
691
+ # No user path is supplied here; restore Git's native pathspec handling for its own subcommands.
692
+ # Submodule recursion is turned off so the files of every submodule stay as they are, even with submodule.recurse set.
693
+ arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, "--no-literal-pathspecs", "stash", "push"]
694
+ if intent.option.include_untracked:
695
+ arguments.append("--include-untracked")
696
+ if intent.option.keep_index:
697
+ arguments.append("--keep-index")
698
+ if intent.message:
699
+ arguments.extend(["--message", intent.message])
700
+ yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
701
+ process_status, return_code = ProcessStatus.SUCCESS, 0
702
+ except (ValueError, OSError, GitCommandError) as error:
703
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Create stash", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
704
+ process_status = error.process_status if isinstance(error, GitCommandError) else None
705
+ return_code = error.return_code if isinstance(error, GitCommandError) else None
706
+ status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
707
+ try:
708
+ after = yield from self.observe(snapshot.context)
709
+ if native_started and before is not None and after.added_after(before):
710
+ effect = "created"
711
+ status = OperationStatus.SUCCESS if process_status == ProcessStatus.SUCCESS else OperationStatus.PARTIAL
712
+ elif process_status == ProcessStatus.SUCCESS:
713
+ status = OperationStatus.NO_CHANGE if after == before else OperationStatus.UNKNOWN
714
+ effect = "unchanged" if after == before else "unknown"
715
+ cause = "No changes to stash. Change a file, then try again." if status == OperationStatus.NO_CHANGE else "Stash changed unexpectedly; inspect the repository before trying again"
716
+ diagnostic = Diagnostic("Create stash", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
717
+ except (ValueError, OSError, GitCommandError) as error:
718
+ status, effect = OperationStatus.UNKNOWN, "unknown"
719
+ diagnostic = Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
720
+ try:
721
+ if not snapshot.context.is_bare:
722
+ refreshed = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
723
+ except (ValueError, OSError, GitCommandError) as error:
724
+ status = OperationStatus.UNKNOWN
725
+ diagnostic = Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
726
+ step = OperationStepResult("Create stash", status, before, after, process_status, effect, diagnostic, return_code)
727
+ refresh = OperationStepResult("Read working tree", OperationStatus.SUCCESS if refreshed is not None else OperationStatus.UNKNOWN, after=refreshed)
728
+ outcome = OperationOutcome(operation.operation_id, status, (step, refresh), diagnostic)
729
+ self.outcome_by_id[operation.operation_id] = outcome
730
+ return outcome