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,447 @@
1
+ """Calculate side-specific file resets, each proven to preserve the other side before any publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import stat
8
+ from dataclasses import dataclass
9
+ from difflib import SequenceMatcher
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING
12
+
13
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS
14
+ from gitpane.history.repository_value import FileStatus
15
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
16
+ from gitpane.tool.system_tool import worktree_filename
17
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
18
+ from gitpane.worktree.partial_patch import byte_lines, filtered_line_slices, line_slices
19
+ from gitpane.worktree.worktree_file import PRIVATE_DIRECTORY_MODE, SINGLE_LINK_COUNT, FileImage, capture_image, materialize_blob, stat_identity
20
+ from gitpane.worktree.worktree_snapshot import StagingSide, read_index
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Generator
24
+
25
+ from gitpane.history.repository_value import RepositoryContext
26
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
27
+
28
+
29
+ FILEMODE_SETTING_KEY = b"core.filemode"
30
+ SYMLINKS_SETTING_KEY = b"core.symlinks"
31
+ ENABLED_SETTING_VALUE = b"true"
32
+ DISABLED_SETTING_VALUE = b"false"
33
+ # Git writes a split index with a literal "link" extension signature. A false match in object or path bytes only
34
+ # costs one harmless expansion of the private copy, while a missed one would publish an index sharing foreign data.
35
+ SPLIT_INDEX_EXTENSION_SIGNATURE = b"link"
36
+ # Refusals of the Reset of a rename whose original path already leads to a file.
37
+ RENAME_ORIGIN_OCCUPIED_MESSAGE = "The rename's original path is occupied; Reset will not overwrite that file"
38
+ OTHER_HARD_LINKS_MESSAGE = "This file has other hard links; Reset will not replace it"
39
+
40
+
41
+ def may_carry_split_index(index_content: bytes | None) -> bool:
42
+ """Tell whether a private index copy must be flattened before Git rewrites it, without parsing Git's binary format."""
43
+ return index_content is not None and SPLIT_INDEX_EXTENSION_SIGNATURE in index_content
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ResetFilePolicy:
48
+ """Hold the two repository settings that decide how Reset restores links and executable bits.
49
+
50
+ Only an explicit false disables a feature, which is what Git itself does when a key is absent.
51
+ """
52
+
53
+ symlinks: bool = True
54
+ filemode: bool = True
55
+
56
+
57
+ def parse_reset_file_policy(output: bytes) -> ResetFilePolicy:
58
+ """Decode the grouped NUL records of both settings by key name.
59
+
60
+ Records of different keys may come in any order; for repeated definitions of one key the later record wins, which
61
+ is the order in which Git applies its configuration files. Anything but a normalized boolean is refused rather
62
+ than guessed, because a wrong guess would publish the wrong file kind or executable bit.
63
+ """
64
+ if output and not output.endswith(b"\0"):
65
+ raise ValueError("Git returned an incomplete core.filemode or core.symlinks setting; Reset refused")
66
+ enabled_by_key: dict[bytes, bool] = {}
67
+ records = output.removesuffix(b"\0").split(b"\0") if output else []
68
+ for record in records:
69
+ key, separator, value = record.partition(b"\n")
70
+ if not separator or key not in (FILEMODE_SETTING_KEY, SYMLINKS_SETTING_KEY) or value not in (ENABLED_SETTING_VALUE, DISABLED_SETTING_VALUE):
71
+ raise ValueError("Git returned an unexpected core.filemode or core.symlinks setting; Reset refused")
72
+ enabled_by_key[key] = value == ENABLED_SETTING_VALUE
73
+ default = ResetFilePolicy()
74
+ return ResetFilePolicy(symlinks=enabled_by_key.get(SYMLINKS_SETTING_KEY, default.symlinks), filemode=enabled_by_key.get(FILEMODE_SETTING_KEY, default.filemode))
75
+
76
+
77
+ def read_reset_file_policy() -> Generator[GitCommand, bytes, ResetFilePolicy]:
78
+ """Read both settings with one Git command for a whole Reset, instead of once per file."""
79
+ try:
80
+ # Exit code 1 means neither key is defined anywhere, so both keep Git's default.
81
+ output = yield GitCommand(["config", "--bool", "--null", "--get-regexp", r"^core\.(filemode|symlinks)$"], accepted_codes=(0, 1))
82
+ except GitCommandError as error:
83
+ raise ValueError("Git cannot read core.filemode or core.symlinks as a boolean; Reset refused") from error
84
+ return parse_reset_file_policy(output)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class FileChange:
89
+ """Keep one exact destination's before and after images for confirmation and recovery."""
90
+
91
+ path: bytes
92
+ before: FileImage
93
+ after: FileImage
94
+ original_path: bytes | None = None
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ResetCandidate:
99
+ """Bind a confirmed result to its source capture and owned recovery files."""
100
+
101
+ operation_id: str
102
+ snapshot: WorktreeSnapshot
103
+ side: StagingSide
104
+ changes: tuple[FileChange, ...]
105
+ before_entries: bytes
106
+ after_entries: bytes
107
+ index_content: bytes | None
108
+ operation_dir: str
109
+ record_by_name: dict[str, object]
110
+ explanation: str
111
+ operation: str = "Reset"
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class Edit:
116
+ """Represent a replacement at exact base and resulting line coordinates."""
117
+
118
+ start: int
119
+ end: int
120
+ result_start: int
121
+ result_end: int
122
+
123
+
124
+ class ResetSource(Enum):
125
+ """Where a line of a staged Reset's result comes from: HEAD's version, or the working file that keeps its own edits."""
126
+
127
+ HEAD = "head"
128
+ WORKING = "working"
129
+
130
+
131
+ def compare_lines(base: list[bytes], result: list[bytes]) -> tuple[list[Edit], dict[int, int]]:
132
+ """Extract base intervals and unchanged-line correspondence without text decoding."""
133
+ edits: list[Edit] = []
134
+ result_index_by_base_index: dict[int, int] = {}
135
+ for kind, start, end, result_start, result_end in SequenceMatcher(None, base, result, autojunk=False).get_opcodes():
136
+ if kind != "equal":
137
+ edits.append(Edit(start, end, result_start, result_end))
138
+ continue
139
+ for offset in range(end - start):
140
+ result_index_by_base_index[start + offset] = result_start + offset
141
+ return edits, result_index_by_base_index
142
+
143
+
144
+ def independent_result(index: bytes, head: bytes, working: bytes) -> tuple[bytes, tuple[tuple[ResetSource, int], ...]]:
145
+ """Replay disjoint changes only when neither shared intervals nor repeated anchors can relocate B."""
146
+ index_lines, head_lines, working_lines = byte_lines(index), byte_lines(head), byte_lines(working)
147
+ if head != index and working != index:
148
+ seen_line_set: set[bytes] = set()
149
+ for line in index_lines:
150
+ if line in seen_line_set:
151
+ raise ValueError("Repeated context makes the staged and unstaged correspondence ambiguous")
152
+ seen_line_set.add(line)
153
+ undo_edits, _head_index_by_base_index = compare_lines(index_lines, head_lines)
154
+ working_edits, working_index_by_base_index = compare_lines(index_lines, working_lines)
155
+ if undo_edits and working_edits:
156
+ for undo in undo_edits:
157
+ for edit in working_edits:
158
+ overlap = max(undo.start, edit.start) < min(undo.end, edit.end)
159
+ insertion = (undo.start == undo.end and edit.start <= undo.start <= edit.end) or (edit.start == edit.end and undo.start <= edit.start <= undo.end)
160
+ if overlap or insertion:
161
+ raise ValueError("Staged and unstaged changes overlap or share an insertion boundary; Reset cannot preserve them independently")
162
+ changes: list[tuple[Edit, ResetSource]] = []
163
+ for edit in undo_edits:
164
+ changes.append((edit, ResetSource.HEAD))
165
+ for edit in working_edits:
166
+ changes.append((edit, ResetSource.WORKING))
167
+ changes.sort(key=lambda item: item[0].start)
168
+ result_lines: list[bytes] = []
169
+ sources: list[tuple[ResetSource, int]] = []
170
+ position = 0
171
+ for edit, source in changes:
172
+ while position < edit.start:
173
+ result_index = working_index_by_base_index[position]
174
+ result_lines.append(working_lines[result_index])
175
+ sources.append((ResetSource.WORKING, result_index))
176
+ position += 1
177
+ source_lines = head_lines if source == ResetSource.HEAD else working_lines
178
+ for result_index in range(edit.result_start, edit.result_end):
179
+ result_lines.append(source_lines[result_index])
180
+ sources.append((source, result_index))
181
+ position = edit.end
182
+ while position < len(index_lines):
183
+ result_index = working_index_by_base_index[position]
184
+ result_lines.append(working_lines[result_index])
185
+ sources.append((ResetSource.WORKING, result_index))
186
+ position += 1
187
+ return b"".join(result_lines), tuple(sources)
188
+
189
+
190
+ def verify_canonical(content: bytes, expected: bytes, path: bytes) -> Generator[GitCommand, bytes]:
191
+ """Require Git to map the published raw bytes back to the independently computed blob."""
192
+ expected_oid = yield GitCommand(["hash-object", "--no-filters", "--stdin"], stdin=expected)
193
+ actual_oid = yield GitCommand(["hash-object", "--path=" + os.fsdecode(path), "--stdin"], stdin=content,
194
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
195
+ if expected_oid != actual_oid:
196
+ raise ValueError("Git conversion cannot preserve the unstaged bytes in the Reset result")
197
+
198
+
199
+ def merge_content(index: bytes, head: bytes, working: bytes, raw: FileImage, restored: FileImage, path: bytes,
200
+ operation_dir: str, mode: str, destination_path: bytes) -> Generator[GitCommand | FileTask, bytes, bytes]:
201
+ """Cross-check Git's merge with interval replay and preserve original unmodified raw slices."""
202
+ if index == head:
203
+ yield from verify_canonical(raw.read(), working, destination_path)
204
+ return raw.read()
205
+ if working == index:
206
+ return restored.read()
207
+ if any(b"\0" in content for content in (index, head, working)):
208
+ raise ValueError("Binary staged and unstaged changes cannot be separated safely")
209
+ expected, sources = yield FileTask(lambda: independent_result(index, head, working))
210
+ filenames: list[str] = []
211
+ for basename, content in (("merge-working", working), ("merge-index", index), ("merge-head", head)):
212
+ filename = os.path.join(operation_dir, basename)
213
+ with open(filename, "wb") as stream:
214
+ stream.write(content)
215
+ filenames.append(filename)
216
+ try:
217
+ merged = yield GitCommand(["merge-file", "-p", "--", *filenames], timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS)
218
+ except GitCommandError as error:
219
+ raise ValueError("Git could not merge the staged Reset without conflicts; no working bytes were changed") from error
220
+ if merged != expected:
221
+ raise ValueError("Git's merged result differs from the independently preserved unstaged changes")
222
+ attributes = yield GitCommand(["check-attr", "-z", "filter", "working-tree-encoding", "--stdin"], stdin=path + b"\0")
223
+ attribute_by_name: dict[bytes, bytes] = {}
224
+ fields = iter(attributes.removesuffix(b"\0").split(b"\0"))
225
+ for _path in fields:
226
+ name, value = next(fields), next(fields)
227
+ attribute_by_name[name] = value
228
+ encoding_value = attribute_by_name.get(b"working-tree-encoding", b"unspecified")
229
+ encoding = "" if encoding_value in (b"unspecified", b"unset") else encoding_value.decode("ascii")
230
+ if attribute_by_name.get(b"filter", b"unspecified") not in (b"unspecified", b"unset"):
231
+ working_oid = (yield GitCommand(["hash-object", "-w", "--no-filters", "--stdin"], stdin=working, profile=LOCAL_WRITE_PROFILE)).strip().decode("ascii")
232
+ materialized = yield from materialize_blob(working_oid, path, mode, os.path.join(operation_dir, "materialized-working"))
233
+ head_slices = yield from filtered_line_slices(head, restored.read(), restored.read(), path)
234
+ working_slices = yield from filtered_line_slices(working, raw.read(), materialized.read(), path)
235
+ else:
236
+ head_slices = line_slices(head, restored.read(), encoding)
237
+ working_slices = line_slices(working, raw.read(), encoding)
238
+ raw_lines: list[bytes] = []
239
+ for source, line_index in sources:
240
+ line = head_slices.lines[line_index] if source == ResetSource.HEAD else working_slices.lines[line_index]
241
+ if source == ResetSource.HEAD and head_slices.codec and working_slices.codec and head_slices.codec != working_slices.codec:
242
+ line = line.decode(head_slices.codec).encode(working_slices.codec)
243
+ raw_lines.append(line)
244
+ candidate = working_slices.prefix + b"".join(raw_lines)
245
+ yield from verify_canonical(candidate, expected, destination_path)
246
+ return candidate
247
+
248
+
249
+ def index_records(output: bytes) -> dict[bytes, bytes]:
250
+ """Validate NUL index records before comparing selected and unrelated entries."""
251
+ record_by_path: dict[bytes, bytes] = {}
252
+ if output and not output.endswith(b"\0"):
253
+ raise ValueError("Incomplete candidate index entries")
254
+ for record in output.split(b"\0"):
255
+ if not record:
256
+ continue
257
+ metadata, separator, path = record.partition(b"\t")
258
+ if not separator or not metadata.endswith(b" 0") or path in record_by_path:
259
+ raise ValueError("Conflicted or malformed candidate index entry")
260
+ record_by_path[path] = record
261
+ return record_by_path
262
+
263
+
264
+ def reset_executable_mode(mode: int, git_mode: str) -> int:
265
+ """Restore Git executability while retaining local read/write and special permission bits."""
266
+ if bool(mode & stat.S_IXUSR) == (git_mode == "100755"):
267
+ return mode
268
+ executable_mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
269
+ mode &= ~executable_mask
270
+ if git_mode == "100755":
271
+ read_mask = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
272
+ read_to_execute_shift = 2
273
+ mode |= (mode & read_mask) >> read_to_execute_shift
274
+ return mode
275
+
276
+
277
+ def capture_rename_origin(context: RepositoryContext, path: bytes) -> tuple[FileImage, int]:
278
+ """Capture a rename's original path with its link count, so Reset can tell one entry seen twice from a second file.
279
+
280
+ The count is read from the same file the image describes. On Linux and macOS a link added or removed while reading
281
+ also moves the file's change time, so a moved identity refuses instead of trusting a stale count. On Windows that
282
+ time is the creation time and does not move, so the count read here is the only check; that is enough, because
283
+ replacing the directory entry never rewrites the bytes of another link.
284
+ """
285
+ image = capture_image(context, path, allow_missing_parents=True)
286
+ if not image.exists:
287
+ return image, 0
288
+ metadata = os.lstat(worktree_filename(context, path))
289
+ if stat_identity(metadata) != image.identity:
290
+ raise ValueError("Working file changed while reading")
291
+ return image, metadata.st_nlink
292
+
293
+
294
+ def directory_lists_name(context: RepositoryContext, path: bytes) -> bool:
295
+ """Tell whether the working folder holds a directory entry spelled exactly like ``path``.
296
+
297
+ A disk that ignores letter case or Unicode form finds a renamed file under its old spelling too, but lists it only
298
+ under the spelling the entry has; a disk that keeps spellings apart lists a second link under its own spelling.
299
+ """
300
+ parent_dir, basename = os.path.split(worktree_filename(context, path))
301
+ with os.scandir(parent_dir) as iterator:
302
+ for entry in iterator:
303
+ if entry.name == basename:
304
+ return True
305
+ return False
306
+
307
+
308
+ def calculate_file(snapshot: WorktreeSnapshot, entry: LocalEntry, record: bytes | None, file_dir: str, symlinks: bool, *, filemode: bool = True) -> Generator:
309
+ """Handle absence, renames and file metadata separately from the content-preservation proof.
310
+
311
+ Both settings are read once by the caller for the whole operation; filemode keeps Git's default when omitted.
312
+ """
313
+ path = entry.path
314
+ if "160000" in (entry.old_mode, entry.new_mode):
315
+ raise ValueError("Reset cannot change submodule working files; use the submodule's own checkout controls")
316
+ before = yield FileTask(lambda: capture_image(snapshot.context, path, allow_missing_parents=True))
317
+ index_mode, index_oid = "000000", ""
318
+ if record is not None:
319
+ raw_mode, raw_oid, _stage = record.partition(b"\t")[0].split(b" ")
320
+ index_mode, index_oid = raw_mode.decode("ascii"), raw_oid.decode("ascii")
321
+ if entry.side == StagingSide.UNSTAGED and not index_oid:
322
+ raise ValueError("This file has no index version; use Delete file explicitly for untracked files")
323
+ target_oid = index_oid if entry.side == StagingSide.UNSTAGED else entry.old_blob_oid
324
+ target_mode = index_mode if entry.side == StagingSide.UNSTAGED else entry.old_mode
325
+ destination_path = entry.old_path if entry.side == StagingSide.STAGED and entry.status == FileStatus.RENAMED else path
326
+ if target_mode not in ("000000", "100644", "100755", "120000") or index_mode not in ("000000", "100644", "100755", "120000"):
327
+ raise ValueError("This Git file mode cannot be reset safely")
328
+ restored = FileImage(False, 0)
329
+ if target_oid and target_mode != "000000":
330
+ restored = yield from materialize_blob(target_oid, destination_path, target_mode, os.path.join(file_dir, "materialized-source"), symlinks)
331
+ if entry.side == StagingSide.UNSTAGED:
332
+ mode = restored.mode
333
+ if before.exists and stat.S_IFMT(before.mode) == stat.S_IFMT(mode):
334
+ mode = before.mode
335
+ if stat.S_ISREG(mode) and index_mode in ("100644", "100755") and filemode:
336
+ mode = reset_executable_mode(mode, index_mode)
337
+ return (FileChange(path, before, FileImage(restored.exists, mode, restored.read())),)
338
+ index = (yield GitCommand(["cat-file", "blob", index_oid])) if index_oid else None
339
+ working = None
340
+ if before.exists:
341
+ if stat.S_ISLNK(before.mode) or index_mode == "120000":
342
+ working = before.read()
343
+ else:
344
+ working_oid = (yield GitCommand(["hash-object", "-w", "--path=" + os.fsdecode(path), "--stdin"], stdin=before.read(),
345
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)).strip().decode("ascii")
346
+ working = yield GitCommand(["cat-file", "blob", working_oid])
347
+ if index is None:
348
+ if before.exists:
349
+ raise ValueError("A file recreated after staged deletion collides with the version to restore")
350
+ return (FileChange(path, before, restored),)
351
+ if not restored.exists:
352
+ if working is not None and working != index:
353
+ raise ValueError("An added file's unstaged changes cannot be separated from its staged content")
354
+ return (FileChange(path, before, FileImage(False, 0)),)
355
+ head = yield GitCommand(["cat-file", "blob", target_oid])
356
+ if working is None:
357
+ if head != index:
358
+ raise ValueError("Unstaged deletion overlaps the staged content; Reset cannot preserve both")
359
+ after = FileImage(False, 0)
360
+ else:
361
+ type_changed = stat.S_ISLNK(before.mode) != (index_mode == "120000" and symlinks)
362
+ if type_changed or ("120000" in (index_mode, target_mode) and working != index and head != index):
363
+ raise ValueError("The staged and unstaged file types or link targets cannot be separated safely")
364
+ regular_mode_changed = index_mode != target_mode and index_mode in ("100644", "100755") and target_mode in ("100644", "100755")
365
+ if regular_mode_changed and filemode and bool(before.mode & stat.S_IXUSR) != (index_mode == "100755"):
366
+ raise ValueError("Staged and unstaged executable mode changes overlap")
367
+ content = yield from merge_content(index, head, working, before, restored, path, file_dir, target_mode, destination_path)
368
+ mode = before.mode if index_mode == target_mode else restored.mode
369
+ if regular_mode_changed:
370
+ mode = reset_executable_mode(before.mode, target_mode)
371
+ after = FileImage(True, mode, content)
372
+ if destination_path == path:
373
+ return (FileChange(path, before, after),)
374
+ old_image, old_link_count = yield FileTask(lambda: capture_rename_origin(snapshot.context, destination_path))
375
+ same_identity = old_image.exists and before.exists and old_image.identity == before.identity
376
+ if same_identity and old_link_count != SINGLE_LINK_COUNT:
377
+ # When nothing is listed under the original spelling, the extra links live under unrelated names: the refusal
378
+ # stays, but it names those links rather than an original path that nothing occupies.
379
+ origin_listed = yield FileTask(lambda: directory_lists_name(snapshot.context, destination_path))
380
+ if not origin_listed:
381
+ raise ValueError(OTHER_HARD_LINKS_MESSAGE)
382
+ same_file = same_identity and old_link_count == SINGLE_LINK_COUNT
383
+ if old_image.exists and not same_file:
384
+ raise ValueError(RENAME_ORIGIN_OCCUPIED_MESSAGE)
385
+ if same_file:
386
+ # Both names lead to the same file and that file has a single link, so they are one directory entry that a
387
+ # filesystem ignoring letter case or Unicode form finds under both spellings (a case-only rename on macOS or
388
+ # Windows). A private candidate replaces that entry under its original name. A file with two links is a real
389
+ # second file under the other name and keeps the refusal above.
390
+ return (FileChange(destination_path, old_image, after, path),)
391
+ return (FileChange(path, before, FileImage(False, 0)), FileChange(destination_path, old_image, after))
392
+
393
+
394
+ def calculate_reset(snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation_dir: str) -> Generator:
395
+ """Compute all selected images and a private index, leaving live Git and files untouched.
396
+
397
+ The captured index entries stand for the first listing of the private copy, since that copy is proven byte for
398
+ byte identical to the captured index; only a capture without entries lists the copy again. The final listing
399
+ always runs, because it is the proof that nothing outside the selection changed.
400
+ """
401
+ if not snapshot.fresh or snapshot.context.is_bare or not entries:
402
+ raise ValueError("Choose files from a complete current working tree read")
403
+ side = next(iter(entries)).side
404
+ if side not in (StagingSide.STAGED, StagingSide.UNSTAGED) or any(entry not in snapshot.entries or entry.side != side or entry.skip_worktree for entry in entries):
405
+ raise ValueError("Choose current files on the same side; skip-worktree files cannot be reset")
406
+ if any(entry.conflict_stages for entry in snapshot.entries):
407
+ raise ValueError("Resolve all conflicts before Reset")
408
+ candidate_filename = os.path.join(operation_dir, "candidate-index")
409
+ environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
410
+ if snapshot.index_content is None:
411
+ yield GitCommand(["read-tree", "--empty"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
412
+ else:
413
+ shutil.copy2(snapshot.index_filename, candidate_filename)
414
+ if read_index(candidate_filename) != snapshot.index_content:
415
+ raise ValueError("The index changed while preparing Reset")
416
+ if may_carry_split_index(snapshot.index_content):
417
+ yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
418
+ before_entries = snapshot.index_entries
419
+ if before_entries is None:
420
+ before_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
421
+ record_by_path = index_records(before_entries)
422
+ changes: list[FileChange] = []
423
+ policy = yield from read_reset_file_policy()
424
+ for number, entry in enumerate(entries):
425
+ path = entry.path
426
+ file_dir = os.path.join(operation_dir, str(number))
427
+ os.mkdir(file_dir, PRIVATE_DIRECTORY_MODE)
428
+ changes.extend((yield from calculate_file(snapshot, entry, record_by_path.get(path), file_dir, policy.symlinks, filemode=policy.filemode)))
429
+ changed_path_set: set[bytes] = set()
430
+ for change in changes:
431
+ if change.path in changed_path_set:
432
+ raise ValueError("Selected structural changes share a destination; choose an unambiguous batch")
433
+ changed_path_set.add(change.path)
434
+ paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
435
+ if side == StagingSide.STAGED:
436
+ if snapshot.head_oid:
437
+ yield GitCommand(["restore", "--staged", "--source=" + snapshot.head_oid, "--pathspec-from-file=-", "--pathspec-file-nul"],
438
+ stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
439
+ else:
440
+ yield GitCommand(["update-index", "--force-remove", "-z", "--stdin"], stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
441
+ after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
442
+ after_record_by_path = index_records(after_entries)
443
+ path_set = set(paths)
444
+ for path in set(record_by_path) | set(after_record_by_path):
445
+ if path not in path_set and record_by_path.get(path) != after_record_by_path.get(path):
446
+ raise ValueError("Reset candidate changed an unrelated index entry")
447
+ return tuple(changes), before_entries, after_entries, read_index(candidate_filename) if side == StagingSide.STAGED else snapshot.index_content