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,1600 @@
1
+ """Read a repository through asynchronous Git commands, never writing to it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from collections import OrderedDict, deque
9
+ from dataclasses import dataclass, field, replace
10
+ from functools import wraps
11
+ from time import monotonic
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QObject, QTimer, Signal
15
+
16
+ from gitpane.config import (
17
+ COMMIT_BATCH_SIZE,
18
+ COMMIT_HEADER_LIMIT_BYTES,
19
+ COMMIT_LIMIT_BYTES,
20
+ DIFF_CACHE_ENTRY_LIMIT,
21
+ DIFF_CACHE_LIMIT_BYTES,
22
+ FILE_CACHE_LIMIT_BYTES,
23
+ FILE_HISTORY_BATCH_SIZE,
24
+ FILE_PARSE_BUDGET_MS,
25
+ FILE_PREVIEW_LIMIT_BYTES,
26
+ GIT_OPEN_TIMEOUT_MS,
27
+ GIT_READ_TIMEOUT_MS,
28
+ HISTORY_PUBLISH_INTERVAL_MS,
29
+ HISTORY_STREAM_TIMEOUT_MS,
30
+ MESSAGE_CACHE_LIMIT_BYTES,
31
+ MILLISECONDS_PER_SECOND,
32
+ PARSING_BACKLOG_LIMIT_BYTES,
33
+ PARSING_BUDGET_MS,
34
+ SNAPSHOT_RETRY_LIMIT,
35
+ )
36
+ from gitpane.history.history_filter import HistoryFilterMode
37
+ from gitpane.history.history_parse import (
38
+ CHANGED_PATH_ARGUMENTS,
39
+ HISTORY_LOG_ARGUMENTS,
40
+ CatFileParser,
41
+ ChangedPathParser,
42
+ HistoryLogParser,
43
+ commit_matches_query,
44
+ commit_subject,
45
+ oversized_commit_cause,
46
+ parse_abbreviation_answer,
47
+ parse_commit,
48
+ parse_history_comparisons,
49
+ parse_presence_answer,
50
+ parse_revision_entries,
51
+ parse_tree_entries,
52
+ relation_link,
53
+ summarize_commit,
54
+ unavailable_commit,
55
+ )
56
+ from gitpane.history.repository_value import (
57
+ COMMIT_PEEL_SUFFIX,
58
+ SUCCESS_EXIT_CODE,
59
+ CommitLink,
60
+ CommitRelation,
61
+ ComparisonGroup,
62
+ FileStatus,
63
+ HistorySnapshot,
64
+ OpeningHeadRead,
65
+ Reference,
66
+ ReferenceKind,
67
+ RepositoryContext,
68
+ RepositoryFailure,
69
+ validate_oid,
70
+ )
71
+ from gitpane.history.revision_file import comparison_metadata, decode_blob_display, unchanged_context
72
+ from gitpane.process.process_runner import ProcessRequest, ProcessResult, ProcessRunner, ProcessStatus
73
+ from gitpane.reference.reference_snapshot import REFERENCE_FORMAT, parse_reference_read
74
+ from gitpane.system.path_identity import canonical_path
75
+ from gitpane.text.text_query import TextQuery
76
+
77
+ if TYPE_CHECKING:
78
+ from collections.abc import Callable
79
+
80
+ from gitpane.history.repository_value import BlobContent, Commit, FilePanel, FileReadIdentity, RequestIdentity, RevisionEntry, TreeEntry
81
+ from gitpane.reference.reference_snapshot import ReferenceRead, ReferenceSnapshot
82
+
83
+
84
+ MISSING_HEAD_EXIT_CODE = 1
85
+ BARE_ROOT_EXIT_CODE = 128
86
+ GIT_TRUE = "true"
87
+ GIT_FALSE = "false"
88
+ GIT_LINE_FEED = "\n"
89
+ # The root question comes last on purpose: a bare repository prints the three answers before it, then fails on it
90
+ # with BARE_ROOT_EXIT_CODE. Any other failure stops before printing, or prints something else in last position.
91
+ REPOSITORY_LOCATION_OPTIONS = ("--git-dir", "--git-common-dir", "--is-bare-repository", "--show-toplevel")
92
+ GIT_DIR_LOCATION_INDEX, COMMON_GIT_DIR_LOCATION_INDEX, BARE_FLAG_LOCATION_INDEX, ROOT_LOCATION_INDEX = range(len(REPOSITORY_LOCATION_OPTIONS))
93
+ ABSOLUTE_PATH_FORMAT_ARGUMENT = "--path-format=absolute"
94
+ REPOSITORY_LOCATION_ARGUMENTS = ["rev-parse", ABSOLUTE_PATH_FORMAT_ARGUMENT, *REPOSITORY_LOCATION_OPTIONS]
95
+ # HEAD without a commit answers the shallow flag alone with MISSING_HEAD_EXIT_CODE, and nothing on the error output.
96
+ HEAD_READ_ARGUMENTS = ["rev-parse", "--is-shallow-repository", "--verify", "--quiet", "HEAD"]
97
+ HEAD_READ_EXPECTED_CODES = (SUCCESS_EXIT_CODE, MISSING_HEAD_EXIT_CODE)
98
+ SHALLOW_FLAG_INDEX = 0
99
+ HEAD_OID_INDEX = 1
100
+ SYMBOLIC_HEAD_ARGUMENTS = ["symbolic-ref", "--quiet", "HEAD"]
101
+ SYMBOLIC_HEAD_EXPECTED_CODES = (SUCCESS_EXIT_CODE, MISSING_HEAD_EXIT_CODE)
102
+ # Namespaces the history lists and shows, in the order of the kinds: local branches, remote branches, tags.
103
+ SHOWN_REFERENCE_PREFIXES = tuple(kind.prefix for kind in ReferenceKind if kind.prefix)
104
+
105
+
106
+ def git_answer_lines(output: bytes) -> list[str]:
107
+ """Split one answer into its lines, removing only the line feed Git writes after the last one."""
108
+ if not output:
109
+ return []
110
+ return os.fsdecode(output.removesuffix(b"\n")).split(GIT_LINE_FEED)
111
+
112
+
113
+ def location_context(values: list[str]) -> RepositoryContext:
114
+ """Build a repository context from Git's location answers, refusing anything but absolute paths and a real flag.
115
+
116
+ A bare repository has three answers and no working tree; any other repository has four, its root last.
117
+ """
118
+ bare_text = values[BARE_FLAG_LOCATION_INDEX]
119
+ if bare_text not in (GIT_TRUE, GIT_FALSE):
120
+ raise ValueError("Git returned an invalid repository location")
121
+ is_bare = bare_text == GIT_TRUE
122
+ expected_count = ROOT_LOCATION_INDEX if is_bare else len(REPOSITORY_LOCATION_OPTIONS)
123
+ if len(values) != expected_count:
124
+ raise ValueError("Git returned an incomplete repository location")
125
+ for path in values[:BARE_FLAG_LOCATION_INDEX] + values[ROOT_LOCATION_INDEX:]:
126
+ if not os.path.isabs(path):
127
+ raise ValueError("Git returned an invalid repository location")
128
+ worktree_dir = None if is_bare else values[ROOT_LOCATION_INDEX]
129
+ return RepositoryContext(worktree_dir, values[GIT_DIR_LOCATION_INDEX], values[COMMON_GIT_DIR_LOCATION_INDEX], is_bare)
130
+
131
+
132
+ def parse_repository_location(output: bytes, return_code: int) -> RepositoryContext | None:
133
+ """Read the grouped location answer, or answer nothing when a line feed inside a path makes it ambiguous.
134
+
135
+ With a success every question is answered. With the bare code only the three answers before the root question
136
+ exist, the last one saying the repository is bare. More lines than expected can only come from a path holding a
137
+ line feed, so the caller asks each question again on its own instead of guessing where a path ends.
138
+ """
139
+ values = git_answer_lines(output)
140
+ expected_count = len(REPOSITORY_LOCATION_OPTIONS) if return_code == SUCCESS_EXIT_CODE else ROOT_LOCATION_INDEX
141
+ if len(values) > expected_count:
142
+ return None
143
+ if len(values) < expected_count:
144
+ raise ValueError("Git returned an incomplete repository location")
145
+ if return_code != SUCCESS_EXIT_CODE and values[BARE_FLAG_LOCATION_INDEX] != GIT_TRUE:
146
+ raise ValueError("Git could not locate a working tree for this repository")
147
+ return location_context(values)
148
+
149
+
150
+ def is_bare_location_answer(output: bytes) -> bool:
151
+ """Tell whether a failed location read printed what only a bare repository prints before the root question."""
152
+ values = git_answer_lines(output)
153
+ return len(values) >= ROOT_LOCATION_INDEX and values[-1] == GIT_TRUE
154
+
155
+
156
+ def parse_head_read(output: bytes, return_code: int) -> tuple[bool, str | None]:
157
+ """Read the shallow flag and HEAD's commit from one grouped answer.
158
+
159
+ A success holds the flag then a full object ID. HEAD without a commit holds the flag alone and exits with the
160
+ missing code. Any other shape is refused rather than read as an empty or partial HEAD.
161
+ """
162
+ if output and not output.endswith(b"\n"):
163
+ raise ValueError("Git returned an incomplete HEAD read")
164
+ values = output.decode("ascii").removesuffix(GIT_LINE_FEED).split(GIT_LINE_FEED) if output else []
165
+ expected_count = HEAD_OID_INDEX + 1 if return_code == SUCCESS_EXIT_CODE else SHALLOW_FLAG_INDEX + 1
166
+ if len(values) != expected_count or values[SHALLOW_FLAG_INDEX] not in (GIT_TRUE, GIT_FALSE):
167
+ raise ValueError("Git returned an invalid shallow or HEAD answer")
168
+ head_oid = validate_oid(values[HEAD_OID_INDEX]) if return_code == SUCCESS_EXIT_CODE else None
169
+ return values[SHALLOW_FLAG_INDEX] == GIT_TRUE, head_oid
170
+
171
+
172
+ def reference_snapshot_fits(snapshot: ReferenceSnapshot, identity: RequestIdentity, context: RepositoryContext, git_filename: str) -> bool:
173
+ """Tell whether a reference capture was taken for this very read: same generation, same Git, same repository, listing kept.
174
+
175
+ The session itself is checked by the caller's request identity; a capture carries no session of its own.
176
+ """
177
+ captured = snapshot.context
178
+ return (snapshot.reference_read is not None and snapshot.generation == identity.generation and snapshot.git_filename == git_filename
179
+ and captured.is_bare == context.is_bare and canonical_path(captured.git_dir) == canonical_path(context.git_dir)
180
+ and canonical_path(captured.repository_dir) == canonical_path(context.repository_dir))
181
+
182
+
183
+ def parse_symbolic_head(output: bytes, return_code: int) -> str | None:
184
+ """Read the branch HEAD names, or nothing when Git says HEAD is not symbolic."""
185
+ if return_code != SUCCESS_EXIT_CODE:
186
+ return None
187
+ head_ref = output.decode("utf-8", "surrogateescape").removesuffix(GIT_LINE_FEED)
188
+ if not head_ref or GIT_LINE_FEED in head_ref:
189
+ raise ValueError("Git returned an invalid HEAD branch")
190
+ return head_ref
191
+
192
+
193
+ class CommitCache:
194
+ """Keep repository-specific commit metadata under one shared LRU byte budget."""
195
+
196
+ def __init__(self, limit_bytes: int = MESSAGE_CACHE_LIMIT_BYTES) -> None:
197
+ """Allow small limits for boundary tests without changing production settings."""
198
+ self.limit_bytes = limit_bytes
199
+ self.size_bytes = 0
200
+ self.entry_by_key: OrderedDict[tuple[str, str], tuple[Commit, int]] = OrderedDict()
201
+
202
+ def get(self, repository_dir: str, oid: str) -> Commit | None:
203
+ """Reuse metadata only in the repository where Git calculated its abbreviation."""
204
+ key = (repository_dir, oid)
205
+ entry = self.entry_by_key.get(key)
206
+ if entry is None:
207
+ return None
208
+ self.entry_by_key.move_to_end(key)
209
+ commit, _size = entry
210
+ return commit
211
+
212
+ def put(self, repository_dir: str, commit: Commit) -> None:
213
+ """Charge repository keys and message storage to the same global eviction budget."""
214
+ key = (repository_dir, commit.oid)
215
+ # Reading the history again gives back the same commits: an unchanged entry only needs to become the most recent.
216
+ entry = self.entry_by_key.get(key)
217
+ if entry is not None:
218
+ cached_commit, _size = entry
219
+ if cached_commit == commit:
220
+ self.entry_by_key.move_to_end(key)
221
+ return
222
+ size = (sys.getsizeof(commit.message) + sys.getsizeof(commit.body_preview) + sys.getsizeof(commit.author_name) + sys.getsizeof(commit.author_email) + sys.getsizeof(commit)
223
+ + sys.getsizeof(key) + sys.getsizeof(repository_dir) + sys.getsizeof(commit.oid) + sys.getsizeof(commit.short_oid))
224
+ previous = self.entry_by_key.pop(key, None)
225
+ if previous is not None:
226
+ _, previous_size = previous
227
+ self.size_bytes -= previous_size
228
+ if size > self.limit_bytes:
229
+ return
230
+ while self.entry_by_key and self.size_bytes + size > self.limit_bytes:
231
+ _, (_, oldest_size) = self.entry_by_key.popitem(last=False)
232
+ self.size_bytes -= oldest_size
233
+ self.entry_by_key[key] = (commit, size)
234
+ self.size_bytes += size
235
+
236
+
237
+ @dataclass
238
+ class _StreamRead:
239
+ """Retain bounded chunks while Qt schedules the next parsing turn.
240
+
241
+ ``ready`` tells whether the chunks can be parsed yet; while it answers no, the chunk already received is kept,
242
+ Git's output stays paused and ``held_request`` remembers the request whose parsing resumes once it can.
243
+ """
244
+
245
+ consume: Callable[[bytes], None]
246
+ chunks: deque[bytes] = field(default_factory=deque)
247
+ size_bytes: int = 0
248
+ scheduled: bool = False
249
+ ready: Callable[[], bool] | None = None
250
+ held_request: ProcessRequest | None = None
251
+
252
+
253
+ @dataclass
254
+ class _CommitBatchRead:
255
+ """The two reads of one commit batch, started together: Git's abbreviations and the raw objects.
256
+
257
+ It only lets the two be stopped and forgotten together when one of them fails, is cancelled or no longer belongs
258
+ to the current request; it schedules nothing and caches nothing.
259
+ """
260
+
261
+ identity: RequestIdentity
262
+ request_id_set: set[str] = field(default_factory=set)
263
+ short_oid_by_oid: dict[str, str] | None = None
264
+ object_request_id: str = ""
265
+
266
+
267
+ class GitRepository(QObject):
268
+ """Translate repository reads into asynchronous Git commands without a shell."""
269
+
270
+ opened = Signal(object, object, object)
271
+ commit_read = Signal(object, object)
272
+ failed = Signal(object, object)
273
+ snapshot_read = Signal(object, object)
274
+ commit_batch_read = Signal(object, object)
275
+ matching_read = Signal(object, object, int, bool)
276
+ relation_read = Signal(object, object)
277
+ changes_read = Signal(object, object)
278
+ diff_read = Signal(object, object, bytes)
279
+ tree_read = Signal(object, object)
280
+ blob_read = Signal(object, object)
281
+ file_failed = Signal(object, object)
282
+ # Emitted when the last callback queued for the next turn of the event loop has run.
283
+ next_turn_work_done = Signal()
284
+
285
+ def __init__(self, git_filename: str, parent: QObject | None = None, runner: ProcessRunner | None = None,
286
+ backlog_limit_bytes: int = PARSING_BACKLOG_LIMIT_BYTES, object_limit_bytes: int = COMMIT_LIMIT_BYTES, cache_limit_bytes: int = MESSAGE_CACHE_LIMIT_BYTES) -> None:
287
+ """Keep executable selection and request ownership separate from widgets.
288
+
289
+ ``git_version`` is the ``git --version`` line of the chosen Git, empty until a check has read it; it says which
290
+ options that Git knows.
291
+ """
292
+ super().__init__(parent)
293
+ self.git_filename = git_filename
294
+ self.git_version = ""
295
+ self.runner = runner if runner is not None else ProcessRunner(self)
296
+ self.identity_by_session: dict[str, RequestIdentity] = {}
297
+ self.context_by_session: dict[str, RepositoryContext] = {}
298
+ self.callback_by_id: dict[str, Callable[[bytes], None]] = {}
299
+ self.identity_by_id: dict[str, RequestIdentity] = {}
300
+ self.output_by_id: dict[str, bytearray] = {}
301
+ self.result_callback_by_id: dict[str, tuple[Callable[[bytes, ProcessResult], None], tuple[int, ...]]] = {}
302
+ self.opening_head_read_by_session: dict[str, OpeningHeadRead] = {}
303
+ self.executable_by_session: dict[str, str] = {}
304
+ self.snapshot_by_session: dict[str, HistorySnapshot] = {}
305
+ self.selection_identity_by_session: dict[str, RequestIdentity] = {}
306
+ self.search_identity_by_session: dict[str, RequestIdentity] = {}
307
+ self.stream_by_id: dict[str, _StreamRead] = {}
308
+ # Both reads of a commit batch point at the same record, so either one can stop the other.
309
+ self.batch_read_by_request_id: dict[str, _CommitBatchRead] = {}
310
+ # The window session each callback queued for the next turn of the event loop and not run yet was queued for, by
311
+ # one token per callback: a cached answer about to be published, or the next page or slice of a read. No request
312
+ # map holds them, so this is what tells they are pending, and for which window; ``None`` when no window is known.
313
+ self.next_turn_session_id_by_token: dict[object, str | None] = {}
314
+ self.backlog_limit_bytes = backlog_limit_bytes
315
+ self.object_limit_bytes = object_limit_bytes
316
+ self.commit_cache = CommitCache(cache_limit_bytes)
317
+ self.relation_identity_by_session: dict[str, RequestIdentity] = {}
318
+ self.file_identity_by_key: dict[tuple[str, FilePanel], FileReadIdentity] = {}
319
+ self.file_identity_by_id: dict[str, FileReadIdentity] = {}
320
+ self.patch_by_key: OrderedDict[tuple[str, RevisionEntry, bool], bytes] = OrderedDict()
321
+ self.patch_cache_size_bytes = 0
322
+ self.blob_by_key: OrderedDict[tuple[str, str], tuple[BlobContent, int]] = OrderedDict()
323
+ self.blob_cache_size_bytes = 0
324
+ self.runner.stdout_chunk.connect(self._receive_chunk)
325
+ self.runner.finished.connect(self._receive_result)
326
+
327
+ def open_repository(self, repository_dir: str, request_identity: RequestIdentity) -> None:
328
+ """Resolve repository locations and load HEAD before reporting a successful opening.
329
+
330
+ The executable is not asked for its version here: the application checks it at startup and whenever the
331
+ setting changes, and a file that is not Git fails the location read below with its own diagnostic. One
332
+ command locates the repository and one reads the shallow flag with HEAD; only a location that cannot be
333
+ split with certainty, or HEAD without a commit, costs more.
334
+ """
335
+ self.cancel(request_identity.session_id)
336
+ self.identity_by_session[request_identity.session_id] = request_identity
337
+ git_filename = self.git_filename
338
+ self.executable_by_session[request_identity.session_id] = git_filename
339
+ repository_dir = os.path.abspath(repository_dir)
340
+
341
+ def located(output: bytes, result: ProcessResult) -> None:
342
+ """Accept a complete answer, or the bare repository's documented failure on its root, and nothing else."""
343
+ if result.return_code != SUCCESS_EXIT_CODE and not is_bare_location_answer(output):
344
+ self._emit_result_failure(request_identity, result)
345
+ return
346
+ context = parse_repository_location(output, result.return_code)
347
+ if context is None:
348
+ read_location_separately([])
349
+ return
350
+ read_head(context)
351
+
352
+ def read_location_separately(values: list[str]) -> None:
353
+ """Ask each location question on its own, removing only the line feed Git ends each answer with."""
354
+ answered_count = len(values)
355
+ if answered_count > BARE_FLAG_LOCATION_INDEX and (values[BARE_FLAG_LOCATION_INDEX] == GIT_TRUE or answered_count == len(REPOSITORY_LOCATION_OPTIONS)):
356
+ read_head(location_context(values))
357
+ return
358
+
359
+ def answered(output: bytes) -> None:
360
+ read_location_separately([*values, os.fsdecode(output.removesuffix(b"\n"))])
361
+
362
+ self._execute(repository_dir, ["rev-parse", ABSOLUTE_PATH_FORMAT_ARGUMENT, REPOSITORY_LOCATION_OPTIONS[answered_count]], request_identity, answered)
363
+
364
+ def read_head(context: RepositoryContext) -> None:
365
+ """Read the shallow flag and HEAD together; only HEAD without a commit asks Git for its branch name."""
366
+ self.context_by_session[request_identity.session_id] = context
367
+
368
+ def head_read(output: bytes, result: ProcessResult) -> None:
369
+ shallow, head_oid = parse_head_read(output, result.return_code)
370
+ if head_oid is not None:
371
+ self._read_commit(head_oid, request_identity, lambda commit: publish(OpeningHeadRead(request_identity, context, git_filename, shallow, head_oid), commit))
372
+ return
373
+
374
+ def symbolic_read(symbolic_output: bytes, symbolic_result: ProcessResult) -> None:
375
+ head_ref = parse_symbolic_head(symbolic_output, symbolic_result.return_code)
376
+ publish(OpeningHeadRead(request_identity, context, git_filename, shallow, None, head_ref), None)
377
+
378
+ self._execute_expecting(context.repository_dir, SYMBOLIC_HEAD_ARGUMENTS, request_identity, symbolic_read, SYMBOLIC_HEAD_EXPECTED_CODES)
379
+
380
+ self._execute_expecting(context.repository_dir, HEAD_READ_ARGUMENTS, request_identity, head_read, HEAD_READ_EXPECTED_CODES)
381
+
382
+ def publish(head_read: OpeningHeadRead, commit: Commit | None) -> None:
383
+ """Keep what this opening read for its own first history capture, then report the opening."""
384
+ self.opening_head_read_by_session[request_identity.session_id] = head_read
385
+ self.opened.emit(request_identity, head_read.context, commit)
386
+
387
+ self._execute_expecting(repository_dir, REPOSITORY_LOCATION_ARGUMENTS, request_identity, located, (SUCCESS_EXIT_CODE, BARE_ROOT_EXIT_CODE))
388
+
389
+ def read_commit(self, oid: str, request_identity: RequestIdentity, verify_exists: bool = False) -> None:
390
+ """Read a commit into the same shared value used by the initial view."""
391
+ validate_oid(oid)
392
+ current = self.identity_by_session.get(request_identity.session_id)
393
+ if current is None or current.generation != request_identity.generation:
394
+ self.cancel(request_identity.session_id)
395
+ self.identity_by_session[request_identity.session_id] = request_identity
396
+ self.executable_by_session[request_identity.session_id] = self.git_filename
397
+ self.selection_identity_by_session[request_identity.session_id] = request_identity
398
+ for request_id, pending_identity in tuple(self.identity_by_id.items()):
399
+ if pending_identity.session_id == request_identity.session_id and pending_identity.oid is not None and pending_identity != request_identity:
400
+ self.runner.cancel(request_id)
401
+ def read_verified(_output: bytes = b"") -> None:
402
+ """Use cached metadata only after any requested object-presence check."""
403
+ cached = self.commit_cache.get(self.context_by_session[request_identity.session_id].git_dir, oid)
404
+ if cached is not None and not cached.detail_error:
405
+ self._call_on_next_turn(lambda: self.commit_read.emit(request_identity, cached) if self._is_current(request_identity) else None, request_identity.session_id)
406
+ return
407
+ self._read_commit(oid, request_identity, lambda commit: self.commit_read.emit(request_identity, commit))
408
+
409
+ if verify_exists:
410
+ context = self.context_by_session[request_identity.session_id]
411
+ self._execute(context.repository_dir, ["rev-parse", "--verify", oid + COMMIT_PEEL_SUFFIX], request_identity, read_verified)
412
+ else:
413
+ read_verified()
414
+
415
+ def read_relation(self, commit: Commit, snapshot: HistorySnapshot | None, identity: RequestIdentity) -> None:
416
+ """Read direct relatives independently of displayed pages, preserving captured order."""
417
+ self.relation_identity_by_session[identity.session_id] = identity
418
+ complete = snapshot is not None and snapshot.complete and not snapshot.error
419
+ child_oids = []
420
+ if snapshot is not None:
421
+ child_oid_set = set(snapshot.child_oids_by_oid.get(commit.oid, []))
422
+ for oid in snapshot.ordered_oids:
423
+ if oid in child_oid_set:
424
+ child_oids.append(oid)
425
+ for explicit_commit in snapshot.explicit_commits:
426
+ if commit.oid in explicit_commit.parent_oids and explicit_commit.oid not in child_oid_set:
427
+ child_oids.append(explicit_commit.oid)
428
+ relation_oids = list(dict.fromkeys([*commit.parent_oids, *child_oids]))
429
+ link_by_oid: dict[str, CommitLink] = {}
430
+ context = self.context_by_session[identity.session_id]
431
+ # The history already read the full commits it shows: only the relatives it never read are asked of Git. The
432
+ # cache is display data here, never a proof that an object still exists.
433
+ missing_oids = []
434
+ for oid in relation_oids:
435
+ cached = self.commit_cache.get(context.git_dir, oid)
436
+ if cached is None:
437
+ missing_oids.append(oid)
438
+ else:
439
+ link_by_oid[oid] = relation_link(cached)
440
+ offset = 0
441
+
442
+ def publish() -> None:
443
+ """Show the relatives in their captured order, once, for a still current request."""
444
+ if not self._is_current(identity):
445
+ return
446
+ parents = []
447
+ child_links = []
448
+ for oid in commit.parent_oids:
449
+ parents.append(link_by_oid[oid])
450
+ for oid in child_oids:
451
+ child_links.append(link_by_oid[oid])
452
+ self.relation_read.emit(identity, CommitRelation(commit.oid, parents, child_links, complete))
453
+
454
+ def next_page() -> None:
455
+ """Bound object reads and yield between pages of unusually large ancestry."""
456
+ nonlocal offset
457
+ if not self._is_current(identity):
458
+ return
459
+ if offset >= len(missing_oids):
460
+ publish()
461
+ return
462
+ page_oids = missing_oids[offset:offset + COMMIT_BATCH_SIZE]
463
+ offset += len(page_oids)
464
+
465
+ def presence_read(output: bytes) -> None:
466
+ type_by_oid = parse_presence_answer(output, page_oids)
467
+ existing_oids = []
468
+ for oid in page_oids:
469
+ if type_by_oid[oid] != "commit":
470
+ link_by_oid[oid] = CommitLink(oid, "", "Object unavailable", False)
471
+ continue
472
+ cached = self.commit_cache.get(context.git_dir, oid)
473
+ if cached is not None:
474
+ link_by_oid[oid] = relation_link(cached)
475
+ else:
476
+ existing_oids.append(oid)
477
+ if not existing_oids:
478
+ self._call_on_next_turn(next_page, identity.session_id)
479
+ return
480
+
481
+ def consume(relative: Commit) -> None:
482
+ """Retain only the full subject, while the common cache bounds message bodies."""
483
+ link_by_oid[relative.oid] = relation_link(relative)
484
+
485
+ self.request_commit_batch(existing_oids, identity, lambda _commits: self._call_on_next_turn(next_page, identity.session_id), consume)
486
+
487
+ stdin = ("\n".join(page_oids) + "\n").encode("ascii")
488
+ self._execute(context.repository_dir, ["cat-file", "--batch-check=%(objectname) %(objecttype)"], identity, presence_read, GIT_READ_TIMEOUT_MS, stdin=stdin)
489
+
490
+ if not missing_oids:
491
+ # Published on the next turn, like every other answer, so the caller has finished recording its request.
492
+ self._call_on_next_turn(publish, identity.session_id)
493
+ return
494
+ next_page()
495
+
496
+ def read_snapshot(self, identity: RequestIdentity, committed_parent_oid: str | None = None, *, reference_snapshot: ReferenceSnapshot | None = None) -> None:
497
+ """Capture refs and HEAD, optionally including an available unnamed parent gitlink.
498
+
499
+ One listing of the references and one read of the shallow flag with HEAD make a capture. A reference capture
500
+ taken for this very window, repository, Git and generation replaces the listing, and the first capture after an
501
+ opening reuses the HEAD that opening read. Neither is ever used as a reason to write anything.
502
+ """
503
+ if committed_parent_oid is not None:
504
+ validate_oid(committed_parent_oid)
505
+ # Taken before the cancellation below, which forgets what any earlier request of this window read.
506
+ opening_head_read = self.opening_head_read_by_session.pop(identity.session_id, None)
507
+ self.cancel(identity.session_id)
508
+ self.identity_by_session[identity.session_id] = identity
509
+ git_filename = self.git_filename
510
+ self.executable_by_session[identity.session_id] = git_filename
511
+ context = self.context_by_session[identity.session_id]
512
+ if opening_head_read is not None and (opening_head_read.identity != identity or opening_head_read.context != context or opening_head_read.git_filename != git_filename):
513
+ opening_head_read = None
514
+ if reference_snapshot is not None and not reference_snapshot_fits(reference_snapshot, identity, context, git_filename):
515
+ reference_snapshot = None
516
+
517
+ def start() -> None:
518
+ """Begin on the next turn of the event loop, so even a capture that needs no process never answers inside this call."""
519
+ if not self._is_current(identity):
520
+ return
521
+ try:
522
+ self._capture(identity, context, 1, reference_snapshot, opening_head_read, committed_parent_oid)
523
+ except (ValueError, KeyError, UnicodeError, OSError) as error:
524
+ self.failed.emit(identity, RepositoryFailure("Read Git", context.repository_dir, git_filename, [], str(error)))
525
+
526
+ self._call_on_next_turn(start, identity.session_id)
527
+
528
+ def _read_tag_targets(self, oids: list[str], identity: RequestIdentity, callback: Callable[[dict[str, str | None]], None]) -> None:
529
+ """Peel nested tags in bounded batches and identify replies by the original object ID."""
530
+ target_by_oid: dict[str, str | None] = {}
531
+ offset = 0
532
+
533
+ def next_page() -> None:
534
+ """Yield between batches so large ref collections remain cancellable."""
535
+ nonlocal offset
536
+ if not self._is_current(identity):
537
+ return
538
+ page_oids = oids[offset:offset + COMMIT_BATCH_SIZE]
539
+ if not page_oids:
540
+ callback(target_by_oid)
541
+ return
542
+ offset += len(page_oids)
543
+ pending_oid_set = set(page_oids)
544
+ expressions = []
545
+ for oid in page_oids:
546
+ expressions.append(f"{validate_oid(oid)}^{{}} {oid}")
547
+
548
+ def receive(output: bytes) -> None:
549
+ """Keep blob/tree tags non-navigable and reject incomplete or unexpected responses."""
550
+ for line in output.decode("ascii").splitlines():
551
+ if line.endswith(" missing"):
552
+ expression, _status = line.split(" ", 1)
553
+ oid = validate_oid(expression.removesuffix("^{}"))
554
+ target = None
555
+ else:
556
+ oid, target_oid, object_type = line.split("\t")
557
+ validate_oid(oid)
558
+ validate_oid(target_oid)
559
+ target = target_oid if object_type == "commit" else None
560
+ if oid not in pending_oid_set:
561
+ raise ValueError("Unexpected tag lookup result")
562
+ pending_oid_set.remove(oid)
563
+ target_by_oid[oid] = target
564
+ if pending_oid_set:
565
+ raise ValueError("Incomplete tag lookup results")
566
+ self._call_on_next_turn(next_page, identity.session_id)
567
+
568
+ context = self.context_by_session[identity.session_id]
569
+ self._execute(context.repository_dir, ["cat-file", "--batch-check=%(rest)\t%(objectname)\t%(objecttype)"], identity, receive,
570
+ GIT_READ_TIMEOUT_MS, stdin=("\n".join(expressions) + "\n").encode("ascii"))
571
+
572
+ next_page()
573
+
574
+ def _capture(self, identity: RequestIdentity, context: RepositoryContext, attempt_number: int, shared: ReferenceSnapshot | None,
575
+ opening_head_read: OpeningHeadRead | None, committed_parent_oid: str | None) -> None:
576
+ """Gather one reference listing and one HEAD read that agree, then publish the history's references.
577
+
578
+ The listing is the shared capture when one was given, otherwise one for-each-ref in the common format. HEAD
579
+ comes from the opening's read when there is one, otherwise from one grouped rev-parse. When HEAD disagrees with
580
+ the listing, HEAD kept from the opening is dropped and read again first; answers that still disagree are all read
581
+ again, a bounded number of times, and then reported instead of being assembled into a history.
582
+ """
583
+
584
+ def read_again() -> None:
585
+ if attempt_number >= SNAPSHOT_RETRY_LIMIT:
586
+ raise ValueError("The repository changed while reading; references not refreshed")
587
+ self._capture(identity, context, attempt_number + 1, None, None, committed_parent_oid)
588
+
589
+ def read_head(receive: Callable[[bool, str | None, bool], None]) -> None:
590
+ self._execute_expecting(context.repository_dir, HEAD_READ_ARGUMENTS, identity,
591
+ lambda output, result: receive(*parse_head_read(output, result.return_code), False), HEAD_READ_EXPECTED_CODES)
592
+
593
+ def begin(receive: Callable[[bool, str | None, bool], None]) -> None:
594
+ if opening_head_read is None:
595
+ read_head(receive)
596
+ return
597
+ receive(opening_head_read.shallow, opening_head_read.head_oid, True)
598
+
599
+ if shared is not None:
600
+ shared_read = shared.reference_read
601
+
602
+ def shared_head(shallow: bool, head_oid: str | None, from_opening: bool) -> None:
603
+ """Use the shared capture only if HEAD has not moved since it was taken; otherwise list the references afresh."""
604
+ opening_disagrees = from_opening and opening_head_read.head_ref is not None and opening_head_read.head_ref != shared.head_ref
605
+ if head_oid == shared.head_oid and not opening_disagrees:
606
+ self._complete_capture(identity, shared_read, shared.references, shallow, head_oid, shared.head_ref, shared, committed_parent_oid)
607
+ return
608
+ if from_opening:
609
+ read_head(shared_head)
610
+ return
611
+ read_again()
612
+
613
+ begin(shared_head)
614
+ return
615
+
616
+ def listed(output: bytes) -> None:
617
+ listing = parse_reference_read(output)
618
+
619
+ def listed_head(shallow: bool, head_oid: str | None, from_opening: bool) -> None:
620
+ """A branch marked current must hold the very commit HEAD resolves to; a listing without a mark means a detached or unborn HEAD."""
621
+ if listing.head_ref is None or head_oid == listing.head_oid:
622
+ self._complete_capture(identity, listing, listing.references, shallow, head_oid, listing.head_ref, None, committed_parent_oid)
623
+ return
624
+ if from_opening:
625
+ read_head(listed_head)
626
+ return
627
+ read_again()
628
+
629
+ begin(listed_head)
630
+
631
+ self._execute(context.repository_dir, ["for-each-ref", "--format=" + REFERENCE_FORMAT, *SHOWN_REFERENCE_PREFIXES], identity, listed)
632
+
633
+ def _complete_capture(self, identity: RequestIdentity, read: ReferenceRead, references: tuple[Reference, ...], shallow: bool, head_oid: str | None,
634
+ head_ref: str | None, shared: ReferenceSnapshot | None, committed_parent_oid: str | None) -> None:
635
+ """Follow only the tags the listing left as tags, then publish the references the graph shows and start its walk.
636
+
637
+ Branches, remote branches and tags are shown; any other reference a shared capture lists is not. A committed
638
+ parent that no listed reference proves to be a commit is checked in the same bounded lookup.
639
+ """
640
+ shown_references: list[Reference] = []
641
+ commit_oid_set: set[str] = set()
642
+ for reference in references:
643
+ if reference.full_name.startswith(SHOWN_REFERENCE_PREFIXES):
644
+ shown_references.append(reference)
645
+ if reference.target_oid is not None:
646
+ commit_oid_set.add(reference.target_oid)
647
+ unresolved_tag_oid_by_name: dict[str, str] = {}
648
+ for full_name, tag_oid in read.unresolved_tag_oid_by_name.items():
649
+ if full_name.startswith(SHOWN_REFERENCE_PREFIXES):
650
+ unresolved_tag_oid_by_name[full_name] = tag_oid
651
+ parent_proven = committed_parent_oid is not None and committed_parent_oid in commit_oid_set
652
+ lookup_oid_set = set(unresolved_tag_oid_by_name.values())
653
+ if committed_parent_oid is not None and not parent_proven:
654
+ lookup_oid_set.add(committed_parent_oid)
655
+
656
+ def resolved(target_by_oid: dict[str, str | None]) -> None:
657
+ """Attach each peeled commit to its tag, then publish one history from this single observation."""
658
+ history_references: list[Reference] = []
659
+ for reference in shown_references:
660
+ tag_oid = unresolved_tag_oid_by_name.get(reference.full_name)
661
+ history_references.append(reference if tag_oid is None else replace(reference, target_oid=target_by_oid[tag_oid]))
662
+ if head_oid is not None:
663
+ history_references.append(Reference("HEAD", ReferenceKind.HEAD, head_oid, head_ref, True))
664
+ history_references.sort(key=lambda reference: (reference.kind, reference.full_name))
665
+ tip_oid_set: set[str] = set()
666
+ if parent_proven or committed_parent_oid is not None and target_by_oid.get(committed_parent_oid) == committed_parent_oid:
667
+ tip_oid_set.add(committed_parent_oid)
668
+ for reference in history_references:
669
+ if reference.target_oid is not None:
670
+ tip_oid_set.add(reference.target_oid)
671
+ snapshot = HistorySnapshot(identity.generation, history_references, sorted(tip_oid_set), head_oid, shallow=shallow, reference_snapshot=shared)
672
+ self.snapshot_by_session[identity.session_id] = snapshot
673
+ self.snapshot_read.emit(identity, snapshot)
674
+ self._walk_snapshot(identity, snapshot)
675
+
676
+ self._read_tag_targets(sorted(lookup_oid_set), identity, resolved)
677
+
678
+ def _walk_snapshot(self, identity: RequestIdentity, snapshot: HistorySnapshot) -> None:
679
+ """Read the whole history with one streamed git log: ancestry in Git's topological order and every row with it.
680
+
681
+ The rows of the first parsed chunk are published at once; later rows are gathered and published at most once
682
+ per short interval, each time followed by the grown history, so the window redraws a few times rather than once
683
+ per chunk. The complete history is published last, after the last rows, so the graph is finished once every
684
+ row is there. A shallow history holds its rows until the end of the read, when the boundary commits get their
685
+ real parents.
686
+ """
687
+ parser = HistoryLogParser(snapshot, self.object_limit_bytes, COMMIT_HEADER_LIMIT_BYTES)
688
+ git_dir = self.context_by_session[identity.session_id].git_dir
689
+ held_commits: list[Commit] = []
690
+ published_at: float | None = None
691
+
692
+ def publish() -> None:
693
+ """Cache the held full commits and hand their rows to the window."""
694
+ nonlocal published_at
695
+ summaries: list[Commit] = []
696
+ for commit in held_commits:
697
+ self.commit_cache.put(git_dir, commit)
698
+ summaries.append(summarize_commit(commit))
699
+ held_commits.clear()
700
+ published_at = monotonic()
701
+ if summaries:
702
+ self.commit_batch_read.emit(identity, summaries)
703
+
704
+ def consume(chunk: bytes) -> None:
705
+ held_commits.extend(parser.feed(chunk))
706
+ if snapshot.shallow or published_at is not None and monotonic() - published_at < HISTORY_PUBLISH_INTERVAL_MS / MILLISECONDS_PER_SECOND:
707
+ return
708
+ publish()
709
+ self.snapshot_read.emit(identity, snapshot)
710
+
711
+ def complete() -> None:
712
+ publish()
713
+ parser.finish()
714
+ self.snapshot_read.emit(identity, snapshot)
715
+
716
+ def finish(_output: bytes) -> None:
717
+ parser.check_ended()
718
+ if snapshot.shallow:
719
+ self._read_boundary_parents(identity, snapshot, held_commits, complete)
720
+ return
721
+ complete()
722
+
723
+ if not snapshot.tip_oids:
724
+ finish(b"")
725
+ return
726
+ self._execute(self.context_by_session[identity.session_id].repository_dir, HISTORY_LOG_ARGUMENTS, identity, finish, HISTORY_STREAM_TIMEOUT_MS,
727
+ stdin=("\n".join(snapshot.tip_oids) + "\n").encode("ascii"), stdout_limit_bytes=None, stream_callback=consume)
728
+
729
+ def _read_boundary_parents(self, identity: RequestIdentity, snapshot: HistorySnapshot, commits: list[Commit], callback: Callable[[], None]) -> None:
730
+ """Give the commits at the edge of a shallow history the parents their objects really name, then call back.
731
+
732
+ Git leaves out a parent it does not have, so at the boundary of a shallow clone the log shows a commit
733
+ without parents although its object names one. The history keeps that parent as an explicit boundary, as a
734
+ raw object read does, so the commits the log showed without parents are read raw, in one read, and only in a
735
+ shallow history.
736
+ """
737
+ rootless_oids: list[str] = []
738
+ for commit in commits:
739
+ if not commit.parent_oids:
740
+ rootless_oids.append(commit.oid)
741
+ if not rootless_oids:
742
+ callback()
743
+ return
744
+ object_parser = CatFileParser(rootless_oids, self.object_limit_bytes + COMMIT_HEADER_LIMIT_BYTES, self.backlog_limit_bytes)
745
+ parent_oids_by_oid: dict[str, list[str]] = {}
746
+
747
+ def consume(chunk: bytes) -> None:
748
+ for oid, raw in object_parser.feed(chunk):
749
+ if raw is not None:
750
+ parent_oids_by_oid[oid] = parse_commit(oid, raw, oid).parent_oids
751
+
752
+ def objects_read(_output: bytes) -> None:
753
+ object_parser.finish()
754
+ for commit_index, commit in enumerate(commits):
755
+ parent_oids = parent_oids_by_oid.get(commit.oid)
756
+ if not parent_oids:
757
+ continue
758
+ commits[commit_index] = replace(commit, parent_oids=parent_oids)
759
+ snapshot.parent_oids_by_oid[commit.oid] = parent_oids
760
+ for parent_oid in parent_oids:
761
+ snapshot.child_oids_by_oid.setdefault(parent_oid, []).append(commit.oid)
762
+ callback()
763
+
764
+ self._execute(self.context_by_session[identity.session_id].repository_dir, ["cat-file", "--batch"], identity, objects_read, GIT_READ_TIMEOUT_MS,
765
+ stdin=("\n".join(rootless_oids) + "\n").encode("ascii"), stdout_limit_bytes=None, stream_callback=consume)
766
+
767
+ def request_commit_batch(self, oids: list[str], identity: RequestIdentity, callback: Callable[[list[Commit]], None] | None = None,
768
+ consume_commit: Callable[[Commit], None] | None = None) -> None:
769
+ """Associate raw objects and Git abbreviations by OID, preserving requested order.
770
+
771
+ Both reads start together, the abbreviations first so that a window's process slots never hold an object read
772
+ waiting for an abbreviation read that cannot start. Objects arriving before the abbreviations wait in their
773
+ first chunk with Git's output paused, and are parsed once the abbreviations are known. The batch publishes once,
774
+ after both reads ended; a failure of either stops the other.
775
+ """
776
+ for oid in oids:
777
+ validate_oid(oid)
778
+ if len(oids) > COMMIT_BATCH_SIZE:
779
+ raise ValueError("Commit batch is too large")
780
+ if not oids or not self._is_current(identity):
781
+ return
782
+ context = self.context_by_session[identity.session_id]
783
+ stdin = ("\n".join(oids) + "\n").encode("ascii")
784
+ batch = _CommitBatchRead(identity)
785
+ parser = CatFileParser(oids, self.object_limit_bytes + COMMIT_HEADER_LIMIT_BYTES, self.backlog_limit_bytes)
786
+ commit_by_oid: dict[str, Commit] = {}
787
+
788
+ def abbreviations_read(output: bytes) -> None:
789
+ """Keep the abbreviations, then let the objects already received be parsed."""
790
+ batch.short_oid_by_oid = parse_abbreviation_answer(output, oids)
791
+ self._resume_held_stream(batch.object_request_id)
792
+
793
+ def consume(chunk: bytes) -> None:
794
+ """Turn every complete object into a commit; the stream only reaches here once the abbreviations are known."""
795
+ short_oid_by_oid = batch.short_oid_by_oid
796
+ if short_oid_by_oid is None:
797
+ raise ValueError("Commit abbreviations missing")
798
+ for oid, raw in parser.feed(chunk):
799
+ header, _separator, body = raw.partition(b"\n\n") if raw is not None else (b"", b"", b"")
800
+ if raw is None or len(body) > self.object_limit_bytes or len(header) > COMMIT_HEADER_LIMIT_BYTES:
801
+ snapshot = self.snapshot_by_session.get(identity.session_id)
802
+ parent_oids = snapshot.parent_oids_by_oid.get(oid, []) if snapshot else []
803
+ commit = unavailable_commit(oid, short_oid_by_oid[oid], parent_oids, oversized_commit_cause(self.object_limit_bytes))
804
+ else:
805
+ commit = parse_commit(oid, raw, short_oid_by_oid[oid])
806
+ snapshot = self.snapshot_by_session.get(identity.session_id)
807
+ if snapshot is not None and oid in snapshot.parent_oids_by_oid:
808
+ snapshot.parent_oids_by_oid[oid] = commit.parent_oids
809
+ for parent_oid in commit.parent_oids:
810
+ child_oids = snapshot.child_oids_by_oid.setdefault(parent_oid, [])
811
+ if oid not in child_oids:
812
+ child_oids.append(oid)
813
+ self.commit_cache.put(context.git_dir, commit)
814
+ if consume_commit is not None:
815
+ consume_commit(commit)
816
+ continue
817
+ if callback is not None:
818
+ commit_by_oid[oid] = commit
819
+ else:
820
+ commit_by_oid[oid] = summarize_commit(commit)
821
+
822
+ def objects_read(output: bytes) -> None:
823
+ """Publish the whole batch in the requested order, once both reads have answered."""
824
+ if batch.short_oid_by_oid is None:
825
+ raise ValueError("Commit abbreviations missing")
826
+ parser.finish()
827
+ commits = []
828
+ if consume_commit is None:
829
+ for oid in oids:
830
+ commits.append(commit_by_oid[oid])
831
+ if callback is not None:
832
+ callback(commits)
833
+ else:
834
+ self.commit_batch_read.emit(identity, commits)
835
+
836
+ self._execute(context.repository_dir, ["log", "--no-walk=unsorted", "--no-show-signature", "--format=%H%x00%h", "--stdin"], identity,
837
+ abbreviations_read, GIT_READ_TIMEOUT_MS, stdin=stdin, batch=batch)
838
+ batch.object_request_id = self._execute(context.repository_dir, ["cat-file", "--batch"], identity, objects_read, GIT_READ_TIMEOUT_MS, stdin=stdin,
839
+ stdout_limit_bytes=None, stream_callback=consume, batch=batch, ready=lambda: batch.short_oid_by_oid is not None)
840
+
841
+ def cancel_search(self, session_id: str) -> None:
842
+ """Abandon history searches without stopping history or selection reads."""
843
+ self.search_identity_by_session.pop(session_id, None)
844
+ for request_id, identity in tuple(self.identity_by_id.items()):
845
+ if identity.session_id == session_id and identity.search_generation:
846
+ self.runner.cancel(request_id)
847
+
848
+ def read_filter_matches(self, snapshot: HistorySnapshot, text: str, mode: HistoryFilterMode, identity: RequestIdentity) -> None:
849
+ """Run the search the window's filter mode asks for: on the changed file names, or on the messages.
850
+
851
+ An empty text shows every commit in both modes, which the message search answers without a Git read when the
852
+ commits are cached.
853
+ """
854
+ if mode == HistoryFilterMode.FILES and text:
855
+ self.read_changed_path_matching_oids(snapshot, text, identity)
856
+ return
857
+ self.read_matching_oids(snapshot, text, identity)
858
+
859
+ def read_matching_oids(self, snapshot: HistorySnapshot, text: str, identity: RequestIdentity) -> None:
860
+ """Check every commit of the shown history against the query, then emit the matches once, in the history's order.
861
+
862
+ When the cache still holds every commit of the history, the messages are checked from there with no Git read.
863
+ Otherwise the whole history is read once more by the same streamed git log as the history itself. Progress is
864
+ emitted while checking; a new query cancels this one through its search generation.
865
+ """
866
+ self.cancel_search(identity.session_id)
867
+ self.search_identity_by_session[identity.session_id] = identity
868
+ query = TextQuery(text)
869
+
870
+ def begin() -> None:
871
+ """Read the matching commits if this search is still the window's latest."""
872
+ if not self._is_current(identity):
873
+ return
874
+ self._read_matching_commits(snapshot, lambda commit: commit_matches_query(commit, query, text), identity)
875
+
876
+ self._call_on_next_turn(begin, identity.session_id)
877
+
878
+ def read_changed_path_matching_oids(self, snapshot: HistorySnapshot, text: str, identity: RequestIdentity) -> None:
879
+ """Keep the commits of the shown history that change a file whose path matches the query, then emit them once, in the history's order.
880
+
881
+ One git diff-tree reads the changed paths of every commit, fed with the shown history's OIDs: a merge counts
882
+ with its changes against each parent, a rename with both names. The rows of the matches then come from the
883
+ cache, or from one more streamed history read when any is missing. A new query cancels this one through its
884
+ search generation.
885
+ """
886
+ self.cancel_search(identity.session_id)
887
+ self.search_identity_by_session[identity.session_id] = identity
888
+ query = TextQuery(text)
889
+
890
+ def begin() -> None:
891
+ """Read the changed paths if this search is still the window's latest."""
892
+ if not self._is_current(identity):
893
+ return
894
+ self._stream_changed_path_matches(snapshot, query, identity)
895
+
896
+ self._call_on_next_turn(begin, identity.session_id)
897
+
898
+ def _stream_changed_path_matches(self, snapshot: HistorySnapshot, query: TextQuery, identity: RequestIdentity) -> None:
899
+ """Read the changed paths of every commit of the shown history in one streamed process, then read the rows of the matching ones.
900
+
901
+ A path is matched on the text the diff panel shows for it. A commit Git answers that the history does not hold,
902
+ or a requested commit Git leaves unanswered, fails the search instead of publishing a wrong list.
903
+ """
904
+ if not snapshot.ordered_oids:
905
+ self.matching_read.emit(identity, [], 0, True)
906
+ return
907
+ context = self.context_by_session[identity.session_id]
908
+ requested_oid_set = set(snapshot.ordered_oids)
909
+ answered_oid_set: set[str] = set()
910
+ matched_oid_set: set[str] = set()
911
+ parser = ChangedPathParser()
912
+ reported_at: float | None = None
913
+
914
+ def check(records: list[tuple[str, list[bytes]]]) -> None:
915
+ """Note every answered commit and keep it when one of its paths matches."""
916
+ for oid, paths in records:
917
+ if oid not in requested_oid_set:
918
+ raise ValueError("Unexpected changed-file answer")
919
+ answered_oid_set.add(oid)
920
+ if oid in matched_oid_set:
921
+ continue
922
+ for path in paths:
923
+ if query.matches(path.decode("utf-8", "replace")):
924
+ matched_oid_set.add(oid)
925
+ break
926
+
927
+ def consume(chunk: bytes) -> None:
928
+ """Check the records this chunk completes, then report progress at most once per publishing interval."""
929
+ nonlocal reported_at
930
+ check(parser.feed(chunk))
931
+ if reported_at is None or monotonic() - reported_at >= HISTORY_PUBLISH_INTERVAL_MS / MILLISECONDS_PER_SECOND:
932
+ reported_at = monotonic()
933
+ self.matching_read.emit(identity, [], len(answered_oid_set), False)
934
+
935
+ def finish(_output: bytes) -> None:
936
+ """Refuse an answer that left a commit out, then read the rows of the matching commits."""
937
+ check(parser.finish())
938
+ if answered_oid_set != requested_oid_set:
939
+ raise ValueError("Incomplete changed-file answer")
940
+ if not self._is_current(identity):
941
+ return
942
+ self._read_matching_commits(snapshot, lambda commit: commit.oid in matched_oid_set, identity)
943
+
944
+ self._execute(context.repository_dir, CHANGED_PATH_ARGUMENTS, identity, finish, HISTORY_STREAM_TIMEOUT_MS,
945
+ stdin=("\n".join(snapshot.ordered_oids) + "\n").encode("ascii"), stdout_limit_bytes=None, stream_callback=consume)
946
+
947
+ def _read_matching_commits(self, snapshot: HistorySnapshot, matches: Callable[[Commit], bool], identity: RequestIdentity) -> None:
948
+ """Keep the commits of the shown history that ``matches`` accepts, from the cache when it holds them all, or by reading the history once more."""
949
+ git_dir = self.context_by_session[identity.session_id].git_dir
950
+ cached_commits: list[Commit] = []
951
+ for oid in snapshot.ordered_oids:
952
+ commit = self.commit_cache.get(git_dir, oid)
953
+ if commit is None:
954
+ self._stream_matching_commits(snapshot, matches, identity)
955
+ return
956
+ cached_commits.append(commit)
957
+ self._scan_cached_matching_commits(cached_commits, matches, identity)
958
+
959
+ def _scan_cached_matching_commits(self, commits: list[Commit], matches: Callable[[Commit], bool], identity: RequestIdentity) -> None:
960
+ """Check cached commits, already in the history's order, a short time slice at a time so the window stays responsive.
961
+
962
+ Only the message check can refuse a commit, when its message could not be read; the failure is reported as such.
963
+ """
964
+ matching_commits: list[Commit] = []
965
+ scanned_count = 0
966
+
967
+ def scan_slice() -> None:
968
+ """Check commits until the time slice ends, then report progress and yield, or publish every match at the end."""
969
+ nonlocal scanned_count
970
+ if not self._is_current(identity):
971
+ return
972
+ deadline = monotonic() + PARSING_BUDGET_MS / MILLISECONDS_PER_SECOND
973
+ try:
974
+ while scanned_count < len(commits) and monotonic() < deadline:
975
+ commit = commits[scanned_count]
976
+ scanned_count += 1
977
+ if matches(commit):
978
+ matching_commits.append(summarize_commit(commit))
979
+ except ValueError as error:
980
+ self.failed.emit(identity, RepositoryFailure("Search messages", self.context_by_session[identity.session_id].repository_dir,
981
+ self.executable_by_session[identity.session_id], [], str(error)))
982
+ return
983
+ if scanned_count < len(commits):
984
+ self.matching_read.emit(identity, [], scanned_count, False)
985
+ self._call_on_next_turn(scan_slice, identity.session_id)
986
+ return
987
+ self.matching_read.emit(identity, matching_commits, scanned_count, True)
988
+
989
+ scan_slice()
990
+
991
+ def _stream_matching_commits(self, snapshot: HistorySnapshot, matches: Callable[[Commit], bool], identity: RequestIdentity) -> None:
992
+ """Read the whole history with the history's own streamed git log and keep the commits that ``matches`` accepts.
993
+
994
+ The read fills a private history, never the one the window shows. Only commits of the shown history count, and
995
+ they are checked against its size at the end, so a history that moved meanwhile fails the search instead of
996
+ publishing a wrong list. The matches are put in the shown history's order, whatever order Git wrote them in.
997
+ """
998
+ parser = HistoryLogParser(HistorySnapshot(snapshot.generation, [], list(snapshot.tip_oids), snapshot.head_oid), self.object_limit_bytes,
999
+ COMMIT_HEADER_LIMIT_BYTES)
1000
+ context = self.context_by_session[identity.session_id]
1001
+ matching_commits: list[Commit] = []
1002
+ scanned_count = 0
1003
+ reported_at: float | None = None
1004
+
1005
+ def consume(chunk: bytes) -> None:
1006
+ """Check the commits this chunk completes, then report progress at most once per publishing interval."""
1007
+ nonlocal scanned_count, reported_at
1008
+ for commit in parser.feed(chunk):
1009
+ parent_oids = snapshot.parent_oids_by_oid.get(commit.oid)
1010
+ if parent_oids is None:
1011
+ continue
1012
+ # The shown history is the reference for the parents: at the edge of a shallow clone it keeps the
1013
+ # parent Git leaves out of its log.
1014
+ if commit.parent_oids != parent_oids:
1015
+ commit = replace(commit, parent_oids=parent_oids)
1016
+ scanned_count += 1
1017
+ self.commit_cache.put(context.git_dir, commit)
1018
+ if matches(commit):
1019
+ matching_commits.append(summarize_commit(commit))
1020
+ if reported_at is None or monotonic() - reported_at >= HISTORY_PUBLISH_INTERVAL_MS / MILLISECONDS_PER_SECOND:
1021
+ reported_at = monotonic()
1022
+ self.matching_read.emit(identity, [], scanned_count, False)
1023
+
1024
+ def finish(_output: bytes) -> None:
1025
+ """Refuse a history that moved while searching, then publish the matches in the shown history's order."""
1026
+ parser.finish()
1027
+ if scanned_count != len(snapshot.ordered_oids):
1028
+ raise ValueError("The history changed while searching. Refresh to search again.")
1029
+ position_by_oid: dict[str, int] = {}
1030
+ for position, oid in enumerate(snapshot.ordered_oids):
1031
+ position_by_oid[oid] = position
1032
+ matching_commits.sort(key=lambda commit: position_by_oid[commit.oid])
1033
+ self.matching_read.emit(identity, matching_commits, scanned_count, True)
1034
+
1035
+ if not snapshot.tip_oids:
1036
+ finish(b"")
1037
+ return
1038
+ self._execute(context.repository_dir, HISTORY_LOG_ARGUMENTS, identity, finish, HISTORY_STREAM_TIMEOUT_MS,
1039
+ stdin=("\n".join(snapshot.tip_oids) + "\n").encode("ascii"), stdout_limit_bytes=None, stream_callback=consume)
1040
+
1041
+ def read_history_comparisons(self, comparisons: list[tuple[str | None, str]], path: bytes, identity: FileReadIdentity,
1042
+ callback: Callable[[dict[tuple[str | None, str], list[RevisionEntry]]], None]) -> None:
1043
+ """Batch captured parent edges, expanding only path boundaries for complete rename detection."""
1044
+ if not comparisons or len(comparisons) > FILE_HISTORY_BATCH_SIZE or len(set(comparisons)) != len(comparisons):
1045
+ raise ValueError("Invalid file-history comparison batch")
1046
+ if not path or b"\0" in path:
1047
+ raise ValueError("Invalid historical Git path")
1048
+ session_id = identity.request_identity.session_id
1049
+ self.cancel_file(session_id, identity.panel_kind)
1050
+ self.file_identity_by_key[(session_id, identity.panel_kind)] = identity
1051
+ repository_dir = self.context_by_session[session_id].repository_dir
1052
+ options = ["diff-tree", "--stdin", "-r", "--raw", "-z", "--no-abbrev", "--format=%H%x00%P%x00", "--always", "--root", "--no-ext-diff", "--no-textconv"]
1053
+
1054
+ def read(batch: list[tuple[str | None, str]], scoped: bool, receive: Callable[[bytes], None]) -> None:
1055
+ """Use explicit immutable child/parent pairs, including root comparisons."""
1056
+ lines = []
1057
+ for parent_oid, child_oid in batch:
1058
+ line = validate_oid(child_oid)
1059
+ if parent_oid is not None:
1060
+ line += " " + validate_oid(parent_oid)
1061
+ lines.append(line)
1062
+ arguments = ["--literal-pathspecs", *options, "--no-renames" if scoped else "-M"]
1063
+ if scoped:
1064
+ arguments.extend(["--", os.fsdecode(path)])
1065
+ self._execute(repository_dir, arguments, identity.request_identity, receive, timeout_ms=GIT_READ_TIMEOUT_MS,
1066
+ stdin=("\n".join(lines) + "\n").encode("ascii"), file_identity=identity)
1067
+
1068
+ def receive_scoped(output: bytes) -> None:
1069
+ """Unchanged paths need no whole-tree read; additions and deletions can conceal renames."""
1070
+ entry_by_comparison = parse_history_comparisons(output, comparisons)
1071
+ boundary_comparisons = []
1072
+ for comparison, entries in entry_by_comparison.items():
1073
+ if any(entry.status in (FileStatus.ADDED, FileStatus.DELETED) for entry in entries):
1074
+ boundary_comparisons.append(comparison)
1075
+ if not boundary_comparisons:
1076
+ callback(entry_by_comparison)
1077
+ return
1078
+
1079
+ def receive_boundaries(boundary_output: bytes) -> None:
1080
+ """Retain only the tracked side after Git detects renames against every changed path."""
1081
+ boundary_entry_by_comparison = parse_history_comparisons(boundary_output, boundary_comparisons)
1082
+ for comparison, entries in boundary_entry_by_comparison.items():
1083
+ retained_entries = []
1084
+ for entry in entries:
1085
+ if path in (entry.old_path, entry.new_path):
1086
+ retained_entries.append(entry)
1087
+ entry_by_comparison[comparison] = retained_entries
1088
+ callback(entry_by_comparison)
1089
+
1090
+ read(boundary_comparisons, False, receive_boundaries)
1091
+
1092
+ read(comparisons, True, receive_scoped)
1093
+
1094
+ def read_changes(self, commit: Commit, identity: FileReadIdentity, callback: Callable[[list[ComparisonGroup]], None] | None = None) -> None:
1095
+ """Read each true-parent comparison, including the empty side of a root commit."""
1096
+ validate_oid(commit.oid)
1097
+ self.cancel_file(identity.request_identity.session_id, identity.panel_kind)
1098
+ self.file_identity_by_key[(identity.request_identity.session_id, identity.panel_kind)] = identity
1099
+ groups: list[ComparisonGroup] = []
1100
+ parents = iter(commit.parent_oids or [None])
1101
+ context = self.context_by_session[identity.request_identity.session_id]
1102
+ repository_dir = context.repository_dir
1103
+
1104
+ def next_parent() -> None:
1105
+ """Serialize parent reads to preserve their commit-object order."""
1106
+ try:
1107
+ parent_oid = next(parents)
1108
+ except StopIteration:
1109
+ self.changes_read.emit(identity, groups)
1110
+ if callback is not None:
1111
+ callback(groups)
1112
+ return
1113
+ arguments = ["diff-tree", "-r", "--raw", "-z", "--no-abbrev", "--no-commit-id", "-M", "--no-ext-diff", "--no-textconv"]
1114
+ if parent_oid is None:
1115
+ arguments.extend(["--root", commit.oid])
1116
+ else:
1117
+ arguments.extend([validate_oid(parent_oid), commit.oid])
1118
+
1119
+ def receive(output: bytes) -> None:
1120
+ """Resolve the parent's display identity without changing comparison order."""
1121
+ entries = parse_revision_entries(output, commit.oid, parent_oid)
1122
+ if parent_oid is None:
1123
+ groups.append(ComparisonGroup(None, "", "Initial commit", entries))
1124
+ next_parent()
1125
+ return
1126
+ # A parent the history already read in full is described from that read; Git is asked only for the others.
1127
+ parent = self.commit_cache.get(context.git_dir, parent_oid)
1128
+ if parent is not None and not parent.detail_error:
1129
+ groups.append(ComparisonGroup(parent_oid, parent.short_oid, commit_subject(parent.message), entries))
1130
+ next_parent()
1131
+ return
1132
+
1133
+ def receive_description(description: bytes) -> None:
1134
+ """Attach Git's abbreviation and subject to the already read changes."""
1135
+ short_oid, subject = description.rstrip(b"\n").split(b"\0", maxsplit=1)
1136
+ groups.append(ComparisonGroup(parent_oid, short_oid.decode("ascii"), subject.decode("utf-8", "replace"), entries))
1137
+ next_parent()
1138
+
1139
+ self._execute(repository_dir, ["show", "-s", "--format=%h%x00%s", "--no-show-signature", parent_oid], identity.request_identity,
1140
+ receive_description, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity)
1141
+
1142
+ self._execute(repository_dir, arguments, identity.request_identity, receive, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity)
1143
+
1144
+ next_parent()
1145
+
1146
+ def read_diff(self, entry: RevisionEntry, identity: FileReadIdentity) -> None:
1147
+ """Compare immutable objects with external diff drivers explicitly disabled."""
1148
+ validate_oid(entry.commit_oid)
1149
+ session_id = identity.request_identity.session_id
1150
+ self.cancel_file(session_id, identity.panel_kind)
1151
+ self.file_identity_by_key[(session_id, identity.panel_kind)] = identity
1152
+ repository_dir = self.context_by_session[session_id].repository_dir
1153
+ cache_key = (self.context_by_session[session_id].git_dir, entry, identity.full_context)
1154
+ cached = self.patch_by_key.get(cache_key)
1155
+ if cached is not None:
1156
+ self.patch_by_key.move_to_end(cache_key)
1157
+
1158
+ def publish_cached() -> None:
1159
+ """Check ownership again after the event loop before publishing a cache hit."""
1160
+ if self.file_identity_by_key.get((session_id, identity.panel_kind)) == identity:
1161
+ self.diff_read.emit(identity, entry, cached)
1162
+
1163
+ self._call_on_next_turn(publish_cached, session_id)
1164
+ return
1165
+ if "160000" in (entry.old_mode, entry.new_mode):
1166
+ lines = ["Submodule commit", f"Old: {entry.old_blob_oid or 'absent'}", f"New: {entry.new_blob_oid or 'absent'}"]
1167
+ self.diff_read.emit(identity, entry, "\n".join(lines).encode())
1168
+ return
1169
+ options = ["--no-ext-diff", "--no-textconv", "--no-color", "--full-index"]
1170
+ format_options = ["-c", "diff.suppressBlankEmpty=false"]
1171
+ if entry.old_blob_oid and entry.new_blob_oid:
1172
+ arguments = [*format_options, "diff", *options, validate_oid(entry.old_blob_oid), validate_oid(entry.new_blob_oid)]
1173
+ else:
1174
+ arguments = [*format_options, "--literal-pathspecs", "diff-tree", "-r", "-p", "--no-commit-id", *options]
1175
+ if entry.parent_oid:
1176
+ arguments.extend([validate_oid(entry.parent_oid), entry.commit_oid])
1177
+ else:
1178
+ arguments.extend(["--root", entry.commit_oid])
1179
+ arguments.append("--")
1180
+ for path in (entry.old_path, entry.new_path):
1181
+ if path is not None:
1182
+ arguments.append(os.fsdecode(path))
1183
+ def receive(output: bytes) -> None:
1184
+ """Restore path and mode metadata lost when comparing blobs directly."""
1185
+ patch = comparison_metadata(entry) + output
1186
+ if len(patch) > COMMIT_LIMIT_BYTES:
1187
+ raise ValueError("Diff exceeds the display size limit")
1188
+ previous = self.patch_by_key.pop(cache_key, b"")
1189
+ self.patch_cache_size_bytes -= len(previous)
1190
+ self.patch_by_key[cache_key] = patch
1191
+ self.patch_cache_size_bytes += len(patch)
1192
+ while self.patch_cache_size_bytes > DIFF_CACHE_LIMIT_BYTES or len(self.patch_by_key) > DIFF_CACHE_ENTRY_LIMIT:
1193
+ _key, evicted = self.patch_by_key.popitem(last=False)
1194
+ self.patch_cache_size_bytes -= len(evicted)
1195
+ self.diff_read.emit(identity, entry, patch)
1196
+
1197
+ if not identity.full_context:
1198
+ self._execute(repository_dir, arguments, identity.request_identity, receive, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity)
1199
+ return
1200
+ blob_oids = []
1201
+ for blob_oid in (entry.old_blob_oid, entry.new_blob_oid):
1202
+ if blob_oid is not None and blob_oid not in blob_oids:
1203
+ blob_oids.append(blob_oid)
1204
+ pending_oids = iter(blob_oids)
1205
+ context_line_count = 0
1206
+
1207
+ def read_next_blob() -> None:
1208
+ """Measure both bounded blobs so full context never assumes a universal length."""
1209
+ try:
1210
+ blob_oid = next(pending_oids)
1211
+ except StopIteration:
1212
+ command_index = arguments.index("diff") if "diff" in arguments else arguments.index("diff-tree")
1213
+ arguments.insert(command_index + 1, f"--unified={context_line_count}")
1214
+ self._execute(repository_dir, arguments, identity.request_identity, receive, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity)
1215
+ return
1216
+
1217
+ def receive_blob(content: bytes) -> None:
1218
+ """Count lines exactly, including a final line without a linefeed."""
1219
+ nonlocal context_line_count
1220
+ line_count = content.count(b"\n") + int(bool(content) and not content.endswith(b"\n"))
1221
+ context_line_count = max(context_line_count, line_count)
1222
+ if entry.old_blob_oid == entry.new_blob_oid:
1223
+ receive(unchanged_context(content))
1224
+ return
1225
+ read_next_blob()
1226
+
1227
+ self._execute(repository_dir, ["cat-file", "blob", validate_oid(blob_oid)], identity.request_identity,
1228
+ receive_blob, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity)
1229
+
1230
+ read_next_blob()
1231
+
1232
+ def read_tree(self, commit_oid: str, identity: FileReadIdentity) -> None:
1233
+ """Read all historical paths from Git, independent of the current working tree."""
1234
+ session_id = identity.request_identity.session_id
1235
+ self.cancel_file(session_id, identity.panel_kind)
1236
+ self.file_identity_by_key[(session_id, identity.panel_kind)] = identity
1237
+ repository_dir = self.context_by_session[session_id].repository_dir
1238
+ buffer = bytearray()
1239
+ entries: list[TreeEntry] = []
1240
+
1241
+ def consume(chunk: bytes) -> None:
1242
+ """Parse complete records per streamed chunk while retaining one partial path."""
1243
+ buffer.extend(chunk)
1244
+ end = buffer.rfind(b"\0")
1245
+ if end >= 0:
1246
+ entries.extend(parse_tree_entries(bytes(buffer[:end + 1])))
1247
+ del buffer[:end + 1]
1248
+ if len(buffer) > self.backlog_limit_bytes:
1249
+ raise ValueError("Tree path exceeds the parsing limit")
1250
+
1251
+ def receive(output: bytes) -> None:
1252
+ """Publish entries only after complete NUL records have been validated."""
1253
+ if buffer:
1254
+ raise ValueError("Incomplete tree record")
1255
+ entries.sort(key=lambda entry: entry.path)
1256
+ self.tree_read.emit(identity, entries)
1257
+
1258
+ self._execute(repository_dir, ["ls-tree", "-r", "--full-tree", "-z", validate_oid(commit_oid)], identity.request_identity,
1259
+ receive, timeout_ms=GIT_READ_TIMEOUT_MS, file_identity=identity, stream_callback=consume, stdout_limit_bytes=None)
1260
+
1261
+ def read_blob(self, blob_oid: str, identity: FileReadIdentity) -> None:
1262
+ """Retain only a bounded historical preview while checking the complete byte stream."""
1263
+ validate_oid(blob_oid)
1264
+ session_id = identity.request_identity.session_id
1265
+ self.cancel_file(session_id, identity.panel_kind)
1266
+ self.file_identity_by_key[(session_id, identity.panel_kind)] = identity
1267
+ key = (self.context_by_session[session_id].git_dir, blob_oid)
1268
+ cached = self.blob_by_key.get(key)
1269
+ if cached is not None:
1270
+ self.blob_by_key.move_to_end(key)
1271
+ cached_content, _cached_size = cached
1272
+
1273
+ def publish_cached() -> None:
1274
+ """Recheck a queued cache response after any intervening selection change."""
1275
+ if self.file_identity_by_key.get((session_id, identity.panel_kind)) == identity:
1276
+ self.blob_read.emit(identity, cached_content)
1277
+
1278
+ self._call_on_next_turn(publish_cached, session_id)
1279
+ return
1280
+ preview = bytearray()
1281
+
1282
+ def consume(chunk: bytes) -> None:
1283
+ """Keep the first display bytes and discard the remaining preview stream."""
1284
+ remaining = FILE_PREVIEW_LIMIT_BYTES - len(preview)
1285
+ if remaining > 0:
1286
+ preview.extend(chunk[:remaining])
1287
+
1288
+ def finish(size_bytes: int) -> None:
1289
+ """Charge the raw and decoded preview to a shared byte-bounded cache."""
1290
+ content = decode_blob_display(blob_oid, bytes(preview), size_bytes)
1291
+ git_dir, _oid = key
1292
+ size = sum(sys.getsizeof(value) for value in (key, git_dir, blob_oid, content, content.raw_bytes, content.display_text, content.diagnostic))
1293
+ previous = self.blob_by_key.pop(key, None)
1294
+ if previous is not None:
1295
+ _previous_content, previous_size = previous
1296
+ self.blob_cache_size_bytes -= previous_size
1297
+ while self.blob_by_key and self.blob_cache_size_bytes + size > FILE_CACHE_LIMIT_BYTES:
1298
+ _key, (_content, evicted_size) = self.blob_by_key.popitem(last=False)
1299
+ self.blob_cache_size_bytes -= evicted_size
1300
+ if size <= FILE_CACHE_LIMIT_BYTES:
1301
+ self.blob_by_key[key] = (content, size)
1302
+ self.blob_cache_size_bytes += size
1303
+ self.blob_read.emit(identity, content)
1304
+
1305
+ self.stream_blob(blob_oid, identity, consume, finish)
1306
+
1307
+ def stream_blob(self, blob_oid: str, identity: FileReadIdentity, consume: Callable[[bytes], None], completed: Callable[[int], None]) -> None:
1308
+ """Deliver exact blob chunks; consumers finalize only on completion and cancel via cancel_file."""
1309
+ validate_oid(blob_oid)
1310
+ session_id = identity.request_identity.session_id
1311
+ self.cancel_file(session_id, identity.panel_kind)
1312
+ self.file_identity_by_key[(session_id, identity.panel_kind)] = identity
1313
+ parser = CatFileParser([blob_oid], object_limit_bytes=0, expected_object_type="blob", body_callback=consume)
1314
+
1315
+ def receive(output: bytes) -> None:
1316
+ """Confirm identity, type, exact body size and terminal framing before success."""
1317
+ parser.finish()
1318
+ completed(parser.size_by_oid[blob_oid])
1319
+
1320
+ self._execute(self.context_by_session[session_id].repository_dir, ["cat-file", "--batch"], identity.request_identity,
1321
+ receive, timeout_ms=GIT_READ_TIMEOUT_MS, stdin=(blob_oid + "\n").encode(), file_identity=identity,
1322
+ stream_callback=parser.feed, stdout_limit_bytes=None)
1323
+
1324
+ def cancel_file(self, session_id: str, panel_kind: FilePanel) -> None:
1325
+ """Cancel one panel without stopping history, detail, or another panel."""
1326
+ self.file_identity_by_key.pop((session_id, panel_kind), None)
1327
+ for request_id, identity in tuple(self.file_identity_by_id.items()):
1328
+ if identity.request_identity.session_id == session_id and identity.panel_kind == panel_kind:
1329
+ self.runner.cancel(request_id)
1330
+
1331
+ def cancel(self, session_id: str) -> None:
1332
+ """Invalidate outstanding callbacks belonging to just one repository window, stopping only the reads this repository started.
1333
+
1334
+ The runner is shared: a Fetch, a Push or a submodule initialization of the same window runs there under the
1335
+ window's session too, and each is stopped by its own service. A new history read cancels the window's older
1336
+ reads through here, so stopping the whole session would end such an operation in the middle.
1337
+ """
1338
+ for file_session_id, panel_kind in tuple(self.file_identity_by_key):
1339
+ if file_session_id == session_id:
1340
+ self.cancel_file(session_id, panel_kind)
1341
+ self.identity_by_session.pop(session_id, None)
1342
+ self.executable_by_session.pop(session_id, None)
1343
+ self.selection_identity_by_session.pop(session_id, None)
1344
+ self.relation_identity_by_session.pop(session_id, None)
1345
+ self.search_identity_by_session.pop(session_id, None)
1346
+ self.snapshot_by_session.pop(session_id, None)
1347
+ self.opening_head_read_by_session.pop(session_id, None)
1348
+ for request_id, identity in tuple(self.identity_by_id.items()):
1349
+ if identity.session_id == session_id:
1350
+ self.runner.cancel(request_id)
1351
+ for request_id, identity in tuple(self.identity_by_id.items()):
1352
+ if identity.session_id == session_id:
1353
+ self.identity_by_id.pop(request_id, None)
1354
+ self.callback_by_id.pop(request_id, None)
1355
+ self.output_by_id.pop(request_id, None)
1356
+ self.result_callback_by_id.pop(request_id, None)
1357
+ self.stream_by_id.pop(request_id, None)
1358
+ self.file_identity_by_id.pop(request_id, None)
1359
+ self.batch_read_by_request_id.pop(request_id, None)
1360
+
1361
+ def close(self) -> None:
1362
+ """Cancel every owned request when application shutdown begins."""
1363
+ for session_id in list(self.identity_by_session):
1364
+ self.cancel(session_id)
1365
+ self.opening_head_read_by_session.clear()
1366
+ self.runner.close()
1367
+ self.patch_by_key.clear()
1368
+ self.patch_cache_size_bytes = 0
1369
+ self.blob_by_key.clear()
1370
+ self.blob_cache_size_bytes = 0
1371
+
1372
+ def _read_commit(self, oid: str, identity: RequestIdentity, callback: Callable[[Commit], None]) -> None:
1373
+ """Reuse byte-framed object reading for opening and individual selection."""
1374
+ def deliver(commits: list[Commit]) -> None:
1375
+ first_commit_index = 0
1376
+ callback(commits[first_commit_index])
1377
+ self.request_commit_batch([oid], identity, deliver)
1378
+
1379
+ def _call_on_next_turn(self, callback: Callable[[], None], session_id: str | None) -> None:
1380
+ """Run a callback on the next turn of the event loop, kept in ``next_turn_session_id_by_token`` until it has run.
1381
+
1382
+ A cached answer, a capture that needs no process and the next page of a long read are all queued this way, so
1383
+ whoever waits for a repository to be idle, like the commit timing of the --log file, sees them as pending work
1384
+ instead of calling it idle one turn before they publish. ``session_id`` is the window the callback serves, so that
1385
+ work queued for a window of another repository is told apart; ``None`` when no window is known. ``next_turn_work_done``
1386
+ is emitted once the last of them has run, even when it raised. The queued function keeps the callback's qualified
1387
+ name: the tests of the commit measurement hold a cached commit answer back by that name, to check that the
1388
+ measurement waits for it.
1389
+ """
1390
+ token = object()
1391
+ self.next_turn_session_id_by_token[token] = session_id
1392
+
1393
+ @wraps(callback)
1394
+ def run() -> None:
1395
+ """Run the callback, then forget its token and tell when no queued callback is left."""
1396
+ try:
1397
+ callback()
1398
+ finally:
1399
+ self.next_turn_session_id_by_token.pop(token, None)
1400
+ if not self.next_turn_session_id_by_token:
1401
+ self.next_turn_work_done.emit()
1402
+
1403
+ QTimer.singleShot(0, run)
1404
+
1405
+ def _is_current(self, identity: RequestIdentity) -> bool:
1406
+ """Accept the active traversal or its latest independent selection only."""
1407
+ current = self.identity_by_session.get(identity.session_id)
1408
+ if current is None or current.generation != identity.generation:
1409
+ return False
1410
+ if identity.relation_generation:
1411
+ return (self.relation_identity_by_session.get(identity.session_id) == identity
1412
+ and self.selection_identity_by_session.get(identity.session_id) == replace(identity, relation_generation=0))
1413
+ if identity.oid is not None:
1414
+ return self.selection_identity_by_session.get(identity.session_id) == identity
1415
+ if identity.search_generation:
1416
+ return self.search_identity_by_session.get(identity.session_id) == identity
1417
+ return current == identity
1418
+
1419
+ def _execute(self, working_dir: str, arguments: list[str], identity: RequestIdentity, callback: Callable[[bytes], None], timeout_ms: int = GIT_OPEN_TIMEOUT_MS,
1420
+ stdin: bytes = b"", stdout_limit_bytes: int | None = COMMIT_LIMIT_BYTES,
1421
+ stream_callback: Callable[[bytes], None] | None = None, file_identity: FileReadIdentity | None = None,
1422
+ batch: _CommitBatchRead | None = None, ready: Callable[[], bool] | None = None) -> str:
1423
+ """Accumulate only a bounded single-object reply while the runner streams chunks, and answer the request's ID.
1424
+
1425
+ ``batch`` ties the read to the other read of the same commit batch before it is launched, so even a launch that
1426
+ fails at once stops both. ``ready`` holds a streamed read's chunks until it answers yes.
1427
+ """
1428
+ request_id = uuid.uuid4().hex
1429
+ self.callback_by_id[request_id] = callback
1430
+ self.identity_by_id[request_id] = identity
1431
+ if file_identity is not None:
1432
+ self.file_identity_by_id[request_id] = file_identity
1433
+ self.output_by_id[request_id] = bytearray()
1434
+ if stream_callback is not None:
1435
+ self.stream_by_id[request_id] = _StreamRead(stream_callback, ready=ready)
1436
+ if batch is not None:
1437
+ batch.request_id_set.add(request_id)
1438
+ self.batch_read_by_request_id[request_id] = batch
1439
+ self._launch(request_id, working_dir, arguments, identity, timeout_ms, stdin, stdout_limit_bytes)
1440
+ return request_id
1441
+
1442
+ def _execute_expecting(self, working_dir: str, arguments: list[str], identity: RequestIdentity, callback: Callable[[bytes, ProcessResult], None],
1443
+ accepted_return_codes: tuple[int, ...], timeout_ms: int = GIT_OPEN_TIMEOUT_MS) -> None:
1444
+ """Run one read whose exit code is part of its answer, such as a missing HEAD or a bare repository's root.
1445
+
1446
+ Only a process that ran to its end and exited with one of the listed codes reaches the callback, with its
1447
+ whole output and outcome. A stop, a limit, a launch failure or any other code keeps the ordinary error path.
1448
+ """
1449
+ request_id = uuid.uuid4().hex
1450
+ self.result_callback_by_id[request_id] = (callback, accepted_return_codes)
1451
+ self.identity_by_id[request_id] = identity
1452
+ self.output_by_id[request_id] = bytearray()
1453
+ self._launch(request_id, working_dir, arguments, identity, timeout_ms, b"", COMMIT_LIMIT_BYTES)
1454
+
1455
+ def _launch(self, request_id: str, working_dir: str, arguments: list[str], identity: RequestIdentity, timeout_ms: int, stdin: bytes, stdout_limit_bytes: int | None) -> None:
1456
+ """Start one read with the executable captured for the requesting window."""
1457
+ self.runner.start(ProcessRequest(request_id, self.executable_by_session[identity.session_id], arguments, working_dir, timeout_ms, identity.session_id,
1458
+ stdout_limit_bytes=stdout_limit_bytes, stdin=stdin))
1459
+
1460
+ def _emit_result_failure(self, identity: RequestIdentity, result: ProcessResult) -> None:
1461
+ """Report a command's own failure, with Git's diagnostic, exactly as an unexpected exit is reported."""
1462
+ request = result.request
1463
+ self.failed.emit(identity, RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, result.cause, result.stderr, result.return_code))
1464
+
1465
+ def _receive_chunk(self, request: ProcessRequest, data: bytes) -> None:
1466
+ """Retain output only for a still-current detail or file request."""
1467
+ identity = self.identity_by_id.get(request.request_id)
1468
+ if identity is None or not self._request_is_current(request.request_id, identity):
1469
+ return
1470
+ stream = self.stream_by_id.get(request.request_id)
1471
+ if stream is not None:
1472
+ if stream.size_bytes + len(data) > self.backlog_limit_bytes:
1473
+ self._stream_failed(request, identity, "Parsing backlog exceeded; history incomplete")
1474
+ return
1475
+ stream.chunks.append(data)
1476
+ stream.size_bytes += len(data)
1477
+ self.runner.pause_stdout(request.request_id)
1478
+ if not stream.scheduled:
1479
+ stream.scheduled = True
1480
+ self._call_on_next_turn(lambda: self._parse_stream(request, identity, stream), identity.session_id)
1481
+ return
1482
+ self.output_by_id[request.request_id].extend(data)
1483
+
1484
+ def _parse_stream(self, request: ProcessRequest, identity: RequestIdentity, stream: _StreamRead) -> None:
1485
+ """Yield between chunks and reject cancelled generations before parsing work."""
1486
+ if self.stream_by_id.get(request.request_id) is not stream or not self._request_is_current(request.request_id, identity):
1487
+ self.runner.resume_stdout(request.request_id)
1488
+ return
1489
+ if stream.ready is not None and not stream.ready():
1490
+ # Nothing can be parsed yet: the chunk stays, Git's output stays paused, and no turn is scheduled again
1491
+ # until the read this stream waits for answers.
1492
+ stream.scheduled = False
1493
+ stream.held_request = request
1494
+ return
1495
+ budget_ms = FILE_PARSE_BUDGET_MS if request.request_id in self.file_identity_by_id else PARSING_BUDGET_MS
1496
+ deadline = monotonic() + budget_ms / 1000
1497
+ try:
1498
+ while stream.chunks and monotonic() < deadline:
1499
+ chunk = stream.chunks.popleft()
1500
+ stream.size_bytes -= len(chunk)
1501
+ stream.consume(chunk)
1502
+ except (ValueError, KeyError, UnicodeError, OSError) as error:
1503
+ self._stream_failed(request, identity, str(error))
1504
+ return
1505
+ if stream.chunks:
1506
+ self._call_on_next_turn(lambda: self._parse_stream(request, identity, stream), identity.session_id)
1507
+ return
1508
+ stream.scheduled = False
1509
+ self.runner.resume_stdout(request.request_id)
1510
+
1511
+ def _resume_held_stream(self, request_id: str) -> None:
1512
+ """Schedule the parsing a stream put off until the read it waited for answered."""
1513
+ stream = self.stream_by_id.get(request_id)
1514
+ identity = self.identity_by_id.get(request_id)
1515
+ if stream is None or identity is None or stream.held_request is None or stream.scheduled:
1516
+ return
1517
+ request = stream.held_request
1518
+ stream.held_request = None
1519
+ stream.scheduled = True
1520
+ self._call_on_next_turn(lambda: self._parse_stream(request, identity, stream), identity.session_id)
1521
+
1522
+ def _abandon_batch_read(self, batch: _CommitBatchRead) -> None:
1523
+ """Stop and forget the reads of a commit batch still running, without any diagnostic of their own.
1524
+
1525
+ Called once one read of the batch failed, was cancelled or no longer belongs to the current request: the other
1526
+ read could publish nothing useful, and its chunks and paused output are released now.
1527
+ """
1528
+ for request_id in tuple(batch.request_id_set):
1529
+ self.batch_read_by_request_id.pop(request_id, None)
1530
+ self.identity_by_id.pop(request_id, None)
1531
+ self.callback_by_id.pop(request_id, None)
1532
+ self.output_by_id.pop(request_id, None)
1533
+ self.stream_by_id.pop(request_id, None)
1534
+ self.runner.cancel(request_id)
1535
+ batch.request_id_set.clear()
1536
+
1537
+ def _stream_failed(self, request: ProcessRequest, identity: RequestIdentity, cause: str) -> None:
1538
+ """Publish a concrete parsing failure once and terminate only its child process, and the other read of its batch."""
1539
+ self.stream_by_id.pop(request.request_id, None)
1540
+ batch = self.batch_read_by_request_id.pop(request.request_id, None)
1541
+ if batch is not None:
1542
+ batch.request_id_set.discard(request.request_id)
1543
+ self._abandon_batch_read(batch)
1544
+ file_identity = self.file_identity_by_id.get(request.request_id)
1545
+ self.runner.cancel(request.request_id)
1546
+ failure = RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, cause)
1547
+ if file_identity is not None:
1548
+ self.file_failed.emit(file_identity, failure)
1549
+ else:
1550
+ self.failed.emit(identity, failure)
1551
+
1552
+ def _request_is_current(self, request_id: str, identity: RequestIdentity) -> bool:
1553
+ """Validate the complete file envelope instead of replacing detail ownership."""
1554
+ file_identity = self.file_identity_by_id.get(request_id)
1555
+ if file_identity is None:
1556
+ return self._is_current(identity)
1557
+ current = self.identity_by_session.get(identity.session_id)
1558
+ return (current is not None and current.generation == identity.generation
1559
+ and self.file_identity_by_key.get((identity.session_id, file_identity.panel_kind)) == file_identity)
1560
+
1561
+ def _receive_result(self, result: ProcessResult) -> None:
1562
+ """Drop abandoned generations before parsing or raising an application diagnostic."""
1563
+ request = result.request
1564
+ pending_identity = self.identity_by_id.get(request.request_id)
1565
+ current = pending_identity is not None and self._request_is_current(request.request_id, pending_identity)
1566
+ file_identity = self.file_identity_by_id.pop(request.request_id, None)
1567
+ identity = self.identity_by_id.pop(request.request_id, None)
1568
+ callback = self.callback_by_id.pop(request.request_id, None)
1569
+ output = self.output_by_id.pop(request.request_id, bytearray())
1570
+ result_entry = self.result_callback_by_id.pop(request.request_id, None)
1571
+ self.stream_by_id.pop(request.request_id, None)
1572
+ batch = self.batch_read_by_request_id.pop(request.request_id, None)
1573
+ if batch is not None:
1574
+ batch.request_id_set.discard(request.request_id)
1575
+ if result.status != ProcessStatus.SUCCESS or not current:
1576
+ self._abandon_batch_read(batch)
1577
+ if identity is None or (callback is None and result_entry is None) or not current or result.status == ProcessStatus.CANCELLED:
1578
+ return
1579
+ failure_signal = self.file_failed if file_identity is not None else self.failed
1580
+ failure_identity = file_identity if file_identity is not None else identity
1581
+ if result_entry is not None:
1582
+ result_callback, accepted_return_codes = result_entry
1583
+ ran_to_its_end = result.status == ProcessStatus.SUCCESS or result.status == ProcessStatus.FAILED and result.return_code != SUCCESS_EXIT_CODE
1584
+ if ran_to_its_end and result.return_code in accepted_return_codes:
1585
+ try:
1586
+ result_callback(bytes(output), result)
1587
+ except (ValueError, KeyError, UnicodeError, OSError) as error:
1588
+ failure_signal.emit(failure_identity, RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, str(error), result.stderr, result.return_code))
1589
+ return
1590
+ failure_signal.emit(failure_identity, RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, result.cause, result.stderr, result.return_code))
1591
+ return
1592
+ if result.status != ProcessStatus.SUCCESS:
1593
+ failure_signal.emit(failure_identity, RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, result.cause, result.stderr, result.return_code))
1594
+ return
1595
+ try:
1596
+ callback(bytes(output))
1597
+ except (ValueError, KeyError, UnicodeError, OSError) as error:
1598
+ if batch is not None:
1599
+ self._abandon_batch_read(batch)
1600
+ failure_signal.emit(failure_identity, RepositoryFailure("Read Git", request.working_dir, request.program_filename, request.arguments, str(error), result.stderr, result.return_code))