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,266 @@
1
+ """Append literal exclusion rules without changing any byte already in the ignore file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
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 IGNORE_RULE_LIMIT_BYTES
15
+ from gitpane.operation.diagnostic import Diagnostic
16
+ from gitpane.operation.operation_status import OperationStatus
17
+ from gitpane.operation.repository_operation import OperationAction
18
+ from gitpane.system.path_identity import canonical_path
19
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
20
+ from gitpane.worktree.worktree_file import FILE_STABLE_IDENTITY_FIELDS, PRIVATE_FILE_MODE, FileImage, creation_permission_mode, stat_identity, validate_destination
21
+ from gitpane.worktree.worktree_snapshot import StagingSide, read_validated_size
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Generator
25
+
26
+ from gitpane.operation.diagnostic import PreparedDiagnostic
27
+ from gitpane.worktree.mutation import MutationService
28
+ from gitpane.worktree.worktree_service import WorktreeService
29
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeKey, WorktreeSnapshot
30
+
31
+ CHECK_IGNORE_RECORD_FIELDS = 4
32
+
33
+
34
+ class IgnoreDestination(Enum):
35
+ """The file an ignore rule is added to, with the label of its menu entry."""
36
+
37
+ WORKTREE = ("worktree", "Add file to .gitignore")
38
+ EXCLUDE = ("exclude", "Add file to .git/info/exclude")
39
+
40
+ def __new__(cls, value: str, label: str) -> Self:
41
+ """Keep the name of the destination as the value, and its menu label beside it."""
42
+ member = object.__new__(cls)
43
+ member._value_ = value
44
+ member.label = label
45
+ return member
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class IgnoreOutcome:
50
+ """Retain publication and effective-pattern results even after the initiating view closes."""
51
+
52
+ key: WorktreeKey
53
+ status: OperationStatus
54
+ destination_filename: str
55
+ added_paths: tuple[bytes, ...] = ()
56
+ existing_paths: tuple[bytes, ...] = ()
57
+ overridden_paths: tuple[bytes, ...] = ()
58
+ diagnostic: PreparedDiagnostic | None = None
59
+
60
+
61
+ def literal_rule(path: bytes) -> bytes:
62
+ """Anchor one byte path at the repository root, escaping Git's pattern grammar."""
63
+ if not path or any(character in path for character in (b"\r", b"\n", b"\0")):
64
+ raise ValueError("Ignore rules cannot represent paths containing CR, LF or NUL")
65
+ if any(part in (b"", b".", b"..") for part in path.split(b"/")):
66
+ raise ValueError("Ignore rules require a relative Git file path")
67
+ escaped = bytearray(b"/")
68
+ for character in path:
69
+ if character in b"!#\\*?[] ":
70
+ escaped.extend(b"\\")
71
+ escaped.append(character)
72
+ return bytes(escaped)
73
+
74
+
75
+ def capture_rule_image(filename: str) -> FileImage:
76
+ """Capture a service-authorized regular rule file and every parent without following links."""
77
+ parent_dir = os.path.dirname(filename)
78
+ parents: list[tuple[str, tuple[int, int]]] = []
79
+ while True:
80
+ metadata = os.lstat(parent_dir)
81
+ if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
82
+ raise ValueError("The ignore destination traverses a linked or invalid directory")
83
+ parents.append((parent_dir, (metadata.st_dev, metadata.st_ino)))
84
+ ancestor_dir = os.path.dirname(parent_dir)
85
+ if ancestor_dir == parent_dir:
86
+ break
87
+ parent_dir = ancestor_dir
88
+ try:
89
+ metadata = os.lstat(filename)
90
+ except FileNotFoundError:
91
+ return FileImage(False, 0, parents=tuple(parents))
92
+ if not stat.S_ISREG(metadata.st_mode) or os.path.isjunction(filename):
93
+ raise ValueError("The ignore destination must be a regular file")
94
+ with open(filename, "rb") as stream:
95
+ opened = os.fstat(stream.fileno())
96
+ if stat_identity(opened)[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
97
+ raise ValueError("Ignore rules changed while reading")
98
+ if opened.st_size > IGNORE_RULE_LIMIT_BYTES:
99
+ raise ValueError("The ignore file exceeds the supported read limit")
100
+ content = read_validated_size(stream, opened.st_size)
101
+ if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(opened)[:FILE_STABLE_IDENTITY_FIELDS]:
102
+ raise ValueError("Ignore rules changed while reading")
103
+ if len(content) != opened.st_size:
104
+ raise ValueError("Ignore rules changed while reading")
105
+ if stat_identity(os.lstat(filename)) != stat_identity(metadata):
106
+ raise ValueError("Ignore rules changed while reading")
107
+ return FileImage(True, metadata.st_mode, content=content, identity=stat_identity(metadata), parents=tuple(parents))
108
+
109
+
110
+ def publish_rule_image(filename: str, before: FileImage, content: bytes) -> None:
111
+ """Publish under an owned lock only while bytes, type and parent identities still agree."""
112
+ if capture_rule_image(filename) != before:
113
+ raise ValueError("Ignore rules or their parent changed; refresh and try again")
114
+ permission_mode = stat.S_IMODE(before.mode) if before.exists else creation_permission_mode(os.path.dirname(filename))
115
+ lock_filename = filename + ".lock"
116
+ descriptor = os.open(lock_filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, PRIVATE_FILE_MODE)
117
+ lock_metadata = os.fstat(descriptor)
118
+ lock_identity = (lock_metadata.st_dev, lock_metadata.st_ino)
119
+ try:
120
+ with os.fdopen(descriptor, "wb") as stream:
121
+ stream.write(content)
122
+ stream.flush()
123
+ os.fsync(stream.fileno())
124
+ if capture_rule_image(filename) != before:
125
+ raise ValueError("Ignore rules or their parent changed; refresh and try again")
126
+ if before.exists and not before.mode & stat.S_IWUSR:
127
+ raise PermissionError("The ignore file is read-only")
128
+ metadata = os.lstat(lock_filename)
129
+ if (metadata.st_dev, metadata.st_ino) != lock_identity:
130
+ raise ValueError("The ignore publication lock changed externally")
131
+ os.chmod(lock_filename, permission_mode)
132
+ if before.exists:
133
+ os.replace(lock_filename, filename)
134
+ else:
135
+ # A hard link prevents overwriting a rule file created since the absence check.
136
+ os.link(lock_filename, filename)
137
+ finally:
138
+ if os.path.lexists(lock_filename):
139
+ metadata = os.lstat(lock_filename)
140
+ if (metadata.st_dev, metadata.st_ino) == lock_identity:
141
+ os.unlink(lock_filename)
142
+
143
+
144
+ class IgnoreRuleService(QObject):
145
+ """Reserve each worktree and canonical rule file across all injected session services."""
146
+
147
+ finished = Signal(object)
148
+ changed = Signal(object)
149
+
150
+ def __init__(self, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None) -> None:
151
+ """Share write reservations and asynchronous filesystem work with the existing services."""
152
+ super().__init__(parent)
153
+ self.worktree_service = worktree_service
154
+ self.mutation = mutation
155
+ self.active = False
156
+
157
+ def submit(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], destination: IgnoreDestination) -> None:
158
+ """Capture selected paths and report a terminal result for every submission.
159
+
160
+ A refused reservation writes no rule and gives the real reason, worked out right away so it describes the state
161
+ that refused it.
162
+ """
163
+ operation_id = uuid.uuid4().hex
164
+ if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
165
+ cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
166
+ diagnostic = Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
167
+ QTimer.singleShot(0, lambda: self.finished.emit(IgnoreOutcome(snapshot.key, OperationStatus.REFUSED, "", diagnostic=diagnostic)))
168
+ return
169
+ self.active = True
170
+
171
+ def complete(outcome: IgnoreOutcome | None, error: Exception | None) -> None:
172
+ """Release reservations before notifying all affected views to reread."""
173
+ self.mutation.release(snapshot.key, operation_id)
174
+ self.active = False
175
+ if error is not None:
176
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
177
+ outcome = IgnoreOutcome(snapshot.key, OperationStatus.REFUSED, "", diagnostic=diagnostic)
178
+ self.finished.emit(outcome)
179
+ self.changed.emit(snapshot.context)
180
+
181
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._append(snapshot, entries, destination), complete, operation_id=operation_id)
182
+
183
+ def _append(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], destination: IgnoreDestination) -> Generator:
184
+ """Resolve the single authorized administrative exception and append without normalizing old rules.
185
+
186
+ Only a member names a destination: the text of a destination is refused like an unknown one.
187
+ """
188
+ if not isinstance(destination, IgnoreDestination) or not snapshot.fresh or not entries:
189
+ raise ValueError("Choose current Unstaged files and a supported ignore destination")
190
+ paths: list[bytes] = []
191
+ rules: list[bytes] = []
192
+ for entry in entries:
193
+ if entry not in snapshot.entries or entry.side != StagingSide.UNSTAGED or entry.conflict_stages or entry.skip_worktree or entry.old_mode == "160000":
194
+ raise ValueError("Choose current ordinary Unstaged files")
195
+ validate_destination(snapshot.context, entry.path, allow_missing_parents=True)
196
+ if entry.path not in paths:
197
+ paths.append(entry.path)
198
+ rules.append(literal_rule(entry.path))
199
+ if destination == IgnoreDestination.EXCLUDE:
200
+ output = yield GitCommand(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"])
201
+ filename = os.fsdecode(output.removesuffix(b"\n").removesuffix(b"\r"))
202
+ if not os.path.isabs(filename):
203
+ raise ValueError("Git did not resolve an absolute exclude file")
204
+ else:
205
+ filename, _parents = validate_destination(snapshot.context, b".gitignore")
206
+ resource = canonical_path(filename)
207
+ if resource in self.mutation.active_rule_filename_set:
208
+ raise ValueError("This ignore file is being edited in another worktree")
209
+ self.mutation.active_rule_filename_set.add(resource)
210
+ added_paths: list[bytes] = []
211
+ existing_paths: list[bytes] = []
212
+ published = False
213
+ try:
214
+ before = yield FileTask(lambda: capture_rule_image(filename))
215
+ content = before.read()
216
+ newline = b"\r\n" if b"\r\n" in content else b"\n"
217
+ existing_rule_set: set[bytes] = set()
218
+ for line in content.split(b"\n"):
219
+ existing_rule_set.add(line.removesuffix(b"\r"))
220
+ for path, rule in zip(paths, rules, strict=True):
221
+ if rule in existing_rule_set:
222
+ existing_paths.append(path)
223
+ continue
224
+ if content and not content.endswith(b"\n"):
225
+ content += newline
226
+ content += rule + newline
227
+ added_paths.append(path)
228
+ existing_rule_set.add(rule)
229
+ if len(content) > IGNORE_RULE_LIMIT_BYTES:
230
+ raise ValueError("The resulting ignore file exceeds the supported read limit")
231
+ # Git compares HEAD, index, configuration, attributes and the chosen files with the selection; the rule
232
+ # file itself is compared with its capture again under the publication lock.
233
+ yield from self.worktree_service.verify(snapshot, tuple(paths))
234
+ if added_paths:
235
+ def publish() -> None:
236
+ """Check native authority in the worker immediately before saving rules."""
237
+ self.mutation.validate_write(snapshot.key)
238
+ publish_rule_image(filename, before, content)
239
+
240
+ yield FileTask(publish)
241
+ published = True
242
+ # check-ignore reads literal NUL paths itself and rejects the runner's pathspec-magic option.
243
+ output = yield GitCommand(["--no-literal-pathspecs", "check-ignore", "--no-index", "-z", "--stdin", "--verbose", "--non-matching"],
244
+ stdin=b"\0".join(paths) + b"\0", accepted_codes=(0, 1))
245
+ fields = output.split(b"\0")
246
+ if fields.pop() != b"" or len(fields) != len(paths) * CHECK_IGNORE_RECORD_FIELDS:
247
+ raise ValueError("Git returned incomplete ignore verification")
248
+ overridden_paths: list[bytes] = []
249
+ for offset in range(0, len(fields), CHECK_IGNORE_RECORD_FIELDS):
250
+ _source, _line, pattern, path = fields[offset:offset + CHECK_IGNORE_RECORD_FIELDS]
251
+ if path not in paths:
252
+ raise ValueError("Git verified a different ignore path")
253
+ if not pattern or pattern.startswith(b"!"):
254
+ overridden_paths.append(path)
255
+ cause = ""
256
+ if overridden_paths:
257
+ cause = "Rules saved, but another ignore rule overrides them: " + ", ".join(os.fsdecode(path) for path in overridden_paths)
258
+ elif any(entry.old_blob_oid is not None for entry in entries):
259
+ cause = "Rules saved. Already tracked files remain tracked; use Stop tracking this file separately if intended."
260
+ diagnostic = Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare() if cause else None
261
+ return IgnoreOutcome(snapshot.key, OperationStatus.SUCCESS, filename, tuple(added_paths), tuple(existing_paths), tuple(overridden_paths), diagnostic)
262
+ except (OSError, ValueError, RuntimeError) as error:
263
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
264
+ return IgnoreOutcome(snapshot.key, OperationStatus.UNKNOWN if published else OperationStatus.REFUSED, filename, tuple(added_paths) if published else (), tuple(existing_paths), diagnostic=diagnostic)
265
+ finally:
266
+ self.mutation.active_rule_filename_set.discard(resource)