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,630 @@
1
+ """Hold the coherent state of a worktree, parsed from Git's status, checked against local file fingerprints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import stat
9
+ from dataclasses import dataclass, field, replace
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING, Self
12
+
13
+ from gitpane.config import PROCESS_CHUNK_BYTES, READ_SIZE_SENTINEL_BYTES, WORKTREE_FILE_WORKER_COUNT, WORKTREE_INDEX_LIMIT_BYTES
14
+ from gitpane.history.repository_value import FileStatus, file_status
15
+ from gitpane.system.path_identity import canonical_path
16
+ from gitpane.tool.system_tool import worktree_filename
17
+ from gitpane.worktree.command_sequence import FileTask, ReadBatch
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Generator
21
+ from typing import BinaryIO
22
+
23
+ from gitpane.history.repository_value import RepositoryContext
24
+ from gitpane.operation.diagnostic import PreparedDiagnostic
25
+ from gitpane.sequencer.merge_state import MergeBannerState
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WorktreeKey:
30
+ """Keep linked worktrees independent by using their own canonical Git directory."""
31
+
32
+ git_dir: str
33
+
34
+ @classmethod
35
+ def from_context(cls, context: RepositoryContext) -> WorktreeKey:
36
+ """Normalize filesystem aliases without merging the common object directory."""
37
+ return cls(canonical_path(context.git_dir))
38
+
39
+
40
+ CONFLICT_ROLE_BY_STAGE = {1: "BASE", 2: "LOCAL", 3: "REMOTE"}
41
+ CONFLICT_ROLE_SET_BY_STATUS = {
42
+ b"DD": frozenset(("BASE",)), b"AU": frozenset(("LOCAL",)), b"UA": frozenset(("REMOTE",)),
43
+ b"UD": frozenset(("BASE", "LOCAL")), b"DU": frozenset(("BASE", "REMOTE")),
44
+ b"AA": frozenset(("LOCAL", "REMOTE")), b"UU": frozenset(CONFLICT_ROLE_BY_STAGE.values()),
45
+ }
46
+ INDEX_MODE_SET = frozenset(("100644", "100755", "120000", "160000", "040000"))
47
+
48
+
49
+ STATUS_HEADER_PREFIX = b"# "
50
+ STATUS_BRANCH_OID_PREFIX = b"# branch.oid "
51
+ STATUS_BRANCH_HEAD_PREFIX = b"# branch.head "
52
+ STATUS_UNMERGED_PREFIX = b"u "
53
+ # An unmerged status record holds ten spaces before its path, which may itself contain spaces.
54
+ STATUS_UNMERGED_SEPARATOR_COUNT = 10
55
+ STATUS_RENAMED_PREFIX = b"2 "
56
+ STATUS_ORDINARY_PREFIX = b"1 "
57
+ STATUS_UNTRACKED_PREFIX = b"? "
58
+ # Letter of the XY column of a porcelain v2 record for the side (index or work tree) that did not change.
59
+ STATUS_UNCHANGED_LETTER = "."
60
+ STATUS_INITIAL_OID = b"(initial)"
61
+ STATUS_DETACHED_HEAD = b"(detached)"
62
+ STATUS_UNKNOWN_HEAD = b"(NULL)"
63
+ SUBMODULE_DIRTY_DESCRIPTION = "These changes are inside the submodule and are not included in the parent commit."
64
+ SUBMODULE_NO_COMMIT_CHANGE = "There is no commit change to stage. Commit internal changes inside the submodule first."
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SubmoduleState:
69
+ """Distinguish gitlink movement from tracked and untracked child changes."""
70
+
71
+ commit_changed: bool | None = None
72
+ tracked_changed: bool | None = None
73
+ untracked_changed: bool | None = None
74
+
75
+ @property
76
+ def dirty(self) -> bool | None:
77
+ """Keep incomplete status unknown without hiding a known internal change."""
78
+ if self.tracked_changed or self.untracked_changed:
79
+ return True
80
+ if self.tracked_changed is None or self.untracked_changed is None:
81
+ return None
82
+ return False
83
+
84
+ @classmethod
85
+ def parse(cls, value: bytes) -> SubmoduleState:
86
+ """Decode native porcelain flags while preserving unsupported values as unknown."""
87
+ if value == b"N...":
88
+ return cls(False, False, False)
89
+ if len(value) != len(b"S...") or not value.startswith(b"S"):
90
+ return cls()
91
+ flags = []
92
+ for actual, changed in zip(value.removeprefix(b"S"), b"CMU", strict=True):
93
+ flags.append(True if actual == changed else False if actual == ord(".") else None)
94
+ return cls(*flags)
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ConflictSide:
99
+ """Identify one version actually present in the unmerged index."""
100
+
101
+ stage: int
102
+ mode: str
103
+ oid: str
104
+
105
+ def __post_init__(self) -> None:
106
+ """Reject invalid object identities before any consumer can use a side."""
107
+ if (self.stage not in CONFLICT_ROLE_BY_STAGE or self.mode not in INDEX_MODE_SET or self.mode == "040000"
108
+ or not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", self.oid) or not self.oid.strip("0")):
109
+ raise ValueError("Malformed unmerged index side")
110
+
111
+
112
+ class StagingSide(Enum):
113
+ """The side of the staging window a changed file is listed on: staged in the index, or unstaged in the working tree.
114
+
115
+ The value is the word written under "side" in a reset's recovery record; ``label`` is the word inside a sentence
116
+ and ``title`` the word that starts a title.
117
+ """
118
+
119
+ STAGED = ("staged", "staged", "Staged")
120
+ UNSTAGED = ("unstaged", "unstaged", "Unstaged")
121
+
122
+ def __new__(cls, value: str, label: str, title: str) -> Self:
123
+ """Keep the recorded word as the value, with the sentence and title words beside it."""
124
+ member = object.__new__(cls)
125
+ member._value_ = value
126
+ member.label = label
127
+ member.title = title
128
+ return member
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class LocalEntry:
133
+ """Retain original Git paths and object identities independently of labels."""
134
+
135
+ side: StagingSide
136
+ old_path: bytes | None
137
+ new_path: bytes | None
138
+ status: FileStatus
139
+ old_mode: str = "000000"
140
+ new_mode: str = "000000"
141
+ old_blob_oid: str | None = None
142
+ new_blob_oid: str | None = None
143
+ conflict_stages: tuple[int, ...] = ()
144
+ skip_worktree: bool = False
145
+ conflict_sides: tuple[ConflictSide, ...] = ()
146
+ conflict_binary: bool | None = None
147
+ submodule: SubmoduleState = SubmoduleState()
148
+
149
+ @property
150
+ def is_gitlink(self) -> bool:
151
+ """Recognize child repositories on either side of additions and deletions."""
152
+ return "160000" in (self.old_mode, self.new_mode)
153
+
154
+ @property
155
+ def stageable(self) -> bool:
156
+ """Allow real reference changes without staging internal-only child edits."""
157
+ return not self.is_gitlink or self.status in (FileStatus.ADDED, FileStatus.DELETED) or self.old_mode != self.new_mode or self.submodule.commit_changed is True
158
+
159
+ @property
160
+ def path(self) -> bytes:
161
+ """Choose the current identity, retaining a deleted path when necessary."""
162
+ return self.new_path if self.new_path is not None else self.old_path or b""
163
+
164
+ @property
165
+ def paths(self) -> tuple[bytes, ...]:
166
+ """Include both ends of a rename for a complete index operation."""
167
+ return tuple(dict.fromkeys(path for path in (self.old_path, self.new_path) if path is not None))
168
+
169
+ @property
170
+ def label(self) -> str:
171
+ """Decode paths for display and distinguish parent gitlinks from internal child files."""
172
+ label = self.path.decode("utf-8", "replace")
173
+ if self.status == FileStatus.RENAMED and self.old_path is not None:
174
+ label += " (" + self.old_path.decode("utf-8", "replace") + ")"
175
+ if self.is_gitlink:
176
+ if self.side == StagingSide.STAGED or self.stageable:
177
+ label += " — Submodule commit"
178
+ if self.side == StagingSide.UNSTAGED and self.submodule.dirty:
179
+ label += " + internal changes"
180
+ elif self.submodule.dirty:
181
+ label += " — Internal changes only"
182
+ return label
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class WorktreeProbe:
187
+ """The facts of a single status that say whether the displayed state still holds.
188
+
189
+ A hint for the views, never a proof a write can rely on.
190
+ """
191
+
192
+ head_oid: str | None
193
+ branch_head: str | None
194
+ unmerged_records: tuple[bytes, ...]
195
+ entry_fingerprint: str
196
+
197
+
198
+ def unmerged_record_path(record: bytes) -> bytes:
199
+ """Answer the path of one unmerged status record, exactly as Git wrote it."""
200
+ if not record.startswith(STATUS_UNMERGED_PREFIX):
201
+ raise ValueError("Malformed unmerged status record")
202
+ fields = record.split(b" ", STATUS_UNMERGED_SEPARATOR_COUNT)
203
+ # Each separator ends one field, and the path is the field after the last one.
204
+ if len(fields) != STATUS_UNMERGED_SEPARATOR_COUNT + 1:
205
+ raise ValueError("Malformed unmerged status record")
206
+ path = fields[STATUS_UNMERGED_SEPARATOR_COUNT]
207
+ if not path:
208
+ raise ValueError("Malformed unmerged status record")
209
+ return path
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class PassiveObservationSource:
214
+ """What the automatic refresh already has in hand when the banner follows a pause: the status facts and the pause paths.
215
+
216
+ It lets that display skip reading them again through Git; it is never a proof a write can rely on.
217
+ """
218
+
219
+ head_oid: str | None
220
+ branch_head: str | None
221
+ unmerged_paths: tuple[bytes, ...]
222
+ filename_by_git_path: dict[str, str]
223
+
224
+
225
+ def passive_observation_source(worktree_probe: WorktreeProbe, filename_by_git_path: dict[str, str]) -> PassiveObservationSource:
226
+ """Gather the facts of one status and the pause paths of its session for the observation that follows the pause."""
227
+ path_set = set()
228
+ for record in worktree_probe.unmerged_records:
229
+ path_set.add(unmerged_record_path(record))
230
+ return PassiveObservationSource(worktree_probe.head_oid, worktree_probe.branch_head, tuple(sorted(path_set)), dict(filename_by_git_path))
231
+
232
+
233
+ class WorktreeColorState(Enum):
234
+ """What the Commit icon says about the worktree: which sides hold changes, or unknown before any fresh read."""
235
+
236
+ UNKNOWN = "unknown"
237
+ MIXED = "mixed"
238
+ STAGED = "staged"
239
+ UNSTAGED = "unstaged"
240
+ CLEAN = "clean"
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class WorktreeSnapshot:
245
+ """Capture a complete read, including exact index absence and conversion settings."""
246
+
247
+ key: WorktreeKey
248
+ context: RepositoryContext
249
+ generation: int
250
+ git_filename: str
251
+ head_oid: str | None
252
+ index_filename: str
253
+ index_content: bytes | None
254
+ entries: tuple[LocalEntry, ...]
255
+ fingerprint_by_path: dict[bytes, str]
256
+ conversion: bytes
257
+ fresh: bool = True
258
+ diagnostic: PreparedDiagnostic | None = None
259
+ index_entries: bytes | None = None
260
+ hooks_dir: str | None = None
261
+ hidden_index_paths: tuple[bytes, ...] = ()
262
+ skip_worktree_paths: tuple[bytes, ...] = ()
263
+ probe: WorktreeProbe | None = None
264
+ # The merge state read from Git's files right after this capture, for the repository window's banner; None when the
265
+ # capture did not read it, as the captures made for a write do not. It is left out of the comparison of two
266
+ # snapshots: the session that shows them tracks the merge state on its own, and a changed merge alone does not
267
+ # change the files shown.
268
+ merge_banner_state: MergeBannerState | None = field(default=None, compare=False)
269
+
270
+ @property
271
+ def index_fingerprint(self) -> str:
272
+ """Distinguish an absent index from every existing byte representation."""
273
+ return fingerprint(self.index_content)
274
+
275
+ @property
276
+ def count(self) -> int:
277
+ """Count grouped staged renames once and deduplicate a path on both sides."""
278
+ return len({entry.path for entry in self.entries})
279
+
280
+ def color_state(self) -> WorktreeColorState:
281
+ """Distinguish staged and unstaged changes without letting a message alter repository status."""
282
+ if not self.fresh:
283
+ return WorktreeColorState.UNKNOWN
284
+ side_set = {entry.side for entry in self.entries}
285
+ if StagingSide.STAGED in side_set and StagingSide.UNSTAGED in side_set:
286
+ return WorktreeColorState.MIXED
287
+ if StagingSide.STAGED in side_set:
288
+ return WorktreeColorState.STAGED
289
+ return WorktreeColorState.UNSTAGED if side_set else WorktreeColorState.CLEAN
290
+
291
+ def requires_file_confirmation(self, paths: tuple[bytes, ...]) -> bool:
292
+ """Confirm destructive file actions when a target is an addition on either side."""
293
+ path_set = set(paths)
294
+ return any(entry.status == FileStatus.ADDED and entry.path in path_set for entry in self.entries)
295
+
296
+
297
+ def local_history_path(snapshot: WorktreeSnapshot, entry: LocalEntry) -> bytes | None:
298
+ """Find a possible HEAD name without pretending an index-only addition has history."""
299
+ if snapshot.head_oid is None or entry not in snapshot.entries or entry.side != StagingSide.UNSTAGED:
300
+ return None
301
+ for staged in snapshot.entries:
302
+ if staged.side != StagingSide.STAGED or staged.path != entry.path:
303
+ continue
304
+ if staged.status == FileStatus.RENAMED:
305
+ return staged.old_path
306
+ if staged.status == FileStatus.ADDED:
307
+ return None
308
+ if staged.status == FileStatus.DELETED:
309
+ return staged.old_path
310
+ return entry.old_path
311
+
312
+
313
+ def fingerprint(content: bytes | None) -> str:
314
+ """Hash exact bytes while keeping absence explicit."""
315
+ return "absent" if content is None else hashlib.sha256(content).hexdigest()
316
+
317
+
318
+ def read_validated_size(stream: BinaryIO, validated_size: int) -> bytes:
319
+ """Read a small file whose size was just validated on its open descriptor, asking for that size and one byte more.
320
+
321
+ A read sized on the largest file accepted makes the interpreter reserve that whole buffer even for a few bytes.
322
+ The extra byte is how growth shows: the caller refuses every length other than the validated size, so a file that
323
+ grew or shrank meanwhile never passes for complete content.
324
+ """
325
+ return stream.read(validated_size + READ_SIZE_SENTINEL_BYTES)
326
+
327
+
328
+ def read_index(index_filename: str) -> bytes | None:
329
+ """Read a bounded index without confusing a missing file with an empty one."""
330
+ try:
331
+ if not stat.S_ISREG(os.lstat(index_filename).st_mode):
332
+ raise ValueError("The Git index is not a regular file")
333
+ # A read sized to the safety limit allocates the maximum even for a small index.
334
+ # Chunked reads retain the same limit without paying that cost on every check.
335
+ content = bytearray()
336
+ with open(index_filename, "rb") as stream:
337
+ while chunk := stream.read(PROCESS_CHUNK_BYTES):
338
+ content.extend(chunk)
339
+ if len(content) > WORKTREE_INDEX_LIMIT_BYTES:
340
+ raise ValueError("The Git index exceeds the supported read limit")
341
+ return bytes(content)
342
+ except FileNotFoundError:
343
+ return None
344
+
345
+
346
+ def _validate_fingerprint_parent(context: RepositoryContext, filename: str, metadata_by_dir: dict[str, os.stat_result | None]) -> None:
347
+ """Check each ancestor once per batch and retain its identity for the closing check."""
348
+ parent_dir = os.path.dirname(filename)
349
+ while os.path.normcase(parent_dir) != os.path.normcase(os.path.abspath(context.worktree_dir)):
350
+ if parent_dir in metadata_by_dir:
351
+ return
352
+ try:
353
+ metadata = os.lstat(parent_dir)
354
+ except FileNotFoundError:
355
+ metadata = None
356
+ if metadata is not None and (stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", None) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -1)):
357
+ raise ValueError("A selected path traverses a linked directory")
358
+ metadata_by_dir[parent_dir] = metadata
359
+ ancestor_dir = os.path.dirname(parent_dir)
360
+ if ancestor_dir == parent_dir:
361
+ raise ValueError("A selected path is outside the working tree")
362
+ parent_dir = ancestor_dir
363
+
364
+
365
+ def _fingerprint_filename(filename: str) -> str:
366
+ """Hash one validated file's bytes and mode without following a final symlink."""
367
+ try:
368
+ metadata = os.lstat(filename)
369
+ except FileNotFoundError:
370
+ return "absent"
371
+ mode = str(stat.S_IFMT(metadata.st_mode)) + ":" + str(stat.S_IMODE(metadata.st_mode))
372
+ if stat.S_ISLNK(metadata.st_mode):
373
+ return mode + ":" + fingerprint(os.fsencode(os.readlink(filename)))
374
+ if stat.S_ISDIR(metadata.st_mode):
375
+ return mode
376
+ if not stat.S_ISREG(metadata.st_mode):
377
+ raise ValueError("Unsupported working file type")
378
+ digest = hashlib.sha256()
379
+ with open(filename, "rb") as stream:
380
+ while chunk := stream.read(PROCESS_CHUNK_BYTES):
381
+ digest.update(chunk)
382
+ return mode + ":" + digest.hexdigest()
383
+
384
+
385
+ def file_fingerprint(context: RepositoryContext, path: bytes) -> str:
386
+ """Hash working bytes and mode without following symlinks or walking submodules."""
387
+ filename = worktree_filename(context, path)
388
+ _validate_fingerprint_parent(context, filename, {})
389
+ return _fingerprint_filename(filename)
390
+
391
+
392
+ def fingerprint_files(context: RepositoryContext, paths: tuple[bytes, ...]) -> dict[bytes, str]:
393
+ """Hash exact bytes in one worker and validate shared ancestor directories once at each end."""
394
+ fingerprint_by_path: dict[bytes, str] = {}
395
+ metadata_by_dir: dict[str, os.stat_result | None] = {}
396
+ for path in paths:
397
+ if path and path not in fingerprint_by_path:
398
+ filename = worktree_filename(context, path)
399
+ _validate_fingerprint_parent(context, filename, metadata_by_dir)
400
+ fingerprint_by_path[path] = _fingerprint_filename(filename)
401
+ for directory, expected in metadata_by_dir.items():
402
+ try:
403
+ actual = os.lstat(directory)
404
+ except FileNotFoundError:
405
+ actual = None
406
+ if actual is None and expected is None:
407
+ continue
408
+ if actual is None or expected is None or (actual.st_dev, actual.st_ino, actual.st_mode, getattr(actual, "st_reparse_tag", None)) != (
409
+ expected.st_dev, expected.st_ino, expected.st_mode, getattr(expected, "st_reparse_tag", None)
410
+ ):
411
+ raise ValueError("A working directory changed during reading; refresh the changes")
412
+ return fingerprint_by_path
413
+
414
+
415
+ def read_fingerprints(context: RepositoryContext, paths: tuple[bytes, ...]) -> Generator:
416
+ """Distribute exact byte reads across the existing bounded filesystem workers."""
417
+ unique_paths = tuple(dict.fromkeys(path for path in paths if path))
418
+ groups = []
419
+ for position in range(min(WORKTREE_FILE_WORKER_COUNT, len(unique_paths))):
420
+ group = unique_paths[position::WORKTREE_FILE_WORKER_COUNT]
421
+ groups.append(FileTask(lambda group=group: fingerprint_files(context, group)))
422
+ results = yield ReadBatch(tuple(groups))
423
+ fingerprint_by_path: dict[bytes, str] = {}
424
+ for result in results:
425
+ fingerprint_by_path.update(result)
426
+ return fingerprint_by_path
427
+
428
+
429
+ def verify_local_state(snapshot: WorktreeSnapshot, paths: tuple[bytes, ...]) -> None:
430
+ """Refuse stale local bytes before private preparation; Git metadata is checked before publication."""
431
+ if read_index(snapshot.index_filename) != snapshot.index_content:
432
+ raise ValueError("The index changed since selection; refresh the changes")
433
+ for path, actual in fingerprint_files(snapshot.context, paths).items():
434
+ expected = snapshot.fingerprint_by_path.get(path)
435
+ # Gitlink HEAD is checked by Git before publication; the directory itself is checked here.
436
+ if actual != expected and (expected is None or not expected.startswith(actual + ":")):
437
+ raise ValueError("Working file changed since selection; refresh the changes")
438
+
439
+
440
+ def parse_status(output: bytes) -> tuple[LocalEntry, ...]:
441
+ """Parse NUL records without splitting or normalizing the path bytes."""
442
+ if output and not output.endswith(b"\0"):
443
+ raise ValueError("Incomplete Git status output")
444
+ entries: list[LocalEntry] = []
445
+ renamed_separator_count, ordinary_separator_count = 9, 8
446
+ records = iter(output.split(b"\0"))
447
+ for record in records:
448
+ if not record or record.startswith(STATUS_HEADER_PREFIX):
449
+ continue
450
+ if record.startswith(STATUS_UNTRACKED_PREFIX):
451
+ entries.append(LocalEntry(StagingSide.UNSTAGED, None, record[len(STATUS_UNTRACKED_PREFIX):], FileStatus.ADDED))
452
+ continue
453
+ if record.startswith(STATUS_UNMERGED_PREFIX):
454
+ fields = record.split(b" ", STATUS_UNMERGED_SEPARATOR_COUNT)
455
+ _kind, _status, _submodule, base_mode, local_mode, remote_mode, work_mode, base_oid, local_oid, remote_oid, path = fields
456
+ if not path or work_mode.decode("ascii") not in INDEX_MODE_SET | {"000000"}:
457
+ raise ValueError("Malformed unmerged status path or mode")
458
+ sides = []
459
+ stages = []
460
+ role_set: set[str] = set()
461
+ for stage, mode, oid in zip(CONFLICT_ROLE_BY_STAGE, (base_mode, local_mode, remote_mode), (base_oid, local_oid, remote_oid), strict=True):
462
+ if mode == b"000000":
463
+ if not re.fullmatch(rb"(?:0{40}|0{64})", oid):
464
+ raise ValueError("Absent conflict side has a nonzero object")
465
+ continue
466
+ sides.append(ConflictSide(stage, mode.decode("ascii"), oid.decode("ascii")))
467
+ stages.append(stage)
468
+ role_set.add(CONFLICT_ROLE_BY_STAGE[stage])
469
+ if (not sides or not len(base_oid) == len(local_oid) == len(remote_oid)
470
+ or role_set != CONFLICT_ROLE_SET_BY_STATUS.get(_status)):
471
+ raise ValueError("Malformed unmerged status sides")
472
+ entries.append(LocalEntry(StagingSide.UNSTAGED, path, path, FileStatus.UNMERGED, base_mode.decode(), work_mode.decode(), base_oid.decode(),
473
+ conflict_stages=tuple(stages), conflict_sides=tuple(sides), submodule=SubmoduleState.parse(_submodule)))
474
+ continue
475
+ if not record.startswith((STATUS_ORDINARY_PREFIX, STATUS_RENAMED_PREFIX)):
476
+ raise ValueError("Unsupported Git status record")
477
+ old_path = None
478
+ if record.startswith(STATUS_RENAMED_PREFIX):
479
+ _kind, status, _submodule, head_mode, index_mode, work_mode, head_oid, index_oid, _score, path = record.split(b" ", renamed_separator_count)
480
+ old_path = next(records, None)
481
+ if not old_path:
482
+ raise ValueError("Missing renamed status path")
483
+ else:
484
+ _kind, status, _submodule, head_mode, index_mode, work_mode, head_oid, index_oid, path = record.split(b" ", ordinary_separator_count)
485
+ staged_letter, unstaged_letter = status.decode("ascii")
486
+ if staged_letter != STATUS_UNCHANGED_LETTER:
487
+ staged_status = file_status(staged_letter)
488
+ entries.append(LocalEntry(StagingSide.STAGED, (old_path or path) if staged_status != FileStatus.ADDED else None, path if staged_status != FileStatus.DELETED else None, staged_status,
489
+ head_mode.decode(), index_mode.decode(), head_oid.decode(), index_oid.decode(), submodule=SubmoduleState.parse(_submodule)))
490
+ if unstaged_letter != STATUS_UNCHANGED_LETTER:
491
+ unstaged_status = file_status(unstaged_letter)
492
+ entries.append(LocalEntry(StagingSide.UNSTAGED, path, path if unstaged_status != FileStatus.DELETED else None, unstaged_status,
493
+ index_mode.decode(), work_mode.decode(), index_oid.decode(), submodule=SubmoduleState.parse(_submodule)))
494
+ return tuple(entries)
495
+
496
+
497
+ @dataclass(frozen=True)
498
+ class IndexListing:
499
+ """What `ls-files --stage -v -z` says about an index: a pure function of the index bytes it listed."""
500
+
501
+ index_entries: bytes
502
+ hidden_index_paths: tuple[bytes, ...]
503
+ skip_worktree_paths: tuple[bytes, ...]
504
+
505
+
506
+ def parse_index_listing(flag_output: bytes) -> IndexListing:
507
+ """Split a flagged index listing into sorted entry records and the paths Git hides from the working tree."""
508
+ if flag_output and not flag_output.endswith(b"\0"):
509
+ raise ValueError("Incomplete Git index flag output")
510
+ index_metadata_field_count = 3
511
+ skip_paths: list[bytes] = []
512
+ hidden_paths: list[bytes] = []
513
+ index_records: list[bytes] = []
514
+ for record in flag_output.split(b"\0"):
515
+ if not record:
516
+ continue
517
+ flag, separator, index_record = record.partition(b" ")
518
+ metadata, path_separator, path = index_record.partition(b"\t")
519
+ if not separator or not path_separator or len(metadata.split(b" ")) != index_metadata_field_count or not path:
520
+ raise ValueError("Malformed Git index entry output")
521
+ index_records.append(index_record + b"\0")
522
+ if flag.upper() == b"S":
523
+ skip_paths.append(path)
524
+ if flag.upper() == b"S" or flag.islower():
525
+ hidden_paths.append(path)
526
+ return IndexListing(b"".join(sorted(index_records)), tuple(hidden_paths), tuple(skip_paths))
527
+
528
+
529
+ def reusable_index_listing(previous: WorktreeSnapshot | None, context: RepositoryContext, git_filename: str, index_filename: str,
530
+ index_content: bytes | None) -> IndexListing | None:
531
+ """Return the index listing of a previous read of the same worktree, index file and Git, when its index bytes are the ones just read."""
532
+ if previous is None or previous.index_entries is None:
533
+ return None
534
+ if previous.key != WorktreeKey.from_context(context) or previous.git_filename != git_filename:
535
+ return None
536
+ if canonical_path(previous.index_filename) != canonical_path(index_filename):
537
+ return None
538
+ if previous.index_content != index_content:
539
+ return None
540
+ return IndexListing(previous.index_entries, previous.hidden_index_paths, previous.skip_worktree_paths)
541
+
542
+
543
+ def conflict_side_by_path(index_entries: bytes) -> dict[bytes, tuple[ConflictSide, ...]]:
544
+ """Validate captured index records and group only their nonzero stages."""
545
+ if index_entries and not index_entries.endswith(b"\0"):
546
+ raise ValueError("Incomplete Git index entries")
547
+ side_by_path: dict[bytes, list[ConflictSide]] = {}
548
+ stage_by_path: dict[bytes, set[int]] = {}
549
+ for record in index_entries.split(b"\0"):
550
+ if not record:
551
+ continue
552
+ metadata, separator, path = record.partition(b"\t")
553
+ mode, oid, stage_value = metadata.split(b" ")
554
+ if (not separator or not path or stage_value not in (b"0", b"1", b"2", b"3") or mode.decode("ascii") not in INDEX_MODE_SET
555
+ or not re.fullmatch(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})", oid) or not oid.strip(b"0")):
556
+ raise ValueError("Malformed Git index entry")
557
+ stage = int(stage_value)
558
+ stage_set = stage_by_path.setdefault(path, set())
559
+ if stage in stage_set or (stage_set and (stage == 0 or 0 in stage_set)):
560
+ raise ValueError("Duplicate or conflicting Git index stage")
561
+ stage_set.add(stage)
562
+ if stage:
563
+ side_by_path.setdefault(path, []).append(ConflictSide(stage, mode.decode("ascii"), oid.decode("ascii")))
564
+ result_by_path = {}
565
+ for path, sides in side_by_path.items():
566
+ result_by_path[path] = tuple(sorted(sides, key=lambda side: side.stage))
567
+ return result_by_path
568
+
569
+
570
+ def validate_conflict_index(entries: tuple[LocalEntry, ...], index_entries: bytes) -> None:
571
+ """Refuse disagreement between status and index from the same capture."""
572
+ side_by_path = conflict_side_by_path(index_entries)
573
+ seen_path_set: set[bytes] = set()
574
+ for entry in entries:
575
+ if entry.status != FileStatus.UNMERGED:
576
+ if entry.path in side_by_path:
577
+ raise ValueError("A path is both resolved and unmerged in status")
578
+ continue
579
+ if (entry.path in seen_path_set or side_by_path.get(entry.path) != entry.conflict_sides
580
+ or tuple(side.stage for side in entry.conflict_sides) != entry.conflict_stages):
581
+ raise ValueError("Unmerged status and index disagree; refresh the changes")
582
+ seen_path_set.add(entry.path)
583
+ if seen_path_set != set(side_by_path):
584
+ raise ValueError("Unmerged index paths are missing from status; refresh the changes")
585
+
586
+
587
+ def apply_conflict_attributes(entries: tuple[LocalEntry, ...], attributes: bytes) -> tuple[LocalEntry, ...]:
588
+ """Retain native binary merge/diff attributes even when the blob contains only text."""
589
+ if attributes and not attributes.endswith(b"\0"):
590
+ raise ValueError("Incomplete conflict attributes")
591
+ fields = attributes.removesuffix(b"\0").split(b"\0") if attributes else []
592
+ attribute_field_count = 3
593
+ if len(fields) % attribute_field_count:
594
+ raise ValueError("Malformed conflict attributes")
595
+ binary_path_set: set[bytes] = set()
596
+ iterator = iter(fields)
597
+ for path, attribute, value in zip(iterator, iterator, iterator, strict=True):
598
+ if ((attribute in (b"diff", b"merge") and value == b"unset") or (attribute == b"merge" and value == b"binary")
599
+ or (attribute == b"binary" and value == b"set")):
600
+ binary_path_set.add(path)
601
+ result_entries = []
602
+ for entry in entries:
603
+ result_entries.append(replace(entry, conflict_binary=True) if entry.conflict_sides and entry.path in binary_path_set else entry)
604
+ return tuple(result_entries)
605
+
606
+
607
+ def parse_staged(output: bytes) -> tuple[LocalEntry, ...]:
608
+ """Read rename-aware raw comparisons using Git's exact object modes and paths."""
609
+ if output and not output.endswith(b"\0"):
610
+ raise ValueError("Incomplete staged comparison output")
611
+ records = iter(output.split(b"\0"))
612
+ entries: list[LocalEntry] = []
613
+ for record in records:
614
+ if not record:
615
+ continue
616
+ if not record.startswith(b":"):
617
+ raise ValueError("Malformed staged comparison")
618
+ old_mode, new_mode, old_oid, new_oid, raw_status = record[len(b":"):].split(b" ")
619
+ status = file_status(raw_status.decode("ascii"))
620
+ path = next(records, None)
621
+ if not path:
622
+ raise ValueError("Missing staged path")
623
+ old_path = None if status == FileStatus.ADDED else path
624
+ new_path = None if status == FileStatus.DELETED else path
625
+ if status in (FileStatus.RENAMED, FileStatus.COPIED):
626
+ new_path = next(records, None)
627
+ if not new_path:
628
+ raise ValueError("Missing renamed path")
629
+ entries.append(LocalEntry(StagingSide.STAGED, old_path, new_path, status, old_mode.decode(), new_mode.decode(), old_oid.decode(), new_oid.decode()))
630
+ return tuple(entries)