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,406 @@
1
+ """Handle owned file images (capture, materialization, replacement) without following working-tree links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import stat
8
+ import tempfile
9
+ import uuid
10
+ from dataclasses import dataclass, replace
11
+ from typing import TYPE_CHECKING
12
+
13
+ from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS, PROCESS_CHUNK_BYTES
14
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
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 GitCommand
18
+ from gitpane.worktree.worktree_snapshot import fingerprint
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Generator
22
+ from typing import BinaryIO
23
+
24
+ from gitpane.history.repository_value import RepositoryContext
25
+
26
+ PRIVATE_DIRECTORY_MODE = 0o700
27
+ PRIVATE_FILE_MODE = 0o600
28
+ REGULAR_FILE_MODE = 0o666
29
+ BATCH_TERMINATOR_BYTES = 1
30
+ FILE_OBJECT_IDENTITY_FIELDS = 2
31
+ # Position of st_mode in stat_identity; the change time is the only field after the stable ones.
32
+ FILE_MODE_IDENTITY_FIELD = 2
33
+ FILE_STABLE_IDENTITY_FIELDS = 5
34
+ MISSING_PARENT_IDENTITY = (0, 0)
35
+ # A file reachable under two names while it has a single link can only be one directory entry seen twice.
36
+ SINGLE_LINK_COUNT = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FileImage:
41
+ """Distinguish absence, file type, exact bytes and filesystem identity for safe replacement."""
42
+
43
+ exists: bool
44
+ mode: int
45
+ content: bytes | None = None
46
+ content_filename: str | None = None
47
+ identity: tuple[int, ...] | None = None
48
+ parents: tuple[tuple[str, tuple[int, int]], ...] = ()
49
+ publication_mode: int | None = None
50
+
51
+ def read(self) -> bytes:
52
+ """Read owned content while keeping missing files distinct from empty files."""
53
+ if not self.exists:
54
+ return b""
55
+ if self.content is not None:
56
+ return self.content
57
+ if self.content_filename is None:
58
+ raise ValueError("The file image has no owned content")
59
+ with open(self.content_filename, "rb") as stream:
60
+ return stream.read()
61
+
62
+ def digest(self) -> str:
63
+ """Describe content and type without putting private file bytes in the journal."""
64
+ if not self.exists:
65
+ return "absent"
66
+ if self.content is not None or self.content_filename is None:
67
+ return str(self.mode) + ":" + fingerprint(self.read())
68
+ digest = hashlib.sha256()
69
+ with open(self.content_filename, "rb") as stream:
70
+ while chunk := stream.read(PROCESS_CHUNK_BYTES):
71
+ digest.update(chunk)
72
+ return str(self.mode) + ":" + digest.hexdigest()
73
+
74
+
75
+ def stat_identity(metadata: os.stat_result) -> tuple[int, ...]:
76
+ """Recognize a replaced file even when its content and size were preserved."""
77
+ return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns)
78
+
79
+
80
+ def matches_after_announced_mode(actual: tuple[int, ...], recorded: tuple[int, ...], announced_mode: int | None) -> bool:
81
+ """Recognize a recorded file after the permission change its owner announced, and nothing else.
82
+
83
+ Changing permissions also moves the change time, so the file only differs from its recorded identity in those two
84
+ fields. Device, inode, size and modification time must still match and the mode must be exactly the announced one,
85
+ so a file another program rewrote, replaced or gave other permissions is still a different file.
86
+ """
87
+ if announced_mode is None:
88
+ return False
89
+ expected = list(recorded)
90
+ expected[FILE_MODE_IDENTITY_FIELD] = announced_mode
91
+ return tuple(actual)[:FILE_STABLE_IDENTITY_FIELDS] == tuple(expected)[:FILE_STABLE_IDENTITY_FIELDS]
92
+
93
+
94
+ def validate_destination(context: RepositoryContext, path: bytes, allow_missing_parents: bool = False) -> tuple[str, tuple[tuple[str, tuple[int, int]], ...]]:
95
+ """Reject administrative and linked paths, optionally capturing missing historical parents."""
96
+ filename = worktree_filename(context, path)
97
+ for part in path.split(b"/"):
98
+ if part.rstrip(b" .").lower() in (b".git", b"git~1"):
99
+ raise ValueError("Git administrative paths cannot be replaced")
100
+ # Git names its folders as the disk spells them, while the file is built from the folder the user opened, which on
101
+ # macOS may be typed in another letter case or Unicode form: both sides take the same identity before comparing.
102
+ normalized_filename = canonical_path(filename)
103
+ for protected_dir in (context.git_dir, context.common_git_dir):
104
+ normalized_dir = canonical_path(protected_dir)
105
+ try:
106
+ ancestor_dir = os.path.commonpath((normalized_dir, normalized_filename))
107
+ except ValueError:
108
+ # Windows worktrees can live on a different drive from their shared Git directory.
109
+ continue
110
+ if ancestor_dir == normalized_dir:
111
+ raise ValueError("Git administrative paths cannot be replaced")
112
+ root_dir = os.path.normcase(os.path.abspath(context.worktree_dir))
113
+ parent_dir = os.path.dirname(filename)
114
+ parents: list[tuple[str, tuple[int, int]]] = []
115
+ while True:
116
+ try:
117
+ metadata = os.lstat(parent_dir)
118
+ except FileNotFoundError:
119
+ if not allow_missing_parents or os.path.normcase(parent_dir) == root_dir:
120
+ raise
121
+ parents.append((parent_dir, MISSING_PARENT_IDENTITY))
122
+ else:
123
+ if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
124
+ raise ValueError("A selected path traverses a linked or invalid directory")
125
+ parents.append((parent_dir, (metadata.st_dev, metadata.st_ino)))
126
+ if os.path.normcase(parent_dir) == root_dir:
127
+ break
128
+ ancestor_dir = os.path.dirname(parent_dir)
129
+ if ancestor_dir == parent_dir:
130
+ raise ValueError("A selected path is outside the working tree")
131
+ parent_dir = ancestor_dir
132
+ return filename, tuple(parents)
133
+
134
+
135
+ def capture_image(context: RepositoryContext, path: bytes, allow_missing_parents: bool = False) -> FileImage:
136
+ """Capture exact file bytes and lstat identity, refusing links and replacement during reading."""
137
+ filename, parents = validate_destination(context, path, allow_missing_parents)
138
+ try:
139
+ metadata = os.lstat(filename)
140
+ except FileNotFoundError:
141
+ return FileImage(False, 0, parents=parents)
142
+ if os.path.isjunction(filename):
143
+ raise ValueError("A junction cannot be replaced as a file")
144
+ if stat.S_ISLNK(metadata.st_mode):
145
+ content = os.fsencode(os.readlink(filename))
146
+ elif stat.S_ISREG(metadata.st_mode):
147
+ with open(filename, "rb") as stream:
148
+ # Windows fstat and lstat can report different ctime meanings; compare handle identity, mode, size and mtime here.
149
+ if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
150
+ raise ValueError("Working file changed while reading")
151
+ content = stream.read()
152
+ else:
153
+ raise ValueError("This working file type cannot be replaced")
154
+ if stat_identity(os.lstat(filename)) != stat_identity(metadata):
155
+ raise ValueError("Working file changed while reading")
156
+ return FileImage(True, metadata.st_mode, content, identity=stat_identity(metadata), parents=parents)
157
+
158
+
159
+ def validate_image(context: RepositoryContext, path: bytes, expected: FileImage) -> None:
160
+ """Require unchanged parents, type, identity and bytes immediately before publication."""
161
+ current = capture_image(context, path, allow_missing_parents=any(identity == MISSING_PARENT_IDENTITY for _parent_dir, identity in expected.parents))
162
+ if (current.exists, current.identity, current.parents, current.digest()) != (expected.exists, expected.identity, expected.parents, expected.digest()):
163
+ raise ValueError("Working file or parent changed after confirmation; select the changes again")
164
+
165
+
166
+ def private_file_dir(context: RepositoryContext, path: bytes, operation_dir: str, allow_missing_parents: bool = False) -> str:
167
+ """Choose a private directory on the target volume, never a temporary file inside user content."""
168
+ _filename, parents = validate_destination(context, path, allow_missing_parents)
169
+ existing_parent_dir = next(parent_dir for parent_dir, identity in parents if identity != MISSING_PARENT_IDENTITY)
170
+ target_device = os.stat(existing_parent_dir).st_dev
171
+ if os.stat(operation_dir).st_dev == target_device:
172
+ return operation_dir
173
+ parent_dir = os.path.dirname(os.path.abspath(context.worktree_dir))
174
+ if os.path.islink(parent_dir) or os.path.isjunction(parent_dir) or os.stat(parent_dir).st_dev != target_device:
175
+ raise ValueError("No private directory on the working file's volume is available")
176
+ private_dir = tempfile.mkdtemp(prefix=".gitpane-operation-", dir=parent_dir)
177
+ os.chmod(private_dir, PRIVATE_DIRECTORY_MODE)
178
+ root_dir = canonical_path(context.worktree_dir)
179
+ if os.path.commonpath((root_dir, canonical_path(private_dir))) == root_dir:
180
+ os.rmdir(private_dir)
181
+ raise ValueError("Private file preparation would enter the working tree")
182
+ return private_dir
183
+
184
+
185
+ def publication_image(expected: FileImage, created_dirs: list[tuple[str, tuple[int, int]]]) -> FileImage:
186
+ """Account only for parents created and still owned by this publication."""
187
+ identity_by_directory = dict(created_dirs)
188
+ parents = []
189
+ for directory, identity in expected.parents:
190
+ parents.append((directory, identity_by_directory.get(directory, identity) if identity == MISSING_PARENT_IDENTITY else identity))
191
+ return replace(expected, parents=tuple(parents))
192
+
193
+
194
+ def create_missing_parents(context: RepositoryContext, path: bytes, expected: FileImage, created_dirs: list[tuple[str, tuple[int, int]]], persist: Callable[[], None]) -> FileImage:
195
+ """Revalidate each ancestor before creating and journaling missing destination directories."""
196
+ validate_image(context, path, publication_image(expected, created_dirs))
197
+ for directory, identity in reversed(expected.parents):
198
+ if identity != MISSING_PARENT_IDENTITY or directory in dict(created_dirs):
199
+ continue
200
+ validate_image(context, path, publication_image(expected, created_dirs))
201
+ os.mkdir(directory)
202
+ created_dirs.append((directory, stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]))
203
+ persist()
204
+ current = publication_image(expected, created_dirs)
205
+ validate_image(context, path, current)
206
+ return current
207
+
208
+
209
+ def clean_created_parents(created_dirs: list[tuple[str, tuple[int, int]]]) -> None:
210
+ """Remove only owned empty parents, retaining external files and replacement directories."""
211
+ for directory, identity in reversed(created_dirs):
212
+ try:
213
+ if not os.path.islink(directory) and not os.path.isjunction(directory) and stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS] == tuple(identity):
214
+ os.rmdir(directory)
215
+ except OSError:
216
+ # Another writer may now own content inside this directory.
217
+ pass
218
+
219
+
220
+ class FilteredBlobStream:
221
+ """Validate one NUL-framed filtered blob while retaining only its trailing separator."""
222
+
223
+ def __init__(self, stream: BinaryIO, oid: str) -> None:
224
+ """Bind an owned destination to the exact requested Git object and blob type."""
225
+ self.stream = stream
226
+ self.expected_header = oid.encode("ascii") + b" blob"
227
+ self.header = bytearray()
228
+ self.header_complete = False
229
+ self.tail = b""
230
+
231
+ def consume(self, chunk: bytes) -> None:
232
+ """Write bounded body chunks, rejecting unexpected headers before any result is usable."""
233
+ if not self.header_complete:
234
+ header, separator, remainder = chunk.partition(b"\0")
235
+ self.header.extend(header)
236
+ if len(self.header) > len(self.expected_header):
237
+ raise ValueError("Git returned an invalid filtered blob header")
238
+ if not separator:
239
+ return
240
+ if bytes(self.header) != self.expected_header:
241
+ raise ValueError("Git returned a different filtered object")
242
+ self.header_complete = True
243
+ chunk = remainder
244
+ content = self.tail + chunk
245
+ if content:
246
+ self.stream.write(content[:-BATCH_TERMINATOR_BYTES])
247
+ self.tail = content[-BATCH_TERMINATOR_BYTES:]
248
+
249
+ def finish(self) -> None:
250
+ """Require the terminal NUL only after Git has exited successfully and stdout reached EOF."""
251
+ if not self.header_complete or self.tail != b"\0":
252
+ raise ValueError("Git returned an incomplete filtered blob stream")
253
+ self.stream.flush()
254
+ os.fsync(self.stream.fileno())
255
+
256
+
257
+ def materialize_blob(oid: str, path: bytes, mode: str, destination_filename: str, symlinks: bool = True) -> Generator[GitCommand, bytes, FileImage]:
258
+ """Stream Git's actual working representation into an owned file outside the working tree."""
259
+ if mode == "160000":
260
+ raise ValueError("A submodule cannot be materialized as a file")
261
+ if mode not in ("100644", "100755", "120000"):
262
+ raise ValueError("Unsupported Git file mode")
263
+ with open(destination_filename, "xb") as stream:
264
+ os.chmod(destination_filename, PRIVATE_FILE_MODE)
265
+ if mode == "120000":
266
+ yield GitCommand(["cat-file", "blob", oid], stdout_limit_bytes=None, consume=stream.write,
267
+ timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS)
268
+ else:
269
+ consumer = FilteredBlobStream(stream, oid)
270
+ yield GitCommand(["cat-file", "--batch=%(objectname) %(objecttype)", "--filters", "-Z"], stdin=oid.encode("ascii") + b" ./" + path + b"\0",
271
+ stdout_limit_bytes=None, consume=consumer.consume, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
272
+ consumer.finish()
273
+ stream.flush()
274
+ os.fsync(stream.fileno())
275
+ file_mode = stat.S_IFLNK if mode == "120000" and symlinks else stat.S_IFREG
276
+ permission_mode = 0o755 if mode == "100755" else REGULAR_FILE_MODE
277
+ return FileImage(True, file_mode | permission_mode, content_filename=destination_filename)
278
+
279
+
280
+ def write_image(filename: str, image: FileImage) -> None:
281
+ """Create an owned publication candidate with the requested regular-file bytes and mode."""
282
+ if not image.exists:
283
+ return
284
+ if stat.S_ISLNK(image.mode):
285
+ os.symlink(os.fsdecode(image.read()), filename)
286
+ return
287
+ with open(filename, "xb") as stream:
288
+ os.chmod(filename, PRIVATE_FILE_MODE)
289
+ if image.content_filename is not None and image.content is None:
290
+ with open(image.content_filename, "rb") as source:
291
+ while chunk := source.read(PROCESS_CHUNK_BYTES):
292
+ stream.write(chunk)
293
+ else:
294
+ stream.write(image.read())
295
+ stream.flush()
296
+ os.fsync(stream.fileno())
297
+ os.chmod(filename, stat.S_IMODE(image.mode))
298
+
299
+
300
+ def creation_permission_mode(directory: str, executable: bool = False) -> int:
301
+ """Observe kernel-granted creation permissions using an empty file, without changing umask."""
302
+ filename = os.path.join(directory, "creation-mode-" + uuid.uuid4().hex)
303
+ requested_mode = REGULAR_FILE_MODE | (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) if executable else REGULAR_FILE_MODE
304
+ descriptor = os.open(filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, requested_mode)
305
+ metadata: os.stat_result | None = None
306
+ try:
307
+ metadata = os.fstat(descriptor)
308
+ return stat.S_IMODE(metadata.st_mode)
309
+ finally:
310
+ os.close(descriptor)
311
+ current = os.lstat(filename)
312
+ if metadata is not None and (current.st_dev, current.st_ino) == (metadata.st_dev, metadata.st_ino):
313
+ os.unlink(filename)
314
+
315
+
316
+ def prepare_publication_image(filename: str, image: FileImage, before: FileImage) -> FileImage:
317
+ """Keep content private while recording final permissions separately from the candidate's mode."""
318
+ if not image.exists or stat.S_ISLNK(image.mode):
319
+ write_image(filename, image)
320
+ return replace(image, mode=os.lstat(filename).st_mode) if image.exists else image
321
+ permission_mode = stat.S_IMODE(image.mode)
322
+ if not before.exists or not stat.S_ISREG(before.mode):
323
+ permission_mode = creation_permission_mode(os.path.dirname(filename), bool(image.mode & stat.S_IXUSR))
324
+ write_image(filename, replace(image, mode=stat.S_IFREG | PRIVATE_FILE_MODE))
325
+ # Windows chmod controls only the read-only flag, so the image digest must describe its actual file mode.
326
+ mode = stat.S_IFREG | permission_mode
327
+ if os.name == "nt":
328
+ write_mask = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
329
+ mode = stat.S_IFREG | (REGULAR_FILE_MODE if permission_mode & stat.S_IWUSR else REGULAR_FILE_MODE & ~write_mask)
330
+ return replace(image, mode=mode, publication_mode=permission_mode)
331
+
332
+
333
+ def publication_permission(image: FileImage) -> int | None:
334
+ """Name the permissions publish_image_mode gives a candidate, or None when it leaves the file as it is.
335
+
336
+ A recovery journal records this before the change happens, so an interruption right after it is still recognizable.
337
+ """
338
+ if not image.exists or stat.S_ISLNK(image.mode):
339
+ return None
340
+ return image.publication_mode if image.publication_mode is not None else stat.S_IMODE(image.mode)
341
+
342
+
343
+ def publish_image_mode(filename: str, image: FileImage) -> None:
344
+ """Apply accepted permissions only at publication, leaving symbolic links themselves untouched."""
345
+ permission = publication_permission(image)
346
+ if permission is not None:
347
+ os.chmod(filename, permission)
348
+
349
+
350
+ def respell_single_entry(context: RepositoryContext, spelled_path: bytes, wanted_path: bytes) -> None:
351
+ """Give one directory entry the spelling Git expects when the filesystem finds it under another letter case or Unicode form.
352
+
353
+ Replacing a file through another spelling of its name keeps the spelling already on disk on macOS, so the Reset of
354
+ a case-only rename would leave the restored file under the renamed spelling. Renaming the entry onto itself under
355
+ the wanted spelling changes only its name. Both names must still lead to the same single-link file: anything else
356
+ is a second file, which is left untouched rather than replaced.
357
+ """
358
+ if spelled_path == wanted_path:
359
+ return
360
+ spelled_filename, _parents = validate_destination(context, spelled_path)
361
+ wanted_filename, _parents = validate_destination(context, wanted_path)
362
+ spelled_metadata = os.lstat(spelled_filename)
363
+ wanted_metadata = os.lstat(wanted_filename)
364
+ if (spelled_metadata.st_dev, spelled_metadata.st_ino) != (wanted_metadata.st_dev, wanted_metadata.st_ino) or wanted_metadata.st_nlink != SINGLE_LINK_COUNT:
365
+ return
366
+ os.replace(spelled_filename, wanted_filename)
367
+
368
+
369
+ def owned_inventory(directory: str) -> list[tuple[str, tuple[int, ...]]]:
370
+ """Record private files and directories without following any symbolic links."""
371
+ owned_files: list[tuple[str, tuple[int, ...]]] = []
372
+ with os.scandir(directory) as iterator:
373
+ entries = sorted(iterator, key=lambda entry: entry.name)
374
+ for entry in entries:
375
+ if entry.name in ("record.json", "record.next"):
376
+ continue
377
+ if entry.is_dir(follow_symlinks=False) and not os.path.isjunction(entry.path):
378
+ owned_files.extend(owned_inventory(entry.path))
379
+ owned_files.append((entry.path, stat_identity(os.lstat(entry.path))))
380
+ return owned_files
381
+
382
+
383
+ def clean_owned(directory: str, owned_files: list[tuple[str, tuple[int, ...]]]) -> None:
384
+ """Delete only inventoried private artifacts whose paths and filesystem identities still agree."""
385
+ root_dir = os.path.normcase(os.path.abspath(directory))
386
+ for filename, expected in owned_files:
387
+ normalized_filename = os.path.normcase(os.path.abspath(filename))
388
+ if os.path.commonpath((root_dir, normalized_filename)) != root_dir or normalized_filename == root_dir:
389
+ raise ValueError("Recovery cleanup path escapes its private directory")
390
+ parent_dir = os.path.dirname(filename)
391
+ while os.path.normcase(os.path.abspath(parent_dir)) != root_dir:
392
+ if os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
393
+ raise ValueError("Recovery cleanup path traverses a replaced directory")
394
+ parent_dir = os.path.dirname(parent_dir)
395
+ if not os.path.lexists(filename):
396
+ continue
397
+ metadata = os.lstat(filename)
398
+ actual = stat_identity(metadata)
399
+ if stat.S_ISDIR(metadata.st_mode):
400
+ if actual[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(expected)[:FILE_OBJECT_IDENTITY_FIELDS] or os.path.isjunction(filename):
401
+ raise ValueError("An owned directory changed; cleanup refused")
402
+ os.rmdir(filename)
403
+ else:
404
+ if actual != tuple(expected):
405
+ raise ValueError("An owned file changed; cleanup refused")
406
+ os.unlink(filename)