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,830 @@
1
+ """Create native Git commits, reporting success only once their actual effects are checked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from PySide6.QtCore import QObject, QTimer, Signal
14
+
15
+ from gitpane.config import COMMIT_IDLE_TIMEOUT_MS, DETACHED_HEAD_NAME, GIT_COMMIT_TIMEOUT_MS, REFERENCE_ROOT_PREFIX, SENSITIVE_VALUE_MIN_LENGTH, SUBMODULE_RECURSION_OFF_ARGUMENTS, SharedText
16
+ from gitpane.history.history_parse import parse_commit
17
+ from gitpane.history.repository_value import validate_oid
18
+ from gitpane.operation.diagnostic import MASKED_VALUE, Diagnostic
19
+ from gitpane.operation.launch_signature import LaunchSignature, sign_launch_paths, verify_launch_signature, worktree_location_paths
20
+ from gitpane.operation.operation_status import OperationStatus
21
+ from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OperationAction, OperationIntent
22
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessRequest, ProcessResult, ProcessStatus
23
+ from gitpane.sequencer.conflict import conflict_path_label
24
+ from gitpane.sequencer.merge_state import read_merge_in_progress
25
+ from gitpane.text.typed_text import typed_value
26
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
27
+ from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, read_fingerprints, read_index
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Generator
31
+
32
+ from gitpane.history.repository_value import RepositoryContext
33
+ from gitpane.operation.diagnostic import PreparedDiagnostic
34
+ from gitpane.process.process_runner import ProcessRunner
35
+ from gitpane.sequencer.rebase_state import NativeMetadata
36
+ from gitpane.worktree.mutation import MutationService
37
+ from gitpane.worktree.worktree_service import WorktreeService
38
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
39
+
40
+ HOOK_SAMPLE_SUFFIX = ".sample"
41
+ POST_COMMIT_HOOK_BASENAME = "post-commit"
42
+ PRE_COMMIT_HOOK_BASENAME = "pre-commit"
43
+ # Git for Windows also runs a hook saved as a program with this suffix.
44
+ WINDOWS_PROGRAM_SUFFIX = ".exe"
45
+ # Windows has no executable permission, so Git for Windows runs any hook file that exists; elsewhere Git skips a hook
46
+ # file without it.
47
+ HOOK_EXECUTABLE_BIT_USED = os.name != "nt"
48
+ # The neutral notice a commit leaves in the repository windows when files changed during it without any active commit hook.
49
+ COMMIT_CREATED_NOTICE_PREFIX = "Commit created. "
50
+ CHANGED_AFTER_COMMIT_NOTICE_TEMPLATE = "{subject} changed right after the commit; another program may have modified {pronoun}."
51
+ INDEX_NOTICE_SUBJECT = "The index"
52
+ COUNTED_FILES_NOTICE_SUBJECT_TEMPLATE = "{count} files"
53
+ SINGLE_NOTICE_PRONOUN = "it"
54
+ PLURAL_NOTICE_PRONOUN = "them"
55
+ # Beyond this many files, the notice counts them instead of naming them, so it stays one line.
56
+ NOTICE_NAMED_FILE_LIMIT = 3
57
+ # The grouped HEAD read ends with the branch HEAD names, or with the detached HEAD word. That read gives bytes, so the
58
+ # two values are encoded from their one text form instead of being written again here.
59
+ DETACHED_HEAD_NAME_BYTES = DETACHED_HEAD_NAME.encode("ascii")
60
+ REFERENCE_ROOT_PREFIX_BYTES = REFERENCE_ROOT_PREFIX.encode("ascii")
61
+ # The lines of the grouped read: HEAD, its tree, one line per parent (none for a root commit), then the branch line.
62
+ GROUPED_HEAD_MINIMUM_LINE_COUNT = 3
63
+ HEAD_DESCRIPTION_ERROR = "Git described HEAD in an unexpected form"
64
+ COMMIT_LAUNCH_CHANGED_CAUSE = "HEAD, references, configuration or the index changed before commit. Refresh before writing."
65
+ COMMIT_LAUNCH_UNREADABLE_CAUSE = "The files checked before commit could not be read again. Refresh before writing."
66
+ # The plain first sentences of a commit result that is not the requested commit; the object IDs and change flags go to
67
+ # the details only.
68
+ COMMIT_CHANGED_ELSEWHERE_CAUSE = "Commit created, but it also contains changes made by a hook or another program."
69
+ # The plain first sentence of a commit Git did not create while files, the index or the branch changed: it says who
70
+ # refused it when an active pre-commit hook did, and what changed. Files are named when few, otherwise counted.
71
+ PRE_COMMIT_HOOK_REFUSED_NAMED_FILES_TEMPLATE = "Commit refused by your pre-commit hook. It also changed: {subject}."
72
+ PRE_COMMIT_HOOK_REFUSED_TEMPLATE = "Commit refused by your pre-commit hook. It also changed {subject}."
73
+ COMMIT_FAILED_NAMED_FILES_TEMPLATE = "Commit failed, and files changed: {subject}. Click {details} for full information."
74
+ COMMIT_FAILED_TEMPLATE = "Commit failed, and {subject} changed. Click {details} for full information."
75
+ NO_COMMIT_INDEX_SUBJECT = "the index"
76
+ NO_COMMIT_BRANCH_SUBJECT = "the current branch"
77
+ NO_COMMIT_FOUND_CAUSE = "Git reported success, but no new commit was found."
78
+ SUCCESS_WITH_CHANGES_LOG_CAUSE = "Commit created as requested; files or the index changed around it."
79
+ PRE_COMMIT_HOOK_CONTENT_LOG_CAUSE = "Commit created with files an active pre-commit hook added or changed."
80
+ REVIEW_BEFORE_RETRY_TEXT = "Review the changes before retrying."
81
+ GIT_CAUSE_DETAIL_PREFIX = "Git: "
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class CommitHeadFacts:
86
+ """HEAD, its tree, its parents in their order and the branch HEAD names, as read together by Git.
87
+
88
+ The branch keeps the exact bytes symbolic-ref prints, line end included; a detached HEAD has empty bytes. Without
89
+ any commit, HEAD has no object ID, the tree is empty and there are no parents.
90
+ """
91
+
92
+ head_oid: str | None
93
+ tree_oid: str
94
+ parent_oids: tuple[str, ...]
95
+ ref_target: bytes
96
+
97
+
98
+ def parse_commit_head(output: bytes) -> CommitHeadFacts:
99
+ """Split the grouped HEAD read by position, accepting only full object IDs and a branch name or the detached marker.
100
+
101
+ Every line but the last is an object ID, so the last line alone can be the branch; an answer where it is not one
102
+ is refused rather than guessed, and the caller then reads each fact separately.
103
+ """
104
+ lines = output.removesuffix(b"\n").split(b"\n")
105
+ if len(lines) < GROUPED_HEAD_MINIMUM_LINE_COUNT:
106
+ raise ValueError(HEAD_DESCRIPTION_ERROR)
107
+ head_line, tree_line, *parent_lines, reference_line = lines
108
+ try:
109
+ head_oid = validate_oid(head_line.decode("ascii"))
110
+ tree_oid = validate_oid(tree_line.decode("ascii"))
111
+ parent_oids = []
112
+ for parent_line in parent_lines:
113
+ parent_oids.append(validate_oid(parent_line.decode("ascii")))
114
+ except (UnicodeDecodeError, ValueError) as error:
115
+ raise ValueError(HEAD_DESCRIPTION_ERROR) from error
116
+ if reference_line == DETACHED_HEAD_NAME_BYTES:
117
+ ref_target = b""
118
+ elif reference_line.startswith(REFERENCE_ROOT_PREFIX_BYTES):
119
+ # Restore the line end symbolic-ref prints, so both reads give the same bytes for the same branch.
120
+ ref_target = reference_line + b"\n"
121
+ else:
122
+ raise ValueError(HEAD_DESCRIPTION_ERROR)
123
+ return CommitHeadFacts(head_oid, tree_oid, tuple(parent_oids), ref_target)
124
+
125
+
126
+ def read_commit_head() -> Generator[GitCommand | ReadBatch, object, CommitHeadFacts]:
127
+ """Read HEAD, its tree, its parents and its branch with one Git process on the normal path.
128
+
129
+ When Git refuses the grouped read, as it does without any commit, or answers in a form that cannot be split with
130
+ certainty, the facts are read again separately. A read that timed out, was cancelled or could not start stays an
131
+ error: its partial output is never taken for an answer.
132
+ """
133
+ try:
134
+ output = yield GitCommand(["rev-parse", "HEAD", "HEAD^{tree}", "HEAD^@", "--symbolic-full-name", "HEAD"])
135
+ except GitCommandError as error:
136
+ # Only a failed process ran to its end and refused: timeouts, cancellations and failed starts say nothing about
137
+ # the repository and must never be answered by another read.
138
+ if error.process_status != ProcessStatus.FAILED:
139
+ raise
140
+ return (yield from _read_commit_head_separately())
141
+ try:
142
+ return parse_commit_head(output)
143
+ except ValueError:
144
+ return (yield from _read_commit_head_separately())
145
+
146
+
147
+ def _read_commit_head_separately() -> Generator[GitCommand | ReadBatch, object, CommitHeadFacts]:
148
+ """Verify HEAD on its own, read its tree and parents by that object ID, and read the branch HEAD names.
149
+
150
+ A HEAD without a commit is accepted only when HEAD names a branch, which is the only way Git can have no commit
151
+ yet: a detached HEAD always points at an object, so an empty answer for both is an error.
152
+ """
153
+ head_output, ref_target = yield ReadBatch((GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1)),
154
+ GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))))
155
+ head_text = head_output.strip().decode("ascii", "replace")
156
+ if not head_text:
157
+ if not ref_target:
158
+ raise ValueError("HEAD names neither a commit nor a branch; Refresh to inspect the repository")
159
+ return CommitHeadFacts(None, "", (), ref_target)
160
+ head_oid = validate_oid(head_text)
161
+ identity_output = yield GitCommand(["rev-parse", head_oid + "^{tree}", head_oid + "^@"])
162
+ tree_line, *parent_lines = identity_output.removesuffix(b"\n").split(b"\n")
163
+ tree_oid = validate_oid(tree_line.decode("ascii", "replace"))
164
+ parent_oids = []
165
+ for parent_line in parent_lines:
166
+ parent_oids.append(validate_oid(parent_line.decode("ascii", "replace")))
167
+ return CommitHeadFacts(head_oid, tree_oid, tuple(parent_oids), ref_target)
168
+
169
+
170
+ def reduced_merge_parent_oids(parent_oids: tuple[str, ...]) -> Generator[GitCommand, object, tuple[str, ...]]:
171
+ """Answer the parents Git records when it concludes a merge that may drop redundant ones, in Git's order.
172
+
173
+ Without --no-ff, git commit removes every parent that another parent already contains, and duplicates, keeping the
174
+ others in their order. Git lists the parents to keep; their order is taken from ``parent_oids``, never from its output.
175
+ """
176
+ output = yield GitCommand(["merge-base", "--independent", *parent_oids], profile=READ_PROFILE)
177
+ independent_set = set()
178
+ for line in output.decode("ascii").split():
179
+ independent_set.add(validate_oid(line))
180
+ reduced_oids = []
181
+ for oid in parent_oids:
182
+ if oid in independent_set and oid not in reduced_oids:
183
+ reduced_oids.append(oid)
184
+ return tuple(reduced_oids)
185
+
186
+
187
+ def capture_commit_launch_signature(context: RepositoryContext, index_filename: str) -> LaunchSignature:
188
+ """Sign the index, every file and folder the reference signature covers, and the files and folders that name this worktree.
189
+
190
+ The reference files include HEAD, the branches, packed and table references and the configuration of every
191
+ related worktree. The Git directories, the index folder and the worktree root are containing folders, compared
192
+ by identity only; a linked worktree's pointer files are signed like any other file.
193
+ """
194
+ from gitpane.reference.reference_edit import reference_signature_paths
195
+
196
+ reference_filenames, watched_dirs = reference_signature_paths(context)
197
+ location_filenames, location_parent_dirs = worktree_location_paths(context)
198
+ filenames = [*reference_filenames, index_filename, *location_filenames]
199
+ parent_dirs = [os.path.dirname(index_filename), *location_parent_dirs]
200
+ return sign_launch_paths(filenames, watched_dirs, parent_dirs)
201
+
202
+
203
+ def prove_commit_launch(context: RepositoryContext, index_filename: str, expected_index_content: bytes | None, expected_reference_signature: tuple) -> LaunchSignature:
204
+ """Compare the index and reference contents with the checked ones between two signatures of the same paths.
205
+
206
+ The reference contents are compared with the signature taken before the checks started, so a change made by
207
+ another program meanwhile is refused instead of adopted. The signature handed back describes exactly the state
208
+ these comparisons accepted; it is the only thing the launch callback checks again.
209
+ """
210
+ from gitpane.reference.reference_edit import reference_signature
211
+
212
+ before = capture_commit_launch_signature(context, index_filename)
213
+ if read_index(index_filename) != expected_index_content:
214
+ raise ValueError("The index changed before commit. Refresh before writing.")
215
+ if reference_signature(context) != expected_reference_signature:
216
+ raise ValueError("HEAD or repository references changed before commit. Refresh before writing.")
217
+ after = capture_commit_launch_signature(context, index_filename)
218
+ if after != before:
219
+ raise ValueError("HEAD, references, configuration or the index changed while the commit was checked. Refresh before writing.")
220
+ return after
221
+
222
+
223
+ def commit_hook_exists(hooks_dir: str) -> bool:
224
+ """Tell whether the hooks directory Git resolved for a worktree holds anything that could run during a commit.
225
+
226
+ Every file except Git's own samples counts, whatever its name or mode: Git runs more hooks than the commit ones,
227
+ and a helper could live under any name. Only a path that is not a directory, or a complete listing without such a
228
+ file, answers False; any error raised while inspecting it answers True, so a doubt keeps the full byte checks.
229
+ """
230
+ try:
231
+ if not os.path.isdir(hooks_dir):
232
+ return False
233
+ with os.scandir(hooks_dir) as entries:
234
+ for entry in entries:
235
+ if entry.name.endswith(HOOK_SAMPLE_SUFFIX):
236
+ continue
237
+ if entry.is_file():
238
+ return True
239
+ except OSError:
240
+ return True
241
+ return False
242
+
243
+
244
+ def git_hook_active(hooks_dir: str, hook_basename: str, *, executable_bit_used: bool = HOOK_EXECUTABLE_BIT_USED) -> bool:
245
+ """Tell whether Git would run the hook with this name from the hooks directory it resolved for a worktree.
246
+
247
+ Git looks for a file with the hook's name and, on Linux and macOS, runs it only when it may be executed. Git for
248
+ Windows does not use that permission: the file, or the same name with .exe, only has to exist. A folder with
249
+ that name is not run, and a directory that cannot be read answers False: the commit result then only loses the
250
+ knowledge that the user's own hook explains a change.
251
+ """
252
+ candidate_basenames = [hook_basename]
253
+ if not executable_bit_used:
254
+ candidate_basenames.append(hook_basename + WINDOWS_PROGRAM_SUFFIX)
255
+ for basename in candidate_basenames:
256
+ hook_filename = os.path.join(hooks_dir, basename)
257
+ if not os.path.isfile(hook_filename):
258
+ continue
259
+ if not executable_bit_used or os.access(hook_filename, os.X_OK):
260
+ return True
261
+ return False
262
+
263
+
264
+ def changed_working_paths(before: CommitState, after: CommitState) -> list[bytes]:
265
+ """List, sorted, the files hashed at both commit boundaries whose bytes differ between them."""
266
+ paths = []
267
+ for path, fingerprint in before.working_by_path.items():
268
+ if after.working_by_path.get(path) != fingerprint:
269
+ paths.append(path)
270
+ return sorted(paths)
271
+
272
+
273
+ def noticed_paths(before: CommitState, after: CommitState) -> list[bytes]:
274
+ """List, sorted, the files a commit notice names: changed bytes, and files Git lists after the commit whose earlier bytes are unknown.
275
+
276
+ A file hashed for the first time after the commit only because a hook appeared is not named: nothing says it changed.
277
+ """
278
+ listed_path_set: set[bytes] = set()
279
+ for entry in after.snapshot.entries:
280
+ listed_path_set.update(entry.paths)
281
+ paths = changed_working_paths(before, after)
282
+ for path in after.working_by_path.keys() - before.working_by_path.keys():
283
+ if path in listed_path_set:
284
+ paths.append(path)
285
+ return sorted(paths)
286
+
287
+
288
+ def changed_files_subject(paths: list[bytes]) -> tuple[str, bool]:
289
+ """Name the changed files the way every commit sentence does, and tell whether the answer names them.
290
+
291
+ Up to three files are named in sorted order (« a.txt », « a.txt and b.txt », « a.txt, b.txt and c.txt »), more
292
+ are counted (« 4 files ») so the sentence stays one line. Without any file the subject is empty.
293
+ """
294
+ labels = []
295
+ for path in sorted(paths):
296
+ labels.append(conflict_path_label(path))
297
+ if not labels:
298
+ return "", False
299
+ if len(labels) == 1:
300
+ return labels[0], True
301
+ if len(labels) <= NOTICE_NAMED_FILE_LIMIT:
302
+ last_label = labels.pop()
303
+ return ", ".join(labels) + " and " + last_label, True
304
+ return COUNTED_FILES_NOTICE_SUBJECT_TEMPLATE.format(count=len(labels)), False
305
+
306
+
307
+ def commit_change_notice(paths: list[bytes], index_changed: bool) -> str:
308
+ """Build the one neutral sentence telling the user which files, or the index, changed right after a commit.
309
+
310
+ Up to three files are named in order, more are counted; the index is named only when no file changed. Without
311
+ any change the answer is empty.
312
+ """
313
+ subject, _names_files = changed_files_subject(paths)
314
+ if not subject and not index_changed:
315
+ return ""
316
+ if not subject:
317
+ subject, pronoun = INDEX_NOTICE_SUBJECT, SINGLE_NOTICE_PRONOUN
318
+ elif len(paths) == 1:
319
+ pronoun = SINGLE_NOTICE_PRONOUN
320
+ else:
321
+ pronoun = PLURAL_NOTICE_PRONOUN
322
+ return COMMIT_CREATED_NOTICE_PREFIX + CHANGED_AFTER_COMMIT_NOTICE_TEMPLATE.format(subject=subject, pronoun=pronoun)
323
+
324
+
325
+ def no_commit_change_cause(paths: list[bytes], index_changed: bool, refused_by_pre_commit_hook: bool) -> str:
326
+ """Build the warning of a commit Git did not create while something changed: who refused it, and what changed.
327
+
328
+ A user who clicked Commit must not read that nothing happened. When Git refused and an active pre-commit hook
329
+ explains the refusal, the sentence blames that hook; otherwise it says the commit failed and points at the details,
330
+ where Git's own message stays. Files are named or counted; with no file to name, the index or the current branch is.
331
+ """
332
+ subject, names_files = changed_files_subject(paths)
333
+ if not subject:
334
+ subject = NO_COMMIT_INDEX_SUBJECT if index_changed else NO_COMMIT_BRANCH_SUBJECT
335
+ if refused_by_pre_commit_hook:
336
+ template = PRE_COMMIT_HOOK_REFUSED_NAMED_FILES_TEMPLATE if names_files else PRE_COMMIT_HOOK_REFUSED_TEMPLATE
337
+ else:
338
+ template = COMMIT_FAILED_NAMED_FILES_TEMPLATE if names_files else COMMIT_FAILED_TEMPLATE
339
+ # The label of the Show details button of Prepare changes, read from the one shared value so the sentence names the real button.
340
+ return template.format(subject=subject, details=SharedText.RESULT_DETAILS.value)
341
+
342
+
343
+ def index_records_by_path(index_entries: bytes) -> dict[bytes, list[bytes]]:
344
+ """Group the records of ls-files --stage -z by path, so a path with several conflict stages keeps all of them."""
345
+ records_by_path: dict[bytes, list[bytes]] = {}
346
+ for record in index_entries.split(b"\0"):
347
+ if not record:
348
+ continue
349
+ _metadata, _separator, path = record.partition(b"\t")
350
+ records_by_path.setdefault(path, []).append(record)
351
+ return records_by_path
352
+
353
+
354
+ def changed_staged_paths(before: CommitState, after: CommitState) -> list[bytes]:
355
+ """List, sorted, the paths whose index entries differ between two commit boundaries: added, changed or removed.
356
+
357
+ It tells which files a hook staged during the commit, without asking Git again.
358
+ """
359
+ before_records_by_path = index_records_by_path(before.index_entries)
360
+ after_records_by_path = index_records_by_path(after.index_entries)
361
+ paths = []
362
+ for path in before_records_by_path.keys() | after_records_by_path.keys():
363
+ if sorted(before_records_by_path.get(path, [])) != sorted(after_records_by_path.get(path, [])):
364
+ paths.append(path)
365
+ return sorted(paths)
366
+
367
+
368
+ def describe_commit_boundaries(before: CommitState, after: CommitState) -> str:
369
+ """Describe in English, for the details and the --log file, what changed around a commit, with the Git identities.
370
+
371
+ The commit message is never part of it; the caller still masks it, as for any text a commit diagnostic shows.
372
+ """
373
+ changed_labels = []
374
+ for path in changed_working_paths(before, after):
375
+ changed_labels.append(conflict_path_label(path))
376
+ unverified_labels = []
377
+ for path in sorted(after.working_by_path.keys() - before.working_by_path.keys()):
378
+ unverified_labels.append(conflict_path_label(path))
379
+ post_commit_hook_active = before.post_commit_hook_active or after.post_commit_hook_active
380
+ pre_commit_hook_active = before.pre_commit_hook_active or after.pre_commit_hook_active
381
+ lines = [
382
+ "A post-commit hook is active." if post_commit_hook_active else "No active post-commit hook was found.",
383
+ "A pre-commit hook is active." if pre_commit_hook_active else "No active pre-commit hook was found.",
384
+ f"Index changed: {'yes' if before.index_entries != after.index_entries else 'no'}",
385
+ ]
386
+ staged_labels = []
387
+ for path in changed_staged_paths(before, after):
388
+ staged_labels.append(conflict_path_label(path))
389
+ if staged_labels:
390
+ lines.append(f"Staged files changed during the commit: {', '.join(staged_labels)}")
391
+ lines.append(f"Working files changed: {', '.join(changed_labels) or 'none'}")
392
+ if unverified_labels:
393
+ lines.append(f"Files whose previous bytes were not captured ({len(unverified_labels)}): {', '.join(unverified_labels)}")
394
+ lines.append(f"HEAD before: {before.snapshot.head_oid or 'unborn'}; HEAD after: {after.snapshot.head_oid or 'unborn'}")
395
+ lines.append(f"Tree: {after.tree_oid or 'none'}; parents: {', '.join(after.parent_oids) or 'none'}")
396
+ return "\n".join(lines)
397
+
398
+
399
+ def mask_commit_message(message: str, text: str) -> str:
400
+ """Hide every line of the commit message, and the whole message, wherever it appears in ``text``.
401
+
402
+ A line shorter than the length diagnostics ever hide is left alone, and a line is only hidden where it is not glued
403
+ to a letter or digit on either side: otherwise a message line such as "a" or "12" would also be replaced inside the
404
+ commit IDs the details show, which are exactly what lets the user find the commit again. Each line is also hidden
405
+ without its trailing blanks, the form Git writes once its cleanup has run on the message.
406
+ """
407
+ hidden_set = {message}
408
+ for line in message.splitlines():
409
+ hidden_set.add(line)
410
+ hidden_set.add(line.rstrip())
411
+ for line in sorted(hidden_set, key=len, reverse=True):
412
+ if len(line.strip()) < SENSITIVE_VALUE_MIN_LENGTH:
413
+ continue
414
+ text = re.sub(r"(?<![0-9A-Za-z])" + re.escape(line) + r"(?![0-9A-Za-z])", MASKED_VALUE, text)
415
+ return text
416
+
417
+
418
+ @dataclass(frozen=True)
419
+ class CommitRequest:
420
+ """Freeze the intended index, HEAD and complete UTF-8 message for one explicit action."""
421
+
422
+ operation_id: str
423
+ key: WorktreeKey
424
+ expected_head_oid: str | None
425
+ index_fingerprint: str
426
+ amend: bool
427
+ message: bytes
428
+ snapshot: WorktreeSnapshot
429
+ expected_head_ref: str | None = None
430
+ rebase_capture: tuple[NativeMetadata | None, ...] | None = None
431
+
432
+ @classmethod
433
+ def capture(cls, snapshot: WorktreeSnapshot, message: str, amend: bool = False) -> CommitRequest:
434
+ """Capture the currently displayed preparation without including working files.
435
+
436
+ The message is sent exactly as typed and Git applies the repository's ``commit.cleanup`` setting: by default it
437
+ drops blank lines at both ends and trailing blanks of each line while keeping the indentation of the first line,
438
+ and ``verbatim`` keeps the message exactly. Cleaning the message in GitPane before Git would override that
439
+ setting. Commit and Commit & push both capture here, and the draft the caller holds stays as typed. Diagnostics
440
+ mask the sent message and its lines as Git writes them.
441
+ """
442
+ operation_id = uuid.uuid4().hex
443
+ intent = OperationIntent.capture(operation_id, snapshot.context, snapshot.git_filename)
444
+ return cls(operation_id, snapshot.key, snapshot.head_oid, snapshot.index_fingerprint, amend, message.encode("utf-8"), snapshot,
445
+ rebase_capture=intent.rebase_capture)
446
+
447
+
448
+ @dataclass(frozen=True)
449
+ class CommitState:
450
+ """Keep observed Git identities and working fingerprints separate from the user's intent.
451
+
452
+ ``post_commit_hook_active`` and ``pre_commit_hook_active`` tell whether Git would run that hook from the hooks
453
+ directory of this boundary; both are False when that directory is unknown.
454
+ """
455
+
456
+ snapshot: WorktreeSnapshot
457
+ tree_oid: str
458
+ parent_oids: tuple[str, ...]
459
+ index_entries: bytes
460
+ working_by_path: dict[bytes, str]
461
+ ref_target: bytes
462
+ post_commit_hook_active: bool = False
463
+ pre_commit_hook_active: bool = False
464
+
465
+
466
+ @dataclass(frozen=True)
467
+ class CommitOutcome:
468
+ """Retain observed before/after state even when the native command reports failure.
469
+
470
+ ``commit_created`` tells that the read after the write proved a new commit, whatever the status. ``notice`` is a
471
+ neutral sentence for the repository windows, and ``log_diagnostic`` the details written only to the --log file,
472
+ for a commit that succeeded although files changed around it.
473
+ """
474
+
475
+ operation_id: str
476
+ key: WorktreeKey
477
+ status: OperationStatus
478
+ before: CommitState | None
479
+ after: CommitState | None
480
+ diagnostic: PreparedDiagnostic | None
481
+ process_status: ProcessStatus | None = None
482
+ commit_created: bool = False
483
+ notice: str = ""
484
+ log_diagnostic: PreparedDiagnostic | None = None
485
+
486
+
487
+ @dataclass
488
+ class _CommitOperation:
489
+ """Own the reservation and captured message until reconciliation has completed.
490
+
491
+ ``merge_parent_oids`` are the commits a merge waiting at launch adds after HEAD, as Git listed them then; they are
492
+ the only parents beyond HEAD a Commit accepts. ``merge_expected_parent_oids`` are the parents the commit concluding
493
+ that merge must have: HEAD then those commits, less the ones Git drops as already contained in another parent.
494
+ """
495
+
496
+ request: CommitRequest
497
+ before: CommitState | None = None
498
+ expected_tree_oid: str = ""
499
+ merge_parent_oids: tuple[str, ...] = ()
500
+ merge_expected_parent_oids: tuple[str, ...] = ()
501
+ reference_signature: tuple = ()
502
+ launch_signature: LaunchSignature | None = None
503
+ reconciling: bool = False
504
+
505
+
506
+ class CommitService(QObject):
507
+ """Reserve GitPane writes while letting native Git own its own index and ref locks."""
508
+
509
+ finished = Signal(object)
510
+ waiting = Signal(object)
511
+
512
+ def __init__(self, runner: ProcessRunner, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None) -> None:
513
+ """Inject the existing process, read and mutation owners for shared lifetime control."""
514
+ super().__init__(parent)
515
+ self.runner = runner
516
+ self.worktree_service = worktree_service
517
+ self.mutation = mutation
518
+ self.repository_operation = mutation.repository_operation
519
+ self.active_by_id: dict[str, _CommitOperation] = {}
520
+ self.outcome_by_id: dict[str, CommitOutcome] = {}
521
+ runner.finished.connect(self._process_finished)
522
+ runner.stop_requested.connect(self._stopping)
523
+
524
+ def submit(self, request: CommitRequest) -> str:
525
+ """Validate and reserve exactly one user request without retrying rejected writes.
526
+
527
+ A refusal says first that GitPane is closing, since no write is accepted any more whatever else is wrong; then it
528
+ gives the write policy's reason, or else the real reason the reservation was refused, worked out right away so it
529
+ describes the state that refused it.
530
+ """
531
+ if request.operation_id in self.active_by_id or request.operation_id in self.outcome_by_id:
532
+ return request.operation_id
533
+ commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
534
+ action = OperationAction.COMMIT_PUSH if request.expected_head_ref is not None else commit_action
535
+ intent = OperationIntent.capture(request.operation_id, request.snapshot.context, request.snapshot.git_filename, action=action, rebase_capture=request.rebase_capture,
536
+ operation=commit_action.commit_label)
537
+ reason = GITPANE_CLOSING_TEXT if self.repository_operation.closed else self.repository_operation.policy_diagnostic(intent)
538
+ if reason or not self.mutation.acquire(request.snapshot, request.operation_id, action=action, operation=commit_action.commit_label):
539
+ diagnostic = self._diagnostic(request, reason or self.mutation.acquire_refusal_cause(request.snapshot, request.operation_id, action=action, operation=commit_action.commit_label))
540
+ outcome = CommitOutcome(request.operation_id, request.key, OperationStatus.REFUSED, None, None, diagnostic)
541
+ self.outcome_by_id[request.operation_id] = outcome
542
+ QTimer.singleShot(0, lambda: self.finished.emit(outcome))
543
+ return request.operation_id
544
+ operation = _CommitOperation(request)
545
+ self.active_by_id[request.operation_id] = operation
546
+
547
+ def prepared(value: object, error: Exception | None) -> None:
548
+ """Launch the native write only after every captured precondition still matches."""
549
+ if error is not None:
550
+ self._finish(operation, OperationStatus.REFUSED, None, self._diagnostic(request, str(error)))
551
+ return
552
+ snapshot = request.snapshot
553
+ arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, "commit", "--file=-"]
554
+ if request.amend:
555
+ arguments.append("--amend")
556
+
557
+ def validate_launch() -> None:
558
+ """Recheck the current owner, write policy and authority, then only the metadata the launch proof covered.
559
+
560
+ The index and reference contents were compared by a file worker when the preparation ended; here, on
561
+ the Qt thread, their metadata alone says whether anything moved while the commit waited to start.
562
+ """
563
+ self.mutation.validate_write(request.key)
564
+ if operation.launch_signature is None:
565
+ raise ValueError("The commit launch was not checked. Refresh before writing.")
566
+ verify_launch_signature(operation.launch_signature, COMMIT_LAUNCH_CHANGED_CAUSE, COMMIT_LAUNCH_UNREADABLE_CAUSE)
567
+
568
+ process_request = ProcessRequest(request.operation_id, snapshot.git_filename, arguments, snapshot.context.repository_dir, GIT_COMMIT_TIMEOUT_MS,
569
+ session_id="commit:" + request.key.git_dir, stdin=request.message, idle_timeout_ms=COMMIT_IDLE_TIMEOUT_MS,
570
+ operation="Amend" if request.amend else "Commit", wait_for_exit_on_stop=True, profile=LOCAL_WRITE_PROFILE, operation_id=request.operation_id,
571
+ before_start=validate_launch)
572
+ try:
573
+ self.runner.start(process_request)
574
+ except (ValueError, RuntimeError) as failure:
575
+ self._process_finished(ProcessResult(process_request, ProcessStatus.START_FAILED, None, str(failure), b""))
576
+
577
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, self._prepare(operation), prepared, operation_id=request.operation_id)
578
+ return request.operation_id
579
+
580
+ def _prepare(self, operation: _CommitOperation) -> Generator:
581
+ """Check freshness, compute the expected tree with a private index copy, then prove what the launch will check.
582
+
583
+ The displayed read is checked against fresh Git reads rather than read again in full. Once the tree is known,
584
+ a file worker compares the index and reference contents with the checked ones and leaves a metadata signature,
585
+ which is all the launch callback reads again on the Qt thread.
586
+
587
+ A merge waiting for its commit is read from its files by a file worker, not by Git: its commits become the
588
+ expected parents after HEAD, and it may be concluded with nothing staged, since keeping HEAD's side of every
589
+ file is a valid resolution Git accepts.
590
+ """
591
+ from gitpane.reference.reference_edit import reference_signature
592
+
593
+ request = operation.request
594
+ snapshot = request.snapshot
595
+ if not snapshot.fresh or request.key != snapshot.key or request.key != WorktreeKey.from_context(snapshot.context):
596
+ raise ValueError("Preparation is stale; refresh the changes")
597
+ if request.expected_head_oid != snapshot.head_oid or request.index_fingerprint != snapshot.index_fingerprint:
598
+ raise ValueError("Commit intention does not match the displayed preparation")
599
+ if not typed_value(request.message.decode("utf-8")) or b"\0" in request.message:
600
+ raise ValueError("Enter a nonblank commit message without NUL characters")
601
+ if request.amend and request.expected_head_oid is None:
602
+ raise ValueError("There is no HEAD commit to amend")
603
+ # Capture before the native identity reads so a queued launch cannot adopt newer ref storage.
604
+ operation.reference_signature = yield FileTask(lambda: reference_signature(snapshot.context))
605
+ before = yield from self._read_state(snapshot)
606
+ if request.expected_head_ref is not None and before.ref_target.decode("utf-8", "surrogateescape").strip() != request.expected_head_ref:
607
+ raise ValueError("The current branch changed after Commit & push was captured; refresh the changes")
608
+ # The state before the write carries the displayed read itself, which _read_state has just proven current: the
609
+ # identity, the index path and bytes, HEAD and the configuration and attributes were compared with fresh Git
610
+ # reads. Equal index bytes and HEAD give the same staged entries and the same conflict stages, so the entries
611
+ # below describe the index the commit will record.
612
+ if any(entry.conflict_stages for entry in before.snapshot.entries):
613
+ raise ValueError("Resolve all conflicts before committing")
614
+ if not request.amend:
615
+ git_dir = snapshot.context.git_dir
616
+ merge = yield FileTask(lambda: read_merge_in_progress(git_dir))
617
+ if merge is not None:
618
+ operation.merge_parent_oids = merge.parent_oids
619
+ head_parents = (request.expected_head_oid,) if request.expected_head_oid else ()
620
+ operation.merge_expected_parent_oids = (*head_parents, *merge.parent_oids)
621
+ if not merge.keeps_every_parent and len(operation.merge_expected_parent_oids) > 1:
622
+ operation.merge_expected_parent_oids = yield from reduced_merge_parent_oids(operation.merge_expected_parent_oids)
623
+ if not request.amend and not operation.merge_parent_oids and not any(entry.side == StagingSide.STAGED for entry in before.snapshot.entries):
624
+ raise ValueError("Stage changes before committing")
625
+ recovery = self.mutation.recover(snapshot.context, snapshot.index_filename, operation_id=request.operation_id)
626
+ if recovery:
627
+ raise ValueError(recovery)
628
+ operation.before = before
629
+ # write-tree may update cached index metadata, so compute against a private copy.
630
+ with tempfile.TemporaryDirectory(prefix="gitpane-commit-", dir=snapshot.context.git_dir) as temporary_dir:
631
+ index_filename = os.path.join(temporary_dir, "index")
632
+ if before.snapshot.index_content is not None:
633
+ yield FileTask(lambda: shutil.copy2(snapshot.index_filename, index_filename))
634
+ tree = yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "write-tree"], environment_by_name={"GIT_INDEX_FILE": index_filename}, profile=LOCAL_WRITE_PROFILE)
635
+ operation.expected_tree_oid = tree.strip().decode("ascii")
636
+ # HEAD and the branch are among the reference files, so this proof also covers what separate HEAD reads checked.
637
+ reference = operation.reference_signature
638
+ operation.launch_signature = yield FileTask(lambda: prove_commit_launch(snapshot.context, snapshot.index_filename, snapshot.index_content, reference))
639
+
640
+ def _read_state(self, snapshot: WorktreeSnapshot, previous: CommitState | None = None) -> Generator:
641
+ """Establish Git identities and the working bytes a commit could change, at one commit boundary.
642
+
643
+ Before the write, without a previous state, the displayed snapshot is not read again: WorktreeService.verify
644
+ compares its identity, index path and bytes, HEAD, configuration and attributes with fresh Git reads, and the
645
+ state carries that same snapshot. After the write, with the previous state, a complete capture is read.
646
+
647
+ HEAD, its tree, its parents and its branch are read at both ends of the boundary, around the file reads, and
648
+ must not move, nor may the index bytes. Files Git lists, files of the displayed preparation and files already
649
+ hashed at the previous boundary are always hashed. Every other tracked file, hidden index entries included, is
650
+ hashed only when the hooks directory of this boundary holds a hook, since only a hook runs inside the commit and
651
+ can rewrite a file Git still reports as clean. A snapshot that did not resolve its hooks directory keeps the
652
+ full hashing.
653
+ """
654
+ if previous is None:
655
+ yield from self.worktree_service.verify(snapshot, ())
656
+ current = snapshot
657
+ else:
658
+ current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
659
+ facts = yield from read_commit_head()
660
+ hook_present = True
661
+ post_commit_hook_active = False
662
+ pre_commit_hook_active = False
663
+ if current.hooks_dir is not None:
664
+ hooks_dir = current.hooks_dir
665
+ hook_present, post_commit_hook_active, pre_commit_hook_active = yield FileTask(lambda: (commit_hook_exists(hooks_dir), git_hook_active(hooks_dir, POST_COMMIT_HOOK_BASENAME),
666
+ git_hook_active(hooks_dir, PRE_COMMIT_HOOK_BASENAME)))
667
+ index_entries = current.index_entries
668
+ if index_entries is None:
669
+ index_entries = yield GitCommand(["ls-files", "--stage", "-z"])
670
+ paths = list(snapshot.fingerprint_by_path)
671
+ tracked_paths = []
672
+ for record in index_entries.split(b"\0"):
673
+ if record:
674
+ _metadata, separator, path = record.partition(b"\t")
675
+ if not separator:
676
+ raise ValueError("Malformed Git index entry output")
677
+ tracked_paths.append(path)
678
+ if hook_present:
679
+ # Equal metadata can hide a same-size rewrite with restored timestamps, even when Git reports a clean file.
680
+ paths.extend(current.hidden_index_paths)
681
+ paths.extend(tracked_paths)
682
+ if previous is not None:
683
+ paths.extend(previous.working_by_path)
684
+ for entry in current.entries:
685
+ paths.extend(entry.paths)
686
+ working_by_path = yield from read_fingerprints(snapshot.context, tuple(paths))
687
+ after_facts = yield from read_commit_head()
688
+ index = yield FileTask(lambda: read_index(current.index_filename))
689
+ if facts.head_oid != current.head_oid or after_facts != facts or index != current.index_content:
690
+ raise ValueError("Repository changed during commit verification; Refresh to inspect the result")
691
+ return CommitState(current, facts.tree_oid, facts.parent_oids, index_entries, working_by_path, facts.ref_target, post_commit_hook_active, pre_commit_hook_active)
692
+
693
+ def _stopping(self, process_request: ProcessRequest, cause: str) -> None:
694
+ """Explain an exceeded budget while the native Git process still owns its hooks."""
695
+ operation = self.active_by_id.get(process_request.request_id)
696
+ if operation is not None:
697
+ self.waiting.emit(self._diagnostic(operation.request, cause + ". Waiting for Git and its hooks to exit; no second commit will start"))
698
+
699
+ def _process_finished(self, result: ProcessResult) -> None:
700
+ """Reconcile a single terminal process response while retaining the write reservation."""
701
+ operation = self.active_by_id.get(result.request.request_id)
702
+ if operation is None or operation.reconciling:
703
+ return
704
+ operation.reconciling = True
705
+ request = operation.request
706
+
707
+ def inspected(after: CommitState | None, error: Exception | None) -> None:
708
+ """Classify the result from the states read before and after the write.
709
+
710
+ The commit that was asked for, with its expected tree, parents and branch, is a success even when files or
711
+ the index changed around it: a hook or another program changed them, not the commit. Those details go to
712
+ the --log file; only when neither a pre-commit nor a post-commit hook is active to explain them does a
713
+ neutral notice also name the changed files. A new commit with the expected parents and branch whose tree differs only because an active
714
+ pre-commit hook added or changed files is the user's own program at work: a success with nothing on screen,
715
+ the details in the --log file. Any other new commit, or a change without one, is partial and starts with a plain sentence:
716
+ Git's own cause when Git did not succeed but a commit exists, or when Git never started, the object IDs
717
+ always in the details. A HEAD that moved while Git never started is not a commit this request created.
718
+ When Git ran but no commit exists while something changed, the sentence names the changed files and blames
719
+ the pre-commit hook only when Git ended with a refusal and such a hook is active; the hook's own message
720
+ stays in the details.
721
+ """
722
+ before = operation.before
723
+ if error is not None:
724
+ cause = result.cause + ". " if result.cause else ""
725
+ cause += "Commit result could not be verified; Refresh. " + str(error)
726
+ self._finish(operation, OperationStatus.UNKNOWN, None, self._diagnostic(request, cause, result.stderr, result.return_code), result.status)
727
+ return
728
+ head_parents = (request.expected_head_oid,) if request.expected_head_oid else ()
729
+ expected_parents = operation.merge_expected_parent_oids if operation.merge_parent_oids else head_parents
730
+ if request.amend:
731
+ expected_parents = before.parent_oids
732
+ new_head_with_expected_parents = after.snapshot.head_oid is not None and (request.amend or after.snapshot.head_oid != request.expected_head_oid) and after.parent_oids == expected_parents
733
+ expected_tree = after.tree_oid == operation.expected_tree_oid
734
+ same_branch = before.ref_target == after.ref_target
735
+ unchanged_local = before.index_entries == after.index_entries and before.working_by_path == after.working_by_path and same_branch
736
+ # Whatever moved in the repository while Git never started was not this commit's doing.
737
+ git_ran = result.status != ProcessStatus.START_FAILED
738
+ commit_created = git_ran and after.snapshot.head_oid is not None and after.snapshot.head_oid != before.snapshot.head_oid
739
+ requested = new_head_with_expected_parents and expected_tree and same_branch
740
+ pre_commit_hook_active = before.pre_commit_hook_active or after.pre_commit_hook_active
741
+ if result.status == ProcessStatus.SUCCESS and requested:
742
+ log_diagnostic = None
743
+ notice = ""
744
+ if not unchanged_local:
745
+ log_diagnostic = self._diagnostic(request, SUCCESS_WITH_CHANGES_LOG_CAUSE, result.stderr, result.return_code, describe_commit_boundaries(before, after))
746
+ # An active pre-commit or post-commit hook is the user's own program, expected to change files around
747
+ # every commit; only a change no such hook explains is worth a notice.
748
+ post_commit_hook_active = before.post_commit_hook_active or after.post_commit_hook_active
749
+ if not post_commit_hook_active and not pre_commit_hook_active:
750
+ notice = commit_change_notice(noticed_paths(before, after), before.index_entries != after.index_entries)
751
+ self._finish(operation, OperationStatus.SUCCESS, after, None, result.status, commit_created=True, notice=notice, log_diagnostic=log_diagnostic)
752
+ return
753
+ if result.status == ProcessStatus.SUCCESS and commit_created and new_head_with_expected_parents and same_branch and pre_commit_hook_active:
754
+ # The tree differs from what was staged, but the user's own pre-commit hook explains it: nothing to warn about.
755
+ log_diagnostic = self._diagnostic(request, PRE_COMMIT_HOOK_CONTENT_LOG_CAUSE, result.stderr, result.return_code, describe_commit_boundaries(before, after))
756
+ self._finish(operation, OperationStatus.SUCCESS, after, None, result.status, commit_created=True, notice="", log_diagnostic=log_diagnostic)
757
+ return
758
+ changed = before.snapshot.head_oid != after.snapshot.head_oid or not unchanged_local
759
+ # Another commit than the one asked for, or none while files changed: partial, a warning to look at.
760
+ status = OperationStatus.PARTIAL if changed else OperationStatus.REFUSED
761
+ if commit_created:
762
+ cause = COMMIT_CHANGED_ELSEWHERE_CAUSE if result.status == ProcessStatus.SUCCESS else result.cause or COMMIT_CHANGED_ELSEWHERE_CAUSE
763
+ elif changed and git_ran:
764
+ refused_by_pre_commit_hook = result.status == ProcessStatus.FAILED and pre_commit_hook_active
765
+ cause = no_commit_change_cause(noticed_paths(before, after), before.index_entries != after.index_entries, refused_by_pre_commit_hook)
766
+ elif result.cause:
767
+ cause = result.cause
768
+ elif changed:
769
+ cause = no_commit_change_cause(noticed_paths(before, after), before.index_entries != after.index_entries, False)
770
+ else:
771
+ cause = NO_COMMIT_FOUND_CAUSE
772
+ technical_detail = describe_commit_boundaries(before, after) + "\n" + REVIEW_BEFORE_RETRY_TEXT
773
+ if result.cause and result.cause != cause:
774
+ technical_detail = GIT_CAUSE_DETAIL_PREFIX + result.cause + "\n" + technical_detail
775
+ diagnostic = self._diagnostic(request, cause, result.stderr, result.return_code, technical_detail)
776
+ self._finish(operation, status, after, diagnostic, result.status, commit_created=commit_created)
777
+
778
+ # Both branches name the displayed snapshot: the state before the write carries it once it was verified. It only
779
+ # seeds the list of files hashed after the write, next to every file hashed before it and every file the new
780
+ # capture lists; a file that appears only in that capture has no earlier bytes and is reported as not verified.
781
+ baseline = operation.before.snapshot if operation.before is not None else request.snapshot
782
+ self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, self._read_state(baseline, operation.before), inspected, operation_id=request.operation_id)
783
+
784
+ def read_amend_message(self, snapshot: WorktreeSnapshot) -> Generator:
785
+ """Decode the full commit object the displayed snapshot names, then check once that HEAD still names it.
786
+
787
+ The object is asked for by its ID, which never changes, so HEAD moving before the read cannot change what is
788
+ read: the single check at the end refuses the message instead.
789
+ """
790
+ if snapshot.head_oid is None:
791
+ raise ValueError("There is no HEAD commit to amend")
792
+ head_oid = validate_oid(snapshot.head_oid)
793
+ raw = yield GitCommand(["cat-file", "commit", head_oid])
794
+ commit = parse_commit(head_oid, raw, head_oid)
795
+ after = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
796
+ if after.strip().decode("ascii", "replace") != head_oid:
797
+ raise ValueError("HEAD changed; Refresh and load Amend again")
798
+ return head_oid, commit.message
799
+
800
+ def _diagnostic(self, request: CommitRequest, cause: str, stderr: bytes = b"", return_code: int | None = None, technical_detail: str = "") -> PreparedDiagnostic:
801
+ """Remove echoed message text as well as credentials before any output surface sees it.
802
+
803
+ The technical details, which only the details and the --log file show, are masked the same way.
804
+ """
805
+ message = request.message.decode("utf-8", "replace")
806
+ cause = mask_commit_message(message, cause)
807
+ text = mask_commit_message(message, stderr.decode("utf-8", "replace"))
808
+ technical_detail = mask_commit_message(message, technical_detail)
809
+ arguments = ["commit", "--file=-"]
810
+ if request.amend:
811
+ arguments.append("--amend")
812
+ action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
813
+ return Diagnostic(action.commit_label, request.snapshot.context.repository_dir, request.snapshot.git_filename, arguments, cause, text.encode("utf-8"), return_code,
814
+ technical_detail=technical_detail).prepare()
815
+
816
+ def _finish(self, operation: _CommitOperation, status: OperationStatus, after: CommitState | None, diagnostic: PreparedDiagnostic | None, process_status: ProcessStatus | None = None, *,
817
+ commit_created: bool = False, notice: str = "", log_diagnostic: PreparedDiagnostic | None = None) -> None:
818
+ """Release only this completed operation and publish one retained outcome."""
819
+ request = operation.request
820
+ if self.active_by_id.pop(request.operation_id, None) is None:
821
+ return
822
+ self.mutation.release(request.snapshot.key, request.operation_id)
823
+ self._publish(CommitOutcome(request.operation_id, request.key, status, operation.before, after, diagnostic, process_status, commit_created, notice, log_diagnostic))
824
+
825
+ def _publish(self, outcome: CommitOutcome) -> None:
826
+ """Retain outcomes independently of any view and notify interested repository windows."""
827
+ if outcome.operation_id in self.outcome_by_id:
828
+ return
829
+ self.outcome_by_id[outcome.operation_id] = outcome
830
+ self.finished.emit(outcome)