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,566 @@
1
+ """Time each commit in the optional --log file, from the click on Commit to the displayed result.
2
+
3
+ The click line is written when Prepare changes sends its commit request, from Commit or from Commit & push. The result
4
+ line is written once the new commit is painted in the history of every repository window that manages the worktree, and
5
+ no work of that repository is left:
6
+ refreshes and searches of its sessions, preparation reads, processes of the two runners started in one of its folders,
7
+ worktree continuations and file tasks of it, its reservations, repository parsing and callbacks (those queued for the
8
+ next turn of the event loop included), reference continuations
9
+ and shared refreshes of its sessions. The repository's folders are its common Git folder and the worktree of each of its
10
+ open sessions and Prepare changes windows, the submodules inside them included. Work whose repository is not known counts,
11
+ and work of another repository open in GitPane does not, so the time of a commit does not grow with a transfer elsewhere.
12
+ In a measured test, which opens only one repository, these are the bounds of the commit measurement, so a time read in the
13
+ log of a real commit compares with the time the commit measurement of the tests reads; that measurement checks that they agree
14
+ with its own.
15
+
16
+ A window whose list shows the new commit's row outside its visible part, as after the user scrolled it, has no paint to
17
+ wait for: the published history that holds the commit is what the user sees by scrolling back. A commit not seen
18
+ displayed within COMMIT_TIMING_LIMIT_MS of its click writes an "abandoned" line and is no longer followed, so a window
19
+ whose history never shows it is not watched for the rest of the session.
20
+
21
+ The push of Commit & push is work of the same repository started by the same click, so the result line comes after it.
22
+
23
+ A commit refused or failed, or one whose refresh fails, writes a failure line instead, and never a result line after
24
+ it; so does a Commit & push whose request is dropped before the commit service gives a result, as when the reading of
25
+ where to push is refused. Without an open log, nothing is subscribed: the commit goes the same way as without this controller, and a log that
26
+ a disk error closes on the way ends every timing without an error.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from enum import Enum
36
+ from typing import TYPE_CHECKING
37
+
38
+ from PySide6.QtCore import QEvent, QObject, QTimer
39
+ from shiboken6 import isValid
40
+
41
+ from gitpane.config import COMMIT_TIMING_LIMIT_MS, NANOSECONDS_PER_MILLISECOND
42
+ from gitpane.operation.operation_status import OperationStatus
43
+ from gitpane.process.process_log import local_now
44
+ from gitpane.system.path_identity import canonical_path
45
+ from gitpane.ui.repository.history_model import MESSAGE_COLUMN
46
+
47
+ if TYPE_CHECKING:
48
+ from datetime import datetime
49
+
50
+ from PySide6.QtCore import QRect
51
+ from PySide6.QtWidgets import QWidget
52
+
53
+ from gitpane.account.authentication import AuthenticationService
54
+ from gitpane.commit.commit_write import CommitOutcome, CommitRequest
55
+ from gitpane.controller.history_read_controller import HistoryReadController
56
+ from gitpane.controller.reference_read_controller import ReferenceReadController
57
+ from gitpane.controller.refresh_controller import RefreshController
58
+ from gitpane.controller.session_registry import RepositorySession, SessionRegistry
59
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
60
+ from gitpane.controller.worktree_state_controller import WorktreeStateController
61
+ from gitpane.history.repository import GitRepository
62
+ from gitpane.history.repository_value import RepositoryContext
63
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
64
+ from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
65
+ from gitpane.process.process_runner import ProcessRunner
66
+ from gitpane.ui.repository.repository_window import RepositoryWindow
67
+ from gitpane.worktree.staging import StagingSession
68
+ from gitpane.worktree.worktree_service import WorktreeService
69
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
70
+
71
+ COMMIT_TIMING_LOG_PREFIX = "Commit timing: "
72
+ # The complete shape of a record, in writing order, the same for every event: a reader takes each field without looking
73
+ # for it, like the process lines written next to it.
74
+ COMMIT_TIMING_LOG_KEYS = ("time", "event", "operation_id", "elapsed_ms")
75
+ # The end is checked one turn of the event loop after what may end the commit: the slots of a published history, of a
76
+ # painted row or of a finished process still run their own work after it, and that work must be seen before the result
77
+ # is called displayed.
78
+ FINISH_CHECK_DELAY_MS = 0
79
+
80
+
81
+ class CommitTimingEvent(Enum):
82
+ """The moments of a commit written in the log."""
83
+
84
+ CLICKED = "clicked"
85
+ SHOWN = "shown"
86
+ FAILED = "failed"
87
+ # The commit was not seen displayed before the time limit after its click, and is no longer followed.
88
+ ABANDONED = "abandoned"
89
+
90
+
91
+ class PendingWork(Enum):
92
+ """A kind of repository work still running after a commit; the value names it in a measurement's failure message."""
93
+
94
+ REPOSITORY_REFRESH = "repository refresh or search"
95
+ PREPARATION = "preparation refresh or operation"
96
+ PROCESS_RUNNER = "process runner"
97
+ WORKTREE_TASK = "worktree continuation or file task"
98
+ RESERVATION = "repository reservation"
99
+ REPOSITORY_PARSING = "repository parsing or callback"
100
+ REFERENCE_CONTINUATION = "reference continuation"
101
+ SHARED_REFRESH = "shared refresh"
102
+
103
+
104
+ def format_commit_timing_line(event: CommitTimingEvent, operation_id: str, now: datetime, elapsed_ms: int | None) -> str:
105
+ """Return the one-line record of a commit moment, which never holds the message, a path or a command.
106
+
107
+ ``elapsed_ms`` is the time since the click, absent from the click's own line.
108
+ """
109
+ value_by_key: dict[str, object] = {
110
+ "time": now.isoformat(timespec="milliseconds"),
111
+ "event": event.value,
112
+ "operation_id": operation_id,
113
+ "elapsed_ms": elapsed_ms,
114
+ }
115
+ # ASCII only, like a process line: a record stays one physical line whatever a reader treats as a line break.
116
+ return COMMIT_TIMING_LOG_PREFIX + json.dumps(value_by_key, ensure_ascii=True)
117
+
118
+
119
+ def commit_row_rectangle(window: RepositoryWindow, oid: str) -> QRect | None:
120
+ """Return where the message of this commit's row sits in the history list, or nothing when the list has no such row."""
121
+ model = window.history_model
122
+ row = model.row_by_oid.get(oid)
123
+ if row is None:
124
+ return None
125
+ return window.commit_list.visualRect(model.index(row, MESSAGE_COLUMN))
126
+
127
+
128
+ def commit_row_current(window: RepositoryWindow, session: RepositorySession | None, oid: str | None, published_generation: int | None) -> bool:
129
+ """Tell whether the window's complete current history, the one this publication made, holds this commit in its list.
130
+
131
+ The history must be complete and without error, the generation published equal to the session's and to the shown
132
+ history's, the list model built on that history and holding every one of its commits.
133
+ """
134
+ snapshot = window.snapshot
135
+ if (session is None or snapshot is None or not snapshot.complete or snapshot.error or oid is None
136
+ or published_generation != session.generation or snapshot.generation != session.generation
137
+ or window.history_model.snapshot is not snapshot):
138
+ return False
139
+ return window.history_model.rowCount() == len(snapshot.ordered_oids) and oid in snapshot.ordered_oids
140
+
141
+
142
+ def commit_row_in_view(window: RepositoryWindow, oid: str) -> bool:
143
+ """Tell whether this commit's row crosses the visible part of the history list."""
144
+ rectangle = commit_row_rectangle(window, oid)
145
+ if rectangle is None:
146
+ return False
147
+ viewport = window.commit_list.viewport()
148
+ return viewport.isVisible() and not rectangle.isEmpty() and viewport.rect().intersects(rectangle)
149
+
150
+
151
+ def commit_row_visible(window: RepositoryWindow, session: RepositorySession | None, oid: str | None, published_generation: int | None) -> bool:
152
+ """Tell whether the window shows this commit in its complete current history, in the visible part of its list."""
153
+ return commit_row_current(window, session, oid, published_generation) and commit_row_in_view(window, oid)
154
+
155
+
156
+ @dataclass
157
+ class CommitTiming:
158
+ """One commit followed from its click until its result is displayed in the windows that manage its worktree.
159
+
160
+ Each followed window is known by its session id: the generation its history had at the click, the generation a
161
+ refresh published after it, the one whose paint is waiting for confirmation, and the one confirmed painted.
162
+ ``limit_timer`` ends the timing with an "abandoned" line once the time limit since the click has passed.
163
+ """
164
+
165
+ operation_id: str
166
+ key: WorktreeKey
167
+ common_dir: str
168
+ clicked_ns: int
169
+ initial_generation_by_session_id: dict[str, int]
170
+ viewport_by_session_id: dict[str, QWidget]
171
+ oid: str | None = None
172
+ published_generation_by_session_id: dict[str, int] = field(default_factory=dict)
173
+ confirming_generation_by_session_id: dict[str, int] = field(default_factory=dict)
174
+ painted_generation_by_session_id: dict[str, int] = field(default_factory=dict)
175
+ limit_timer: QTimer | None = None
176
+
177
+
178
+ class CommitTimingController(QObject):
179
+ """Write the click and the outcome of every commit into the --log file, when there is one.
180
+
181
+ The outcome is the displayed result of a commit that succeeded, the failure of a commit refused, failed or whose
182
+ refresh failed, or the end of a commit not seen displayed within the time limit after its click. The end conditions
183
+ read here look at the work of the commit's repository only; the commit measurement, which looks at the work of the
184
+ whole application, checks that they agree with its own.
185
+ """
186
+
187
+ def __init__(self, *, session_registry_controller: SessionRegistry, staging_session_registry_controller: StagingSessionRegistry, worktree_state_controller: WorktreeStateController,
188
+ history_read_controller: HistoryReadController, refresh_controller: RefreshController, reference_read_controller: ReferenceReadController, repository: GitRepository,
189
+ authentication: AuthenticationService, worktree_service: WorktreeService, repository_operation: RepositoryOperationService, diagnostic_log: DiagnosticLog | None,
190
+ parent: QObject | None = None) -> None:
191
+ """Receive every state the end of a commit is read from; subscribe to nothing unless the log is open.
192
+
193
+ The condition is on the open file, like the process lines: without a log, no slot is added to the path every
194
+ process and every published history goes through.
195
+ """
196
+ super().__init__(parent)
197
+ self.session_registry_controller = session_registry_controller
198
+ self.staging_session_registry_controller = staging_session_registry_controller
199
+ self.worktree_state_controller = worktree_state_controller
200
+ self.history_read_controller = history_read_controller
201
+ self.refresh_controller = refresh_controller
202
+ self.reference_read_controller = reference_read_controller
203
+ self.repository = repository
204
+ self.authentication = authentication
205
+ self.worktree_service = worktree_service
206
+ self.repository_operation = repository_operation
207
+ self.diagnostic_log = diagnostic_log
208
+ # The two runners GitPane starts Git with: the repository's, and the one of the operations that use an account.
209
+ self.runners: list[ProcessRunner] = [repository.runner, authentication.runner]
210
+ self.timing_by_operation_id: dict[str, CommitTiming] = {}
211
+ self.finish_check_timer = QTimer(self)
212
+ self.finish_check_timer.setSingleShot(True)
213
+ self.finish_check_timer.setInterval(FINISH_CHECK_DELAY_MS)
214
+ self.finish_check_timer.timeout.connect(self._check_finished)
215
+ if not self._log_open():
216
+ return
217
+ history_read_controller.history_published.connect(self._history_published)
218
+ history_read_controller.refresh_failed.connect(self._refresh_failed)
219
+ for runner in self.runners:
220
+ runner.finished.connect(self._work_finished)
221
+ # A file task, the last continuation of a read or a queued repository callback can end after the last process:
222
+ # their end is told by these.
223
+ worktree_service.idle.connect(self._work_finished)
224
+ repository_operation.idle.connect(self._work_finished)
225
+ repository.next_turn_work_done.connect(self._work_finished)
226
+
227
+ def _log_open(self) -> bool:
228
+ """Tell whether a line written now reaches the file; a disk error closes it for good."""
229
+ return self.diagnostic_log is not None and self.diagnostic_log.stream is not None
230
+
231
+ def pending_work(self, common_dir: str) -> list[str]:
232
+ """Name the work of this repository still running, without mistaking kept histories or generation maps for requests.
233
+
234
+ ``common_dir`` is the canonical common Git folder of the repository. Its sessions and Prepare changes windows are
235
+ known by that folder. A process counts when it runs in one of the repository's folders, a worktree continuation
236
+ or file task or a reservation when one of its repositories or reserved folders is one of them, and repository
237
+ parsing, reference continuations and shared refreshes when they serve one of its sessions. Work whose repository
238
+ is not known here counts: a sequence the worktree service holds between two steps, a read with no session, a
239
+ session already forgotten. Work of another repository does not. One name is given per kind of running work, per
240
+ runner and per shared refresh, so the same name can come more than once.
241
+ """
242
+ repository_dir_set = self._repository_dir_set(common_dir)
243
+ reasons: list[str] = []
244
+ for session in tuple(self.session_registry_controller.session_by_id.values()):
245
+ if session.is_closed or canonical_path(session.repository_context.common_git_dir) != common_dir:
246
+ continue
247
+ if session.refresh_active or session.refresh_queued is not None or session.search_pending:
248
+ reasons.append(PendingWork.REPOSITORY_REFRESH.value)
249
+ for staging in tuple(self.staging_session_registry_controller.staging_by_key.values()):
250
+ if canonical_path(staging.context.common_git_dir) != common_dir:
251
+ continue
252
+ if staging.refresh_active or staging.refresh_queued or staging.explicit_refresh or staging.probe_active or staging.busy:
253
+ reasons.append(PendingWork.PREPARATION.value)
254
+ for runner in self.runners:
255
+ requests = [*runner.pending_requests]
256
+ for active in tuple(runner.active_by_id.values()):
257
+ requests.append(active.request)
258
+ for request in requests:
259
+ if self._inside_repository(request.working_dir, repository_dir_set):
260
+ reasons.append(PendingWork.PROCESS_RUNNER.value)
261
+ break
262
+ if self._worktree_task_running(repository_dir_set):
263
+ reasons.append(PendingWork.WORKTREE_TASK.value)
264
+ for intent in tuple(self.repository_operation.active_by_id.values()):
265
+ if self._intent_in_repository(intent, repository_dir_set):
266
+ reasons.append(PendingWork.RESERVATION.value)
267
+ break
268
+ if self._repository_parsing_running(repository_dir_set):
269
+ reasons.append(PendingWork.REPOSITORY_PARSING.value)
270
+ for session_id in tuple(self.reference_read_controller.reference_continuation_by_session):
271
+ if self._session_in_repository(session_id, repository_dir_set):
272
+ reasons.append(PendingWork.REFERENCE_CONTINUATION.value)
273
+ break
274
+ for shared in tuple(self.refresh_controller.full_refresh_read_by_key.values()):
275
+ for session_id in tuple(shared.session_generation_by_id):
276
+ if self._session_in_repository(session_id, repository_dir_set):
277
+ reasons.append(PendingWork.SHARED_REFRESH.value)
278
+ break
279
+ return reasons
280
+
281
+ def _repository_dir_set(self, common_dir: str) -> set[str]:
282
+ """Return the canonical folders of the repository: its common Git folder and the worktree of each of its open sessions and Prepare changes windows."""
283
+ repository_dir_set = {common_dir}
284
+ for session in tuple(self.session_registry_controller.session_by_id.values()):
285
+ if not session.is_closed and canonical_path(session.repository_context.common_git_dir) == common_dir:
286
+ repository_dir_set.add(canonical_path(session.repository_context.repository_dir))
287
+ for staging in tuple(self.staging_session_registry_controller.staging_by_key.values()):
288
+ if canonical_path(staging.context.common_git_dir) == common_dir:
289
+ repository_dir_set.add(canonical_path(staging.context.repository_dir))
290
+ return repository_dir_set
291
+
292
+ @staticmethod
293
+ def _inside_repository(folder: str, repository_dir_set: set[str]) -> bool:
294
+ """Tell whether a folder is one of the repository's folders or inside one; a folder on another drive is outside."""
295
+ canonical_folder = canonical_path(folder)
296
+ for repository_dir in repository_dir_set:
297
+ try:
298
+ if os.path.commonpath((canonical_folder, repository_dir)) == repository_dir:
299
+ return True
300
+ except ValueError:
301
+ continue
302
+ return False
303
+
304
+ def _context_in_repository(self, context: RepositoryContext, repository_dir_set: set[str]) -> bool:
305
+ """Tell whether a repository location is the repository's own, one of its linked worktrees, or one of its submodules."""
306
+ return self._inside_repository(context.common_git_dir, repository_dir_set) or self._inside_repository(context.repository_dir, repository_dir_set)
307
+
308
+ def _session_in_repository(self, session_id: str, repository_dir_set: set[str]) -> bool:
309
+ """Tell whether work done for a session may be the repository's; a session no longer known counts, its repository being unknown."""
310
+ session = self.session_registry_controller.session_by_id.get(session_id)
311
+ if session is None:
312
+ return True
313
+ return self._context_in_repository(session.repository_context, repository_dir_set)
314
+
315
+ def _intent_in_repository(self, intent: OperationIntent, repository_dir_set: set[str]) -> bool:
316
+ """Tell whether a reservation holds the repository, by its repository, the other repositories it reserves or its reserved folders."""
317
+ for context in (intent.context, *intent.reserved_contexts):
318
+ if self._context_in_repository(context, repository_dir_set):
319
+ return True
320
+ for reserved_dir in intent.reserved_repository_dirs:
321
+ if self._inside_repository(reserved_dir, repository_dir_set):
322
+ return True
323
+ return False
324
+
325
+ def _worktree_task_running(self, repository_dir_set: set[str]) -> bool:
326
+ """Tell whether a worktree continuation or file task of the repository runs, or a sequence is held between two steps.
327
+
328
+ A sequence the service holds with no continuation or file task running is between two of its steps, where its
329
+ repository is not known: it counts.
330
+ """
331
+ worktree = self.worktree_service
332
+ running_sequence_set: set[object] = set()
333
+ found = False
334
+ for sequence, _callback, context, *_rest in (*worktree.continuation_by_id.values(), *worktree.file_task_by_id.values()):
335
+ running_sequence_set.add(sequence)
336
+ if self._context_in_repository(context, repository_dir_set):
337
+ found = True
338
+ if found:
339
+ return True
340
+ for sequence in tuple(worktree.operation_id_by_sequence):
341
+ if sequence not in running_sequence_set:
342
+ return True
343
+ return False
344
+
345
+ def _repository_parsing_running(self, repository_dir_set: set[str]) -> bool:
346
+ """Tell whether a read of the repository is parsed or waits for its callback, by the session its request was made for.
347
+
348
+ A request with no identity has no known session: it counts. A callback the repository queued for the next turn
349
+ of the event loop, such as a cached commit or diff about to be published, is held by no request: it is judged by
350
+ the session it was queued for, and counts when that session is unknown. Without it the result line could be
351
+ written one turn before the commit's details are shown; the one service serves every open window, so a callback
352
+ queued for a window of another repository does not count.
353
+ """
354
+ repository = self.repository
355
+ for session_id in tuple(repository.next_turn_session_id_by_token.values()):
356
+ if session_id is None or self._session_in_repository(session_id, repository_dir_set):
357
+ return True
358
+ request_id_set = {*repository.stream_by_id, *repository.batch_read_by_request_id, *repository.callback_by_id, *repository.result_callback_by_id}
359
+ for request_id in request_id_set:
360
+ identity = repository.identity_by_id.get(request_id)
361
+ if identity is None or self._session_in_repository(identity.session_id, repository_dir_set):
362
+ return True
363
+ return False
364
+
365
+ def commit_clicked(self, staging: StagingSession, request: CommitRequest) -> None:
366
+ """Start the clock of a commit Prepare changes has just sent, and watch the history of each window of its worktree.
367
+
368
+ Commit and Commit & push both come here. The end is checked once the click's own slots have run, in case the
369
+ request was already dropped by then.
370
+ """
371
+ if not self._log_open():
372
+ return
373
+ clicked_ns = time.monotonic_ns()
374
+ initial_generation_by_session_id: dict[str, int] = {}
375
+ viewport_by_session_id: dict[str, QWidget] = {}
376
+ for session_id, window in self.worktree_state_controller.managed_windows_of_worktree(staging.key):
377
+ initial_generation_by_session_id[session_id] = self.session_registry_controller.session_by_id[session_id].generation
378
+ viewport = window.commit_list.viewport()
379
+ viewport.installEventFilter(self)
380
+ viewport_by_session_id[session_id] = viewport
381
+ timing = CommitTiming(request.operation_id, staging.key, canonical_path(staging.context.common_git_dir), clicked_ns, initial_generation_by_session_id, viewport_by_session_id)
382
+ timing.limit_timer = QTimer(self)
383
+ timing.limit_timer.setSingleShot(True)
384
+ timing.limit_timer.setInterval(COMMIT_TIMING_LIMIT_MS)
385
+ timing.limit_timer.timeout.connect(lambda: self._limit_reached(request.operation_id))
386
+ timing.limit_timer.start()
387
+ self.timing_by_operation_id[request.operation_id] = timing
388
+ self._write(CommitTimingEvent.CLICKED, request.operation_id, None)
389
+ self.finish_check_timer.start()
390
+
391
+ def commit_finished(self, outcome: CommitOutcome) -> None:
392
+ """Keep the new commit of a followed click, so its row is looked for in the refreshed histories.
393
+
394
+ Any other result, a commit that succeeded with a warning included, ends the timing with a failure line: no
395
+ result of this click will be called displayed.
396
+ """
397
+ timing = self.timing_by_operation_id.get(outcome.operation_id)
398
+ if timing is None:
399
+ return
400
+ if outcome.status != OperationStatus.SUCCESS or outcome.after is None or not outcome.after.snapshot.head_oid:
401
+ self._finish(timing, CommitTimingEvent.FAILED)
402
+ return
403
+ timing.oid = outcome.after.snapshot.head_oid
404
+ self.finish_check_timer.start()
405
+
406
+ def _refresh_failed(self, session_id: str, diagnostic: PreparedDiagnostic) -> None:
407
+ """End with a failure line every followed commit of the repository whose window could not read its history again."""
408
+ session = self.session_registry_controller.session_by_id.get(session_id)
409
+ if session is None or not self.timing_by_operation_id:
410
+ return
411
+ common_dir = canonical_path(session.repository_context.common_git_dir)
412
+ for timing in tuple(self.timing_by_operation_id.values()):
413
+ if timing.common_dir == common_dir:
414
+ self._finish(timing, CommitTimingEvent.FAILED)
415
+
416
+ def _history_published(self, session_id: str, generation: int) -> None:
417
+ """Take a history published after the click as the one to see painted, forgetting the paint of an older one."""
418
+ if not self.timing_by_operation_id:
419
+ return
420
+ session = self.session_registry_controller.session_by_id.get(session_id)
421
+ if session is None or generation != session.generation:
422
+ return
423
+ for timing in self.timing_by_operation_id.values():
424
+ initial_generation = timing.initial_generation_by_session_id.get(session_id)
425
+ if initial_generation is None or generation <= initial_generation:
426
+ continue
427
+ timing.published_generation_by_session_id[session_id] = generation
428
+ timing.confirming_generation_by_session_id.pop(session_id, None)
429
+ timing.painted_generation_by_session_id.pop(session_id, None)
430
+ self.finish_check_timer.start()
431
+
432
+ def _work_finished(self, *arguments: object) -> None:
433
+ """Look at the end again once the slots of this finished work have run, when a commit is followed."""
434
+ if self.timing_by_operation_id:
435
+ self.finish_check_timer.start()
436
+
437
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
438
+ """Note that Qt is painting the new commit's row; the paint is confirmed after Qt has processed it.
439
+
440
+ Nothing is built or changed during the paint: only the generation to confirm is noted, and the event goes on.
441
+ """
442
+ if event.type() != QEvent.Type.Paint or not self.timing_by_operation_id:
443
+ return False
444
+ for timing in self.timing_by_operation_id.values():
445
+ for session_id, viewport in timing.viewport_by_session_id.items():
446
+ if viewport is not watched:
447
+ continue
448
+ window = self.session_registry_controller.window_by_session.get(session_id)
449
+ session = self.session_registry_controller.session_by_id.get(session_id)
450
+ published_generation = timing.published_generation_by_session_id.get(session_id)
451
+ if window is None or not isValid(window) or not commit_row_visible(window, session, timing.oid, published_generation):
452
+ continue
453
+ rectangle = commit_row_rectangle(window, timing.oid)
454
+ if rectangle is None or not event.region().intersects(rectangle):
455
+ continue
456
+ timing.confirming_generation_by_session_id[session_id] = published_generation
457
+ self.finish_check_timer.start()
458
+ return False
459
+
460
+ def _confirm_paints(self, timing: CommitTiming) -> None:
461
+ """Keep a noted paint only if the row it painted still shows the same published history."""
462
+ for session_id, generation in tuple(timing.confirming_generation_by_session_id.items()):
463
+ del timing.confirming_generation_by_session_id[session_id]
464
+ window = self.session_registry_controller.window_by_session.get(session_id)
465
+ session = self.session_registry_controller.session_by_id.get(session_id)
466
+ if (window is not None and isValid(window) and generation == timing.published_generation_by_session_id.get(session_id)
467
+ and commit_row_visible(window, session, timing.oid, generation)):
468
+ timing.painted_generation_by_session_id[session_id] = generation
469
+
470
+ def _displayed_everywhere(self, timing: CommitTiming) -> bool:
471
+ """Tell whether every followed window the user can see shows the new commit in its last published history.
472
+
473
+ A window closed, hidden or minimized since the click shows nothing to wait for. A row outside the visible part
474
+ of the list has no paint to wait for either: the published history that holds it is what the user sees by
475
+ scrolling to it. A row in view must have been painted.
476
+ """
477
+ for session_id in timing.initial_generation_by_session_id:
478
+ session = self.session_registry_controller.session_by_id.get(session_id)
479
+ window = self.session_registry_controller.window_by_session.get(session_id)
480
+ if session is None or session.is_closed or window is None or not isValid(window) or window.is_closed or not window.isVisible() or window.isMinimized():
481
+ continue
482
+ published_generation = timing.published_generation_by_session_id.get(session_id)
483
+ if published_generation is None or not commit_row_current(window, session, timing.oid, published_generation):
484
+ return False
485
+ if commit_row_in_view(window, timing.oid) and timing.painted_generation_by_session_id.get(session_id) != published_generation:
486
+ return False
487
+ return True
488
+
489
+ def _check_finished(self) -> None:
490
+ """Write the result line of every followed commit now displayed with no work of its repository left.
491
+
492
+ A followed commit without a new commit whose request was dropped ends with a failure line instead.
493
+ """
494
+ if not self._log_open():
495
+ self._forget_every_timing()
496
+ return
497
+ for timing in tuple(self.timing_by_operation_id.values()):
498
+ if timing.oid is None and self._request_dropped(timing):
499
+ self._finish(timing, CommitTimingEvent.FAILED)
500
+ continue
501
+ self._confirm_paints(timing)
502
+ if timing.oid is None or not self._displayed_everywhere(timing) or self.pending_work(timing.common_dir):
503
+ continue
504
+ self._finish(timing, CommitTimingEvent.SHOWN)
505
+
506
+ def _request_dropped(self, timing: CommitTiming) -> bool:
507
+ """Tell whether the commit's request was dropped before the commit service gave a result for it.
508
+
509
+ Prepare changes clears its active request and gives the result in the same call, with no turn of the event
510
+ loop between them: a request cleared without a result cannot be a commit about to finish. That is a Commit &
511
+ push refused while it read where to push, or a Prepare changes session that no longer exists.
512
+ """
513
+ staging = self.staging_session_registry_controller.staging_by_key.get(timing.key)
514
+ if staging is None:
515
+ return True
516
+ request = staging.active_commit_request
517
+ return request is None or request.operation_id != timing.operation_id
518
+
519
+ def _limit_reached(self, operation_id: str) -> None:
520
+ """End with an "abandoned" line a commit still followed when the time limit since its click has passed."""
521
+ timing = self.timing_by_operation_id.get(operation_id)
522
+ if timing is None:
523
+ return
524
+ elapsed_ms = (time.monotonic_ns() - timing.clicked_ns) // NANOSECONDS_PER_MILLISECOND
525
+ if elapsed_ms < COMMIT_TIMING_LIMIT_MS and timing.limit_timer is not None:
526
+ # Qt lets a coarse timer fire up to a twentieth of its interval early: wait for the rest of the limit.
527
+ timing.limit_timer.start(COMMIT_TIMING_LIMIT_MS - elapsed_ms)
528
+ return
529
+ self._finish(timing, CommitTimingEvent.ABANDONED)
530
+
531
+ def _finish(self, timing: CommitTiming, event: CommitTimingEvent) -> None:
532
+ """Write the last line of a followed commit, with the time since its click, then stop following it."""
533
+ elapsed_ms = (time.monotonic_ns() - timing.clicked_ns) // NANOSECONDS_PER_MILLISECOND
534
+ self._forget(timing)
535
+ self._write(event, timing.operation_id, elapsed_ms)
536
+
537
+ def _forget(self, timing: CommitTiming) -> None:
538
+ """Stop following a commit: its time limit is stopped and released, and the filter taken off every viewport no other followed commit watches."""
539
+ self.timing_by_operation_id.pop(timing.operation_id, None)
540
+ if timing.limit_timer is not None:
541
+ timing.limit_timer.stop()
542
+ timing.limit_timer.deleteLater()
543
+ timing.limit_timer = None
544
+ for session_id, viewport in timing.viewport_by_session_id.items():
545
+ if not isValid(viewport):
546
+ continue
547
+ watched_elsewhere = False
548
+ for other in self.timing_by_operation_id.values():
549
+ if other.viewport_by_session_id.get(session_id) is viewport:
550
+ watched_elsewhere = True
551
+ if not watched_elsewhere:
552
+ viewport.removeEventFilter(self)
553
+
554
+ def _forget_every_timing(self) -> None:
555
+ """Stop following every commit once the log can take no more lines."""
556
+ for timing in tuple(self.timing_by_operation_id.values()):
557
+ self._forget(timing)
558
+
559
+ def _write(self, event: CommitTimingEvent, operation_id: str, elapsed_ms: int | None) -> None:
560
+ """Write one line; a disk error that closes the log ends every timing without raising."""
561
+ if not self._log_open():
562
+ self._forget_every_timing()
563
+ return
564
+ self.diagnostic_log.write_line(format_commit_timing_line(event, operation_id, local_now(), elapsed_ms))
565
+ if not self._log_open():
566
+ self._forget_every_timing()