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
gitpane/tool/editor.py ADDED
@@ -0,0 +1,445 @@
1
+ """Serve the editor requests Git sends through the bridge until each one is answered."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import shlex
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import TYPE_CHECKING
18
+
19
+ from PySide6.QtCore import QObject, QTimer, Signal
20
+ from PySide6.QtNetwork import QLocalServer, QLocalSocket
21
+
22
+ import gitpane.tool.editor_bridge
23
+ from gitpane.account.ssh_askpass import peer_process_id
24
+ from gitpane.config import (
25
+ EDITOR_ANSWERED_CLOSE_TIMEOUT_MS,
26
+ EDITOR_CONNECT_TIMEOUT_MS,
27
+ EDITOR_HEARTBEAT_INTERVAL_MS,
28
+ EDITOR_IPC_LIMIT_BYTES,
29
+ EDITOR_LIVENESS_TIMEOUT_MS,
30
+ EDITOR_PENDING_LIMIT,
31
+ EDITOR_REQUEST_LIMIT,
32
+ EDITOR_TOKEN_BYTES,
33
+ MILLISECONDS_PER_SECOND,
34
+ MODULE_OPTION,
35
+ application_module_interpreter_options,
36
+ )
37
+ from gitpane.sequencer.rebase_edit_file import edit_file_open_flags
38
+ from gitpane.system.path_identity import canonical_path, link_resolved_path
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Callable
42
+
43
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
44
+
45
+
46
+ def editor_bridge_module_name() -> str:
47
+ """Answer the module name Git receives after -m to start the editor bridge.
48
+
49
+ It is read from the imported module, so a move that rewrites the import cannot leave a stale name in the command.
50
+ """
51
+ return gitpane.tool.editor_bridge.__name__
52
+
53
+
54
+ class EditorRequestKind(Enum):
55
+ """Which file Git asks to edit: the rebase instruction list or a commit message, as the bridge names them."""
56
+
57
+ SEQUENCE = "sequence"
58
+ MESSAGE = "message"
59
+
60
+ @property
61
+ def window_title(self) -> str:
62
+ """Answer the title of the window that shows this kind of edit."""
63
+ return "Rebase instructions" if self == EditorRequestKind.SEQUENCE else "Commit message"
64
+
65
+ @property
66
+ def progress_text(self) -> str:
67
+ """Answer the progress line shown while Git waits for this kind of edit."""
68
+ return "Editing rebase instructions…" if self == EditorRequestKind.SEQUENCE else "Editing commit message…"
69
+
70
+
71
+ class EditorFailure(Enum):
72
+ """Why the editor service could not take an edit request of Git; each value is the cause written in the output of the rebase."""
73
+
74
+ REQUEST_REFUSED = "The GitPane editor could not take this edit request."
75
+ CLOSED = "The GitPane rebase editor is closed."
76
+
77
+
78
+ class EditorAnswer(Enum):
79
+ """What the user chose for one edit Git waits for."""
80
+
81
+ CONTINUE = 0
82
+ ABORT = 1
83
+
84
+ @property
85
+ def return_code(self) -> int:
86
+ """Answer the exit status the bridge reports to Git for this choice."""
87
+ return self.value
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class EditorRequest:
92
+ """Identify one file waiter without exposing private bridge credentials."""
93
+
94
+ operation_id: str
95
+ request_id: str
96
+ kind: EditorRequestKind
97
+ file_filename: str
98
+ file_identity: tuple[int, int] | None = None
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class EditorResult:
103
+ """Report how one edit ended: the exit status Git receives, the user's choice when a GitPane window answered, and a fixed cause on failure."""
104
+
105
+ request: EditorRequest
106
+ return_code: int
107
+ cause: str = ""
108
+ answer: EditorAnswer | None = None
109
+
110
+
111
+ class PendingEdit:
112
+ """One edit Git waits for until a GitPane window answers it, answered at most once."""
113
+
114
+ def __init__(self, request: EditorRequest, finish: Callable[[PendingEdit, EditorAnswer], None]) -> None:
115
+ """Keep the request and the service's way to end it; the service builds it when the bridge asks for an edit."""
116
+ self.request = request
117
+ self.answered = False
118
+ self.finish = finish
119
+
120
+ def answer(self, answer: EditorAnswer) -> None:
121
+ """Send the user's choice to Git once; a later answer, or one given after the bridge was lost, changes nothing."""
122
+ if self.answered:
123
+ return
124
+ self.answered = True
125
+ self.finish(self, answer)
126
+
127
+
128
+ class EditorService(QObject):
129
+ """Hold every edit Git waits for until a GitPane window answers it, while Qt keeps serving other windows.
130
+
131
+ It serves the requests of Git only and never starts a program: Show in text editor opens ordinary files elsewhere.
132
+ """
133
+
134
+ waiting = Signal(object)
135
+ finished = Signal(object)
136
+ idle = Signal()
137
+ edit_requested = Signal(object)
138
+ edit_cancelled = Signal(object)
139
+
140
+ def __init__(self, parent: QObject | None = None) -> None:
141
+ """Keep each request and its direct waiter under this service's ownership."""
142
+ super().__init__(parent)
143
+ # One entry per edit Git waits for, until a GitPane window answers it or its bridge is lost.
144
+ self.active_by_id: dict[str, PendingEdit] = {}
145
+ self.context_by_id: dict[str, EditorExecutionContext] = {}
146
+ self.closed = False
147
+
148
+ def close(self) -> bool:
149
+ """Expire idle channels only after every owned file waiter has exited normally."""
150
+ if self.active_by_id:
151
+ return False
152
+ self.closed = True
153
+ for context in tuple(self.findChildren(EditorExecutionContext)):
154
+ context.close()
155
+ return not self.findChildren(EditorExecutionContext)
156
+
157
+ def create_context(self, operation_id: str, git_dir: str, worktree_key: WorktreeKey | None = None) -> EditorExecutionContext:
158
+ """Capture one canonical Git directory for the lifetime of an operation; every edit of it waits for a GitPane window's answer."""
159
+ if self.closed:
160
+ raise ValueError("The editor service is closed.")
161
+ if not operation_id or operation_id in self.context_by_id:
162
+ raise ValueError("An editor operation must have a unique identity.")
163
+ if worktree_key is not None and worktree_key.git_dir != canonical_path(git_dir):
164
+ raise ValueError("The editor belongs to another worktree.")
165
+ context = EditorExecutionContext(self, operation_id, git_dir, worktree_key)
166
+ self.context_by_id[operation_id] = context
167
+ return context
168
+
169
+ def request_edit(self, request: EditorRequest, complete: Callable[[EditorResult], None]) -> PendingEdit:
170
+ """Hold one edit Git waits for until a GitPane window answers it, and announce it to whoever shows it.
171
+
172
+ The request stays in ``active_by_id`` until its answer, so closing GitPane or its repository window waits for it.
173
+ """
174
+ if self.closed:
175
+ raise ValueError("The editor service is closed.")
176
+ if request.request_id in self.active_by_id:
177
+ raise ValueError("This editor request is already active.")
178
+
179
+ def finish(pending: PendingEdit, answer: EditorAnswer) -> None:
180
+ """Publish the one answer of this request while it is still the active one."""
181
+ if self.active_by_id.get(request.request_id) is not pending:
182
+ return
183
+ del self.active_by_id[request.request_id]
184
+ result = EditorResult(request, answer.return_code, answer=answer)
185
+ complete(result)
186
+ self.finished.emit(result)
187
+ if not self.active_by_id:
188
+ self.idle.emit()
189
+
190
+ pending = PendingEdit(request, finish)
191
+ self.active_by_id[request.request_id] = pending
192
+ self.waiting.emit(request)
193
+ self.edit_requested.emit(pending)
194
+ return pending
195
+
196
+ def cancel(self, request_id: str) -> None:
197
+ """Forget a request whose bridge was lost; it takes no answer any more, since the connection that would carry it to Git is already gone."""
198
+ active = self.active_by_id.pop(request_id, None)
199
+ if active is None:
200
+ return
201
+ active.answered = True
202
+ self.edit_cancelled.emit(active.request)
203
+ if not self.active_by_id:
204
+ self.idle.emit()
205
+
206
+
207
+ class EditorExecutionContext(QObject):
208
+ """Authenticate exact Git descendants and files before handing a live editing request to a GitPane window.
209
+
210
+ ``abort_requested`` turns true when the user answers Abort, so the rebase that owns this context knows its command
211
+ failed because the whole rebase must be cancelled, not because of Git or a lost bridge.
212
+ """
213
+
214
+ wait_changed = Signal(bool)
215
+ failed = Signal(str)
216
+
217
+ def __init__(self, service: EditorService, operation_id: str, git_dir: str, worktree_key: WorktreeKey | None = None) -> None:
218
+ """Own one private channel for one operation without placing a token in argv; the service builds it in create_context."""
219
+ super().__init__(service)
220
+ self.service = service
221
+ self.operation_id = operation_id
222
+ self.git_dir = os.path.realpath(git_dir)
223
+ self.worktree_key = worktree_key
224
+ self.closed = False
225
+ self.abort_requested = False
226
+ self.cleanup_complete = False
227
+ self.process_request_id = ""
228
+ self.token = secrets.token_hex(EDITOR_TOKEN_BYTES)
229
+ self.authorize_peer: Callable[[int], bool] = lambda _peer: False
230
+ self.request_id_set: set[str] = set()
231
+ self.buffer_by_socket: dict[QLocalSocket, bytearray] = {}
232
+ self.deadline_by_socket: dict[QLocalSocket, float] = {}
233
+ # The connections that received their answer and wait for the bridge to close them.
234
+ self.answered_socket_set: set[QLocalSocket] = set()
235
+ self.pending_socket: QLocalSocket | None = None
236
+ self.pending_request: EditorRequest | None = None
237
+ self.directory = tempfile.TemporaryDirectory(prefix="gitpane-editor-")
238
+ self.server = QLocalServer(self)
239
+ self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
240
+ self.server.setMaxPendingConnections(EDITOR_PENDING_LIMIT)
241
+ pipe_name = "gitpane-editor-" + uuid.uuid4().hex if os.name == "nt" else os.path.join(self.directory.name, "socket")
242
+ if not self.server.listen(pipe_name):
243
+ self.directory.cleanup()
244
+ raise OSError("The private editor channel could not be opened.")
245
+ self.server.newConnection.connect(self._accept)
246
+ self.timer = QTimer(self)
247
+ self.timer.setInterval(EDITOR_HEARTBEAT_INTERVAL_MS)
248
+ self.timer.timeout.connect(self._check_liveness)
249
+ self.timer.start()
250
+
251
+ def bind_peer(self, operation_id: str, authorize_peer: Callable[[int], bool]) -> None:
252
+ """Bind native peer validation to the command that owns this operation."""
253
+ if self.closed or operation_id != self.operation_id:
254
+ raise ValueError("The editor context belongs to another operation.")
255
+ self.authorize_peer = authorize_peer
256
+
257
+ def environment(self) -> dict[str, str]:
258
+ """Generate Git shell commands containing only the installed bridge, started without the Python variables the interpreter reads itself or the current folder, and its kind."""
259
+ if self.closed:
260
+ raise ValueError("The editor context has expired.")
261
+ command = shlex.join([sys.executable, *application_module_interpreter_options(bool(sys.flags.no_user_site)), MODULE_OPTION, editor_bridge_module_name()]) + " "
262
+ return {"GIT_EDITOR": command + "message", "GIT_SEQUENCE_EDITOR": command + "sequence", "GITPANE_EDITOR_PIPE": self.server.fullServerName(),
263
+ "GITPANE_EDITOR_TOKEN": self.token, "GITPANE_EDITOR_OPERATION": self.operation_id, "GITPANE_EDITOR_OWNER_PID": str(os.getpid())}
264
+
265
+ def owns(self, operation_id: str) -> bool:
266
+ """Check channel ownership without accepting environment variables as authority."""
267
+ return not self.closed and self.server.isListening() and operation_id == self.operation_id and self.service.context_by_id.get(operation_id) is self
268
+
269
+ def close(self) -> str:
270
+ """Expire authority immediately and retry denied cleanup without blocking command completion."""
271
+ if self.cleanup_complete:
272
+ return ""
273
+ if not self.closed:
274
+ self.closed = True
275
+ self.timer.stop()
276
+ for connection in tuple(self.buffer_by_socket):
277
+ connection.abort()
278
+ self.server.close()
279
+ self.token = ""
280
+ self.service.context_by_id.pop(self.operation_id, None)
281
+ try:
282
+ self.directory.cleanup()
283
+ except OSError:
284
+ # A transient file lock must not keep Git waiting. The service retains this
285
+ # expired context as a Qt child until its timer can remove the private directory.
286
+ self.timer.start()
287
+ return "Command finished, but private editor files could not be removed; cleanup will be retried."
288
+ self.cleanup_complete = True
289
+ self.timer.stop()
290
+ self.setParent(None)
291
+ self.deleteLater()
292
+ return ""
293
+
294
+ def _accept(self) -> None:
295
+ """Reject native peers outside the current supervised Git group before reading JSON."""
296
+ while self.server.hasPendingConnections():
297
+ connection = self.server.nextPendingConnection()
298
+ connection.setReadBufferSize(EDITOR_IPC_LIMIT_BYTES + 1)
299
+ try:
300
+ if not self.owns(self.operation_id) or len(self.buffer_by_socket) >= EDITOR_PENDING_LIMIT or not self.authorize_peer(peer_process_id(connection)):
301
+ raise ValueError("Unrecognized editor peer")
302
+ except (OSError, ValueError):
303
+ connection.abort()
304
+ connection.deleteLater()
305
+ continue
306
+ self.buffer_by_socket[connection] = bytearray()
307
+ self.deadline_by_socket[connection] = time.monotonic() + EDITOR_CONNECT_TIMEOUT_MS / 1000
308
+ connection.readyRead.connect(lambda current=connection: self._read(current))
309
+ connection.disconnected.connect(lambda current=connection: self._disconnect(current))
310
+ self._read(connection)
311
+
312
+ def _read(self, connection: QLocalSocket) -> None:
313
+ """Validate framed credentials, nonce and exact regular file before handing the edit to a GitPane window."""
314
+ buffer = self.buffer_by_socket.get(connection)
315
+ if buffer is None:
316
+ return
317
+ buffer.extend(bytes(connection.readAll()))
318
+ if len(buffer) > EDITOR_IPC_LIMIT_BYTES:
319
+ connection.abort()
320
+ return
321
+ while b"\n" in buffer:
322
+ line, _, remainder = buffer.partition(b"\n")
323
+ buffer[:] = remainder
324
+ try:
325
+ data_by_key = json.loads(line)
326
+ if connection in self.answered_socket_set:
327
+ # The bridge can write one more liveness message before it reads the answer; anything else on an
328
+ # answered connection is refused.
329
+ if data_by_key != {"alive": True}:
330
+ raise ValueError("Unexpected line after the editor answer")
331
+ continue
332
+ if connection is self.pending_socket and data_by_key == {"alive": True}:
333
+ if not self.authorize_peer(peer_process_id(connection)):
334
+ raise ValueError("The editor peer expired")
335
+ self.deadline_by_socket[connection] = time.monotonic() + EDITOR_LIVENESS_TIMEOUT_MS / 1000
336
+ connection.write(b'{"alive":true}\n')
337
+ continue
338
+ request = self._validate(data_by_key)
339
+ self.request_id_set.add(request.request_id)
340
+ self.pending_socket = connection
341
+ self.pending_request = request
342
+ self.deadline_by_socket[connection] = time.monotonic() + EDITOR_LIVENESS_TIMEOUT_MS / 1000
343
+ self.wait_changed.emit(True)
344
+ connection.write(b'{"alive":true}\n')
345
+ if self.service.closed:
346
+ self._finish(connection, EditorResult(request, 1, EditorFailure.CLOSED.value))
347
+ continue
348
+ try:
349
+ self.service.request_edit(request, lambda result, current=connection: self._finish(current, result))
350
+ except ValueError:
351
+ self._finish(connection, EditorResult(request, 1, EditorFailure.REQUEST_REFUSED.value))
352
+ except (OSError, ValueError, TypeError, AttributeError, UnicodeError):
353
+ connection.abort()
354
+ return
355
+
356
+ def _validate(self, data_by_key: dict[str, object]) -> EditorRequest:
357
+ """Limit authorized filenames to canonical native sequence and message locations."""
358
+ token = data_by_key.get("token")
359
+ request_id = data_by_key.get("request")
360
+ if (not self.owns(self.operation_id) or not isinstance(token, str) or not hmac.compare_digest(token, self.token)
361
+ or data_by_key.get("operation") != self.operation_id or not isinstance(request_id, str) or len(request_id) != len(uuid.uuid4().hex)
362
+ or request_id in self.request_id_set or len(self.request_id_set) >= EDITOR_REQUEST_LIMIT or self.pending_socket is not None):
363
+ raise ValueError("Invalid editor authorization")
364
+ filename = data_by_key.get("file")
365
+ if not isinstance(filename, str) or not os.path.isabs(filename) or "\0" in filename:
366
+ raise ValueError("Invalid editor filename")
367
+ try:
368
+ kind = EditorRequestKind(data_by_key.get("kind"))
369
+ except ValueError:
370
+ raise ValueError("Invalid editor kind") from None
371
+ if kind == EditorRequestKind.SEQUENCE:
372
+ allowed_filenames = (os.path.join(self.git_dir, "rebase-merge", "git-rebase-todo"),)
373
+ else:
374
+ allowed_filenames = (os.path.join(self.git_dir, "COMMIT_EDITMSG"), os.path.join(self.git_dir, "SQUASH_MSG"), os.path.join(self.git_dir, "MERGE_MSG"),
375
+ os.path.join(self.git_dir, "rebase-merge", "message"), os.path.join(self.git_dir, "rebase-merge", "message-squash"))
376
+ # A link is found by resolving it without the disk spelling, so a name typed with another letter case is not
377
+ # taken for a link; the allowed files are then compared by identity, both sides spelled the same way.
378
+ canonical_filename = canonical_path(filename)
379
+ if link_resolved_path(filename) != os.path.normcase(os.path.abspath(filename)) or not any(canonical_filename == canonical_path(allowed) for allowed in allowed_filenames):
380
+ raise ValueError("This file is outside the current editor request")
381
+ identity = os.lstat(filename)
382
+ if not stat.S_ISREG(identity.st_mode) or identity.st_nlink != 1:
383
+ raise ValueError("The editor requires a private regular Git file")
384
+ descriptor = os.open(filename, edit_file_open_flags(os.name))
385
+ try:
386
+ opened = os.fstat(descriptor)
387
+ if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
388
+ raise ValueError("The editor requires a private regular Git file")
389
+ current = os.lstat(filename)
390
+ if (not stat.S_ISREG(current.st_mode) or current.st_nlink != 1
391
+ or (identity.st_dev, identity.st_ino) != (opened.st_dev, opened.st_ino) or (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino)):
392
+ raise ValueError("The editor file changed before launch")
393
+ finally:
394
+ os.close(descriptor)
395
+ return EditorRequest(self.operation_id, request_id, kind, os.path.realpath(filename), (identity.st_dev, identity.st_ino))
396
+
397
+ def _finish(self, connection: QLocalSocket, result: EditorResult) -> None:
398
+ """Release only the matching live request, remember an Abort answer and return the exit code to Git.
399
+
400
+ The connection stays open: the bridge closes it once it has read the answer, and GitPane only cuts it when that
401
+ does not happen within its delay. Closing it right after the answer made a liveness message the bridge wrote
402
+ meanwhile fail, and Qt then dropped the bridge's socket before it read the answer. The connection is registered as
403
+ answered before the answer is written, so that the cleanup Qt runs during the flush, when the bridge has already
404
+ closed its side, finds it and forgets it.
405
+ """
406
+ if connection is not self.pending_socket or result.request is not self.pending_request:
407
+ return
408
+ self.pending_socket = None
409
+ self.pending_request = None
410
+ if result.answer == EditorAnswer.ABORT:
411
+ self.abort_requested = True
412
+ if result.cause:
413
+ self.failed.emit(result.cause)
414
+ self.wait_changed.emit(False)
415
+ # Registered before the write: when the bridge has already closed its side, Qt reports the disconnection during
416
+ # the flush, and the cleanup that runs then must find the connection to forget it.
417
+ self.answered_socket_set.add(connection)
418
+ self.deadline_by_socket[connection] = time.monotonic() + EDITOR_ANSWERED_CLOSE_TIMEOUT_MS / MILLISECONDS_PER_SECOND
419
+ connection.write(json.dumps({"code": result.return_code}).encode() + b"\n")
420
+ connection.flush()
421
+
422
+ def _disconnect(self, connection: QLocalSocket) -> None:
423
+ """End a lost authenticated wait without keeping Git deadlines disabled, and forget a connection the bridge closed after its answer."""
424
+ self.buffer_by_socket.pop(connection, None)
425
+ self.deadline_by_socket.pop(connection, None)
426
+ self.answered_socket_set.discard(connection)
427
+ if connection is self.pending_socket:
428
+ request = self.pending_request
429
+ self.pending_socket = None
430
+ self.pending_request = None
431
+ self.wait_changed.emit(False)
432
+ self.failed.emit("The private editor connection was lost. Git execution deadlines have resumed.")
433
+ if request is not None:
434
+ self.service.cancel(request.request_id)
435
+ connection.deleteLater()
436
+
437
+ def _check_liveness(self) -> None:
438
+ """Expire silent peers while allowing an authenticated human edit to take any duration."""
439
+ if self.closed:
440
+ self.close()
441
+ return
442
+ now = time.monotonic()
443
+ for connection, deadline in tuple(self.deadline_by_socket.items()):
444
+ if now >= deadline:
445
+ connection.abort()
@@ -0,0 +1,193 @@
1
+ """Wait for one authenticated editor request using only the installed application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ import uuid
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING
12
+
13
+ from PySide6.QtCore import QCoreApplication
14
+ from PySide6.QtNetwork import QLocalSocket
15
+
16
+ from gitpane.account.ssh_askpass import peer_process_id
17
+ from gitpane.config import (
18
+ EDITOR_CONNECT_TIMEOUT_MS,
19
+ EDITOR_HEARTBEAT_INTERVAL_MS,
20
+ EDITOR_IPC_LIMIT_BYTES,
21
+ EDITOR_LIVENESS_TIMEOUT_MS,
22
+ LINUX_PLATFORM,
23
+ MACOS_PLATFORM,
24
+ MILLISECONDS_PER_SECOND,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import MutableMapping
29
+
30
+ EDITOR_ENVIRONMENT_NAME_SET = frozenset({"GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GITPANE_EDITOR_PIPE", "GITPANE_EDITOR_TOKEN", "GITPANE_EDITOR_OPERATION", "GITPANE_EDITOR_OWNER_PID"})
31
+ # The variables that name the character encoding, in the order the C library reads them: the first one set wins.
32
+ LOCALE_VARIABLE_NAMES = ("LC_ALL", "LC_CTYPE", "LANG")
33
+ UTF8_CODESET_SET = frozenset(("utf-8", "utf8"))
34
+ # Present on every glibc the Linux targets ship and on macOS, and needs no generated locale.
35
+ UTF8_LOCALE_NAME = "C.UTF-8"
36
+ # The systems where Qt requires a UTF-8 locale and warns about any other one; compared as prefixes of sys.platform.
37
+ UTF8_LOCALE_PLATFORM_PREFIXES = (LINUX_PLATFORM, MACOS_PLATFORM)
38
+ # Every line the bridge writes starts with this, so a reader of Git's output knows which program gave up.
39
+ BRIDGE_REASON_PREFIX = "GitPane editor bridge: "
40
+ # The shortest wait for GitPane's next line: waitForReadyRead(0) returns at once, so a wait that reached zero while the
41
+ # next liveness message is due would turn the loop back into a busy loop.
42
+ BRIDGE_MINIMUM_WAIT_MS = 1
43
+
44
+
45
+ class BridgeExitReason(Enum):
46
+ """Why the bridge gave up on an edit, as the fixed English text it writes on its error output for Git to copy.
47
+
48
+ The texts never carry the token, the socket name, the edited file or its content: Git copies them into the rebase
49
+ output that GitPane shows and that --log can keep.
50
+ """
51
+
52
+ ARGUMENTS = "unexpected arguments."
53
+ CONNECT = "could not connect to GitPane."
54
+ OWNER = "the program answering is not the GitPane that started Git."
55
+ REQUEST_TOO_LARGE = "the request is too large."
56
+ SEND = "could not send the request."
57
+ ANSWER_TOO_LARGE = "GitPane's answer is too large."
58
+ INVALID_ANSWER = "GitPane's answer is not valid."
59
+ NO_ANSWER = "no answer from GitPane for {timeout_ms} ms."
60
+ DISCONNECTED = "GitPane closed the connection."
61
+ ABORTED = "the edit was aborted in GitPane."
62
+ ERROR = "unexpected error ({error_type})."
63
+
64
+ def text(self, detail: str = "") -> str:
65
+ """Answer the whole line written for this reason; ``detail`` is only ever the class name of an unexpected exception."""
66
+ return BRIDGE_REASON_PREFIX + self.value.format(timeout_ms=EDITOR_LIVENESS_TIMEOUT_MS, error_type=detail)
67
+
68
+
69
+ def write_exit_reason(reason: BridgeExitReason, detail: str = "") -> None:
70
+ """Write why the bridge gives up on its error output, which Git copies into the output of the command it runs."""
71
+ sys.stderr.write(reason.text(detail) + "\n")
72
+ sys.stderr.flush()
73
+
74
+
75
+ def use_utf8_locale(platform: str, environment: MutableMapping[str, str]) -> None:
76
+ """Give the bridge a UTF-8 locale on Linux and macOS before Qt starts, leaving Windows and a UTF-8 locale alone.
77
+
78
+ Workaround: GitPane runs Git with LC_ALL=C so it can read Git's output, and the bridge Git starts as its editor
79
+ inherits that locale. Qt requires a UTF-8 locale on Linux and macOS and otherwise writes a warning on its error
80
+ output, which Git copies into the rebase output; C.UTF-8 exists on both. Only the environment of the bridge
81
+ process changes; Git keeps LC_ALL=C.
82
+ """
83
+ if not platform.startswith(UTF8_LOCALE_PLATFORM_PREFIXES):
84
+ return
85
+ locale_name = ""
86
+ for name in LOCALE_VARIABLE_NAMES:
87
+ locale_name = environment.get(name, "")
88
+ if locale_name:
89
+ break
90
+ codeset = locale_name.partition(".")[2].partition("@")[0].lower()
91
+ if codeset in UTF8_CODESET_SET:
92
+ return
93
+ environment["LC_ALL"] = UTF8_LOCALE_NAME
94
+
95
+
96
+ def submit_request(connection: QLocalSocket, kind: str, filename: str) -> BridgeExitReason | None:
97
+ """Connect to the GitPane that started Git, check that it is that process, and send it the edit request.
98
+
99
+ Answers why the request could not be sent, or None once it is sent.
100
+ """
101
+ connection.connectToServer(os.environ["GITPANE_EDITOR_PIPE"])
102
+ if not connection.waitForConnected(EDITOR_CONNECT_TIMEOUT_MS):
103
+ return BridgeExitReason.CONNECT
104
+ if peer_process_id(connection, server=True) != int(os.environ["GITPANE_EDITOR_OWNER_PID"]):
105
+ return BridgeExitReason.OWNER
106
+ request_by_key = {"token": os.environ["GITPANE_EDITOR_TOKEN"], "operation": os.environ["GITPANE_EDITOR_OPERATION"],
107
+ "request": uuid.uuid4().hex, "kind": kind, "file": os.path.abspath(filename)}
108
+ content = json.dumps(request_by_key).encode("utf-8") + b"\n"
109
+ if len(content) > EDITOR_IPC_LIMIT_BYTES:
110
+ return BridgeExitReason.REQUEST_TOO_LARGE
111
+ connection.write(content)
112
+ if not connection.waitForBytesWritten(EDITOR_CONNECT_TIMEOUT_MS):
113
+ return BridgeExitReason.SEND
114
+ return None
115
+
116
+
117
+ def read_answer(line: bytes) -> dict[str, object] | None:
118
+ """Decode one line GitPane wrote, or None when it is not a JSON object."""
119
+ try:
120
+ answer_by_key = json.loads(line)
121
+ except ValueError:
122
+ return None
123
+ if not isinstance(answer_by_key, dict):
124
+ return None
125
+ return answer_by_key
126
+
127
+
128
+ def wait_for_answer(connection: QLocalSocket) -> BridgeExitReason | None:
129
+ """Send one liveness message per interval until GitPane answers the edit.
130
+
131
+ Answers why the edit failed, or None when GitPane answered with code 0. The wait for the next line lasts until the
132
+ next liveness message is due, so GitPane's immediate reply to each message does not make the bridge send another
133
+ one at once: the exchange stays at one message per interval however long the user takes.
134
+ """
135
+ buffer = bytearray()
136
+ heartbeat_interval_seconds = EDITOR_HEARTBEAT_INTERVAL_MS / MILLISECONDS_PER_SECOND
137
+ next_send = time.monotonic()
138
+ last_response = next_send
139
+ while connection.state() == QLocalSocket.LocalSocketState.ConnectedState:
140
+ wait_ms = max(BRIDGE_MINIMUM_WAIT_MS, int((next_send - time.monotonic()) * MILLISECONDS_PER_SECOND))
141
+ connection.waitForReadyRead(wait_ms)
142
+ buffer.extend(bytes(connection.readAll()))
143
+ if len(buffer) > EDITOR_IPC_LIMIT_BYTES:
144
+ return BridgeExitReason.ANSWER_TOO_LARGE
145
+ while b"\n" in buffer:
146
+ line, _, remainder = buffer.partition(b"\n")
147
+ buffer = bytearray(remainder)
148
+ answer_by_key = read_answer(bytes(line))
149
+ if answer_by_key == {"alive": True}:
150
+ last_response = time.monotonic()
151
+ continue
152
+ if answer_by_key is None or type(answer_by_key.get("code")) is not int:
153
+ return BridgeExitReason.INVALID_ANSWER
154
+ return None if answer_by_key["code"] == 0 else BridgeExitReason.ABORTED
155
+ now = time.monotonic()
156
+ if (now - last_response) * MILLISECONDS_PER_SECOND > EDITOR_LIVENESS_TIMEOUT_MS:
157
+ return BridgeExitReason.NO_ANSWER
158
+ if now >= next_send:
159
+ connection.write(b'{"alive":true}\n')
160
+ connection.flush()
161
+ next_send = now + heartbeat_interval_seconds
162
+ return BridgeExitReason.DISCONNECTED
163
+
164
+
165
+ def main() -> int:
166
+ """Hand one file Git wants edited to the GitPane that started Git and return its answer as the exit status.
167
+
168
+ Every way of giving up writes its fixed reason on the error output, which Git copies into the output of the
169
+ command, so a failed rebase says which step of the exchange failed.
170
+ """
171
+ use_utf8_locale(sys.platform, os.environ)
172
+ application = QCoreApplication.instance() or QCoreApplication(sys.argv)
173
+ kind_index, file_index, argument_count = 1, 2, 3
174
+ if len(sys.argv) != argument_count or sys.argv[kind_index] not in {"sequence", "message"}:
175
+ write_exit_reason(BridgeExitReason.ARGUMENTS)
176
+ return 1
177
+ connection = QLocalSocket()
178
+ connection.setReadBufferSize(EDITOR_IPC_LIMIT_BYTES + 1)
179
+ try:
180
+ reason = submit_request(connection, sys.argv[kind_index], sys.argv[file_index]) or wait_for_answer(connection)
181
+ except (OSError, ValueError, KeyError, TypeError, AttributeError) as error:
182
+ write_exit_reason(BridgeExitReason.ERROR, type(error).__name__)
183
+ return 1
184
+ finally:
185
+ connection.abort()
186
+ del application
187
+ if reason is not None:
188
+ write_exit_reason(reason)
189
+ return 1
190
+ return 0
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())