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,441 @@
1
+ """Give Settings targeted access to Git configuration values, without copying whole config files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Self
11
+
12
+ from PySide6.QtCore import QObject, QTimer, Signal
13
+
14
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS
15
+ from gitpane.history.repository_value import RepositoryContext
16
+ from gitpane.operation.repository_operation import OWNED_REPOSITORY_TEXT, OperationAction, OperationIntent, StaleCaptureError
17
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE
18
+ from gitpane.transport.remote_config import GitConfigScope, config_scope, parse_config
19
+ from gitpane.worktree.command_sequence import GitCommand, GitCommandError, ReadBatch
20
+ from gitpane.worktree.worktree_snapshot import fingerprint
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Generator
24
+
25
+ from gitpane.process.process_runner import ProcessRunner
26
+ from gitpane.transport.remote_config import RemoteValue
27
+ from gitpane.worktree.mutation import MutationService
28
+
29
+ AUTOCRLF_KEY = "core.autocrlf"
30
+ GIT_SETTING_KEYS = ("user.name", "user.email", AUTOCRLF_KEY)
31
+ SETTING_LABEL_BY_KEY = {"user.name": "Name", "user.email": "Email", AUTOCRLF_KEY: "Line endings"}
32
+ # The scopes Settings writes, in the order it shows them.
33
+ SETTING_SCOPES = (GitConfigScope.GLOBAL, GitConfigScope.LOCAL)
34
+ AUTOCRLF_DESCRIPTION_BY_VALUE = {
35
+ "true": "Check out Windows-style, commit Unix-style line endings",
36
+ "input": "Check out as-is, commit Unix-style line endings",
37
+ "false": "No line-ending conversion by core.autocrlf",
38
+ }
39
+
40
+
41
+ class SettingView(Enum):
42
+ """One reading of a setting that Settings keeps for each key, with the scope whose values it keeps.
43
+
44
+ The global and local views keep, from the effective listing, only the values Git attributes to their scope; the
45
+ direct view is the repository's own file without its includes, and the effective view is the value Git uses.
46
+ """
47
+
48
+ GLOBAL = ("global", GitConfigScope.GLOBAL)
49
+ LOCAL = ("local", GitConfigScope.LOCAL)
50
+ DIRECT = ("direct", None)
51
+ EFFECTIVE = ("effective", None)
52
+
53
+ def __new__(cls, value: str, scope: GitConfigScope | None) -> Self:
54
+ """Keep the view's name as the value and the scope it keeps beside it."""
55
+ member = object.__new__(cls)
56
+ member._value_ = value
57
+ member.scope = scope
58
+ return member
59
+
60
+ @classmethod
61
+ def for_scope(cls, scope: GitConfigScope) -> SettingView:
62
+ """Answer the view that shows the values of one scope Settings writes."""
63
+ for view in cls:
64
+ if view.scope == scope:
65
+ return view
66
+ raise ValueError("No Settings view keeps this Git configuration scope")
67
+
68
+
69
+ class GitSettingOperation(Enum):
70
+ """What one Settings change does to a key: write the value, or remove every value of the key in its scope."""
71
+
72
+ SET = "set"
73
+ UNSET = "unset"
74
+
75
+
76
+ REPOSITORY_SETTING_VIEWS = (SettingView.GLOBAL, SettingView.LOCAL, SettingView.DIRECT, SettingView.EFFECTIVE)
77
+ GLOBAL_SETTING_VIEWS = (SettingView.GLOBAL, SettingView.EFFECTIVE)
78
+ SETTING_READ_ACCEPTED_CODES = (0, 1)
79
+ SETTING_FIELD_TERMINATOR = b"\0"
80
+ SETTING_READ_OPTIONS = ("config", "--null", "--show-origin", "--show-scope")
81
+ # The grouped reads search the supported keys only: no unrelated value, secrets included, is ever read, and a missing
82
+ # global file answers "nothing found" instead of failing the way a listing of every global value does.
83
+ SETTING_KEY_PATTERN = "^(" + "|".join(re.escape(key) for key in GIT_SETTING_KEYS) + ")$"
84
+ AUTOCRLF_KEY_PATTERN = "^" + re.escape(AUTOCRLF_KEY) + "$"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class GitSettingValue:
89
+ """Distinguish absence, explicit empty values and unreadable configuration."""
90
+
91
+ value: str | None = None
92
+ scope: GitConfigScope | None = None
93
+ origin: str = ""
94
+ state: str = "absent"
95
+ source_fingerprint: str = "absent"
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class GitSettingSnapshot:
100
+ """Capture direct and resolved values in the repository chosen when settings opened."""
101
+
102
+ context: RepositoryContext | None
103
+ git_filename: str
104
+ generation: int
105
+ value_by_target: dict[tuple[SettingView, str], GitSettingValue]
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class GitSettingChange:
110
+ """Carry one exact explicit edit independently of other save outcomes."""
111
+
112
+ key: str
113
+ scope: GitConfigScope
114
+ operation: GitSettingOperation
115
+ value: str = ""
116
+
117
+ def validate(self, context: RepositoryContext | None) -> None:
118
+ """Reject unsupported keys, scopes and operations before any process or write starts.
119
+
120
+ Only a member names an operation: the write treats anything that is not SET as a removal, so the text of an
121
+ operation, or any other value, would erase the user's setting instead of being refused.
122
+ """
123
+ if self.key not in GIT_SETTING_KEYS or self.scope not in SETTING_SCOPES or not isinstance(self.operation, GitSettingOperation):
124
+ raise ValueError("Unsupported Git setting change")
125
+ if self.scope == GitConfigScope.LOCAL and context is None:
126
+ raise ValueError("No repository was selected for these settings")
127
+ if "\0" in self.value:
128
+ raise ValueError("Git setting values cannot contain NUL characters")
129
+ if self.key == AUTOCRLF_KEY and self.operation == GitSettingOperation.SET and self.value not in AUTOCRLF_DESCRIPTION_BY_VALUE:
130
+ raise ValueError("Choose true, input or false for core.autocrlf")
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class GitSettingResult:
135
+ """Retain one write result even if another key could not be saved."""
136
+
137
+ change: GitSettingChange
138
+ saved: bool
139
+ cause: str = ""
140
+
141
+
142
+ def parse_value(output: bytes) -> GitSettingValue:
143
+ """Decode Git's NUL scope/origin/value triples, retaining the last effective entry."""
144
+ if not output:
145
+ return GitSettingValue()
146
+ if not output.endswith(b"\0"):
147
+ raise ValueError("Incomplete Git setting output")
148
+ fields = iter(output.removesuffix(b"\0").split(b"\0"))
149
+ result = GitSettingValue()
150
+ try:
151
+ for scope in fields:
152
+ origin, value = next(fields), next(fields)
153
+ text = value.decode("utf-8", "replace")
154
+ result = GitSettingValue(text, config_scope(scope.decode("utf-8", "replace")), origin.decode("utf-8", "replace"), "empty" if not text else "present", fingerprint(output))
155
+ except StopIteration as error:
156
+ raise ValueError("Incomplete Git setting record") from error
157
+ return result
158
+
159
+
160
+ def classified_setting_value(key: str, value: GitSettingValue) -> GitSettingValue:
161
+ """Mark a core.autocrlf value the form cannot offer as invalid, keeping what Git answered."""
162
+ if key == AUTOCRLF_KEY and value.value is not None and value.value not in AUTOCRLF_DESCRIPTION_BY_VALUE:
163
+ return GitSettingValue(value.value, value.scope, value.origin, "invalid", value.source_fingerprint)
164
+ return value
165
+
166
+
167
+ def setting_value_from_records(values: tuple[RemoteValue, ...], key: str) -> GitSettingValue:
168
+ """Read one key from listed config records exactly as from Git's own answer for that key alone.
169
+
170
+ Only the key's occurrences are kept and written back in the scope, origin and value triples Git prints for that
171
+ key, so the final value, its origin, an absent or empty value and the fingerprint of the occurrences all come
172
+ from parse_value; other settings of the listing never enter the fingerprint.
173
+ """
174
+ records: list[bytes] = []
175
+ for value in values:
176
+ if value.key != key:
177
+ continue
178
+ for text in (value.scope.value, value.origin, value.value):
179
+ records.append(text.encode("utf-8", "surrogateescape") + SETTING_FIELD_TERMINATOR)
180
+ return classified_setting_value(key, parse_value(b"".join(records)))
181
+
182
+
183
+ def count_by_source(values: tuple[RemoteValue, ...]) -> dict[tuple[GitConfigScope, str], int]:
184
+ """Count occurrences per scope and origin, the identity Git gives each configuration source."""
185
+ result: dict[tuple[GitConfigScope, str], int] = {}
186
+ for value in values:
187
+ source = (value.scope, value.origin)
188
+ result[source] = result.get(source, 0) + 1
189
+ return result
190
+
191
+
192
+ def converted_occurrences(selected_values: tuple[RemoteValue, ...], raw_values: tuple[RemoteValue, ...],
193
+ typed_values: tuple[RemoteValue, ...]) -> tuple[RemoteValue, ...] | None:
194
+ """Replace a view's raw occurrences of a key by the ones Git converted, matched by scope, origin and position.
195
+
196
+ ``raw_values`` and ``typed_values`` list the same effective occurrences from two reads; the selected occurrences
197
+ come from either the effective listing or the listing of the repository's own file, and always hold every
198
+ occurrence of their sources, so the k-th selected occurrence of a source is the k-th converted one of that source.
199
+ Answers None when the reads or the view do not pair one to one, so a converted value never lands on the wrong
200
+ occurrence.
201
+ """
202
+ typed_by_source: dict[tuple[GitConfigScope, str], list[RemoteValue]] = {}
203
+ for value in typed_values:
204
+ typed_by_source.setdefault((value.scope, value.origin), []).append(value)
205
+ typed_count_by_source: dict[tuple[GitConfigScope, str], int] = {}
206
+ for source, typed in typed_by_source.items():
207
+ typed_count_by_source[source] = len(typed)
208
+ if count_by_source(raw_values) != typed_count_by_source:
209
+ return None
210
+ for source, count in count_by_source(selected_values).items():
211
+ if typed_count_by_source.get(source, 0) != count:
212
+ return None
213
+ position_by_source: dict[tuple[GitConfigScope, str], int] = {}
214
+ converted: list[RemoteValue] = []
215
+ for value in selected_values:
216
+ source = (value.scope, value.origin)
217
+ position = position_by_source.get(source, 0)
218
+ converted.append(typed_by_source[source][position])
219
+ position_by_source[source] = position + 1
220
+ return tuple(converted)
221
+
222
+
223
+ def key_values(values: tuple[RemoteValue, ...], key: str) -> tuple[RemoteValue, ...]:
224
+ """Keep the occurrences of one key, in their listed order."""
225
+ selected: list[RemoteValue] = []
226
+ for value in values:
227
+ if value.key == key:
228
+ selected.append(value)
229
+ return tuple(selected)
230
+
231
+
232
+ class GitSettingService(QObject):
233
+ """Serialize application config writes while letting Git own its native file locks."""
234
+
235
+ changed = Signal(object)
236
+
237
+ def __init__(self, runner: ProcessRunner, mutation_service: MutationService, parent: QObject | None = None) -> None:
238
+ """Share bounded asynchronous command execution and mutation ownership."""
239
+ super().__init__(parent)
240
+ self.runner = runner
241
+ self.mutation_service = mutation_service
242
+ self.worktree_service = mutation_service.worktree_service
243
+ self.generation = 0
244
+
245
+ def read(self, context: RepositoryContext | None, git_filename: str, callback: Callable, is_current: Callable[[], bool] | None = None) -> Generator:
246
+ """Read only supported keys, never writing defaults or enabling worktree configuration."""
247
+ sequence = self.capture(context, git_filename, is_current)
248
+ self.worktree_service.run(self._command_context(context), git_filename, sequence, callback)
249
+ return sequence
250
+
251
+ def cancel_read(self, sequence: Generator) -> None:
252
+ """Cancel only this form's read, leaving shared worktree requests and saves alone."""
253
+ for request_id, continuation in tuple(self.worktree_service.continuation_by_id.items()):
254
+ current_sequence, *_rest = continuation
255
+ if current_sequence is sequence:
256
+ self.runner.cancel(request_id)
257
+
258
+ def _command_context(self, context: RepositoryContext | None) -> RepositoryContext:
259
+ """Use the user's home for global-only reads so a checkout cannot affect includes."""
260
+ home_dir = os.path.expanduser("~")
261
+ return context if context is not None else RepositoryContext(None, home_dir, home_dir, True)
262
+
263
+ def capture(self, context: RepositoryContext | None, git_filename: str, is_current: Callable[[], bool] | None = None) -> Generator:
264
+ """Read the global, local, direct and effective views of every supported key with three Git reads.
265
+
266
+ One effective search of the supported keys gives the global, local and effective views, one search of the
267
+ repository's own file gives the direct view, and Git itself converts core.autocrlf with bool-or-str; outside a
268
+ repository the two global reads suffice. Whatever these reads cannot prove complete, a failed or cut listing, or converted values
269
+ that cannot be paired with their occurrences, is read again one key and view at a time, so an error stays
270
+ attached to the value it concerns. Nothing read here is kept for another operation.
271
+ """
272
+ if is_current is not None and not is_current():
273
+ return None
274
+ in_repository = context is not None
275
+ # Outside a repository only the global files count, and the commands run from the home directory.
276
+ global_options = () if in_repository else ("--global",)
277
+ commands = [GitCommand([*SETTING_READ_OPTIONS, "--includes", *global_options, "--get-regexp", SETTING_KEY_PATTERN], accepted_codes=SETTING_READ_ACCEPTED_CODES, profile=READ_PROFILE),
278
+ GitCommand([*SETTING_READ_OPTIONS, "--includes", *global_options, "--type=bool-or-str", "--get-regexp", AUTOCRLF_KEY_PATTERN],
279
+ accepted_codes=SETTING_READ_ACCEPTED_CODES, profile=READ_PROFILE)]
280
+ if in_repository:
281
+ commands.append(GitCommand([*SETTING_READ_OPTIONS, "--local", "--no-includes", "--get-regexp", SETTING_KEY_PATTERN], accepted_codes=SETTING_READ_ACCEPTED_CODES, profile=READ_PROFILE))
282
+ try:
283
+ effective_output, typed_output, *direct_outputs = yield ReadBatch(tuple(commands))
284
+ effective_values = parse_config(effective_output)
285
+ typed_values = parse_config(typed_output)
286
+ direct_values: tuple[RemoteValue, ...] = ()
287
+ for direct_output in direct_outputs:
288
+ direct_values = parse_config(direct_output)
289
+ except (GitCommandError, ValueError):
290
+ return (yield from self._capture_separately(context, git_filename, is_current))
291
+ if is_current is not None and not is_current():
292
+ return None
293
+ value_by_view: dict[SettingView, tuple[RemoteValue, ...]] = {SettingView.EFFECTIVE: effective_values}
294
+ for view in SettingView:
295
+ if view.scope is None:
296
+ continue
297
+ scoped_values: list[RemoteValue] = []
298
+ for value in effective_values:
299
+ if value.scope == view.scope:
300
+ scoped_values.append(value)
301
+ value_by_view[view] = tuple(scoped_values)
302
+ if in_repository:
303
+ value_by_view[SettingView.DIRECT] = direct_values
304
+ value_by_target: dict[tuple[SettingView, str], GitSettingValue] = {}
305
+ for key in GIT_SETTING_KEYS:
306
+ for view in REPOSITORY_SETTING_VIEWS if in_repository else GLOBAL_SETTING_VIEWS:
307
+ view_values = key_values(value_by_view[view], key)
308
+ if key == AUTOCRLF_KEY:
309
+ converted = converted_occurrences(view_values, key_values(effective_values, key), key_values(typed_values, key))
310
+ if converted is None:
311
+ # Occurrences Git converted in another read cannot be paired: ask Git for this view alone.
312
+ value_by_target[(view, key)] = yield from self._read_view(context, key, view)
313
+ continue
314
+ view_values = converted
315
+ value_by_target[(view, key)] = setting_value_from_records(view_values, key)
316
+ self.generation += 1
317
+ return GitSettingSnapshot(context, git_filename, self.generation, value_by_target)
318
+
319
+ def _capture_separately(self, context: RepositoryContext | None, git_filename: str, is_current: Callable[[], bool] | None = None) -> Generator:
320
+ """Read each key in each view with its own Git command, so one unreadable source fails only the values it affects."""
321
+ value_by_target: dict[tuple[SettingView, str], GitSettingValue] = {}
322
+ for key in GIT_SETTING_KEYS:
323
+ for view in REPOSITORY_SETTING_VIEWS if context is not None else GLOBAL_SETTING_VIEWS:
324
+ if is_current is not None and not is_current():
325
+ return None
326
+ value_by_target[(view, key)] = yield from self._read_view(context, key, view)
327
+ self.generation += 1
328
+ return GitSettingSnapshot(context, git_filename, self.generation, value_by_target)
329
+
330
+ def _read_view(self, context: RepositoryContext | None, key: str, view: SettingView) -> Generator:
331
+ """Read one key in one view with one Git command, answering an error state rather than raising."""
332
+ arguments = [*SETTING_READ_OPTIONS, "--get-all"]
333
+ if view.scope is not None:
334
+ arguments.extend(["--" + view.scope.value, "--includes"])
335
+ elif view == SettingView.DIRECT:
336
+ arguments.extend(["--local", "--no-includes"])
337
+ else:
338
+ arguments.append("--includes")
339
+ if context is None:
340
+ arguments.append("--global")
341
+ arguments.append(key)
342
+ if key == AUTOCRLF_KEY:
343
+ arguments.insert(len(arguments) - 1, "--type=bool-or-str")
344
+ try:
345
+ output = yield GitCommand(arguments, accepted_codes=SETTING_READ_ACCEPTED_CODES, profile=READ_PROFILE)
346
+ return classified_setting_value(key, parse_value(output))
347
+ except (GitCommandError, ValueError):
348
+ # Git stderr may contain arbitrary included configuration; expose only the key's failure.
349
+ return GitSettingValue(state="error")
350
+
351
+ def save(self, snapshot: GitSettingSnapshot, changes: tuple[GitSettingChange, ...], callback: Callable) -> None:
352
+ """Reserve shared configuration and report successful writes without rolling them back.
353
+
354
+ When nothing in the availability checks explains a refusal, a write really holds the worktree or the global
355
+ configuration (another settings save, an index change), so the refusal names that running operation.
356
+ """
357
+ for change in changes:
358
+ change.validate(snapshot.context)
359
+ operation_id = uuid.uuid4().hex
360
+ global_configuration = any(change.scope == GitConfigScope.GLOBAL for change in changes)
361
+ context = self._command_context(snapshot.context)
362
+ intent = OperationIntent.capture(operation_id, context, snapshot.git_filename, generation=snapshot.generation,
363
+ global_configuration=global_configuration, action=OperationAction.CONFIG_WRITE)
364
+ mutation = self.mutation_service
365
+ legacy_busy = bool(mutation.active_key_set) if global_configuration else intent.key in mutation.active_key_set
366
+ reason = mutation.repository_operation.availability_diagnostic(intent)
367
+ if mutation.configuration_busy or legacy_busy or not mutation.repository_operation.reserve(intent):
368
+ results = tuple(GitSettingResult(change, False, reason or OWNED_REPOSITORY_TEXT) for change in changes)
369
+ QTimer.singleShot(0, lambda: callback((snapshot, results), None))
370
+ return
371
+ if global_configuration:
372
+ mutation.configuration_busy = True
373
+
374
+ def complete(result: object, error: Exception | None) -> None:
375
+ """Release ownership after all commands finish, including failed or closed views."""
376
+ if global_configuration:
377
+ mutation.configuration_busy = False
378
+ mutation.repository_operation.release(operation_id)
379
+ callback(result, error)
380
+
381
+ self.worktree_service.run(context, snapshot.git_filename, self._save(snapshot, changes, intent), complete, operation_id=operation_id)
382
+
383
+ def _save(self, snapshot: GitSettingSnapshot, changes: tuple[GitSettingChange, ...], intent: OperationIntent) -> Generator:
384
+ """Revalidate targeted values before each write; cross-process writes remain nontransactional.
385
+
386
+ Each change reads the configuration again just before its own write, and again after it to report the result.
387
+ The last of those reads is what the form shows: it is already fresh, so no further read follows it; a save
388
+ with no change still reads the configuration once. A write refused by the check just before Git starts, for
389
+ example because GitPane began closing between two changes, gives that check's reason rather than blaming file
390
+ permissions.
391
+ """
392
+ expected_by_target = dict(snapshot.value_by_target)
393
+ results: list[GitSettingResult] = []
394
+ latest = None
395
+ launch_refusal_causes: list[str] = []
396
+
397
+ def validate_before_write() -> None:
398
+ """Recheck the reservation just before Git starts, keeping the reason of a refusal so the result can give it."""
399
+ try:
400
+ self.mutation_service.repository_operation.validate(intent)
401
+ except StaleCaptureError as error:
402
+ launch_refusal_causes.append(str(error))
403
+ raise
404
+
405
+ for change in changes:
406
+ current = yield from self.capture(snapshot.context, snapshot.git_filename)
407
+ latest = current
408
+ changed = False
409
+ unreadable = False
410
+ for target, expected in expected_by_target.items():
411
+ _view, key = target
412
+ if key != change.key:
413
+ continue
414
+ actual = current.value_by_target[target]
415
+ changed |= actual != expected
416
+ unreadable |= actual.state == "error" or expected.state == "error"
417
+ if changed or unreadable:
418
+ results.append(GitSettingResult(change, False, "Configuration changed or could not be read. Review the refreshed values and try Save again."))
419
+ continue
420
+ arguments = ["config", "--" + change.scope.value, "--replace-all" if change.operation == GitSettingOperation.SET else "--unset-all", change.key]
421
+ if change.operation == GitSettingOperation.SET:
422
+ arguments.append(change.value)
423
+ launch_refusal_causes.clear()
424
+ try:
425
+ yield GitCommand(arguments, accepted_codes=(0,) if change.operation == GitSettingOperation.SET else (0, 5), timeout_ms=GIT_MUTATION_TIMEOUT_MS,
426
+ before_start=validate_before_write, profile=LOCAL_WRITE_PROFILE)
427
+ results.append(GitSettingResult(change, True))
428
+ except (GitCommandError, ValueError):
429
+ cause = next(iter(launch_refusal_causes), "Git could not write this setting. Check file permissions or an existing config lock, then retry.")
430
+ results.append(GitSettingResult(change, False, cause))
431
+ refreshed = yield from self.capture(snapshot.context, snapshot.git_filename)
432
+ latest = refreshed
433
+ for target, value in refreshed.value_by_target.items():
434
+ _view, key = target
435
+ if key == change.key:
436
+ expected_by_target[target] = value
437
+ if latest is None:
438
+ latest = yield from self.capture(snapshot.context, snapshot.git_filename)
439
+ if any(result.saved for result in results):
440
+ self.changed.emit((snapshot.context, tuple(results)))
441
+ return latest, tuple(results)
File without changes