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,766 @@
|
|
|
1
|
+
"""Collect complete, filtered process records before exposing them to any consumer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import json
|
|
7
|
+
import struct
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import ExitStack
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
14
|
+
|
|
15
|
+
from gitpane.config import LINE_SEPARATOR, TRACE_PAGE_SIZE, TRACE_STORAGE_LIMIT_BYTES, TRACE_UNIT_LIMIT_BYTES, TRACE_VIEW_LIMIT_BYTES
|
|
16
|
+
from gitpane.operation.console_line import (
|
|
17
|
+
CANCELLED_TEXT,
|
|
18
|
+
FAILED_TEXT,
|
|
19
|
+
FAILED_WITH_EXIT_TEMPLATE,
|
|
20
|
+
NOT_STARTED_TEXT,
|
|
21
|
+
ConsoleLine,
|
|
22
|
+
cause_beyond_exit_code,
|
|
23
|
+
cause_without_shown_output,
|
|
24
|
+
command_line,
|
|
25
|
+
header_text,
|
|
26
|
+
line_with_cause,
|
|
27
|
+
process_end_line,
|
|
28
|
+
with_account_hint,
|
|
29
|
+
)
|
|
30
|
+
from gitpane.operation.console_tone import ConsoleTone, git_output_tone, result_tone
|
|
31
|
+
from gitpane.operation.diagnostic import OPAQUE_BODY_PREFIX, Diagnostic, NetworkTextStream, PreparedDiagnostic, SensitiveTextFilter, SensitiveTextStream, TextUnit, TextUnitEnd, TextValueSource
|
|
32
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
33
|
+
from gitpane.process.process_runner import CREDENTIAL_PROFILE, NETWORK_PROFILE, READ_PROFILE
|
|
34
|
+
from gitpane.transport.git_refusal import is_trace_line
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
|
|
39
|
+
from gitpane.operation.console_line import ConsoleHeader
|
|
40
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
41
|
+
from gitpane.process.process_runner import ProcessRequest, ProcessResult, ProcessRunner
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Records of these kinds are errors GitPane reports itself, shown red unless the caller gives another tone.
|
|
45
|
+
FAILURE_KIND_SET = frozenset({"error", "incomplete"})
|
|
46
|
+
# Records of these kinds are what a process wrote; only they can be the description of a binary output.
|
|
47
|
+
PROCESS_OUTPUT_KIND_SET = frozenset({"stdout", "stderr", "http-body"})
|
|
48
|
+
# Kind of the event that tells the view about a console-only line, which is never stored as a record nor logged.
|
|
49
|
+
CONSOLE_ONLY_KIND = "console"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def publish_after_trace(publish: Callable[[], None], context: QObject | None = None) -> None:
|
|
53
|
+
"""Call ``publish`` one event-loop turn later, once the operation's trace has heard the process that just ended.
|
|
54
|
+
|
|
55
|
+
A service hears a process end before the trace of its operation does, because the trace connects to the runner's
|
|
56
|
+
``finished`` signal after the service. Writing the result one turn later lets the trace write the ended process's
|
|
57
|
+
own line first: a diagnostic then knows that line is already on screen, and the end line stays the last line. With
|
|
58
|
+
a ``context``, the call is dropped if that object is destroyed before the turn comes.
|
|
59
|
+
"""
|
|
60
|
+
if context is None:
|
|
61
|
+
QTimer.singleShot(0, publish)
|
|
62
|
+
return
|
|
63
|
+
QTimer.singleShot(0, context, publish)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cause_without_trace_lines(cause: str) -> str:
|
|
67
|
+
"""Return a process failure cause without its trace records, for the result record written to the ``--log`` file.
|
|
68
|
+
|
|
69
|
+
A process that fails without its own explanation takes its whole standard error as cause. Its trace records already
|
|
70
|
+
went to the ``--log`` file one by one as standard error, so the result record repeats only what Git says to the user.
|
|
71
|
+
"""
|
|
72
|
+
kept_lines: list[str] = []
|
|
73
|
+
for line in cause.split("\n"):
|
|
74
|
+
if not is_trace_line(line):
|
|
75
|
+
kept_lines.append(line)
|
|
76
|
+
return "\n".join(kept_lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class TraceEvent:
|
|
81
|
+
"""Carry an operation's already-filtered record with its originating process."""
|
|
82
|
+
|
|
83
|
+
operation_id: str
|
|
84
|
+
process_id: str
|
|
85
|
+
kind: str
|
|
86
|
+
text: str
|
|
87
|
+
sequence: int = 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ToneSpan:
|
|
92
|
+
"""A byte range of the console text and its colour; the view sets the tone on the Qt blocks of that range.
|
|
93
|
+
|
|
94
|
+
``end`` is exclusive. Spans are added in text order and never overlap.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
start: int
|
|
98
|
+
end: int
|
|
99
|
+
tone: ConsoleTone
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class OpenLine:
|
|
104
|
+
"""The console's last line while the process that wrote it can still redraw it."""
|
|
105
|
+
|
|
106
|
+
process_id: str
|
|
107
|
+
kind: str
|
|
108
|
+
line_start: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OperationTrace(QObject):
|
|
112
|
+
"""Retain safe complete units, withholding fragments until redaction is possible."""
|
|
113
|
+
|
|
114
|
+
event_added = Signal(object)
|
|
115
|
+
|
|
116
|
+
def __init__(self, operation_id: str, runner: ProcessRunner, log: DiagnosticLog | None = None, *, native_commands_only: bool = False,
|
|
117
|
+
header: ConsoleHeader | None = None) -> None:
|
|
118
|
+
"""Subscribe to the injected runner without owning its process lifetime.
|
|
119
|
+
|
|
120
|
+
With ``native_commands_only``, the reads the operation runs for its own checks are left out: they are not
|
|
121
|
+
commands the user would type, and the console then shows only what a terminal would. They are not lost: the
|
|
122
|
+
``--log`` file keeps one line for every finished process, written by the launcher whatever the trace keeps.
|
|
123
|
+
The status lines the application appends itself are always kept. Without it, a read still shows only its
|
|
124
|
+
``$`` line: its standard output is recorded and logged but never shown (see ``shows_output``).
|
|
125
|
+
|
|
126
|
+
A ``header`` names the repository (or a clone's destination) once, on the first line of the console, just
|
|
127
|
+
before the first line shown: an empty trace stays empty. It is console text only and never reaches the log.
|
|
128
|
+
"""
|
|
129
|
+
super().__init__()
|
|
130
|
+
self.operation_id = operation_id
|
|
131
|
+
self.runner = runner
|
|
132
|
+
self.log = log
|
|
133
|
+
self.native_commands_only = native_commands_only
|
|
134
|
+
self.header = header
|
|
135
|
+
self.header_written = False
|
|
136
|
+
self.tone_spans: list[ToneSpan] = []
|
|
137
|
+
# Processes the console already ended with their own line, and the error output each of them showed: a
|
|
138
|
+
# diagnostic about one of them adds nothing, and any diagnostic leaves out of its cause what was already shown.
|
|
139
|
+
self.ended_process_id_set: set[str] = set()
|
|
140
|
+
self.failed_outputs: list[str] = []
|
|
141
|
+
# Processes whose ``$ `` line the console wrote and that have not ended yet: a request that ends without one
|
|
142
|
+
# never started, which its end line says.
|
|
143
|
+
self.started_request_id_set: set[str] = set()
|
|
144
|
+
self.event_count = 0
|
|
145
|
+
self.stored_count = 0
|
|
146
|
+
self.storage_size = 0
|
|
147
|
+
self.text_size = 0
|
|
148
|
+
self.open_line: OpenLine | None = None
|
|
149
|
+
self.capture_failed = False
|
|
150
|
+
self.failure_event: TraceEvent | None = None
|
|
151
|
+
self.failure_text = b""
|
|
152
|
+
self.index_record = struct.Struct("!Q")
|
|
153
|
+
self.resource = ExitStack()
|
|
154
|
+
self.stream = None
|
|
155
|
+
self.index_stream = None
|
|
156
|
+
self.text_stream = None
|
|
157
|
+
self.filter = SensitiveTextFilter()
|
|
158
|
+
self.complete = True
|
|
159
|
+
self.stream_by_key: dict[tuple[str, str], SensitiveTextStream] = {}
|
|
160
|
+
self.config_buffer_by_id: dict[str, bytearray] = {}
|
|
161
|
+
self.network_stream_by_id: dict[str, NetworkTextStream] = {}
|
|
162
|
+
self.closed = False
|
|
163
|
+
self.close_requested = False
|
|
164
|
+
runner.started.connect(self._started)
|
|
165
|
+
runner.stdout_chunk.connect(self._stdout)
|
|
166
|
+
runner.stderr_chunk.connect(self._stderr)
|
|
167
|
+
runner.finished.connect(self._finished)
|
|
168
|
+
try:
|
|
169
|
+
# Anonymous private handles belong to this collector and disappear on close or process exit.
|
|
170
|
+
self.stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
|
|
171
|
+
self.index_stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
|
|
172
|
+
self.text_stream = self.resource.enter_context(tempfile.TemporaryFile(mode="w+b")) # noqa: SIM115
|
|
173
|
+
except OSError:
|
|
174
|
+
self.mark_incomplete("Filtered output storage could not be created. Check temporary-directory permissions or available disk space.")
|
|
175
|
+
|
|
176
|
+
def append_event(self, process_id: str, kind: str, text: str, tone: ConsoleTone = ConsoleTone.NEUTRAL) -> None:
|
|
177
|
+
"""Record a text and show it as it is, as a terminal would, without any label.
|
|
178
|
+
|
|
179
|
+
A Git, curl or Git Credential Manager trace record on standard error stays out of the console text and of what
|
|
180
|
+
it copies: there it buries what Git says to the user. It is still stored as a record, so a reader of the
|
|
181
|
+
records, such as a test, sees every one, and it is still written to the ``--log``
|
|
182
|
+
file, which keeps the complete output for diagnosis. Such a record notifies no view, since none shows it. The
|
|
183
|
+
one-line description that replaces a binary output is kept the same way: it says nothing a terminal would show.
|
|
184
|
+
|
|
185
|
+
Without a given tone, a line of standard error takes the colour of what Git says on it (error, warning), an
|
|
186
|
+
error GitPane reports itself is red, and anything else, the status lines GitPane writes included, is neutral.
|
|
187
|
+
"""
|
|
188
|
+
self._append(process_id, kind, text, self._output_console_line(kind, text, tone), hide_trace_line=kind == "stderr", hide_opaque_body=kind in PROCESS_OUTPUT_KIND_SET)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _output_console_line(kind: str, text: str, tone: ConsoleTone = ConsoleTone.NEUTRAL) -> ConsoleLine:
|
|
192
|
+
"""Choose an output line's colour and account hint consistently for records and redraws."""
|
|
193
|
+
is_stderr = kind == "stderr"
|
|
194
|
+
if tone == ConsoleTone.NEUTRAL:
|
|
195
|
+
if is_stderr:
|
|
196
|
+
tone = git_output_tone(text)
|
|
197
|
+
elif kind in FAILURE_KIND_SET:
|
|
198
|
+
tone = ConsoleTone.FAILURE
|
|
199
|
+
console_text = with_account_hint(text) if is_stderr else text
|
|
200
|
+
return ConsoleLine(console_text, tone)
|
|
201
|
+
|
|
202
|
+
def append_record(self, process_id: str, kind: str, text: str, console_line: ConsoleLine | None) -> None:
|
|
203
|
+
"""Store and log a record as it is, and show a different, shorter line in the console, or nothing.
|
|
204
|
+
|
|
205
|
+
The ``--log`` file keeps the full detail, such as the block describing a command; the console shows what a
|
|
206
|
+
terminal would, such as the command line itself.
|
|
207
|
+
"""
|
|
208
|
+
self._append(process_id, kind, text, console_line)
|
|
209
|
+
|
|
210
|
+
def append_failure_cause(self, process_id: str, text: str) -> None:
|
|
211
|
+
"""Write a cause GitPane found for a failure, such as a setting that made Git refuse something, apart from Git's own lines.
|
|
212
|
+
|
|
213
|
+
It is stored as an error record, written to the ``--log`` file and shown as one console line of its own, red and
|
|
214
|
+
bold even during a pause, so the user reads in plain words why the operation failed and what to change.
|
|
215
|
+
"""
|
|
216
|
+
self.append_record(process_id, "error", text, ConsoleLine(text, ConsoleTone.FAILURE_CAUSE))
|
|
217
|
+
|
|
218
|
+
def append_end_line(self, process_id: str, kind: str, log_text: str, console_text: str, status: OperationStatus, *, failure_reported: bool = False,
|
|
219
|
+
expected_stop: bool = False) -> None:
|
|
220
|
+
"""Write the line that ends an operation: its old text in the records and the ``--log`` file, the readable one in the console.
|
|
221
|
+
|
|
222
|
+
Its colour comes from ``end_tone``, the one rule every end line follows.
|
|
223
|
+
"""
|
|
224
|
+
tone = self.end_tone(status, failure_reported=failure_reported, expected_stop=expected_stop)
|
|
225
|
+
self.append_record(process_id, kind, log_text, ConsoleLine(console_text, tone))
|
|
226
|
+
|
|
227
|
+
def end_tone(self, status: OperationStatus, *, failure_reported: bool = False, expected_stop: bool = False) -> ConsoleTone:
|
|
228
|
+
"""Return the colour of the line that ends an operation: the only rule for an end line's colour, wherever it is written.
|
|
229
|
+
|
|
230
|
+
It is the rule of the console window, which takes an incomplete trace for a reported failure: a pause on an
|
|
231
|
+
incomplete trace ends red, as its window ends failed, so the end line and the window cannot disagree.
|
|
232
|
+
"""
|
|
233
|
+
return result_tone(status, failure_reported=failure_reported or not self.complete, expected_stop=expected_stop)
|
|
234
|
+
|
|
235
|
+
def unshown_cause(self, diagnostic: PreparedDiagnostic) -> str | None:
|
|
236
|
+
"""Return the part of a GitPane diagnostic's cause the console has not shown yet, or None when it has nothing to add.
|
|
237
|
+
|
|
238
|
+
This is the one place that reads a cause for the console, so a diagnostic line and an end line that carries its
|
|
239
|
+
cause cannot disagree. Nothing is added for a process the console already ended with its own line, and the error
|
|
240
|
+
output the console already showed is removed; when nothing remains, the answer is None. The runner's generic
|
|
241
|
+
sentence for a silent failure only repeats the exit code, so it becomes an empty cause: the failure still has
|
|
242
|
+
something to say (its exit code), but no words to add.
|
|
243
|
+
"""
|
|
244
|
+
if diagnostic.process_id and diagnostic.process_id in self.ended_process_id_set:
|
|
245
|
+
return None
|
|
246
|
+
cause = cause_without_shown_output(diagnostic.cause, "")
|
|
247
|
+
for failed_output in self.failed_outputs:
|
|
248
|
+
cause = cause_without_shown_output(cause, failed_output)
|
|
249
|
+
if not cause:
|
|
250
|
+
return None
|
|
251
|
+
return cause_beyond_exit_code(cause, diagnostic.return_code)
|
|
252
|
+
|
|
253
|
+
def diagnostic_console_line(self, diagnostic: PreparedDiagnostic, status: OperationStatus, *, paused: bool = False) -> ConsoleLine | None:
|
|
254
|
+
"""Return the one line a console shows for a GitPane diagnostic, or None when it would only repeat what is on screen.
|
|
255
|
+
|
|
256
|
+
The rules apply in this order:
|
|
257
|
+
1. a diagnostic about a process the console already ended with its own line adds nothing;
|
|
258
|
+
2. the cause loses the error output the console already showed, and an empty cause adds nothing;
|
|
259
|
+
3. the runner's generic sentence for a silent failure is never written, since the exit code already says it;
|
|
260
|
+
4. during a pause the cause explains why the operation waits, so it is shown neutral, without a word of failure,
|
|
261
|
+
and nothing is added when no cause remains;
|
|
262
|
+
5. a failure that carries an exit code (a process this console hid) reads ``Failed (exit N): <cause>``, or
|
|
263
|
+
``Failed (exit N)`` alone when no cause remains: its failure is shown nowhere else;
|
|
264
|
+
6. an operation GitPane refused before launching Git reads ``Not started: <cause>``;
|
|
265
|
+
7. an operation the user cancelled reads ``Cancelled: <cause>``, in orange;
|
|
266
|
+
8. anything else reads ``Failed: <cause>``.
|
|
267
|
+
Failures and refusals are red. The full diagnostic block is not shown: it belongs to the ``--log`` file.
|
|
268
|
+
"""
|
|
269
|
+
cause = self.unshown_cause(diagnostic)
|
|
270
|
+
if cause is None:
|
|
271
|
+
return None
|
|
272
|
+
if paused:
|
|
273
|
+
return ConsoleLine(cause) if cause else None
|
|
274
|
+
if diagnostic.return_code is not None:
|
|
275
|
+
return ConsoleLine(line_with_cause(FAILED_WITH_EXIT_TEMPLATE.format(return_code=diagnostic.return_code), cause), ConsoleTone.FAILURE)
|
|
276
|
+
if status == OperationStatus.REFUSED:
|
|
277
|
+
return ConsoleLine(line_with_cause(NOT_STARTED_TEXT, cause), ConsoleTone.FAILURE)
|
|
278
|
+
if status == OperationStatus.CANCELLED:
|
|
279
|
+
return ConsoleLine(line_with_cause(CANCELLED_TEXT, cause), ConsoleTone.CANCELLED)
|
|
280
|
+
return ConsoleLine(line_with_cause(FAILED_TEXT, cause), ConsoleTone.FAILURE)
|
|
281
|
+
|
|
282
|
+
def append_diagnostic(self, process_id: str, kind: str, diagnostic: PreparedDiagnostic, status: OperationStatus, *, paused: bool = False) -> None:
|
|
283
|
+
"""Log a GitPane diagnostic as its full block and show at most one line of it, by the rules of ``diagnostic_console_line``.
|
|
284
|
+
|
|
285
|
+
``status`` is the operation's outcome, which tells a refusal from a failure; ``paused`` tells that the
|
|
286
|
+
operation stopped to wait for the user, which the diagnostic then explains.
|
|
287
|
+
"""
|
|
288
|
+
self.append_record(process_id, kind, diagnostic.detail, self.diagnostic_console_line(diagnostic, status, paused=paused))
|
|
289
|
+
|
|
290
|
+
def append_console_line(self, console_line: ConsoleLine) -> None:
|
|
291
|
+
"""Show a line in the console alone, such as the end line of an operation that no record carried.
|
|
292
|
+
|
|
293
|
+
The records and the ``--log`` file stay exactly as they were: no record is stored, none is counted and nothing
|
|
294
|
+
is logged. The line goes through the same filter as every shown text, and the view is told to show it.
|
|
295
|
+
"""
|
|
296
|
+
if self.closed or self.capture_failed:
|
|
297
|
+
return
|
|
298
|
+
if len(console_line.text) > TRACE_UNIT_LIMIT_BYTES or len(console_line.text.encode("utf-8", "replace")) > TRACE_UNIT_LIMIT_BYTES:
|
|
299
|
+
self.mark_incomplete("An output record exceeded the text-unit limit.")
|
|
300
|
+
return
|
|
301
|
+
line_text = self.filter.redact(console_line.text)
|
|
302
|
+
self._close_open_line()
|
|
303
|
+
display_data, tone_span = self._display(line_text, console_line.tone)
|
|
304
|
+
if self.storage_size + len(display_data) > TRACE_STORAGE_LIMIT_BYTES:
|
|
305
|
+
self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
|
|
306
|
+
return
|
|
307
|
+
try:
|
|
308
|
+
self._write_display(display_data)
|
|
309
|
+
except OSError:
|
|
310
|
+
self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
|
|
311
|
+
return
|
|
312
|
+
self.storage_size += len(display_data)
|
|
313
|
+
self._displayed(display_data, tone_span)
|
|
314
|
+
self.event_added.emit(TraceEvent(self.operation_id, self.operation_id, CONSOLE_ONLY_KIND, line_text, self.event_count))
|
|
315
|
+
|
|
316
|
+
def tone_spans_between(self, start: int, end: int) -> list[ToneSpan]:
|
|
317
|
+
"""Return, in text order, the coloured byte ranges that overlap the console text from ``start`` to ``end`` (exclusive)."""
|
|
318
|
+
spans: list[ToneSpan] = []
|
|
319
|
+
first_index = bisect.bisect_right(self.tone_spans, start, key=lambda span: span.end)
|
|
320
|
+
for span in self.tone_spans[first_index:]:
|
|
321
|
+
if span.start >= end:
|
|
322
|
+
break
|
|
323
|
+
spans.append(span)
|
|
324
|
+
return spans
|
|
325
|
+
|
|
326
|
+
def _display_prefix(self) -> str:
|
|
327
|
+
"""Return what comes before the next shown line: a line break after earlier text, and the header before the first line."""
|
|
328
|
+
prefix = LINE_SEPARATOR if self.text_size else ""
|
|
329
|
+
if self.header is not None and not self.header_written:
|
|
330
|
+
prefix += self.filter.redact(header_text(self.header)) + LINE_SEPARATOR
|
|
331
|
+
return prefix
|
|
332
|
+
|
|
333
|
+
def _display(self, line_text: str, tone: ConsoleTone) -> tuple[bytes, ToneSpan | None]:
|
|
334
|
+
"""Return the bytes a shown line adds to the console text, line break and header included, and the coloured range of the line if any."""
|
|
335
|
+
prefix_data = self._display_prefix().encode("utf-8", "replace")
|
|
336
|
+
line_data = line_text.encode("utf-8", "replace")
|
|
337
|
+
tone_span = None
|
|
338
|
+
if tone != ConsoleTone.NEUTRAL and line_data:
|
|
339
|
+
line_start = self.text_size + len(prefix_data)
|
|
340
|
+
tone_span = ToneSpan(line_start, line_start + len(line_data), tone)
|
|
341
|
+
return prefix_data + line_data, tone_span
|
|
342
|
+
|
|
343
|
+
def _write_display(self, display_data: bytes) -> None:
|
|
344
|
+
"""Write shown bytes at the end of the console text, flushed so that a full disk fails here; raise OSError on failure."""
|
|
345
|
+
self.text_stream.seek(self.text_size)
|
|
346
|
+
self.text_stream.write(display_data)
|
|
347
|
+
self.text_stream.flush()
|
|
348
|
+
|
|
349
|
+
def _displayed(self, display_data: bytes, tone_span: ToneSpan | None) -> None:
|
|
350
|
+
"""Count written console bytes as shown: the header is now on screen and the line keeps its colour."""
|
|
351
|
+
self.text_size += len(display_data)
|
|
352
|
+
self.header_written = True
|
|
353
|
+
if tone_span is not None:
|
|
354
|
+
self.tone_spans.append(tone_span)
|
|
355
|
+
|
|
356
|
+
def stable_size(self) -> int:
|
|
357
|
+
"""Return the size of the console prefix that no later write can change."""
|
|
358
|
+
if self.open_line is not None:
|
|
359
|
+
return self.open_line.line_start
|
|
360
|
+
return self.text_size + len(self.failure_text)
|
|
361
|
+
|
|
362
|
+
def _close_open_line(self) -> None:
|
|
363
|
+
"""Keep the last drawn value as finished text before another line is written."""
|
|
364
|
+
self.open_line = None
|
|
365
|
+
|
|
366
|
+
def _rewrite_open_line(self, line_text: str, tone: ConsoleTone) -> bool:
|
|
367
|
+
"""Replace only the open line's bytes, counting the size difference against storage limits."""
|
|
368
|
+
line_start = self.open_line.line_start
|
|
369
|
+
line_data = line_text.encode("utf-8", "replace")
|
|
370
|
+
size_difference = line_start + len(line_data) - self.text_size
|
|
371
|
+
if self.storage_size + size_difference > TRACE_STORAGE_LIMIT_BYTES:
|
|
372
|
+
self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
|
|
373
|
+
return False
|
|
374
|
+
try:
|
|
375
|
+
self.text_stream.seek(line_start)
|
|
376
|
+
self.text_stream.write(line_data)
|
|
377
|
+
self.text_stream.truncate()
|
|
378
|
+
self.text_stream.flush()
|
|
379
|
+
except OSError:
|
|
380
|
+
self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
|
|
381
|
+
return False
|
|
382
|
+
self.storage_size += size_difference
|
|
383
|
+
self.text_size = line_start + len(line_data)
|
|
384
|
+
last_span_index = -1
|
|
385
|
+
if self.tone_spans and self.tone_spans[last_span_index].start >= line_start:
|
|
386
|
+
self.tone_spans.pop()
|
|
387
|
+
if tone != ConsoleTone.NEUTRAL and line_data:
|
|
388
|
+
self.tone_spans.append(ToneSpan(line_start, self.text_size, tone))
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
def _draw_open_line(self, process_id: str, kind: str, value: str) -> None:
|
|
392
|
+
"""Show a filtered redraw without adding a stored record or a log line."""
|
|
393
|
+
if self.closed or self.capture_failed:
|
|
394
|
+
return
|
|
395
|
+
console_line = self._output_console_line(kind, value)
|
|
396
|
+
line_text = self.filter.redact(console_line.text)
|
|
397
|
+
if self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == (process_id, kind):
|
|
398
|
+
if not self._rewrite_open_line(line_text, console_line.tone):
|
|
399
|
+
return
|
|
400
|
+
else:
|
|
401
|
+
self._close_open_line()
|
|
402
|
+
display_data, tone_span = self._display(line_text, console_line.tone)
|
|
403
|
+
line_start = self.text_size + len(display_data) - len(line_text.encode("utf-8", "replace"))
|
|
404
|
+
if self.storage_size + len(display_data) > TRACE_STORAGE_LIMIT_BYTES:
|
|
405
|
+
self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
|
|
406
|
+
return
|
|
407
|
+
try:
|
|
408
|
+
self._write_display(display_data)
|
|
409
|
+
except OSError:
|
|
410
|
+
self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
|
|
411
|
+
return
|
|
412
|
+
self.storage_size += len(display_data)
|
|
413
|
+
self._displayed(display_data, tone_span)
|
|
414
|
+
self.open_line = OpenLine(process_id, kind, line_start)
|
|
415
|
+
self.event_added.emit(TraceEvent(self.operation_id, process_id, CONSOLE_ONLY_KIND, line_text, self.event_count))
|
|
416
|
+
|
|
417
|
+
def _append(self, process_id: str, kind: str, text: str, console_line: ConsoleLine | None, *, hide_trace_line: bool = False, hide_opaque_body: bool = False,
|
|
418
|
+
console_text_filtered: bool = False, open_line_key: tuple[str, str] | None = None) -> None:
|
|
419
|
+
"""Filter an entire record once before model, clipboard or optional log access, and show its console line if any.
|
|
420
|
+
|
|
421
|
+
The stored record and the logged text are the record itself, labelled with its process and kind; only the
|
|
422
|
+
console text differs. A console line equal to the record reuses its filtered text, so Git's output is filtered
|
|
423
|
+
once; a different line (a command, an end line) goes through the same filter, which knows every secret seen.
|
|
424
|
+
``hide_trace_line`` leaves out of the console a record whose filtered text is a debug trace line, and
|
|
425
|
+
``hide_opaque_body`` one that describes a binary output. ``console_text_filtered`` tells that the console line
|
|
426
|
+
already went through the filter piece by piece, as a command line does word by word, and must not be filtered
|
|
427
|
+
again as a whole.
|
|
428
|
+
"""
|
|
429
|
+
if self.closed or self.capture_failed:
|
|
430
|
+
return
|
|
431
|
+
if len(text) > TRACE_UNIT_LIMIT_BYTES or len(text.encode("utf-8", "replace")) > TRACE_UNIT_LIMIT_BYTES:
|
|
432
|
+
self.mark_incomplete("An output record exceeded the text-unit limit.")
|
|
433
|
+
return
|
|
434
|
+
safe_text = self.filter.redact(text)
|
|
435
|
+
if '"result": "incomplete"' in safe_text or '"result":"incomplete"' in safe_text or "Trace incomplete:" in safe_text:
|
|
436
|
+
self.complete = False
|
|
437
|
+
event = TraceEvent(self.operation_id, process_id, kind, safe_text, self.event_count)
|
|
438
|
+
if hide_trace_line and is_trace_line(safe_text):
|
|
439
|
+
console_line = None
|
|
440
|
+
if hide_opaque_body and safe_text.startswith(OPAQUE_BODY_PREFIX):
|
|
441
|
+
console_line = None
|
|
442
|
+
data = json.dumps([event.operation_id, event.process_id, event.kind, event.text, event.sequence], ensure_ascii=True).encode("utf-8") + b"\n"
|
|
443
|
+
display_data = b""
|
|
444
|
+
tone_span = None
|
|
445
|
+
replaces_open_line = False
|
|
446
|
+
display_size = 0
|
|
447
|
+
if console_line is not None:
|
|
448
|
+
if console_text_filtered:
|
|
449
|
+
line_text = console_line.text
|
|
450
|
+
elif console_line.text == text:
|
|
451
|
+
line_text = safe_text
|
|
452
|
+
else:
|
|
453
|
+
line_text = self.filter.redact(console_line.text)
|
|
454
|
+
replaces_open_line = self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == open_line_key
|
|
455
|
+
if replaces_open_line:
|
|
456
|
+
if line_text:
|
|
457
|
+
display_size = self.open_line.line_start + len(line_text.encode("utf-8", "replace")) - self.text_size
|
|
458
|
+
else:
|
|
459
|
+
self._close_open_line()
|
|
460
|
+
display_data, tone_span = self._display(line_text, console_line.tone)
|
|
461
|
+
display_size = len(display_data)
|
|
462
|
+
size = len(data) + display_size + self.index_record.size
|
|
463
|
+
if self.storage_size + size > TRACE_STORAGE_LIMIT_BYTES:
|
|
464
|
+
self.mark_incomplete("Filtered storage quota exceeded. Earlier output remains available.")
|
|
465
|
+
return
|
|
466
|
+
# Flushing each record makes a full disk fail here, before the counters count the record as stored, and leaves
|
|
467
|
+
# nothing buffered for close() to write. It adds no system call: the seek before the next record would flush anyway.
|
|
468
|
+
try:
|
|
469
|
+
self.stream.seek(0, 2)
|
|
470
|
+
offset = self.stream.tell()
|
|
471
|
+
self.stream.write(data)
|
|
472
|
+
self.stream.flush()
|
|
473
|
+
if replaces_open_line:
|
|
474
|
+
if line_text and not self._rewrite_open_line(line_text, console_line.tone):
|
|
475
|
+
return
|
|
476
|
+
self._close_open_line()
|
|
477
|
+
else:
|
|
478
|
+
self._write_display(display_data)
|
|
479
|
+
self.index_stream.seek(self.stored_count * self.index_record.size)
|
|
480
|
+
self.index_stream.write(self.index_record.pack(offset))
|
|
481
|
+
self.index_stream.flush()
|
|
482
|
+
except OSError:
|
|
483
|
+
self.mark_incomplete("Filtered output could not be saved. Check available disk space. Earlier output remains available.")
|
|
484
|
+
return
|
|
485
|
+
self.storage_size += size - display_size if replaces_open_line else size
|
|
486
|
+
self.stored_count += 1
|
|
487
|
+
self.event_count += 1
|
|
488
|
+
if console_line is not None and not replaces_open_line:
|
|
489
|
+
self._displayed(display_data, tone_span)
|
|
490
|
+
if self.log is not None:
|
|
491
|
+
self.log.write(PreparedDiagnostic("Operation", "", self.format_event(event), safe_text != text))
|
|
492
|
+
if console_line is not None:
|
|
493
|
+
self.event_added.emit(event)
|
|
494
|
+
|
|
495
|
+
def mark_incomplete(self, cause: str) -> None:
|
|
496
|
+
"""Retain one bounded failure record even when no further disk writes are possible."""
|
|
497
|
+
if self.closed:
|
|
498
|
+
return
|
|
499
|
+
self._close_open_line()
|
|
500
|
+
self.complete = False
|
|
501
|
+
self.capture_failed = True
|
|
502
|
+
if self.failure_event is not None:
|
|
503
|
+
return
|
|
504
|
+
self.failure_event = TraceEvent(self.operation_id, self.operation_id, "incomplete", "Trace incomplete: " + self.filter.redact(cause), self.event_count)
|
|
505
|
+
prefix_data = self._display_prefix().encode("utf-8")
|
|
506
|
+
line_data = self.failure_event.text.encode("utf-8")
|
|
507
|
+
self.failure_text = prefix_data + line_data
|
|
508
|
+
line_start = self.text_size + len(prefix_data)
|
|
509
|
+
self.tone_spans.append(ToneSpan(line_start, line_start + len(line_data), ConsoleTone.FAILURE))
|
|
510
|
+
self.event_count += 1
|
|
511
|
+
if self.log is not None:
|
|
512
|
+
self.log.write(PreparedDiagnostic("Operation", "", self.format_event(self.failure_event), False))
|
|
513
|
+
self.event_added.emit(self.failure_event)
|
|
514
|
+
|
|
515
|
+
def read_page(self, start: int = 0, count: int = TRACE_PAGE_SIZE) -> list[TraceEvent]:
|
|
516
|
+
"""Return a bounded page without lending mutable storage to the view."""
|
|
517
|
+
events: list[TraceEvent] = []
|
|
518
|
+
if self.closed:
|
|
519
|
+
return events
|
|
520
|
+
for index in range(max(0, start), min(self.event_count, max(0, start) + min(TRACE_PAGE_SIZE, max(0, count)))):
|
|
521
|
+
if index == self.stored_count and self.failure_event is not None:
|
|
522
|
+
events.append(self.failure_event)
|
|
523
|
+
continue
|
|
524
|
+
try:
|
|
525
|
+
self.index_stream.seek(index * self.index_record.size)
|
|
526
|
+
offset, = self.index_record.unpack(self.index_stream.read(self.index_record.size))
|
|
527
|
+
self.stream.seek(offset)
|
|
528
|
+
events.append(TraceEvent(*json.loads(self.stream.readline())))
|
|
529
|
+
except (OSError, ValueError, struct.error):
|
|
530
|
+
self.mark_incomplete("Some saved output records could not be read. Available text can still be copied.")
|
|
531
|
+
break
|
|
532
|
+
return events
|
|
533
|
+
|
|
534
|
+
def read_text_page(self, start: int = 0, count: int = TRACE_VIEW_LIMIT_BYTES) -> tuple[str, int, int]:
|
|
535
|
+
"""Read one bounded UTF-8 page and return its exact byte boundaries for navigation."""
|
|
536
|
+
if self.closed:
|
|
537
|
+
return "", 0, 0
|
|
538
|
+
start = max(0, min(start, self.text_size + len(self.failure_text)))
|
|
539
|
+
limit = min(max(1, count), TRACE_VIEW_LIMIT_BYTES)
|
|
540
|
+
data = self._read_text(start, limit)
|
|
541
|
+
# Page requests can land inside UTF-8; advance only across the remaining continuation bytes.
|
|
542
|
+
first_byte_index = 0
|
|
543
|
+
while data and data[first_byte_index] & 0xC0 == 0x80:
|
|
544
|
+
start += 1
|
|
545
|
+
data = data[1:]
|
|
546
|
+
while data:
|
|
547
|
+
try:
|
|
548
|
+
return data.decode("utf-8"), start, start + len(data)
|
|
549
|
+
except UnicodeDecodeError as error:
|
|
550
|
+
if error.end == len(data):
|
|
551
|
+
data = data[:error.start]
|
|
552
|
+
else:
|
|
553
|
+
self.mark_incomplete("Saved output contained unreadable text.")
|
|
554
|
+
return data.decode("utf-8", "replace"), start, start + len(data)
|
|
555
|
+
return "", start, start
|
|
556
|
+
|
|
557
|
+
def _read_text(self, start: int, count: int) -> bytes:
|
|
558
|
+
"""Combine saved text with the single in-memory storage-failure record."""
|
|
559
|
+
data = b""
|
|
560
|
+
if start < self.text_size:
|
|
561
|
+
try:
|
|
562
|
+
self.text_stream.seek(start)
|
|
563
|
+
expected_count = min(count, self.text_size - start)
|
|
564
|
+
data = self.text_stream.read(expected_count)
|
|
565
|
+
if len(data) != expected_count:
|
|
566
|
+
self.mark_incomplete("Saved output ended unexpectedly. The operation trace is incomplete.")
|
|
567
|
+
return data + self.failure_text[:max(0, count - len(data))]
|
|
568
|
+
except OSError:
|
|
569
|
+
self.mark_incomplete("Saved text could not be read. The operation trace is incomplete.")
|
|
570
|
+
return self.failure_text[:count]
|
|
571
|
+
remaining = count - len(data)
|
|
572
|
+
failure_start = max(0, start - self.text_size)
|
|
573
|
+
return data + self.failure_text[failure_start:failure_start + remaining]
|
|
574
|
+
|
|
575
|
+
def copy_text(self) -> str:
|
|
576
|
+
"""Return exactly the safe records available to the dialog."""
|
|
577
|
+
if self.closed:
|
|
578
|
+
return ""
|
|
579
|
+
return self._read_text(0, self.text_size + len(self.failure_text)).decode("utf-8", "replace")
|
|
580
|
+
|
|
581
|
+
@staticmethod
|
|
582
|
+
def format_event(event: TraceEvent) -> str:
|
|
583
|
+
"""Label process and stream so concurrent commands remain distinguishable."""
|
|
584
|
+
return f"[{event.process_id} {event.kind}] {event.text}"
|
|
585
|
+
|
|
586
|
+
def close(self) -> None:
|
|
587
|
+
"""Disconnect collection and discard any unpublished incomplete fragments."""
|
|
588
|
+
if self.closed:
|
|
589
|
+
return
|
|
590
|
+
for active in self.runner.active_by_id.values():
|
|
591
|
+
if active.request.operation_id == self.operation_id:
|
|
592
|
+
self.close_requested = True
|
|
593
|
+
return
|
|
594
|
+
for request in self.runner.pending_requests:
|
|
595
|
+
if request.operation_id == self.operation_id:
|
|
596
|
+
self.close_requested = True
|
|
597
|
+
return
|
|
598
|
+
self.closed = True
|
|
599
|
+
self.runner.started.disconnect(self._started)
|
|
600
|
+
self.runner.stdout_chunk.disconnect(self._stdout)
|
|
601
|
+
self.runner.stderr_chunk.disconnect(self._stderr)
|
|
602
|
+
self.runner.finished.disconnect(self._finished)
|
|
603
|
+
self.stream_by_key.clear()
|
|
604
|
+
self.config_buffer_by_id.clear()
|
|
605
|
+
self.network_stream_by_id.clear()
|
|
606
|
+
self.resource.close()
|
|
607
|
+
|
|
608
|
+
def _keeps(self, request: ProcessRequest) -> bool:
|
|
609
|
+
"""Tell whether a process belongs to this operation and to what the trace shows of it."""
|
|
610
|
+
if request.operation_id != self.operation_id:
|
|
611
|
+
return False
|
|
612
|
+
return not (self.native_commands_only and request.profile == READ_PROFILE)
|
|
613
|
+
|
|
614
|
+
@staticmethod
|
|
615
|
+
def shows_output(request: ProcessRequest, kind: str) -> bool:
|
|
616
|
+
"""Tell whether the console shows what a process writes on one of its streams; the records and the log keep it either way.
|
|
617
|
+
|
|
618
|
+
A read is a check GitPane runs for itself, and its standard output is in a machine format (fields separated by
|
|
619
|
+
NUL characters, raw listings) that tells the user nothing: the console shows only its ``$`` line, plus its error
|
|
620
|
+
output and its exit code when it fails. The ``--log`` file keeps that output for diagnosis. A program started for
|
|
621
|
+
the user (a write, a network command) keeps all its output on screen, as a terminal would show it.
|
|
622
|
+
"""
|
|
623
|
+
return not (kind == "stdout" and request.profile == READ_PROFILE)
|
|
624
|
+
|
|
625
|
+
def _append_output(self, request: ProcessRequest, kind: str, text: str) -> None:
|
|
626
|
+
"""Record one complete unit a process wrote, and show it only when ``shows_output`` says so."""
|
|
627
|
+
if self.shows_output(request, kind):
|
|
628
|
+
self.append_event(request.request_id, kind, text)
|
|
629
|
+
return
|
|
630
|
+
self.append_record(request.request_id, kind, text, None)
|
|
631
|
+
|
|
632
|
+
def _append_stream_unit(self, request: ProcessRequest, kind: str, unit: TextUnit) -> None:
|
|
633
|
+
"""Store complete stream records while drawing only the unit's visible terminal value."""
|
|
634
|
+
shown_text = unit.shown_text
|
|
635
|
+
owns_open_line = self.open_line is not None and (self.open_line.process_id, self.open_line.kind) == (request.request_id, kind)
|
|
636
|
+
if unit.source == TextValueSource.PUBLISHED and not owns_open_line:
|
|
637
|
+
shown_text = None
|
|
638
|
+
if not self.shows_output(request, kind) or kind in PROCESS_OUTPUT_KIND_SET and unit.text.startswith(OPAQUE_BODY_PREFIX):
|
|
639
|
+
shown_text = None
|
|
640
|
+
if unit.end in (TextUnitEnd.REDRAW, TextUnitEnd.HIDDEN_TAIL):
|
|
641
|
+
if unit.end == TextUnitEnd.HIDDEN_TAIL:
|
|
642
|
+
self.append_record(request.request_id, kind, unit.text, None)
|
|
643
|
+
if shown_text:
|
|
644
|
+
self._draw_open_line(request.request_id, kind, shown_text)
|
|
645
|
+
return
|
|
646
|
+
console_line = self._output_console_line(kind, shown_text) if shown_text is not None else None
|
|
647
|
+
self._append(request.request_id, kind, unit.text, console_line, open_line_key=(request.request_id, kind))
|
|
648
|
+
|
|
649
|
+
def _started(self, request: ProcessRequest) -> None:
|
|
650
|
+
"""Record the command of a process of this operation as soon as it starts.
|
|
651
|
+
|
|
652
|
+
The logged record is written while the process runs, so it cannot carry an exit code yet and says "running";
|
|
653
|
+
the result record that follows the process carries its status and exit code. The console shows the command as
|
|
654
|
+
one would type it, after a ``$ `` prompt, followed by its folder when it runs elsewhere than the header says.
|
|
655
|
+
"""
|
|
656
|
+
if self._keeps(request):
|
|
657
|
+
self.started_request_id_set.add(request.request_id)
|
|
658
|
+
prepared = Diagnostic(request.operation, request.working_dir, request.program_filename, request.arguments, "", process_started=True).prepare()
|
|
659
|
+
console_line = ConsoleLine(command_line(request, self.header, redact=self.filter.redact))
|
|
660
|
+
self._append(request.request_id, "command", prepared.detail, console_line, console_text_filtered=True)
|
|
661
|
+
|
|
662
|
+
def _stdout(self, request: ProcessRequest, data: bytes) -> None:
|
|
663
|
+
self._receive(request, "stdout", data)
|
|
664
|
+
|
|
665
|
+
def _stderr(self, request: ProcessRequest, data: bytes) -> None:
|
|
666
|
+
self._receive(request, "stderr", data)
|
|
667
|
+
|
|
668
|
+
def _receive(self, request: ProcessRequest, kind: str, data: bytes) -> None:
|
|
669
|
+
"""Retain incomplete lines so a chunk boundary cannot split a secret."""
|
|
670
|
+
if self.closed or self.capture_failed or not self._keeps(request):
|
|
671
|
+
return
|
|
672
|
+
if kind == "stdout" and (request.profile == CREDENTIAL_PROFILE or ("cat-file" in request.arguments and "--batch" in request.arguments)):
|
|
673
|
+
# Object bodies are repository contents, not diagnostics; keep command and result only.
|
|
674
|
+
return
|
|
675
|
+
if kind == "stdout" and "config" in request.arguments and "--null" in request.arguments:
|
|
676
|
+
buffer = self.config_buffer_by_id.setdefault(request.request_id, bytearray())
|
|
677
|
+
if len(buffer) + len(data) > TRACE_UNIT_LIMIT_BYTES:
|
|
678
|
+
self.mark_incomplete("A configuration response exceeded the text-unit limit.")
|
|
679
|
+
return
|
|
680
|
+
buffer.extend(data)
|
|
681
|
+
return
|
|
682
|
+
if kind == "stdout" and any("%(" in argument and "%00" in argument for argument in request.arguments):
|
|
683
|
+
data = data.replace(b"\0", b"\\0")
|
|
684
|
+
if kind == "stderr" and request.profile == NETWORK_PROFILE:
|
|
685
|
+
self._receive_network(request, data)
|
|
686
|
+
return
|
|
687
|
+
self._feed(request, kind, data)
|
|
688
|
+
|
|
689
|
+
def _feed(self, request: ProcessRequest, kind: str, data: bytes) -> None:
|
|
690
|
+
"""Keep incomplete ordinary text units private until the shared filter can process them."""
|
|
691
|
+
key = (request.request_id, kind)
|
|
692
|
+
stream = self.stream_by_key.setdefault(key, SensitiveTextStream(self.filter, hides_value=is_trace_line if kind == "stderr" else None))
|
|
693
|
+
for unit in stream.feed_units(data):
|
|
694
|
+
self._append_stream_unit(request, kind, unit)
|
|
695
|
+
self.complete = self.complete and stream.complete
|
|
696
|
+
|
|
697
|
+
def _receive_network(self, request: ProcessRequest, data: bytes, final: bool = False) -> None:
|
|
698
|
+
"""Reassemble curl body display lines so secrets split at trace widths stay private."""
|
|
699
|
+
process_id = request.request_id
|
|
700
|
+
stream = self.network_stream_by_id.setdefault(process_id, NetworkTextStream(self.filter, hides_value=is_trace_line))
|
|
701
|
+
for kind, unit in stream.feed_units(data, final):
|
|
702
|
+
self._append_stream_unit(request, kind, unit)
|
|
703
|
+
self.complete = self.complete and stream.complete
|
|
704
|
+
if final:
|
|
705
|
+
self.network_stream_by_id.pop(process_id, None)
|
|
706
|
+
|
|
707
|
+
def _finished(self, result: ProcessResult) -> None:
|
|
708
|
+
"""Flush terminal fragments before publishing the process's actual result.
|
|
709
|
+
|
|
710
|
+
The log keeps the result record of every process; the console adds a line only when the process did not succeed,
|
|
711
|
+
and remembers that process and its error output so a later diagnostic does not show them again.
|
|
712
|
+
"""
|
|
713
|
+
if self.closed or result.request.operation_id != self.operation_id:
|
|
714
|
+
return
|
|
715
|
+
if not self._keeps(result.request):
|
|
716
|
+
# A hidden read can be the operation's last process, and a close waits for that one to end.
|
|
717
|
+
if self.close_requested:
|
|
718
|
+
self.close()
|
|
719
|
+
return
|
|
720
|
+
if result.request.profile == NETWORK_PROFILE:
|
|
721
|
+
self._receive_network(result.request, b"", final=True)
|
|
722
|
+
buffer = self.config_buffer_by_id.pop(result.request.request_id, None)
|
|
723
|
+
if buffer is not None:
|
|
724
|
+
from gitpane.transport.remote_config import config_records
|
|
725
|
+
try:
|
|
726
|
+
content = bytes(buffer)
|
|
727
|
+
if any(argument in result.request.arguments for argument in ("--name-only", "--get-regexp", "--list")) and "--show-origin" not in result.request.arguments and "--show-scope" not in result.request.arguments:
|
|
728
|
+
if content and not content.endswith(b"\0"):
|
|
729
|
+
raise ValueError("Incomplete configuration record")
|
|
730
|
+
records = []
|
|
731
|
+
for setting in content.split(b"\0"):
|
|
732
|
+
if setting:
|
|
733
|
+
records.append(b"unknown\0unknown\0" + setting + b"\0")
|
|
734
|
+
content = b"".join(records)
|
|
735
|
+
if content and "--show-origin" in result.request.arguments and "--show-scope" not in result.request.arguments:
|
|
736
|
+
if not content.endswith(b"\0"):
|
|
737
|
+
raise ValueError("Incomplete configuration record")
|
|
738
|
+
fields = content.removesuffix(b"\0").split(b"\0")
|
|
739
|
+
field_count = 2
|
|
740
|
+
if len(fields) % field_count:
|
|
741
|
+
raise ValueError("Incomplete configuration record")
|
|
742
|
+
records = []
|
|
743
|
+
iterator = iter(fields)
|
|
744
|
+
for origin, setting in zip(iterator, iterator, strict=True):
|
|
745
|
+
records.append(b"unknown\0" + origin + b"\0" + setting + b"\0")
|
|
746
|
+
content = b"".join(records)
|
|
747
|
+
# The scope stays the word Git printed: a listing read without --show-scope is given "unknown" above.
|
|
748
|
+
for record in config_records(content):
|
|
749
|
+
self._append_output(result.request, "stdout", f"{record.scope_text} {record.origin} {record.key}={record.value!r}")
|
|
750
|
+
except ValueError:
|
|
751
|
+
self.mark_incomplete("A configuration response ended before its complete record could be filtered.")
|
|
752
|
+
for kind in ("stdout", "stderr"):
|
|
753
|
+
stream = self.stream_by_key.pop((result.request.request_id, kind), None)
|
|
754
|
+
if stream is not None:
|
|
755
|
+
for unit in stream.feed_units(b"", final=True):
|
|
756
|
+
self._append_stream_unit(result.request, kind, unit)
|
|
757
|
+
self.complete = self.complete and stream.complete
|
|
758
|
+
command_shown = result.request.request_id in self.started_request_id_set
|
|
759
|
+
self.started_request_id_set.discard(result.request.request_id)
|
|
760
|
+
end_line = process_end_line(result, command_shown=command_shown)
|
|
761
|
+
self.append_record(result.request.request_id, "result", f"{result.status.value}; exit={result.return_code}; {cause_without_trace_lines(result.cause)}", end_line)
|
|
762
|
+
if end_line is not None:
|
|
763
|
+
self.ended_process_id_set.add(result.request.request_id)
|
|
764
|
+
self.failed_outputs.append(result.stderr.decode("utf-8", "replace"))
|
|
765
|
+
if self.close_requested:
|
|
766
|
+
self.close()
|