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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""Parse the answers of Git's history reads into repository values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from gitpane.config import (
|
|
10
|
+
CAT_FILE_HEADER_LIMIT_BYTES,
|
|
11
|
+
COMMIT_HEADER_LIMIT_BYTES,
|
|
12
|
+
COMMIT_LIMIT_BYTES,
|
|
13
|
+
COMMIT_PREVIEW_LIMIT_CHARS,
|
|
14
|
+
COMMIT_SUBJECT_LIMIT_CHARS,
|
|
15
|
+
PARSING_BACKLOG_LIMIT_BYTES,
|
|
16
|
+
SharedText,
|
|
17
|
+
)
|
|
18
|
+
from gitpane.history.repository_value import Commit, CommitLink, FileStatus, HistorySnapshot, RevisionEntry, TreeEntry, file_status, validate_oid
|
|
19
|
+
from gitpane.text.text_query import TextQuery
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_tree_entries(output: bytes) -> list[TreeEntry]:
|
|
26
|
+
"""Split only Git's NUL records and first tab, preserving arbitrary path bytes."""
|
|
27
|
+
if output and not output.endswith(b"\0"):
|
|
28
|
+
raise ValueError("Incomplete tree record")
|
|
29
|
+
entries = []
|
|
30
|
+
for record in output.split(b"\0"):
|
|
31
|
+
if not record:
|
|
32
|
+
continue
|
|
33
|
+
header, separator, path = record.partition(b"\t")
|
|
34
|
+
mode, object_type, oid = header.decode("ascii").split()
|
|
35
|
+
if not separator or not path or object_type not in ("blob", "commit"):
|
|
36
|
+
raise ValueError("Invalid tree record")
|
|
37
|
+
entries.append(TreeEntry(path, mode, object_type, validate_oid(oid)))
|
|
38
|
+
entries.sort(key=lambda entry: entry.path)
|
|
39
|
+
return entries
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_revision_entries(output: bytes, commit_oid: str, parent_oid: str | None) -> list[RevisionEntry]:
|
|
43
|
+
"""Decode NUL-delimited raw changes while preserving arbitrary Git path bytes."""
|
|
44
|
+
fields = iter(output.split(b"\0"))
|
|
45
|
+
entries = []
|
|
46
|
+
for header in fields:
|
|
47
|
+
if not header:
|
|
48
|
+
continue
|
|
49
|
+
if not header.startswith(b":"):
|
|
50
|
+
raise ValueError("Invalid changed-file record")
|
|
51
|
+
old_mode, new_mode, old_blob, new_blob, raw_status = header[1:].decode("ascii").split()
|
|
52
|
+
status = file_status(raw_status)
|
|
53
|
+
old_blob_oid = None if set(old_blob) == {"0"} else validate_oid(old_blob)
|
|
54
|
+
new_blob_oid = None if set(new_blob) == {"0"} else validate_oid(new_blob)
|
|
55
|
+
try:
|
|
56
|
+
old_path = new_path = next(fields)
|
|
57
|
+
if status in (FileStatus.RENAMED, FileStatus.COPIED):
|
|
58
|
+
new_path = next(fields)
|
|
59
|
+
except StopIteration as error:
|
|
60
|
+
raise ValueError("Incomplete changed-file record") from error
|
|
61
|
+
entries.append(RevisionEntry(commit_oid, parent_oid, old_path if old_blob_oid else None, new_path if new_blob_oid else None,
|
|
62
|
+
old_blob_oid, new_blob_oid, old_mode, new_mode, status))
|
|
63
|
+
return entries
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_history_comparisons(output: bytes, comparisons: list[tuple[str | None, str]]) -> dict[tuple[str | None, str], list[RevisionEntry]]:
|
|
67
|
+
"""Validate explicitly identified raw comparisons without treating path bytes as framing."""
|
|
68
|
+
expected_comparison_set = set(comparisons)
|
|
69
|
+
entry_by_comparison: dict[tuple[str | None, str], list[RevisionEntry]] = {}
|
|
70
|
+
fields = iter(output.split(b"\0"))
|
|
71
|
+
comparison = None
|
|
72
|
+
for token in fields:
|
|
73
|
+
header = token.lstrip(b"\n")
|
|
74
|
+
if not header:
|
|
75
|
+
continue
|
|
76
|
+
if not header.startswith(b":"):
|
|
77
|
+
child_oid = validate_oid(header.decode("ascii"))
|
|
78
|
+
try:
|
|
79
|
+
parent = next(fields).decode("ascii")
|
|
80
|
+
except StopIteration as error:
|
|
81
|
+
raise ValueError("Incomplete file-history comparison identity") from error
|
|
82
|
+
parent_oid = validate_oid(parent) if parent else None
|
|
83
|
+
comparison = (parent_oid, child_oid)
|
|
84
|
+
if comparison not in expected_comparison_set or comparison in entry_by_comparison:
|
|
85
|
+
raise ValueError("Unexpected file-history comparison identity")
|
|
86
|
+
entry_by_comparison[comparison] = []
|
|
87
|
+
continue
|
|
88
|
+
if comparison is None:
|
|
89
|
+
raise ValueError("Missing file-history comparison identity")
|
|
90
|
+
try:
|
|
91
|
+
paths = [next(fields)]
|
|
92
|
+
status_index = -1
|
|
93
|
+
status = file_status(header.rsplit(b" ", maxsplit=1)[status_index].decode("ascii"))
|
|
94
|
+
if status in (FileStatus.RENAMED, FileStatus.COPIED):
|
|
95
|
+
paths.append(next(fields))
|
|
96
|
+
except StopIteration as error:
|
|
97
|
+
raise ValueError("Incomplete file-history changed path") from error
|
|
98
|
+
if not all(paths):
|
|
99
|
+
raise ValueError("Empty file-history changed path")
|
|
100
|
+
parent_oid, child_oid = comparison
|
|
101
|
+
entry_by_comparison[comparison].extend(parse_revision_entries(b"\0".join([header, *paths, b""]), child_oid, parent_oid))
|
|
102
|
+
if output and not output.endswith(b"\0"):
|
|
103
|
+
raise ValueError("Incomplete file-history comparison output")
|
|
104
|
+
if set(entry_by_comparison) != expected_comparison_set:
|
|
105
|
+
raise ValueError("Missing file-history comparisons")
|
|
106
|
+
return entry_by_comparison
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# The encoding Git assumes for a commit whose object declares none.
|
|
110
|
+
DEFAULT_COMMIT_ENCODING = "UTF-8"
|
|
111
|
+
REPLACEMENT_ENCODING = "utf-8"
|
|
112
|
+
UNKNOWN_ENCODING_SUFFIX = " (unknown, UTF-8 replacement)"
|
|
113
|
+
ENCODING_PROBE_BYTES = b"a"
|
|
114
|
+
UNAVAILABLE_COMMIT_TIMESTAMP = 0
|
|
115
|
+
COMMIT_TIME_UNAVAILABLE_CAUSE = "author or committer time is missing or invalid"
|
|
116
|
+
COMMIT_TIME_PATTERN = re.compile(rb"-?\d+")
|
|
117
|
+
IDENTITY_PATTERN = re.compile(rb"(.*) <(.*)> (-?\d+) [+-]\d{4}")
|
|
118
|
+
IDENTITY_NAME_EMAIL_PATTERN = re.compile(rb"(.*) <(.*?)>")
|
|
119
|
+
IDENTITY_NAME_GROUP = 1
|
|
120
|
+
IDENTITY_EMAIL_GROUP = 2
|
|
121
|
+
IDENTITY_TIME_GROUP = 3
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def decode_commit_fields(oid: str, short_oid: str, parent_oids: list[str], author_name: bytes, author_email: bytes, author_timestamp: int | None,
|
|
125
|
+
committer_timestamp: int | None, encoding: bytes | None, message: bytes, *, converted_to_utf8: bool = False) -> Commit:
|
|
126
|
+
"""Build a commit from the bytes of its fields, decoding the message and the author with the commit's own encoding.
|
|
127
|
+
|
|
128
|
+
Both the raw object reader and the history stream go through here, so a commit reads the same whichever way it
|
|
129
|
+
arrived. A missing encoding means UTF-8; an encoding Python does not know is replaced by UTF-8 and says so in the
|
|
130
|
+
commit's encoding label.
|
|
131
|
+
|
|
132
|
+
``converted_to_utf8`` says Git was asked to convert the text to UTF-8 before handing it over, as the history stream
|
|
133
|
+
does. Git leaves the bytes as they are when its own converter does not know the encoding, so text that is not
|
|
134
|
+
valid UTF-8 is then decoded with the declared encoding, exactly as the raw object would be.
|
|
135
|
+
|
|
136
|
+
A time given as None could not be read: the commit keeps everything else and says its time is unavailable.
|
|
137
|
+
"""
|
|
138
|
+
encoding_label = DEFAULT_COMMIT_ENCODING if encoding is None else encoding.decode("ascii", "replace")
|
|
139
|
+
try:
|
|
140
|
+
# Python skips the codec lookup for empty bytes, so a one-byte probe is what tells an unknown or non-text encoding.
|
|
141
|
+
ENCODING_PROBE_BYTES.decode(encoding_label, "replace")
|
|
142
|
+
text_encoding = encoding_label
|
|
143
|
+
# An unknown name raises LookupError. Python also knows names it refuses for text: "undefined" refuses everything
|
|
144
|
+
# and "idna" refuses the replace handler, both with UnicodeError, and a name holding a NUL byte raises ValueError.
|
|
145
|
+
# Every one of them is read as an unknown encoding.
|
|
146
|
+
except (LookupError, ValueError):
|
|
147
|
+
text_encoding = REPLACEMENT_ENCODING
|
|
148
|
+
encoding_label += UNKNOWN_ENCODING_SUFFIX
|
|
149
|
+
field_texts: list[str] = []
|
|
150
|
+
for value in (message, author_name, author_email):
|
|
151
|
+
field_texts.append(decode_commit_text(value, text_encoding, converted_to_utf8))
|
|
152
|
+
decoded_message, decoded_author_name, decoded_author_email = field_texts
|
|
153
|
+
if author_timestamp is None or committer_timestamp is None:
|
|
154
|
+
return Commit(oid, short_oid, parent_oids, decoded_author_name, decoded_author_email, UNAVAILABLE_COMMIT_TIMESTAMP, UNAVAILABLE_COMMIT_TIMESTAMP,
|
|
155
|
+
encoding_label, decoded_message, time_unavailable=True)
|
|
156
|
+
return Commit(oid, short_oid, parent_oids, decoded_author_name, decoded_author_email, author_timestamp, committer_timestamp, encoding_label, decoded_message)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def readable_commit_time(value: bytes) -> int | None:
|
|
160
|
+
"""Read one commit time, or answer None when it is not a whole number of seconds a date can hold on this platform.
|
|
161
|
+
|
|
162
|
+
Git copies the raw time of the object as it is, so a value can lie far beyond any date; Windows also refuses dates
|
|
163
|
+
after the year 3000 and long before 1970, and every display of a commit turns its time into a date.
|
|
164
|
+
"""
|
|
165
|
+
if COMMIT_TIME_PATTERN.fullmatch(value) is None:
|
|
166
|
+
return None
|
|
167
|
+
timestamp = int(value)
|
|
168
|
+
try:
|
|
169
|
+
datetime.fromtimestamp(timestamp, UTC)
|
|
170
|
+
except (ValueError, OverflowError, OSError):
|
|
171
|
+
return None
|
|
172
|
+
return timestamp
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def decode_commit_text(value: bytes, text_encoding: str, converted_to_utf8: bool) -> str:
|
|
176
|
+
"""Decode one text field of a commit: as UTF-8 when Git converted it, otherwise with the commit's encoding, never failing."""
|
|
177
|
+
if converted_to_utf8:
|
|
178
|
+
try:
|
|
179
|
+
return value.decode(REPLACEMENT_ENCODING)
|
|
180
|
+
except UnicodeDecodeError:
|
|
181
|
+
pass
|
|
182
|
+
return value.decode(text_encoding, "replace")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def oversized_commit_cause(object_limit_bytes: int) -> str:
|
|
186
|
+
"""Name the cause of a commit whose message or header is too large to keep, the same whichever read met it."""
|
|
187
|
+
return f"message exceeds {object_limit_bytes} bytes or its header is too large"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def unavailable_commit(oid: str, short_oid: str, parent_oids: list[str], cause: str) -> Commit:
|
|
191
|
+
"""Stand in for a commit whose details cannot be kept: its row stays, its details say why they are missing.
|
|
192
|
+
|
|
193
|
+
The caller names the cause, since a message too large and a time Git could not give lead to the same row.
|
|
194
|
+
"""
|
|
195
|
+
error = SharedText.DETAIL_UNAVAILABLE.value + " : " + cause
|
|
196
|
+
return Commit(oid, short_oid, parent_oids, "", "", UNAVAILABLE_COMMIT_TIMESTAMP, UNAVAILABLE_COMMIT_TIMESTAMP, DEFAULT_COMMIT_ENCODING, error, error)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_identity(value: bytes) -> tuple[bytes, bytes, int | None]:
|
|
200
|
+
"""Split an author or committer header into name, e-mail and time, keeping what is readable of an identity Git accepted.
|
|
201
|
+
|
|
202
|
+
A missing or unreadable time gives None, and an identity without its ``<e-mail>`` part gives an empty name and
|
|
203
|
+
e-mail, as Git's own log shows such a commit.
|
|
204
|
+
"""
|
|
205
|
+
identity = IDENTITY_PATTERN.fullmatch(value)
|
|
206
|
+
if identity is not None:
|
|
207
|
+
return identity.group(IDENTITY_NAME_GROUP), identity.group(IDENTITY_EMAIL_GROUP), readable_commit_time(identity.group(IDENTITY_TIME_GROUP))
|
|
208
|
+
name_and_email = IDENTITY_NAME_EMAIL_PATTERN.match(value)
|
|
209
|
+
if name_and_email is None:
|
|
210
|
+
return b"", b"", None
|
|
211
|
+
return name_and_email.group(IDENTITY_NAME_GROUP), name_and_email.group(IDENTITY_EMAIL_GROUP), None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_commit(oid: str, raw: bytes, short_oid: str) -> Commit:
|
|
215
|
+
"""Decode an exact commit object without changing its message or ancestry.
|
|
216
|
+
|
|
217
|
+
The ancestry stays strict; an unreadable identity or time only costs the commit its dates, as in the history stream.
|
|
218
|
+
"""
|
|
219
|
+
header, separator, message = raw.partition(b"\n\n")
|
|
220
|
+
if not separator:
|
|
221
|
+
raise ValueError("Incomplete commit header")
|
|
222
|
+
value_by_key: dict[bytes, bytes] = {}
|
|
223
|
+
parent_oids = []
|
|
224
|
+
for line in header.splitlines():
|
|
225
|
+
key, _, value = line.partition(b" ")
|
|
226
|
+
if key == b"parent":
|
|
227
|
+
parent_oids.append(validate_oid(value.decode("ascii")))
|
|
228
|
+
elif not line.startswith(b" "):
|
|
229
|
+
value_by_key[key] = value
|
|
230
|
+
author_name, author_email, author_timestamp = parse_identity(value_by_key.get(b"author", b""))
|
|
231
|
+
_committer_name, _committer_email, committer_timestamp = parse_identity(value_by_key.get(b"committer", b""))
|
|
232
|
+
return decode_commit_fields(oid, short_oid, parent_oids, author_name, author_email, author_timestamp, committer_timestamp,
|
|
233
|
+
value_by_key.get(b"encoding"), message)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# The characters Git's own isspace() accepts; Python's str.isspace() accepts many more.
|
|
237
|
+
GIT_WHITESPACE = " \t\n\r"
|
|
238
|
+
GIT_MESSAGE_END = "\0"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def commit_subject(message: str) -> str:
|
|
242
|
+
"""Answer the subject Git's %s format shows for a commit message.
|
|
243
|
+
|
|
244
|
+
Git skips the blank lines at the start, takes the first paragraph, drops the trailing whitespace of each of its
|
|
245
|
+
lines and joins them with one space; its message handling stops at the first NUL character. A cached commit shows
|
|
246
|
+
the same text for its parent as asking Git would.
|
|
247
|
+
"""
|
|
248
|
+
lines = message.split(GIT_MESSAGE_END, 1)[0].split("\n")
|
|
249
|
+
index = 0
|
|
250
|
+
while index < len(lines) and not lines[index].rstrip(GIT_WHITESPACE):
|
|
251
|
+
index += 1
|
|
252
|
+
subject_lines: list[str] = []
|
|
253
|
+
while index < len(lines):
|
|
254
|
+
line = lines[index].rstrip(GIT_WHITESPACE)
|
|
255
|
+
if not line:
|
|
256
|
+
break
|
|
257
|
+
subject_lines.append(line)
|
|
258
|
+
index += 1
|
|
259
|
+
return " ".join(subject_lines)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def relation_link(commit: Commit) -> CommitLink:
|
|
263
|
+
"""Describe a direct relative from its full commit: Git's abbreviation and the first line of its message."""
|
|
264
|
+
subject, _, _body = commit.message.partition("\n")
|
|
265
|
+
return CommitLink(commit.oid, commit.short_oid, commit.detail_error or subject, not bool(commit.detail_error))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_presence_answer(output: bytes, oids: list[str]) -> dict[str, str]:
|
|
269
|
+
"""Read one presence check, matching Git's answers to the requested object IDs rather than to their order.
|
|
270
|
+
|
|
271
|
+
Every requested object must be answered exactly once, and nothing else; the answer is its type, or "missing".
|
|
272
|
+
"""
|
|
273
|
+
requested_oid_set = set(oids)
|
|
274
|
+
type_by_oid: dict[str, str] = {}
|
|
275
|
+
for line in output.decode("ascii").splitlines():
|
|
276
|
+
oid, _separator, object_type = line.partition(" ")
|
|
277
|
+
validate_oid(oid)
|
|
278
|
+
if oid not in requested_oid_set or oid in type_by_oid or not object_type:
|
|
279
|
+
raise ValueError("Unexpected object presence answer")
|
|
280
|
+
type_by_oid[oid] = object_type
|
|
281
|
+
if len(type_by_oid) != len(requested_oid_set):
|
|
282
|
+
raise ValueError("Incomplete object presence answer")
|
|
283
|
+
return type_by_oid
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def commit_matches_query(commit: Commit, query: TextQuery, text: str) -> bool:
|
|
287
|
+
"""Tell whether a commit's message matches a search, refusing to guess about a message that could not be read.
|
|
288
|
+
|
|
289
|
+
An empty search matches every commit, even one whose details are unavailable; any other search stops there, since
|
|
290
|
+
it cannot tell whether the missing message would have matched.
|
|
291
|
+
"""
|
|
292
|
+
if commit.detail_error and text:
|
|
293
|
+
raise ValueError(commit.detail_error + " ; search incomplete")
|
|
294
|
+
return query.matches(commit.message)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def summarize_commit(commit: Commit) -> Commit:
|
|
298
|
+
"""Retain bounded subject and body snippets in table rows while the full object stays in the cache."""
|
|
299
|
+
subject, _, body = commit.message.partition("\n")
|
|
300
|
+
if len(subject) > COMMIT_SUBJECT_LIMIT_CHARS:
|
|
301
|
+
subject = subject[:COMMIT_SUBJECT_LIMIT_CHARS] + "…"
|
|
302
|
+
preview = " ".join(body[:COMMIT_PREVIEW_LIMIT_CHARS].split())
|
|
303
|
+
# Built field by field rather than with dataclasses.replace, which costs several times more and runs once per row
|
|
304
|
+
# of a history of tens of thousands of commits.
|
|
305
|
+
return Commit(commit.oid, commit.short_oid, commit.parent_oids, commit.author_name, commit.author_email, commit.author_timestamp,
|
|
306
|
+
commit.committer_timestamp, commit.encoding, subject, commit.detail_error, preview, commit.time_unavailable)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class CatFileParser:
|
|
310
|
+
"""Frame arbitrary byte fragments using Git's header, exact size and terminal LF."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, oids: list[str], object_limit_bytes: int = COMMIT_LIMIT_BYTES, backlog_limit_bytes: int = PARSING_BACKLOG_LIMIT_BYTES,
|
|
313
|
+
expected_object_type: str = "commit", body_callback: Callable[[bytes], None] | None = None) -> None:
|
|
314
|
+
"""Capture expected identities and limits before consuming untrusted output."""
|
|
315
|
+
self.expected_oid_set = set(oids)
|
|
316
|
+
self.expected_object_type = expected_object_type
|
|
317
|
+
self.body_callback = body_callback
|
|
318
|
+
self.size_by_oid: dict[str, int] = {}
|
|
319
|
+
self.received_oid_set: set[str] = set()
|
|
320
|
+
self.object_limit_bytes = object_limit_bytes
|
|
321
|
+
self.backlog_limit_bytes = backlog_limit_bytes
|
|
322
|
+
self.buffer = bytearray()
|
|
323
|
+
self.body = bytearray()
|
|
324
|
+
self.oid: str | None = None
|
|
325
|
+
self.remaining_size = 0
|
|
326
|
+
self.oversized = False
|
|
327
|
+
|
|
328
|
+
def feed(self, chunk: bytes) -> list[tuple[str, bytes | None]]:
|
|
329
|
+
"""Return whole objects, or explicit unavailable markers for oversized bodies."""
|
|
330
|
+
if len(self.buffer) + len(chunk) > self.backlog_limit_bytes:
|
|
331
|
+
raise ValueError("Parsing backlog exceeded; history incomplete")
|
|
332
|
+
self.buffer.extend(chunk)
|
|
333
|
+
records: list[tuple[str, bytes | None]] = []
|
|
334
|
+
while self.buffer:
|
|
335
|
+
if self.oid is None:
|
|
336
|
+
header_end = self.buffer.find(b"\n")
|
|
337
|
+
if header_end < 0:
|
|
338
|
+
if len(self.buffer) > CAT_FILE_HEADER_LIMIT_BYTES:
|
|
339
|
+
raise ValueError("cat-file header is too long")
|
|
340
|
+
break
|
|
341
|
+
if header_end > CAT_FILE_HEADER_LIMIT_BYTES:
|
|
342
|
+
raise ValueError("cat-file header is too long")
|
|
343
|
+
header = bytes(self.buffer[:header_end]).decode("ascii")
|
|
344
|
+
del self.buffer[:header_end + 1]
|
|
345
|
+
fields = header.split(" ")
|
|
346
|
+
if len(fields) != 3:
|
|
347
|
+
raise ValueError(f"Git object unavailable: {header}")
|
|
348
|
+
oid, object_type, size_text = fields
|
|
349
|
+
validate_oid(oid)
|
|
350
|
+
if oid not in self.expected_oid_set or oid in self.received_oid_set or object_type != self.expected_object_type or not size_text.isdecimal():
|
|
351
|
+
raise ValueError("Invalid cat-file header")
|
|
352
|
+
self.oid = oid
|
|
353
|
+
self.remaining_size = int(size_text)
|
|
354
|
+
self.size_by_oid[oid] = self.remaining_size
|
|
355
|
+
self.oversized = self.remaining_size > self.object_limit_bytes
|
|
356
|
+
consumed_size = min(self.remaining_size, len(self.buffer))
|
|
357
|
+
if self.body_callback is not None and consumed_size:
|
|
358
|
+
self.body_callback(bytes(self.buffer[:consumed_size]))
|
|
359
|
+
if not self.oversized:
|
|
360
|
+
self.body.extend(self.buffer[:consumed_size])
|
|
361
|
+
del self.buffer[:consumed_size]
|
|
362
|
+
self.remaining_size -= consumed_size
|
|
363
|
+
if self.remaining_size or not self.buffer:
|
|
364
|
+
break
|
|
365
|
+
terminal_index = 0
|
|
366
|
+
if self.buffer[terminal_index] != ord("\n"):
|
|
367
|
+
raise ValueError("Invalid cat-file terminator")
|
|
368
|
+
del self.buffer[:1]
|
|
369
|
+
records.append((self.oid, None if self.oversized else bytes(self.body)))
|
|
370
|
+
self.received_oid_set.add(self.oid)
|
|
371
|
+
self.oid = None
|
|
372
|
+
self.body.clear()
|
|
373
|
+
return records
|
|
374
|
+
|
|
375
|
+
def finish(self) -> None:
|
|
376
|
+
"""Reject missing objects and partial headers, bodies or terminators at EOF."""
|
|
377
|
+
if self.buffer or self.oid is not None or self.received_oid_set != self.expected_oid_set:
|
|
378
|
+
raise ValueError("Incomplete cat-file stream; missing objects")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# The one read of the whole history: each commit is a record of these fields, every field ended by a NUL byte.
|
|
382
|
+
# The message comes last and whole; Git's own message handling stops at a NUL inside it, as the subject does.
|
|
383
|
+
HISTORY_LOG_PLACEHOLDERS = ("%H", "%h", "%P", "%an", "%ae", "%at", "%ct", "%e", "%B")
|
|
384
|
+
HISTORY_LOG_FORMAT = "%x00".join(HISTORY_LOG_PLACEHOLDERS)
|
|
385
|
+
(HISTORY_OID_FIELD, HISTORY_SHORT_OID_FIELD, HISTORY_PARENT_OIDS_FIELD, HISTORY_AUTHOR_NAME_FIELD, HISTORY_AUTHOR_EMAIL_FIELD, HISTORY_AUTHOR_TIMESTAMP_FIELD,
|
|
386
|
+
HISTORY_COMMITTER_TIMESTAMP_FIELD, HISTORY_ENCODING_FIELD, HISTORY_MESSAGE_FIELD) = range(len(HISTORY_LOG_PLACEHOLDERS))
|
|
387
|
+
HISTORY_FIELD_COUNT = len(HISTORY_LOG_PLACEHOLDERS)
|
|
388
|
+
# Signatures and notes are turned off so that no setting of the user can add text into the records. Git converts the
|
|
389
|
+
# message and the author of a commit that declares another encoding to UTF-8. Asking for no conversion at all is not
|
|
390
|
+
# an option: Git then converts every record to the system code page, which on Windows is not UTF-8.
|
|
391
|
+
HISTORY_LOG_ARGUMENTS = ["log", "-z", "--topo-order", "--no-show-signature", "--no-notes", "--encoding=" + DEFAULT_COMMIT_ENCODING, "--format=" + HISTORY_LOG_FORMAT,
|
|
392
|
+
"--stdin"]
|
|
393
|
+
# Only the fields that make the ancestry stop the read when they are too large, since the output can no longer be
|
|
394
|
+
# trusted; any other field too large to keep only costs the commit its details.
|
|
395
|
+
ANCESTRY_HISTORY_FIELD_SET = frozenset({HISTORY_OID_FIELD, HISTORY_SHORT_OID_FIELD, HISTORY_PARENT_OIDS_FIELD})
|
|
396
|
+
DROPPABLE_HISTORY_FIELD_SET = frozenset(range(HISTORY_FIELD_COUNT)) - ANCESTRY_HISTORY_FIELD_SET
|
|
397
|
+
HISTORY_FIELD_SEPARATOR = b"\0"
|
|
398
|
+
HISTORY_PARENT_SEPARATOR = b" "
|
|
399
|
+
# The one read of the files each commit of the history changes, fed with the commits on its input. Each commit is
|
|
400
|
+
# compared with each of its parents (-m), which is what the diff panel lists for a merge; the root commit is compared
|
|
401
|
+
# with the empty tree (--root); --always writes the commit line even when nothing changed, so every requested commit is
|
|
402
|
+
# answered; a rename keeps both of its names (--no-renames). diff-tree rather than log: log's option that shows merges
|
|
403
|
+
# also accepts a mode that runs merge drivers, and the process runner keeps log to its list of read options.
|
|
404
|
+
CHANGED_PATH_ARGUMENTS = ["diff-tree", "--stdin", "-r", "-m", "--root", "--always", "--name-only", "-z", "--no-renames", "--format=%x00%H"]
|
|
405
|
+
CHANGED_PATH_FIELD_SEPARATOR = b"\0"
|
|
406
|
+
# Git writes a newline between the commit line and the names of the files it changed.
|
|
407
|
+
CHANGED_PATH_LIST_START = b"\n"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class HistoryLogParser:
|
|
411
|
+
"""Turn the NUL-framed records of the history read into ancestry and commits, whatever the chunk boundaries.
|
|
412
|
+
|
|
413
|
+
It grows the order, the parents and the children of the shared history exactly once per commit, in Git's order,
|
|
414
|
+
and answers the complete commits of each chunk. Every field is bounded while it is still arriving: a message or
|
|
415
|
+
any other field outside the ancestry above its limit stops being kept at once and the commit becomes a row whose
|
|
416
|
+
details are unavailable.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
def __init__(self, snapshot: HistorySnapshot, object_limit_bytes: int = COMMIT_LIMIT_BYTES, header_limit_bytes: int = COMMIT_HEADER_LIMIT_BYTES) -> None:
|
|
420
|
+
"""Share the accepted capture while keeping the unfinished record private."""
|
|
421
|
+
self.snapshot = snapshot
|
|
422
|
+
self.object_limit_bytes = object_limit_bytes
|
|
423
|
+
self.header_limit_bytes = header_limit_bytes
|
|
424
|
+
# The fields of the record still arriving, the bytes of its unfinished field, and whether one of its fields
|
|
425
|
+
# went over its limit and is no longer kept.
|
|
426
|
+
self.fields: list[bytes] = []
|
|
427
|
+
self.partial_field = bytearray()
|
|
428
|
+
self.field_dropped = False
|
|
429
|
+
self.record_dropped = False
|
|
430
|
+
|
|
431
|
+
def feed(self, chunk: bytes) -> list[Commit]:
|
|
432
|
+
"""Take the next bytes of the output and answer the commits whose record they complete, in Git's order.
|
|
433
|
+
|
|
434
|
+
The record left unfinished by the previous chunk is completed field by field; the whole records of the chunk
|
|
435
|
+
are then read in one go each, which is what keeps a long history fast; what remains starts the next record.
|
|
436
|
+
"""
|
|
437
|
+
commits: list[Commit] = []
|
|
438
|
+
pieces = chunk.split(HISTORY_FIELD_SEPARATOR)
|
|
439
|
+
last_piece_index = len(pieces) - 1
|
|
440
|
+
piece_index = 0
|
|
441
|
+
while piece_index < last_piece_index and self._record_started():
|
|
442
|
+
self._extend_field(pieces[piece_index])
|
|
443
|
+
self._close_field(commits)
|
|
444
|
+
piece_index += 1
|
|
445
|
+
while piece_index + HISTORY_FIELD_COUNT <= last_piece_index:
|
|
446
|
+
fields = pieces[piece_index:piece_index + HISTORY_FIELD_COUNT]
|
|
447
|
+
if len(fields[HISTORY_MESSAGE_FIELD]) > self.object_limit_bytes or max(map(len, fields[:HISTORY_MESSAGE_FIELD])) > self.header_limit_bytes:
|
|
448
|
+
break
|
|
449
|
+
self._add_record(fields, False, commits)
|
|
450
|
+
piece_index += HISTORY_FIELD_COUNT
|
|
451
|
+
while piece_index < last_piece_index:
|
|
452
|
+
self._extend_field(pieces[piece_index])
|
|
453
|
+
self._close_field(commits)
|
|
454
|
+
piece_index += 1
|
|
455
|
+
self._extend_field(pieces[last_piece_index])
|
|
456
|
+
return commits
|
|
457
|
+
|
|
458
|
+
def _record_started(self) -> bool:
|
|
459
|
+
"""Tell whether a record was left unfinished by the bytes already read."""
|
|
460
|
+
return bool(self.fields or self.partial_field or self.field_dropped)
|
|
461
|
+
|
|
462
|
+
def _extend_field(self, piece: bytes) -> None:
|
|
463
|
+
"""Keep the next bytes of the field now arriving, or stop keeping them once the field goes over its limit."""
|
|
464
|
+
if self.field_dropped or not piece:
|
|
465
|
+
return
|
|
466
|
+
field_index = len(self.fields)
|
|
467
|
+
limit_bytes = self.object_limit_bytes if field_index == HISTORY_MESSAGE_FIELD else self.header_limit_bytes
|
|
468
|
+
if len(self.partial_field) + len(piece) <= limit_bytes:
|
|
469
|
+
self.partial_field.extend(piece)
|
|
470
|
+
return
|
|
471
|
+
if field_index not in DROPPABLE_HISTORY_FIELD_SET:
|
|
472
|
+
raise ValueError("History record field is too large; history incomplete")
|
|
473
|
+
self.field_dropped = True
|
|
474
|
+
self.record_dropped = True
|
|
475
|
+
self.partial_field.clear()
|
|
476
|
+
|
|
477
|
+
def _close_field(self, commits: list[Commit]) -> None:
|
|
478
|
+
"""End the field now arriving, and turn the record into a commit once its last field has ended."""
|
|
479
|
+
self.fields.append(bytes(self.partial_field))
|
|
480
|
+
self.partial_field.clear()
|
|
481
|
+
self.field_dropped = False
|
|
482
|
+
if len(self.fields) < HISTORY_FIELD_COUNT:
|
|
483
|
+
return
|
|
484
|
+
fields = self.fields
|
|
485
|
+
record_dropped = self.record_dropped
|
|
486
|
+
self.fields = []
|
|
487
|
+
self.record_dropped = False
|
|
488
|
+
self._add_record(fields, record_dropped, commits)
|
|
489
|
+
|
|
490
|
+
def _add_record(self, fields: list[bytes], record_dropped: bool, commits: list[Commit]) -> None:
|
|
491
|
+
"""Check one whole record, add it to the ancestry and answer its commit, unless the commit was already listed.
|
|
492
|
+
|
|
493
|
+
The ancestry comes first and its fields stay strict: a wrong hash, abbreviation or parent means the output can
|
|
494
|
+
no longer be trusted, so the read stops. A time Git accepted but that cannot be read only costs the commit
|
|
495
|
+
its dates, and a field too large to keep costs it its details: its row stays in place either way.
|
|
496
|
+
"""
|
|
497
|
+
oid = validate_oid(fields[HISTORY_OID_FIELD].decode("ascii"))
|
|
498
|
+
short_oid = fields[HISTORY_SHORT_OID_FIELD].decode("ascii")
|
|
499
|
+
if not short_oid or not oid.startswith(short_oid):
|
|
500
|
+
raise ValueError("Unexpected commit abbreviation in the history")
|
|
501
|
+
parent_oids: list[str] = []
|
|
502
|
+
for parent in fields[HISTORY_PARENT_OIDS_FIELD].split(HISTORY_PARENT_SEPARATOR):
|
|
503
|
+
if parent:
|
|
504
|
+
parent_oids.append(validate_oid(parent.decode("ascii")))
|
|
505
|
+
snapshot = self.snapshot
|
|
506
|
+
if oid in snapshot.parent_oids_by_oid:
|
|
507
|
+
return
|
|
508
|
+
snapshot.ordered_oids.append(oid)
|
|
509
|
+
snapshot.parent_oids_by_oid[oid] = parent_oids
|
|
510
|
+
snapshot.child_oids_by_oid.setdefault(oid, [])
|
|
511
|
+
for parent_oid in parent_oids:
|
|
512
|
+
snapshot.child_oids_by_oid.setdefault(parent_oid, []).append(oid)
|
|
513
|
+
if record_dropped:
|
|
514
|
+
commits.append(unavailable_commit(oid, short_oid, parent_oids, oversized_commit_cause(self.object_limit_bytes)))
|
|
515
|
+
return
|
|
516
|
+
author_timestamp = readable_commit_time(fields[HISTORY_AUTHOR_TIMESTAMP_FIELD])
|
|
517
|
+
committer_timestamp = readable_commit_time(fields[HISTORY_COMMITTER_TIMESTAMP_FIELD])
|
|
518
|
+
encoding = fields[HISTORY_ENCODING_FIELD] or None
|
|
519
|
+
commits.append(decode_commit_fields(oid, short_oid, parent_oids, fields[HISTORY_AUTHOR_NAME_FIELD], fields[HISTORY_AUTHOR_EMAIL_FIELD],
|
|
520
|
+
author_timestamp, committer_timestamp, encoding, fields[HISTORY_MESSAGE_FIELD], converted_to_utf8=True))
|
|
521
|
+
|
|
522
|
+
def finish(self) -> None:
|
|
523
|
+
"""Refuse a record cut short at the end of the output, then declare the ancestry, and every child list, complete."""
|
|
524
|
+
self.check_ended()
|
|
525
|
+
self.snapshot.complete = True
|
|
526
|
+
|
|
527
|
+
def check_ended(self) -> None:
|
|
528
|
+
"""Refuse an output that ended in the middle of a record."""
|
|
529
|
+
if self._record_started():
|
|
530
|
+
raise ValueError("Incomplete final history record")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def parse_abbreviation_answer(output: bytes, oids: list[str]) -> dict[str, str]:
|
|
534
|
+
"""Read Git's abbreviation of each requested commit, matched by object ID rather than by the order Git answers in.
|
|
535
|
+
|
|
536
|
+
Every requested commit must be answered exactly once, with a non-empty prefix of its own object ID.
|
|
537
|
+
"""
|
|
538
|
+
requested_oid_set = set(oids)
|
|
539
|
+
short_oid_by_oid: dict[str, str] = {}
|
|
540
|
+
for line in output.decode("ascii").splitlines():
|
|
541
|
+
oid, separator, short_oid = line.partition("\0")
|
|
542
|
+
validate_oid(oid)
|
|
543
|
+
if not separator or oid not in requested_oid_set or oid in short_oid_by_oid or not short_oid or not oid.startswith(short_oid):
|
|
544
|
+
raise ValueError("Unexpected commit abbreviation answer")
|
|
545
|
+
short_oid_by_oid[oid] = short_oid
|
|
546
|
+
if len(short_oid_by_oid) != len(requested_oid_set):
|
|
547
|
+
raise ValueError("Incomplete commit abbreviation answer")
|
|
548
|
+
return short_oid_by_oid
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class ChangedPathParser:
|
|
552
|
+
"""Turn the NUL-framed answer of the changed-file read into (commit OID, changed paths) records, whatever the chunk boundaries.
|
|
553
|
+
|
|
554
|
+
Each record of the answer is an empty field, the commit's OID, then one field per changed path, the first one
|
|
555
|
+
starting with the newline Git writes after the commit line. A merge compared with each parent gives one record per
|
|
556
|
+
parent, all with its OID: the caller joins them. Paths stay bytes, since Git may write any byte but NUL in them.
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
def __init__(self) -> None:
|
|
560
|
+
"""Start before the first record, with nothing kept from a previous chunk."""
|
|
561
|
+
self.buffer = b""
|
|
562
|
+
self.expecting_oid = False
|
|
563
|
+
self.oid: str | None = None
|
|
564
|
+
self.paths: list[bytes] = []
|
|
565
|
+
|
|
566
|
+
def feed(self, chunk: bytes) -> list[tuple[str, list[bytes]]]:
|
|
567
|
+
"""Read the fields this chunk completes and return the records that ended in it; the last unterminated field is kept for the next chunk."""
|
|
568
|
+
fields = (self.buffer + chunk).split(CHANGED_PATH_FIELD_SEPARATOR)
|
|
569
|
+
self.buffer = fields.pop()
|
|
570
|
+
records: list[tuple[str, list[bytes]]] = []
|
|
571
|
+
for field in fields:
|
|
572
|
+
self._read_field(field, records)
|
|
573
|
+
return records
|
|
574
|
+
|
|
575
|
+
def finish(self) -> list[tuple[str, list[bytes]]]:
|
|
576
|
+
"""Refuse an answer cut in the middle of a field, then return the last record."""
|
|
577
|
+
if self.buffer:
|
|
578
|
+
raise ValueError("Incomplete changed-file answer")
|
|
579
|
+
records: list[tuple[str, list[bytes]]] = []
|
|
580
|
+
self._close_record(records)
|
|
581
|
+
return records
|
|
582
|
+
|
|
583
|
+
def _read_field(self, field: bytes, records: list[tuple[str, list[bytes]]]) -> None:
|
|
584
|
+
"""Close the open record on an empty field, open one on the field that follows it, or add a path to the open record."""
|
|
585
|
+
if not field:
|
|
586
|
+
self._close_record(records)
|
|
587
|
+
self.expecting_oid = True
|
|
588
|
+
return
|
|
589
|
+
if self.expecting_oid:
|
|
590
|
+
self.oid = validate_oid(field.decode("ascii"))
|
|
591
|
+
self.expecting_oid = False
|
|
592
|
+
return
|
|
593
|
+
if self.oid is None:
|
|
594
|
+
raise ValueError("Invalid changed-file answer")
|
|
595
|
+
if not self.paths and field.startswith(CHANGED_PATH_LIST_START):
|
|
596
|
+
field = field[len(CHANGED_PATH_LIST_START):]
|
|
597
|
+
self.paths.append(field)
|
|
598
|
+
|
|
599
|
+
def _close_record(self, records: list[tuple[str, list[bytes]]]) -> None:
|
|
600
|
+
"""Hand over the open record, if any, and start with no record open."""
|
|
601
|
+
if self.oid is None:
|
|
602
|
+
return
|
|
603
|
+
records.append((self.oid, self.paths))
|
|
604
|
+
self.oid = None
|
|
605
|
+
self.paths = []
|