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,429 @@
1
+ """Prepare literal arguments for an explicitly configured historical comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import queue
7
+ import re
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass, field, replace
17
+ from typing import TYPE_CHECKING
18
+
19
+ from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
20
+
21
+ from gitpane.config import DIFFTOOL_ACTIVE_LIMIT, DIFFTOOL_START_TIMEOUT_MS, MILLISECONDS_PER_SECOND, SHELL_SCRIPT_EXTENSION_SET, TOOL_RESULT_POLL_MS
22
+ from gitpane.history.repository_value import FilePanel, FileReadIdentity, RequestIdentity, historical_selection
23
+ from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
24
+ from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
25
+ from gitpane.tool.kdiff3 import tool_program_usable
26
+ from gitpane.tool.kdiff3_install import with_kdiff3_install_line
27
+ from gitpane.tool.system_tool import SystemToolService
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Callable
31
+
32
+ from gitpane.history.repository import GitRepository
33
+ from gitpane.history.repository_value import HistoricalFileSelection, RepositoryContext, RepositoryFailure, RevisionEntry
34
+ from gitpane.tool.system_tool import OperationResult
35
+
36
+ # The only launch states _poll has to look at over time: a reading launch is cancelled once its file selection is no longer current,
37
+ # and a starting launch reports its startup deadline. No worker event announces either change, so only these states keep the timer running.
38
+ DIFFTOOL_WATCHED_STATE_SET = frozenset(("reading", "starting"))
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DifftoolRecipe:
43
+ """Capture the executable separately from its Qt-quoted argument template."""
44
+
45
+ program_filename: str
46
+ command: str
47
+
48
+ def validate(self, *, platform: str = sys.platform, family: DistributionFamily = DistributionFamily.UNKNOWN,
49
+ release: DistributionRelease = DistributionRelease.OTHER) -> None:
50
+ """Reject missing executables and malformed templates before publishing a draft.
51
+
52
+ The refusal of a missing tool says how to install KDiff3 on ``platform``, and under Linux on the distribution
53
+ ``family`` and ``release``, all received so each system's message can be checked from any of them.
54
+ """
55
+ parse_difftool_command(self.command, "before", "after")
56
+ if not self.program_filename:
57
+ return
58
+ if not tool_program_usable(self.program_filename):
59
+ raise ValueError(with_kdiff3_install_line("External diff tool executable is missing or not executable. Choose its full path.", platform, family, release))
60
+ if os.path.splitext(self.program_filename)[1].casefold() in SHELL_SCRIPT_EXTENSION_SET:
61
+ raise ValueError("Choose the real external diff executable instead of a shell script launcher.")
62
+
63
+
64
+ def parse_difftool_command(command: str, before_filename: str, after_filename: str) -> list[str]:
65
+ """Split Qt double-quoted arguments before replacing only the two historical filenames."""
66
+ if "\0" in command:
67
+ raise ValueError("External diff arguments cannot contain a null character.")
68
+ # Qt represents a literal quote with three quotes; backslashes do not escape quotes.
69
+ if command.replace('"""', '').count('"') % 2:
70
+ raise ValueError("External diff arguments contain an unclosed double quote.")
71
+ arguments = QProcess.splitCommand(command)
72
+ replacement_by_token = {"$LOCAL": before_filename, "$REMOTE": after_filename}
73
+ token_set: set[str] = set()
74
+ results = []
75
+ for argument in arguments:
76
+ tokens = re.findall(r"\$[A-Za-z_][A-Za-z_0-9]*|\$\{[^}]*\}|\$", argument)
77
+ for token in tokens:
78
+ if token not in replacement_by_token:
79
+ raise ValueError("External diff arguments contain an unknown parameter. Use $LOCAL and $REMOTE only.")
80
+ token_set.add(token)
81
+ results.append(re.sub(r"\$LOCAL|\$REMOTE", lambda match: replacement_by_token[match.group()], argument))
82
+ if token_set != set(replacement_by_token):
83
+ raise ValueError("External diff arguments must include both $LOCAL and $REMOTE.")
84
+ return results
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class DifftoolLaunch:
89
+ """Keep each comparison's historical versions and private filenames immutable.
90
+
91
+ ``activation`` is the startup value read when the user clicked, which lets the tool's window come to the front.
92
+ """
93
+
94
+ request_id: str
95
+ selection: HistoricalFileSelection
96
+ entry: RevisionEntry
97
+ recipe: DifftoolRecipe
98
+ temporary_dir: str = ""
99
+ before_filename: str = ""
100
+ after_filename: str = ""
101
+ state: str = "preparing"
102
+ # Set once the tool has exited: the --log line of the close names them, and a failure reported afterwards, such as a
103
+ # refused cleanup, says the tool had already closed with that code.
104
+ return_code: int | None = None
105
+ run_duration_ms: int | None = None
106
+ activation: StartupActivation = NO_STARTUP_ACTIVATION
107
+
108
+
109
+ @dataclass
110
+ class PendingComparison:
111
+ """Track extraction and OS startup separately from the immutable comparison values."""
112
+
113
+ launch: DifftoolLaunch
114
+ context: RepositoryContext
115
+ git_filename: str
116
+ is_current: Callable[[], bool]
117
+ read_identity: FileReadIdentity | None = None
118
+ remaining_sides: list[str] = field(default_factory=lambda: ["before", "after"])
119
+ deadline: float | None = None
120
+ reported: bool = False
121
+ cancelled: bool = False
122
+
123
+
124
+ class TemporaryComparisonStore:
125
+ """Create independent before and after folders for historical comparisons."""
126
+
127
+ def __init__(self) -> None:
128
+ """Record ownership separately from public launch values to reject altered paths."""
129
+ self.directory_by_request: dict[str, tuple[str, int, int]] = {}
130
+ self.lock = threading.Lock()
131
+
132
+ def create(self, launch: DifftoolLaunch) -> DifftoolLaunch:
133
+ """Preserve usable basenames while keeping raw Git paths out of local directory traversal."""
134
+ temporary_dir = os.path.realpath(tempfile.mkdtemp(prefix="gitpane-diff-"))
135
+ filenames = []
136
+ try:
137
+ for side, path in (("before", launch.entry.old_path), ("after", launch.entry.new_path)):
138
+ directory = os.path.join(temporary_dir, side)
139
+ os.mkdir(directory)
140
+ basename_index = -1
141
+ basename = os.fsdecode((path or launch.selection.path).split(b"/")[basename_index])
142
+ if basename in ("", ".", "..") or any(character in basename for character in ('\\', '/', '\0')):
143
+ raise ValueError("The historical filename cannot be represented safely on this system.")
144
+ if os.name == "nt" and (os.path.isreserved(basename) or any(character in basename for character in '<>:"|?*') or basename.endswith((".", " "))):
145
+ raise ValueError("The historical filename cannot be represented safely on this system.")
146
+ filenames.append(os.path.join(directory, basename))
147
+ before_filename, after_filename = filenames
148
+ directory_stat = os.stat(temporary_dir)
149
+ with self.lock:
150
+ self.directory_by_request[launch.request_id] = (temporary_dir, directory_stat.st_dev, directory_stat.st_ino)
151
+ return replace(launch, temporary_dir=temporary_dir, before_filename=before_filename, after_filename=after_filename, state="reading")
152
+ except (OSError, ValueError):
153
+ shutil.rmtree(temporary_dir)
154
+ raise
155
+
156
+ def cleanup(self, launch: DifftoolLaunch) -> None:
157
+ """Remove only the recorded directory, refusing replacements and linked descendants."""
158
+ with self.lock:
159
+ owned = self.directory_by_request.get(launch.request_id)
160
+ if owned is None:
161
+ if launch.temporary_dir and os.path.lexists(launch.temporary_dir):
162
+ raise ValueError("The comparison directory is not owned by this service.")
163
+ return
164
+ directory, device, inode = owned
165
+ if launch.temporary_dir != directory or os.path.realpath(launch.temporary_dir) != directory:
166
+ raise ValueError("The comparison directory is not the owned directory.")
167
+ if not os.path.lexists(directory):
168
+ self.directory_by_request.pop(launch.request_id)
169
+ return
170
+ directory_stat = os.lstat(directory)
171
+ if (directory_stat.st_dev, directory_stat.st_ino) != (device, inode):
172
+ raise ValueError("The owned comparison directory was replaced.")
173
+ self._check_directory(directory, directory)
174
+ shutil.rmtree(directory)
175
+ self.directory_by_request.pop(launch.request_id)
176
+
177
+ def _check_directory(self, directory: str, owned_dir: str) -> None:
178
+ """Refuse symlinks and Windows reparse points before recursive deletion."""
179
+ directory_stat = os.lstat(directory)
180
+ if stat.S_ISLNK(directory_stat.st_mode) or getattr(directory_stat, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
181
+ raise ValueError("The owned comparison directory contains a link or reparse point.")
182
+ if os.path.commonpath((os.path.realpath(directory), owned_dir)) != owned_dir:
183
+ raise ValueError("The comparison path resolves outside its owned directory.")
184
+ with os.scandir(directory) as entries:
185
+ for entry in entries:
186
+ entry_stat = entry.stat(follow_symlinks=False)
187
+ if entry.is_symlink() or getattr(entry_stat, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
188
+ raise ValueError("The owned comparison directory contains a link or reparse point.")
189
+ if entry.is_dir(follow_symlinks=False):
190
+ self._check_directory(entry.path, owned_dir)
191
+
192
+
193
+ class DifftoolService(QObject):
194
+ """Own exact historical copies and tracked external processes independently of windows."""
195
+
196
+ started = Signal(object)
197
+ # The tool exited by itself, whatever its exit code: the user closed the comparison.
198
+ tool_exited = Signal(object)
199
+ finished = Signal(object)
200
+ failed = Signal(object, str)
201
+ idle = Signal()
202
+ event_ready = Signal()
203
+
204
+ def __init__(self, repository: GitRepository, process_factory: Callable[..., subprocess.Popen] = subprocess.Popen,
205
+ temporary_store: TemporaryComparisonStore | None = None, parent: QObject | None = None, *, platform: str = sys.platform,
206
+ activation: Callable[[], StartupActivation] = no_startup_activation, distribution_family: DistributionFamily = DistributionFamily.UNKNOWN,
207
+ distribution_release: DistributionRelease = DistributionRelease.OTHER) -> None:
208
+ """Inject OS process creation, copy storage and the system whose install line refusals give, keeping Qt delivery on its owner thread.
209
+
210
+ Under Linux, the install line also follows the distribution ``distribution_family`` and ``distribution_release``
211
+ that the wiring read from the session once; the defaults say GitPane does not know how to install KDiff3 there.
212
+
213
+ The activation source tells the desktop that the user asked for the comparison (an X11 startup identifier or a
214
+ Wayland activation token), so the tool's window may come to the front; it is read on the window's thread when
215
+ a comparison is accepted, never for a refused one. Without a desktop, as in the tests, there is none.
216
+ """
217
+ super().__init__(parent)
218
+ self.repository = repository
219
+ self.platform = platform
220
+ self.distribution_family = distribution_family
221
+ self.distribution_release = distribution_release
222
+ self.activation = activation
223
+ self.process_factory = process_factory
224
+ self.temporary_store = temporary_store if temporary_store is not None else TemporaryComparisonStore()
225
+ self.export = SystemToolService(parent=self)
226
+ self.export.export_finished.connect(self._export_finished)
227
+ self.repository.file_failed.connect(self._read_failed)
228
+ self.active_by_id: dict[str, PendingComparison] = {}
229
+ self.event_queue: queue.SimpleQueue[tuple[str, str, object]] = queue.SimpleQueue()
230
+ self.closed = False
231
+ self.timer = QTimer(self)
232
+ self.timer.setInterval(TOOL_RESULT_POLL_MS)
233
+ self.timer.timeout.connect(self._poll)
234
+ self.event_ready.connect(self._poll, Qt.ConnectionType.QueuedConnection)
235
+
236
+ def compare(self, entry: RevisionEntry, identity: FileReadIdentity, recipe: DifftoolRecipe, is_current: Callable[[], bool]) -> bool:
237
+ """Reserve one launch and capture exact sides only while the source selection is valid.
238
+
239
+ Called by the user's click: once every refusal has passed, the click's startup value is read here, on the
240
+ window's thread, and travels with the launch to the tool's environment.
241
+ """
242
+ if self.closed or not is_current():
243
+ return False
244
+ owner = identity.request_identity.session_id
245
+ for pending in self.active_by_id.values():
246
+ if pending.launch.selection.selection_identity.request_identity.session_id == owner and pending.launch.state != "running":
247
+ self.failed.emit(pending.launch, "External comparison is still starting or finishing. Wait before launching again.")
248
+ return False
249
+ launch = DifftoolLaunch(uuid.uuid4().hex, historical_selection(entry, identity), entry, recipe)
250
+ try:
251
+ parse_difftool_command(recipe.command, "before", "after")
252
+ if not recipe.program_filename:
253
+ raise ValueError(with_kdiff3_install_line("Choose an external diff tool executable in Settings first.", self.platform, self.distribution_family, self.distribution_release))
254
+ # A cheap file check, made here so a saved tool that disappeared is refused with its own cause and install line.
255
+ recipe.validate(platform=self.platform, family=self.distribution_family, release=self.distribution_release)
256
+ if "160000" in (entry.old_mode, entry.new_mode):
257
+ raise ValueError("Submodules contain commit references, not file versions to compare.")
258
+ if len(self.active_by_id) >= DIFFTOOL_ACTIVE_LIMIT:
259
+ raise ValueError("Too many external comparisons are open. Close one before launching another.")
260
+ except ValueError as error:
261
+ self.failed.emit(launch, str(error))
262
+ return False
263
+ launch = replace(launch, activation=self.activation())
264
+ pending = PendingComparison(launch, self.repository.context_by_session[owner], self.repository.executable_by_session[owner], is_current)
265
+ self.active_by_id[launch.request_id] = pending
266
+ threading.Thread(target=self._prepare, args=(launch,), daemon=True).start()
267
+ return True
268
+
269
+ def _queue_event(self, event: tuple[str, str, object]) -> None:
270
+ """Let a worker thread drop one event and wake the Qt thread, the only one that touches the launches."""
271
+ self.event_queue.put(event)
272
+ self.event_ready.emit()
273
+
274
+ def _update_poll_timer(self) -> None:
275
+ """Run the timer only while a launch waits in a state that needs checking over time, never while a tool window is open."""
276
+ watched = any(pending.launch.state in DIFFTOOL_WATCHED_STATE_SET for pending in self.active_by_id.values())
277
+ if watched and not self.timer.isActive():
278
+ self.timer.start()
279
+ elif not watched and self.timer.isActive():
280
+ self.timer.stop()
281
+
282
+ def _prepare(self, launch: DifftoolLaunch) -> None:
283
+ """Create OS folders off the GUI thread before scheduling the Git streams; the recipe was already checked by ``compare``."""
284
+ try:
285
+ self._queue_event((launch.request_id, "prepared", self.temporary_store.create(launch)))
286
+ except (OSError, ValueError) as error:
287
+ self._queue_event((launch.request_id, "error", f"Preparing external comparison failed ({type(error).__name__})."))
288
+
289
+ def _read_next(self, pending: PendingComparison) -> None:
290
+ """Reuse bounded atomic export for each side, including a real empty file for absent blobs."""
291
+ if pending.cancelled or not pending.is_current():
292
+ self._discard(pending, "External comparison cancelled because the file selection changed.")
293
+ return
294
+ if not pending.remaining_sides:
295
+ pending.launch = replace(pending.launch, state="starting")
296
+ pending.deadline = time.monotonic() + DIFFTOOL_START_TIMEOUT_MS / 1000
297
+ threading.Thread(target=self._run, args=(pending.launch,), daemon=True).start()
298
+ self._update_poll_timer()
299
+ return
300
+ first_side_index = 0
301
+ side = pending.remaining_sides.pop(first_side_index)
302
+ launch = pending.launch
303
+ entry = launch.entry
304
+ blob_oid = entry.old_blob_oid if side == "before" else entry.new_blob_oid
305
+ filename = launch.before_filename if side == "before" else launch.after_filename
306
+ identity = FileReadIdentity(RequestIdentity(launch.request_id, 1, oid=entry.commit_oid), FilePanel.DIFFTOOL)
307
+ pending.read_identity = identity
308
+ self.repository.context_by_session[launch.request_id] = pending.context
309
+ self.repository.identity_by_session[launch.request_id] = identity.request_identity
310
+ self.repository.executable_by_session[launch.request_id] = pending.git_filename
311
+
312
+ def read(consume: Callable[[bytes], None], completed: Callable[[int], None]) -> None:
313
+ """Stream the immutable object or complete an absent side with zero bytes."""
314
+ if blob_oid is None:
315
+ completed(0)
316
+ else:
317
+ self.repository.stream_blob(blob_oid, identity, consume, completed)
318
+
319
+ self.export.save_historical_file(identity, pending.context, filename, read, lambda: self.repository.cancel(launch.request_id))
320
+
321
+ def _export_finished(self, result: OperationResult) -> None:
322
+ """Start the second side or the process only after complete bytes reach disk."""
323
+ request_id = result.request_id.request_identity.session_id
324
+ pending = self.active_by_id.get(request_id)
325
+ if pending is None:
326
+ return
327
+ pending.read_identity = None
328
+ if result.cause:
329
+ self._discard(pending, "Reading external comparison files failed: " + result.cause)
330
+ return
331
+ self._read_next(pending)
332
+
333
+ def _read_failed(self, identity: FileReadIdentity, failure: RepositoryFailure) -> None:
334
+ """Route Git failures to the owned writer so no partial side reaches the tool."""
335
+ if identity.request_identity.session_id in self.active_by_id:
336
+ self.export.fail_export(identity, failure.cause)
337
+
338
+ def _run(self, launch: DifftoolLaunch) -> None:
339
+ """Launch directly without a shell and wait off-thread without an inactivity deadline.
340
+
341
+ The tool gets GitPane's environment without any inherited startup value, plus the one read at the click.
342
+ """
343
+ try:
344
+ arguments = parse_difftool_command(launch.recipe.command, launch.before_filename, launch.after_filename)
345
+ process = self.process_factory([launch.recipe.program_filename, *arguments], cwd=launch.temporary_dir, shell=False,
346
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
347
+ env=launch.activation.environment(os.environ),
348
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
349
+ started_at = time.monotonic()
350
+ self._queue_event((launch.request_id, "started", None))
351
+ return_code = process.wait()
352
+ run_duration_ms = round((time.monotonic() - started_at) * MILLISECONDS_PER_SECOND)
353
+ self._queue_event((launch.request_id, "exited", (return_code, run_duration_ms)))
354
+ except (OSError, ValueError) as error:
355
+ self._queue_event((launch.request_id, "error", f"Starting external comparison failed ({type(error).__name__})."))
356
+
357
+ def _discard(self, pending: PendingComparison, cause: str = "") -> None:
358
+ """Clean copies only after their last disk or process worker is finished."""
359
+ pending.launch = replace(pending.launch, state="cleaning")
360
+ if cause and not pending.reported:
361
+ pending.reported = True
362
+ self.failed.emit(pending.launch, cause)
363
+ self.repository.cancel(pending.launch.request_id)
364
+ self.repository.context_by_session.pop(pending.launch.request_id, None)
365
+ threading.Thread(target=self._cleanup, args=(pending.launch,), daemon=True).start()
366
+
367
+ def _cleanup(self, launch: DifftoolLaunch) -> None:
368
+ """Keep filesystem deletion outside the event loop and report a safe cause on refusal."""
369
+ cause = ""
370
+ try:
371
+ self.temporary_store.cleanup(launch)
372
+ except (OSError, ValueError) as error:
373
+ cause = f"External comparison cleanup failed ({type(error).__name__})."
374
+ self._queue_event((launch.request_id, "cleaned", cause))
375
+
376
+ def _poll(self) -> None:
377
+ """Deliver worker progress and keep timed-out OS creations reserved until they return.
378
+
379
+ Worker events wake it through event_ready; the timer only adds the checks over time of the watched states.
380
+ """
381
+ while True:
382
+ try:
383
+ request_id, kind, value = self.event_queue.get_nowait()
384
+ except queue.Empty:
385
+ break
386
+ pending = self.active_by_id.get(request_id)
387
+ if pending is None:
388
+ continue
389
+ if kind == "prepared":
390
+ pending.launch = value
391
+ self._read_next(pending)
392
+ elif kind == "started":
393
+ pending.launch = replace(pending.launch, state="running")
394
+ self.started.emit(pending.launch)
395
+ elif kind == "error":
396
+ self._discard(pending, str(value))
397
+ elif kind == "exited":
398
+ # Like git difftool, the exit code of a comparison is not judged: there is no result to save, and KDiff3
399
+ # returns 1 whenever it is closed without saving a merge result.
400
+ return_code, run_duration_ms = value
401
+ pending.launch = replace(pending.launch, return_code=return_code, run_duration_ms=run_duration_ms)
402
+ self.tool_exited.emit(pending.launch)
403
+ self._discard(pending)
404
+ elif kind == "cleaned":
405
+ self.active_by_id.pop(request_id)
406
+ if value:
407
+ self.failed.emit(pending.launch, str(value))
408
+ self.finished.emit(pending.launch)
409
+ for pending in tuple(self.active_by_id.values()):
410
+ if pending.launch.state == "reading" and not pending.is_current() and pending.read_identity is not None:
411
+ pending.cancelled = True
412
+ self.export.cancel_export(pending.read_identity)
413
+ if pending.launch.state == "starting" and not pending.reported and pending.deadline is not None and time.monotonic() >= pending.deadline:
414
+ pending.reported = True
415
+ self.failed.emit(pending.launch, "External comparison startup timed out. The system may still open it; wait before launching again.")
416
+ self._update_poll_timer()
417
+ if not self.active_by_id:
418
+ self.idle.emit()
419
+
420
+ def close(self) -> None:
421
+ """Cancel unlaunched reads while preserving supervision of active or starting tools."""
422
+ self.closed = True
423
+ for pending in tuple(self.active_by_id.values()):
424
+ if pending.launch.state in ("preparing", "reading"):
425
+ pending.cancelled = True
426
+ if pending.read_identity is not None:
427
+ self.export.cancel_export(pending.read_identity)
428
+ if not self.active_by_id:
429
+ self.idle.emit()