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,241 @@
1
+ """Fetch optional author photos without exposing identities or trusting image input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import time
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING
11
+ from zlib import crc32
12
+
13
+ from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt, QTimer, QUrl, Signal
14
+ from PySide6.QtGui import QImage, QImageReader
15
+ from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
16
+
17
+ from gitpane.config import (
18
+ AVATAR_ACTIVE_LIMIT,
19
+ AVATAR_CACHE_ENTRY_LIMIT,
20
+ AVATAR_CACHE_LIMIT_BYTES,
21
+ AVATAR_CACHE_TTL_SECONDS,
22
+ AVATAR_DECODED_SIDE_LIMIT,
23
+ AVATAR_PENDING_LIMIT,
24
+ AVATAR_REQUEST_SIDE_LIMIT,
25
+ AVATAR_RESPONSE_LIMIT_BYTES,
26
+ AVATAR_TIMEOUT_MS,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Callable
31
+
32
+ GRAVATAR_HOST = "www.gravatar.com"
33
+ HTTP_OK = 200
34
+ RASTER_FORMAT_SET = {b"png", b"jpeg", b"jpg", b"gif", b"webp"}
35
+ IMAGE_CONTENT_TYPE_SET = {"image/png", "image/jpeg", "image/gif", "image/webp"}
36
+ AUTHOR_COLORS = ("#778d13", "#990099", "#267777", "#944913", "#6754a3", "#19699b")
37
+
38
+
39
+ def normalize_author_email(author_email: str) -> str:
40
+ """Use one author identity for Gravatar requests, cache lookups and fallback colors."""
41
+ return author_email.strip().lower()
42
+
43
+
44
+ def author_color(author_name: str, author_email: str) -> str:
45
+ """Keep the same colored initials in history rows and the selected commit's detail."""
46
+ identity = normalize_author_email(author_email) or author_name
47
+ return AUTHOR_COLORS[crc32(identity.encode("utf-8")) % len(AUTHOR_COLORS)]
48
+
49
+
50
+ def author_initials(author_name: str) -> str:
51
+ """Return the first two word initials, retaining a visible unknown-author fallback."""
52
+ initial_word_count = 2
53
+ first_character_index = 0
54
+ initials = ""
55
+ for word in author_name.split()[:initial_word_count]:
56
+ initials += word[first_character_index].upper()
57
+ return initials or "?"
58
+
59
+
60
+ @dataclass
61
+ class PhotoRequest:
62
+ """Retain coalesced consumers while limiting a single network response."""
63
+
64
+ author_hash: str
65
+ side: int
66
+ ratio_by_identity: dict[object, float] = field(default_factory=dict)
67
+ reply: QNetworkReply | None = None
68
+ timer: QTimer | None = None
69
+ data: bytearray = field(default_factory=bytearray)
70
+
71
+
72
+ class AvatarService(QObject):
73
+ """Share bounded HTTPS downloads and decoded image caching across windows."""
74
+
75
+ image_ready = Signal(object, QImage)
76
+
77
+ def __init__(self, manager: QNetworkAccessManager | None = None, clock: Callable[[], float] = time.monotonic, parent: QObject | None = None) -> None:
78
+ """Inject the network and clock so timeout, cache and race tests stay offline."""
79
+ super().__init__(parent)
80
+ self.manager = manager if manager is not None else QNetworkAccessManager(self)
81
+ self.clock = clock
82
+ self.request_by_key: dict[tuple[str, int], PhotoRequest] = {}
83
+ self.cache_by_key: OrderedDict[tuple[str, int], tuple[float, QImage]] = OrderedDict()
84
+ self.cache_bytes = 0
85
+ self.closed = False
86
+
87
+ def request(self, author_email: str, logical_size: int, device_pixel_ratio: float, identity: object) -> None:
88
+ """Coalesce equal images and publish only to consumers still interested in them."""
89
+ if self.closed or not author_email.strip() or logical_size <= 0 or not math.isfinite(device_pixel_ratio) or device_pixel_ratio <= 0:
90
+ return
91
+ side = min(AVATAR_REQUEST_SIDE_LIMIT, math.ceil(logical_size * device_pixel_ratio))
92
+ author_hash = hashlib.sha256(normalize_author_email(author_email).encode("utf-8")).hexdigest()
93
+ key = (author_hash, side)
94
+ cached = self.cache_by_key.get(key)
95
+ if cached is not None:
96
+ expiry, image = cached
97
+ if expiry > self.clock():
98
+ self.cache_by_key.move_to_end(key)
99
+ self._publish(identity, image, device_pixel_ratio)
100
+ return
101
+ self._evict(key)
102
+ pending = self.request_by_key.get(key)
103
+ if pending is None:
104
+ if len(self.request_by_key) >= AVATAR_PENDING_LIMIT:
105
+ return
106
+ pending = PhotoRequest(author_hash, side)
107
+ self.request_by_key[key] = pending
108
+ pending.ratio_by_identity[identity] = device_pixel_ratio
109
+ self._pump()
110
+
111
+ def cancel(self, identity: object) -> None:
112
+ """Remove a consumer and abort a download once nobody needs its response."""
113
+ for key, pending in tuple(self.request_by_key.items()):
114
+ pending.ratio_by_identity.pop(identity, None)
115
+ if not pending.ratio_by_identity:
116
+ self._release(key, abort=True)
117
+ self._pump()
118
+
119
+ def close(self) -> None:
120
+ """Abort outstanding requests before the application's network objects disappear."""
121
+ self.closed = True
122
+ for key in tuple(self.request_by_key):
123
+ self._release(key, abort=True)
124
+ self.cache_by_key.clear()
125
+ self.cache_bytes = 0
126
+
127
+ def _pump(self) -> None:
128
+ """Start queued downloads only while global capacity remains available."""
129
+ active_count = 0
130
+ for pending in self.request_by_key.values():
131
+ active_count += pending.reply is not None
132
+ for key, pending in tuple(self.request_by_key.items()):
133
+ if active_count >= AVATAR_ACTIVE_LIMIT:
134
+ return
135
+ if pending.reply is not None:
136
+ continue
137
+ request = QNetworkRequest(QUrl(f"https://{GRAVATAR_HOST}/avatar/{pending.author_hash}?d=404&s={pending.side}"))
138
+ # Refuse every redirect: optional photos never justify forwarding an identity to another endpoint.
139
+ request.setAttribute(QNetworkRequest.Attribute.RedirectPolicyAttribute, QNetworkRequest.RedirectPolicy.ManualRedirectPolicy)
140
+ request.setAttribute(QNetworkRequest.Attribute.CookieLoadControlAttribute, QNetworkRequest.LoadControl.Manual)
141
+ request.setAttribute(QNetworkRequest.Attribute.CookieSaveControlAttribute, QNetworkRequest.LoadControl.Manual)
142
+ request.setTransferTimeout(AVATAR_TIMEOUT_MS)
143
+ reply = self.manager.get(request)
144
+ pending.reply = reply
145
+ reply.setReadBufferSize(AVATAR_RESPONSE_LIMIT_BYTES + 1)
146
+ reply.readyRead.connect(lambda key=key: self._receive(key))
147
+ reply.metaDataChanged.connect(lambda key=key: self._check_header(key))
148
+ reply.sslErrors.connect(lambda _errors, key=key: self._finish(key, failed=True))
149
+ reply.finished.connect(lambda key=key: self._finish(key))
150
+ timer = QTimer(self)
151
+ timer.setSingleShot(True)
152
+ timer.timeout.connect(lambda key=key: self._finish(key, failed=True))
153
+ pending.timer = timer
154
+ timer.start(AVATAR_TIMEOUT_MS)
155
+ active_count += 1
156
+
157
+ def _check_header(self, key: tuple[str, int]) -> bool:
158
+ """Reject oversized or redirected content before reading its body."""
159
+ pending = self.request_by_key.get(key)
160
+ if pending is None or pending.reply is None:
161
+ return False
162
+ reply = pending.reply
163
+ content_length = reply.header(QNetworkRequest.KnownHeaders.ContentLengthHeader)
164
+ redirect = reply.attribute(QNetworkRequest.Attribute.RedirectionTargetAttribute)
165
+ if redirect is not None or (content_length is not None and int(content_length) > AVATAR_RESPONSE_LIMIT_BYTES):
166
+ self._finish(key, failed=True)
167
+ return False
168
+ return True
169
+
170
+ def _receive(self, key: tuple[str, int]) -> None:
171
+ """Read at most one byte beyond the budget to detect streamed overflow."""
172
+ if not self._check_header(key):
173
+ return
174
+ pending = self.request_by_key[key]
175
+ pending.data.extend(bytes(pending.reply.read(AVATAR_RESPONSE_LIMIT_BYTES + 1 - len(pending.data))))
176
+ if len(pending.data) > AVATAR_RESPONSE_LIMIT_BYTES:
177
+ self._finish(key, failed=True)
178
+
179
+ def _finish(self, key: tuple[str, int], failed: bool = False) -> None:
180
+ """Validate trusted origin and bounded raster data, then release before notifying UI."""
181
+ pending = self.request_by_key.get(key)
182
+ if pending is None:
183
+ return
184
+ image = QImage()
185
+ reply = pending.reply
186
+ if not failed and reply is not None:
187
+ self._receive(key)
188
+ if key not in self.request_by_key:
189
+ return
190
+ url = reply.url()
191
+ content_type = str(reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader) or "").split(";", maxsplit=1)[0].lower()
192
+ if (reply.error() == QNetworkReply.NetworkError.NoError and reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == HTTP_OK
193
+ and url.scheme() == "https" and url.host() == GRAVATAR_HOST and content_type in IMAGE_CONTENT_TYPE_SET):
194
+ image = self._decode(pending)
195
+ self._release(key, abort=failed)
196
+ self.cache_by_key[key] = (self.clock() + AVATAR_CACHE_TTL_SECONDS, image)
197
+ self.cache_bytes += image.sizeInBytes()
198
+ while len(self.cache_by_key) > AVATAR_CACHE_ENTRY_LIMIT or self.cache_bytes > AVATAR_CACHE_LIMIT_BYTES:
199
+ self._evict(next(iter(self.cache_by_key)))
200
+ for identity, ratio in pending.ratio_by_identity.items():
201
+ self._publish(identity, image, ratio)
202
+ self._pump()
203
+
204
+ def _decode(self, pending: PhotoRequest) -> QImage:
205
+ """Inspect raster dimensions before allocation and scale to the requested density."""
206
+ buffer = QBuffer()
207
+ buffer.setData(QByteArray(pending.data))
208
+ buffer.open(QIODevice.OpenModeFlag.ReadOnly)
209
+ reader = QImageReader(buffer)
210
+ size = reader.size()
211
+ if bytes(reader.format()).lower() not in RASTER_FORMAT_SET or not size.isValid() or size.isEmpty() or max(size.width(), size.height()) > AVATAR_DECODED_SIDE_LIMIT:
212
+ return QImage()
213
+ image = reader.read()
214
+ if image.isNull() or max(image.width(), image.height()) > AVATAR_DECODED_SIDE_LIMIT:
215
+ return QImage()
216
+ return image.scaled(pending.side, pending.side, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
217
+
218
+ def _publish(self, identity: object, image: QImage, ratio: float) -> None:
219
+ if image.isNull():
220
+ return
221
+ rendered = QImage(image)
222
+ rendered.setDevicePixelRatio(ratio)
223
+ self.image_ready.emit(identity, rendered)
224
+
225
+ def _evict(self, key: tuple[str, int]) -> None:
226
+ _expiry, image = self.cache_by_key.pop(key)
227
+ self.cache_bytes -= image.sizeInBytes()
228
+
229
+ def _release(self, key: tuple[str, int], abort: bool) -> None:
230
+ """Disconnect callbacks before abort/deleteLater so late signals cannot touch released work."""
231
+ pending = self.request_by_key.pop(key)
232
+ if pending.timer is not None:
233
+ pending.timer.stop()
234
+ pending.timer.timeout.disconnect()
235
+ pending.timer.deleteLater()
236
+ if pending.reply is not None:
237
+ for signal in (pending.reply.readyRead, pending.reply.metaDataChanged, pending.reply.sslErrors, pending.reply.finished):
238
+ signal.disconnect()
239
+ if abort:
240
+ pending.reply.abort()
241
+ pending.reply.deleteLater()
@@ -0,0 +1,359 @@
1
+ """Follow historical file identities without changing repository navigation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict, deque
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from time import monotonic
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtCore import QObject, QTimer, Signal
12
+
13
+ from gitpane.config import FILE_HISTORY_ACTIVE_COMPARISON_LIMIT, FILE_HISTORY_BATCH_SIZE, FILE_HISTORY_CACHE_ENTRY_LIMIT, FILE_HISTORY_COMPARISON_LIMIT, FILE_HISTORY_TURN_BUDGET_MS
14
+ from gitpane.history.repository_value import FilePanel, FileReadIdentity
15
+
16
+ if TYPE_CHECKING:
17
+ from gitpane.history.repository import GitRepository
18
+ from gitpane.history.repository_value import Commit, ComparisonGroup, HistorySnapshot, RepositoryFailure, RequestIdentity, RevisionEntry
19
+
20
+
21
+ class WindowCapability(Enum):
22
+ """Name actions whose availability differs between reading and management windows."""
23
+
24
+ CONSULT = "consult"
25
+ MANAGE = "manage"
26
+ FILE_MENU = "file_menu"
27
+ RESTORE = "restore"
28
+
29
+
30
+ WINDOW_CAPABILITY_BY_MODE = {
31
+ "repository": frozenset(WindowCapability),
32
+ "file_history": frozenset({WindowCapability.CONSULT}),
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class FileHistoryAnchor:
38
+ """Identify an existing side of a historical entry, including its exact object."""
39
+
40
+ commit_oid: str
41
+ path: bytes
42
+ blob_oid: str
43
+ side: str = "after"
44
+
45
+ @classmethod
46
+ def from_entry(cls, entry: RevisionEntry) -> FileHistoryAnchor:
47
+ """Anchor deletions in their parent so the removed identity remains reachable."""
48
+ if entry.new_path is not None and entry.new_blob_oid is not None:
49
+ return cls(entry.commit_oid, entry.new_path, entry.new_blob_oid)
50
+ if entry.parent_oid is not None and entry.old_path is not None and entry.old_blob_oid is not None:
51
+ return cls(entry.parent_oid, entry.old_path, entry.old_blob_oid, "before")
52
+ raise ValueError("The selected entry has no historical file")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FileHistoryResult:
57
+ """Keep file membership, historical names and drawing ancestry independent."""
58
+
59
+ touching_oid_set: set[str]
60
+ path_by_oid: dict[str, tuple[bytes, ...]]
61
+ visible_parent_oids_by_oid: dict[str, list[str]]
62
+ complete: bool
63
+ comparison_count: int = 0
64
+ error: str | None = None
65
+
66
+ def select_entry(self, commit: Commit, groups: list[ComparisonGroup]) -> RevisionEntry | None:
67
+ """Find the tracked revision in true parent order, using the old side for deletions."""
68
+ paths = self.path_by_oid.get(commit.oid, ())
69
+ for parent_oid in commit.parent_oids or [None]:
70
+ for group in groups:
71
+ if group.parent_oid != parent_oid:
72
+ continue
73
+ for entry in group.entries:
74
+ path = entry.new_path if entry.new_path is not None else entry.old_path
75
+ if path in paths:
76
+ return entry
77
+ return None
78
+
79
+
80
+ def visible_parent_map(snapshot: HistorySnapshot, visible_oid_set: set[str]) -> dict[str, list[str]]:
81
+ """Connect the first visible ancestors on each real parent branch, preserving order."""
82
+ nearest_oids_by_oid: dict[str, list[str]] = {}
83
+ result_by_oid = {}
84
+ for oid in snapshot.ordered_oids:
85
+ if oid not in visible_oid_set:
86
+ continue
87
+ pending = [(oid, False)]
88
+ while pending:
89
+ current_oid, expanded = pending.pop()
90
+ if current_oid in nearest_oids_by_oid:
91
+ continue
92
+ parents = snapshot.parent_oids_by_oid.get(current_oid, [])
93
+ if not expanded:
94
+ pending.append((current_oid, True))
95
+ for parent_oid in reversed(parents):
96
+ if parent_oid not in visible_oid_set and parent_oid not in nearest_oids_by_oid:
97
+ pending.append((parent_oid, False))
98
+ continue
99
+ ancestor_oids = []
100
+ ancestor_oid_set: set[str] = set()
101
+ for parent_oid in parents:
102
+ candidates = [parent_oid] if parent_oid in visible_oid_set else nearest_oids_by_oid.get(parent_oid, [])
103
+ for candidate in candidates:
104
+ if candidate not in ancestor_oid_set:
105
+ ancestor_oid_set.add(candidate)
106
+ ancestor_oids.append(candidate)
107
+ nearest_oids_by_oid[current_oid] = ancestor_oids
108
+ result_by_oid[oid] = nearest_oids_by_oid[oid]
109
+ return result_by_oid
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class FileHistoryLimits:
114
+ """Bound comparison concurrency, retained edges and work between event-loop turns."""
115
+
116
+ active_comparison_limit: int = FILE_HISTORY_ACTIVE_COMPARISON_LIMIT
117
+ cache_entry_limit: int = FILE_HISTORY_CACHE_ENTRY_LIMIT
118
+ comparison_limit: int = FILE_HISTORY_COMPARISON_LIMIT
119
+ turn_budget_ms: int = FILE_HISTORY_TURN_BUDGET_MS
120
+
121
+ def __post_init__(self) -> None:
122
+ """Reject unusable limits before starting a calculation."""
123
+ if min(self.active_comparison_limit, self.cache_entry_limit, self.comparison_limit, self.turn_budget_ms) < 1:
124
+ raise ValueError("File history limits must be positive")
125
+
126
+
127
+ class FileHistoryEngine(QObject):
128
+ """Read immutable comparisons asynchronously and follow identities along both directions."""
129
+
130
+ finished = Signal(object)
131
+ progressed = Signal(int, int)
132
+
133
+ def __init__(self, repository: GitRepository, limits: FileHistoryLimits | None = None) -> None:
134
+ """Use the repository's bounded process runner for all historical reads."""
135
+ super().__init__()
136
+ self.repository = repository
137
+ self.limits = limits or FileHistoryLimits()
138
+ self.generation = 0
139
+ self.identity: RequestIdentity | None = None
140
+ self.snapshot: HistorySnapshot | None = None
141
+ self.entry_by_cache_key: OrderedDict[tuple[str, int, int, bytes, str | None, str], list[RevisionEntry]] = OrderedDict()
142
+ self.cache_entry_count = 0
143
+ self.comparison_count = 0
144
+ self.active_comparison_count = 0
145
+ self.round_comparison_count = 0
146
+ self.batch_size_limit = FILE_HISTORY_BATCH_SIZE
147
+ self.active_batch_size = 0
148
+ self.states: deque[tuple[str, bytes, str]] = deque()
149
+ self.edges: deque[tuple[str, bytes, str, str | None, str, bool]] = deque()
150
+ self.seen_state_set: set[tuple[str, bytes, str]] = set()
151
+ self.path_set_by_oid: dict[str, set[bytes]] = {}
152
+ self.touching_oid_set: set[str] = set()
153
+ self.child_oids_by_oid: dict[str, list[str]] = {}
154
+ self.error: str | None = None
155
+ self.paused = False
156
+ self.closed = False
157
+ self.timer = QTimer(self)
158
+ self.timer.setSingleShot(True)
159
+ self.timer.timeout.connect(self._advance)
160
+ self.repository.file_failed.connect(self._failed)
161
+
162
+ def cancel(self) -> None:
163
+ """Invalidate callbacks before cancelling this engine's file comparisons."""
164
+ self.generation += 1
165
+ self.timer.stop()
166
+ self.active_comparison_count = 0
167
+ self.active_batch_size = 0
168
+ self.paused = False
169
+ if self.identity is not None:
170
+ self.repository.cancel_file(self.identity.session_id, FilePanel.FILE_HISTORY)
171
+
172
+ def calculate(self, snapshot: HistorySnapshot, anchor: FileHistoryAnchor, identity: RequestIdentity) -> None:
173
+ """Walk every captured branch, including descendants newer than the opening commit."""
174
+ if self.closed:
175
+ return
176
+ self.cancel()
177
+ self.identity = identity
178
+ self.snapshot = snapshot
179
+ self.comparison_count = 0
180
+ self.round_comparison_count = 0
181
+ self.batch_size_limit = FILE_HISTORY_BATCH_SIZE
182
+ self.states = deque([(anchor.commit_oid, anchor.path, anchor.blob_oid)])
183
+ self.edges.clear()
184
+ self.seen_state_set.clear()
185
+ self.path_set_by_oid.clear()
186
+ self.touching_oid_set.clear()
187
+ self.child_oids_by_oid.clear()
188
+ for child_oid, parent_oids in snapshot.parent_oids_by_oid.items():
189
+ for parent_oid in parent_oids:
190
+ self.child_oids_by_oid.setdefault(parent_oid, []).append(child_oid)
191
+ self.error = snapshot.error
192
+ self.paused = False
193
+ if anchor.commit_oid not in snapshot.parent_oids_by_oid:
194
+ self.error = "The opening revision is outside the captured history"
195
+ self.timer.start(0)
196
+
197
+ def resume(self) -> None:
198
+ """Continue the same captured identity after a comparison budget or read failure."""
199
+ if self.closed or not self.paused or self.snapshot is None or self.snapshot.error:
200
+ return
201
+ self.error = None
202
+ self.paused = False
203
+ self.round_comparison_count = 0
204
+ self.timer.start(0)
205
+
206
+ def close(self) -> None:
207
+ """Disconnect the shared service so closing a window releases its calculation safely."""
208
+ if self.closed:
209
+ return
210
+ self.closed = True
211
+ self.cancel()
212
+ self.repository.file_failed.disconnect(self._failed)
213
+ self.timer.timeout.disconnect(self._advance)
214
+ self.identity = None
215
+ self.states.clear()
216
+ self.edges.clear()
217
+ self.entry_by_cache_key.clear()
218
+ self.cache_entry_count = 0
219
+
220
+ def _advance(self) -> None:
221
+ """Follow cached identity edges cooperatively and prefetch missing neighborhoods in batches."""
222
+ if self.identity is None or self.snapshot is None or self.paused or self.active_comparison_count:
223
+ return
224
+ deadline = monotonic() + self.limits.turn_budget_ms / 1000
225
+ while monotonic() < deadline:
226
+ if self.error:
227
+ self._publish(False)
228
+ return
229
+ if self.edges:
230
+ edge = next(iter(self.edges))
231
+ _oid, path, _blob, parent_oid, child_oid, _forward = edge
232
+ repository_dir = self.repository.context_by_session[self.identity.session_id].repository_dir
233
+ key = (repository_dir, id(self.snapshot), self.snapshot.generation, path, parent_oid, child_oid)
234
+ entries = self.entry_by_cache_key.get(key)
235
+ if entries is None:
236
+ if self.round_comparison_count >= self.limits.comparison_limit:
237
+ self.error = "File history is incomplete. Continue reading to resume the captured history."
238
+ self._publish(False)
239
+ return
240
+ self._read_edge(key, parent_oid, child_oid)
241
+ return
242
+ self.entry_by_cache_key.move_to_end(key)
243
+ self.edges.popleft()
244
+ self._follow_edge(edge, entries)
245
+ continue
246
+ if not self.states:
247
+ self._publish(self.snapshot.complete and not self.snapshot.shallow)
248
+ return
249
+ oid, path, blob = self.states.popleft()
250
+ if (oid, path, blob) in self.seen_state_set:
251
+ continue
252
+ self.seen_state_set.add((oid, path, blob))
253
+ self.path_set_by_oid.setdefault(oid, set()).add(path)
254
+ for parent_oid in self.snapshot.parent_oids_by_oid.get(oid, []) or [None]:
255
+ self.edges.append((oid, path, blob, parent_oid, oid, False))
256
+ for child_oid in self.child_oids_by_oid.get(oid, []):
257
+ self.edges.append((oid, path, blob, oid, child_oid, True))
258
+ self.timer.start(0)
259
+
260
+ def _read_edge(self, key: tuple[str, int, int, bytes, str | None, str], parent_oid: str | None, child_oid: str) -> None:
261
+ """Batch neighboring captured edges while bounding read work and retained cache entries."""
262
+ if self.active_comparison_count >= self.limits.active_comparison_limit:
263
+ return
264
+ generation = self.generation
265
+ repository_dir, snapshot_id, snapshot_generation, path, _parent_oid, _child_oid = key
266
+ prefix = (repository_dir, snapshot_id, snapshot_generation, path)
267
+ batch_limit = min(self.batch_size_limit, self.limits.cache_entry_limit, self.limits.comparison_limit - self.round_comparison_count)
268
+ comparisons = [(parent_oid, child_oid)]
269
+ comparison_set = {(parent_oid, child_oid)}
270
+ pending_oids = deque([child_oid])
271
+ if parent_oid is not None:
272
+ pending_oids.append(parent_oid)
273
+ visited_oid_set: set[str] = set()
274
+ deadline = monotonic() + self.limits.turn_budget_ms / 1000
275
+ while pending_oids and len(comparisons) < batch_limit and monotonic() < deadline:
276
+ oid = pending_oids.popleft()
277
+ if oid in visited_oid_set or oid not in self.snapshot.parent_oids_by_oid:
278
+ continue
279
+ visited_oid_set.add(oid)
280
+ neighbors = []
281
+ for parent in self.snapshot.parent_oids_by_oid[oid] or [None]:
282
+ neighbors.append((parent, oid))
283
+ if parent is not None:
284
+ pending_oids.append(parent)
285
+ for child in self.child_oids_by_oid.get(oid, []):
286
+ neighbors.append((oid, child))
287
+ pending_oids.append(child)
288
+ for comparison in neighbors:
289
+ if comparison in comparison_set or (*prefix, *comparison) in self.entry_by_cache_key:
290
+ continue
291
+ comparison_set.add(comparison)
292
+ comparisons.append(comparison)
293
+ if len(comparisons) == batch_limit:
294
+ break
295
+ self.active_comparison_count += 1
296
+ self.active_batch_size = len(comparisons)
297
+ self.comparison_count += len(comparisons)
298
+ self.round_comparison_count += len(comparisons)
299
+
300
+ def receive(entry_by_comparison: dict[tuple[str | None, str], list[RevisionEntry]]) -> None:
301
+ """Cache only scoped raw metadata, with no parent descriptions or file patches."""
302
+ if generation != self.generation:
303
+ return
304
+ self.active_comparison_count -= 1
305
+ self.active_batch_size = 0
306
+ for comparison, entries in entry_by_comparison.items():
307
+ self.entry_by_cache_key[(*prefix, *comparison)] = entries
308
+ while len(self.entry_by_cache_key) > self.limits.cache_entry_limit:
309
+ self.entry_by_cache_key.popitem(last=False)
310
+ self.cache_entry_count = len(self.entry_by_cache_key)
311
+ self.progressed.emit(len(self.path_set_by_oid), len(self.snapshot.ordered_oids))
312
+ if generation == self.generation:
313
+ self.timer.start(0)
314
+
315
+ self.repository.read_history_comparisons(comparisons, path, FileReadIdentity(self.identity, FilePanel.FILE_HISTORY, generation), receive)
316
+
317
+ def _follow_edge(self, edge: tuple[str, bytes, str, str | None, str, bool], entries: list[RevisionEntry]) -> None:
318
+ """Carry the actual blob across renames, stopping on an addition or deletion."""
319
+ _oid, path, blob, parent_oid, child_oid, forward = edge
320
+ opposite_oid = child_oid if forward else parent_oid
321
+ opposite_path, opposite_blob = path, blob
322
+ for entry in entries:
323
+ own_path = entry.old_path if forward else entry.new_path
324
+ if own_path != path:
325
+ continue
326
+ own_blob = entry.old_blob_oid if forward else entry.new_blob_oid
327
+ if own_blob != blob:
328
+ self.error = "The historical file identity does not match the captured Git object"
329
+ return
330
+ self.touching_oid_set.add(child_oid)
331
+ opposite_path = entry.new_path if forward else entry.old_path
332
+ opposite_blob = entry.new_blob_oid if forward else entry.old_blob_oid
333
+ if opposite_path is None:
334
+ self.path_set_by_oid.setdefault(child_oid, set()).add(path)
335
+ break
336
+ if opposite_oid in self.snapshot.parent_oids_by_oid and opposite_path is not None and opposite_blob is not None:
337
+ self.states.append((opposite_oid, opposite_path, opposite_blob))
338
+
339
+ def _publish(self, complete: bool) -> None:
340
+ """Report completion separately from partial results so callers can offer a retry."""
341
+ self.paused = not complete
342
+ if not complete and self.error is None:
343
+ self.error = "File history is incomplete at the shallow repository boundary"
344
+ path_by_oid = {}
345
+ for oid, path_set in self.path_set_by_oid.items():
346
+ path_by_oid[oid] = tuple(sorted(path_set))
347
+ result = FileHistoryResult(set(self.touching_oid_set), path_by_oid, visible_parent_map(self.snapshot, self.touching_oid_set), complete, self.comparison_count, self.error)
348
+ self.finished.emit(result)
349
+
350
+ def _failed(self, identity: FileReadIdentity, failure: RepositoryFailure) -> None:
351
+ """Retain failed work and shrink the next batch so output or timeout limits can recover."""
352
+ if identity.panel_kind != FilePanel.FILE_HISTORY or identity.request_identity != self.identity or identity.panel_generation != self.generation:
353
+ return
354
+ retry_divisor = 2
355
+ self.batch_size_limit = max(1, self.active_batch_size // retry_divisor)
356
+ self.active_batch_size = 0
357
+ self.active_comparison_count = 0
358
+ self.error = f"File history is incomplete: {failure.cause}"
359
+ self._publish(False)