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,164 @@
|
|
|
1
|
+
"""Read the list of stashes from the stash reflog file, the way `git stash list` shows it, without starting Git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.config import GIT_REFTABLE_DIR_BASENAME, LINE_SEPARATOR
|
|
11
|
+
from gitpane.history.repository_value import FULL_OID_PATTERN
|
|
12
|
+
from gitpane.reference.stash import stash_reflog_filename
|
|
13
|
+
from gitpane.sequencer.rebase_state import checked_metadata, metadata_signature
|
|
14
|
+
from gitpane.worktree.worktree_snapshot import read_validated_size
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
18
|
+
|
|
19
|
+
# One reflog line per stash, about a hundred bytes each: a mebibyte holds thousands of stashes. A larger reflog is
|
|
20
|
+
# not shown at all rather than read in part.
|
|
21
|
+
STASH_REFLOG_LIMIT_BYTES = 1024 * 1024
|
|
22
|
+
STASH_LIST_TOO_LARGE_CAUSE = "Stash list too large to show"
|
|
23
|
+
STASH_LIST_NOT_REGULAR_CAUSE = "Stash list unreadable: the stash reflog is not a regular file"
|
|
24
|
+
STASH_LIST_CHANGED_CAUSE = "Stash list changed while reading; refresh"
|
|
25
|
+
STASH_LIST_UNREADABLE_PREFIX = "Stash list unreadable: "
|
|
26
|
+
STASH_LIST_REFTABLE_CAUSE = "Stash list is not available for repositories using the reftable reference format"
|
|
27
|
+
# A reflog line is `<old oid> <new oid> <name> <<email>> <timestamp> <timezone>[\t<message>]`. The pattern keeps the
|
|
28
|
+
# lines Git keeps when it numbers `stash@{N}` and nothing else: the identity ends at the first ">", the timestamp is
|
|
29
|
+
# digits, the timezone is a sign and four digits, and the tab and message are written only for a non-empty message.
|
|
30
|
+
# A line Git skips must not take a number, or every older stash would be shown under another stash's selector.
|
|
31
|
+
_REFLOG_OID_PATTERN = FULL_OID_PATTERN.pattern.encode("ascii")
|
|
32
|
+
REFLOG_LINE_PATTERN = re.compile(
|
|
33
|
+
rb"(?P<old>" + _REFLOG_OID_PATTERN + rb") (?P<new>" + _REFLOG_OID_PATTERN + rb") [^>]*> (?P<timestamp>[0-9]+) (?P<timezone>[+-][0-9]{4})"
|
|
34
|
+
rb"(?:\t(?P<message>.*)|(?P<trailing>.*))",
|
|
35
|
+
re.DOTALL,
|
|
36
|
+
)
|
|
37
|
+
# Git reads a zero timestamp as a failed parse and skips the line.
|
|
38
|
+
REFLOG_INVALID_TIMESTAMP = 0
|
|
39
|
+
# The reflog is read as bytes; this form is drawn from the one text line end rather than written again.
|
|
40
|
+
LINE_SEPARATOR_BYTES = LINE_SEPARATOR.encode("ascii")
|
|
41
|
+
CARRIAGE_RETURN = b"\r"
|
|
42
|
+
# Message Git writes for a stash made without a message. A reference name cannot contain ":", so the first ": "
|
|
43
|
+
# ends the branch name even when the subject holds more.
|
|
44
|
+
WIP_MESSAGE_PATTERN = re.compile(r"^WIP on (?P<branch>[^:]+): (?P<commit>[0-9a-f]+) (?P<subject>.*)$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class StashEntry:
|
|
49
|
+
"""One stash as `git stash list` shows it: its selector index, stash commit, reflog message and creation time."""
|
|
50
|
+
|
|
51
|
+
index: int
|
|
52
|
+
oid: str
|
|
53
|
+
message: str
|
|
54
|
+
timestamp: int
|
|
55
|
+
timezone: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class StashList:
|
|
60
|
+
"""The stashes of a repository, newest first, or the reason they could not be read.
|
|
61
|
+
|
|
62
|
+
An empty list without a cause means the repository has no stash.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
entries: tuple[StashEntry, ...] = ()
|
|
66
|
+
cause: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stash_selector(index: int) -> str:
|
|
70
|
+
"""Name a stash with Git's own selector syntax, `stash@{N}`."""
|
|
71
|
+
return f"stash@{{{index}}}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stash_base_commit(message: str) -> str:
|
|
75
|
+
"""Name the commit a stash was made from, `<short oid> <subject>`, or an empty text when the message does not say.
|
|
76
|
+
|
|
77
|
+
Only a stash made without a message tells it: Git then writes `WIP on <branch>: <short oid> <subject>`. A stash
|
|
78
|
+
made with a message is written `On <branch>: <message>` and keeps no trace of its starting commit in the reflog.
|
|
79
|
+
"""
|
|
80
|
+
match = WIP_MESSAGE_PATTERN.fullmatch(message)
|
|
81
|
+
if match is None:
|
|
82
|
+
return ""
|
|
83
|
+
return f"{match['commit']} {match['subject']}".rstrip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_reflog_line(line: bytes, index: int) -> StashEntry | None:
|
|
87
|
+
"""Turn one reflog line into a stash entry, or None for a line Git itself skips when it lists the stashes.
|
|
88
|
+
|
|
89
|
+
Only the lines Git keeps may take a `stash@{N}` number, so the numbers shown match the ones Git accepts in commands.
|
|
90
|
+
"""
|
|
91
|
+
match = REFLOG_LINE_PATTERN.fullmatch(line.removesuffix(CARRIAGE_RETURN))
|
|
92
|
+
if match is None:
|
|
93
|
+
return None
|
|
94
|
+
timestamp = int(match["timestamp"])
|
|
95
|
+
if timestamp == REFLOG_INVALID_TIMESTAMP:
|
|
96
|
+
return None
|
|
97
|
+
# Without a tab, Git shows whatever follows the timezone as the message, which is empty for a line it wrote.
|
|
98
|
+
message = match["message"] if match["message"] is not None else match["trailing"]
|
|
99
|
+
return StashEntry(index, match["new"].decode("ascii"), message.decode("utf-8", "replace"), timestamp, match["timezone"].decode("ascii"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_stash_reflog(content: bytes) -> tuple[StashEntry, ...]:
|
|
103
|
+
"""Turn the stash reflog, written oldest first, into the newest-first list `git stash list` prints.
|
|
104
|
+
|
|
105
|
+
Lines that do not have the reflog layout are skipped instead of failing the whole list.
|
|
106
|
+
"""
|
|
107
|
+
entries: list[StashEntry] = []
|
|
108
|
+
for line in reversed(content.split(LINE_SEPARATOR_BYTES)):
|
|
109
|
+
if not line.strip():
|
|
110
|
+
continue
|
|
111
|
+
entry = _parse_reflog_line(line, len(entries))
|
|
112
|
+
if entry is not None:
|
|
113
|
+
entries.append(entry)
|
|
114
|
+
return tuple(entries)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_stash_reflog(filename: str) -> StashList:
|
|
118
|
+
"""Read the stash reflog with a bounded size, refusing links, special files and a file replaced while reading."""
|
|
119
|
+
try:
|
|
120
|
+
before = checked_metadata(filename)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
return StashList()
|
|
123
|
+
except ValueError:
|
|
124
|
+
return StashList(cause=STASH_LIST_NOT_REGULAR_CAUSE)
|
|
125
|
+
if before.st_size > STASH_REFLOG_LIMIT_BYTES:
|
|
126
|
+
return StashList(cause=STASH_LIST_TOO_LARGE_CAUSE)
|
|
127
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
128
|
+
with os.fdopen(os.open(filename, flags), "rb") as stream:
|
|
129
|
+
opened = os.fstat(stream.fileno())
|
|
130
|
+
# Windows descriptor and path stat disagree about ctime; compare it only within the same API.
|
|
131
|
+
if metadata_signature(opened, include_change_time=False) != metadata_signature(before, include_change_time=False):
|
|
132
|
+
return StashList(cause=STASH_LIST_CHANGED_CAUSE)
|
|
133
|
+
if opened.st_size > STASH_REFLOG_LIMIT_BYTES:
|
|
134
|
+
return StashList(cause=STASH_LIST_TOO_LARGE_CAUSE)
|
|
135
|
+
content = read_validated_size(stream, opened.st_size)
|
|
136
|
+
if len(content) != opened.st_size:
|
|
137
|
+
return StashList(cause=STASH_LIST_CHANGED_CAUSE)
|
|
138
|
+
try:
|
|
139
|
+
after = checked_metadata(filename)
|
|
140
|
+
except ValueError:
|
|
141
|
+
# The path became a link or special file while it was read: the content read may not be the stash reflog.
|
|
142
|
+
return StashList(cause=STASH_LIST_CHANGED_CAUSE)
|
|
143
|
+
if metadata_signature(after) != metadata_signature(before):
|
|
144
|
+
return StashList(cause=STASH_LIST_CHANGED_CAUSE)
|
|
145
|
+
return StashList(parse_stash_reflog(content))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_stash_list(context: RepositoryContext) -> StashList:
|
|
149
|
+
"""Read the stashes of a repository from its shared stash reflog, newest first, without ever raising.
|
|
150
|
+
|
|
151
|
+
The reflog lives in the shared Git directory, so a linked worktree lists the stashes of its main repository and a
|
|
152
|
+
submodule its own. An absent reflog means no stash; anything unreadable gives an empty list with a cause.
|
|
153
|
+
A repository using the reftable format keeps the reflog in its tables, so its list is not read at all rather
|
|
154
|
+
than shown as empty, even when a reflog file is left over from before a conversion.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
if os.path.isdir(os.path.join(context.common_git_dir, GIT_REFTABLE_DIR_BASENAME)):
|
|
158
|
+
return StashList(cause=STASH_LIST_REFTABLE_CAUSE)
|
|
159
|
+
return _read_stash_reflog(stash_reflog_filename(context))
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
return StashList()
|
|
162
|
+
except (OSError, ValueError) as error:
|
|
163
|
+
detail = error.strerror if isinstance(error, OSError) and error.strerror else type(error).__name__
|
|
164
|
+
return StashList(cause=STASH_LIST_UNREADABLE_PREFIX + detail)
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Judge a commit message empty the way Git's own cleanup would."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.worktree.command_sequence import GitCommand
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
|
|
15
|
+
# Git's comment character when the configuration names none, and the value of core.commentChar that lets Git pick one
|
|
16
|
+
# per message.
|
|
17
|
+
DEFAULT_COMMENT_PREFIX = "#"
|
|
18
|
+
AUTOMATIC_COMMENT_PREFIX = "auto"
|
|
19
|
+
# What Git 2.53 writes after the comment prefix on the cut line of a message file when commit.cleanup is scissors: that
|
|
20
|
+
# line and everything below it are dropped.
|
|
21
|
+
SCISSORS_LINE_SUFFIX = " ------------------------ >8 ------------------------"
|
|
22
|
+
# The trailer Git ignores when it judges a message: a message made only of sign-offs aborts the commit like an empty one.
|
|
23
|
+
SIGN_OFF_PREFIX = "Signed-off-by: "
|
|
24
|
+
# The only characters Git's own character table counts as blank. A vertical tab, a form feed or a no-break space is
|
|
25
|
+
# message text for Git, so a message made of them is committed.
|
|
26
|
+
GIT_BLANK_CHARACTERS = " \t\n\r"
|
|
27
|
+
# Git cuts a message file into lines on line feeds only; a visual wrap in an editor is not a character of the file.
|
|
28
|
+
MESSAGE_LINE_SEPARATOR = "\n"
|
|
29
|
+
# The settings the rule depends on, as git config --get-regexp lists them: Git writes section and key names in lower case.
|
|
30
|
+
COMMENT_CHAR_KEY = "core.commentchar"
|
|
31
|
+
COMMENT_STRING_KEY = "core.commentstring"
|
|
32
|
+
CLEANUP_KEY = "commit.cleanup"
|
|
33
|
+
COMMIT_ENCODING_KEY = "i18n.commitencoding"
|
|
34
|
+
COMMENT_KEY_SET = frozenset((COMMENT_CHAR_KEY, COMMENT_STRING_KEY))
|
|
35
|
+
COMMIT_MESSAGE_SETTING_PATTERN = "^(" + "|".join(map(re.escape, (COMMENT_CHAR_KEY, COMMENT_STRING_KEY, CLEANUP_KEY, COMMIT_ENCODING_KEY))) + ")$"
|
|
36
|
+
# git config --get-regexp writes one setting per line, its key then a space then its value; a key written without "="
|
|
37
|
+
# in the file is listed alone.
|
|
38
|
+
SETTING_KEY_VALUE_SEPARATOR = " "
|
|
39
|
+
SETTING_OUTPUT_CODEC = "utf-8"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CleanupMode(Enum):
|
|
43
|
+
"""How Git cleans a commit message before judging it, as commit.cleanup names it."""
|
|
44
|
+
|
|
45
|
+
DEFAULT = "default"
|
|
46
|
+
STRIP = "strip"
|
|
47
|
+
WHITESPACE = "whitespace"
|
|
48
|
+
VERBATIM = "verbatim"
|
|
49
|
+
SCISSORS = "scissors"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def drops_comment_lines(self) -> bool:
|
|
53
|
+
"""Tell whether this cleanup removes comment lines; default does, because it means strip when Git opens an editor."""
|
|
54
|
+
return self in (CleanupMode.DEFAULT, CleanupMode.STRIP)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cleanup_mode(value: str) -> CleanupMode:
|
|
58
|
+
"""Read a commit.cleanup value, with the default cleanup for a value Git does not know (Git itself refuses it)."""
|
|
59
|
+
try:
|
|
60
|
+
return CleanupMode(value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return CleanupMode.DEFAULT
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CommitMessageRule:
|
|
67
|
+
"""The repository settings that decide how Git cleans and decodes a commit message the rebase asks for.
|
|
68
|
+
|
|
69
|
+
``comment_prefix`` is None when core.commentChar is auto: Git then picks a character per message, so a comment
|
|
70
|
+
cannot be told from text. ``commit_encoding`` is empty when i18n.commitEncoding is not set.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
comment_prefix: str | None
|
|
74
|
+
cleanup: CleanupMode
|
|
75
|
+
commit_encoding: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def message_is_empty(text: str, rule: CommitMessageRule) -> bool:
|
|
79
|
+
"""Tell whether Git would abort the commit for an empty message once its own cleanup has run.
|
|
80
|
+
|
|
81
|
+
``text`` is the content of the message file. verbatim keeps every character, so only an empty text is empty.
|
|
82
|
+
scissors drops the cut line and everything below it, and keeps the comments above it. default and strip drop the
|
|
83
|
+
lines that start, at the first column, with the comment prefix. What remains is empty when every line, trimmed of
|
|
84
|
+
Git's blanks, is empty or a sign-off. Without a known comment prefix only a blank text is refused: Git judges the
|
|
85
|
+
rest itself and pauses the rebase when it refuses the message.
|
|
86
|
+
"""
|
|
87
|
+
if rule.cleanup == CleanupMode.VERBATIM:
|
|
88
|
+
return text == ""
|
|
89
|
+
lines = text.split(MESSAGE_LINE_SEPARATOR)
|
|
90
|
+
if rule.comment_prefix is None:
|
|
91
|
+
return _all_lines_blank(lines)
|
|
92
|
+
if rule.cleanup == CleanupMode.SCISSORS:
|
|
93
|
+
lines = _lines_above_cut(lines, rule.comment_prefix)
|
|
94
|
+
for line in lines:
|
|
95
|
+
if rule.cleanup.drops_comment_lines and line.startswith(rule.comment_prefix):
|
|
96
|
+
continue
|
|
97
|
+
trimmed_line = line.rstrip(GIT_BLANK_CHARACTERS)
|
|
98
|
+
if trimmed_line and not trimmed_line.startswith(SIGN_OFF_PREFIX):
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _all_lines_blank(lines: list[str]) -> bool:
|
|
104
|
+
"""Tell whether every line holds only characters Git counts as blank."""
|
|
105
|
+
for line in lines:
|
|
106
|
+
if line.strip(GIT_BLANK_CHARACTERS):
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _lines_above_cut(lines: list[str], comment_prefix: str) -> list[str]:
|
|
112
|
+
"""Keep the lines above Git's scissors cut line, the first line included as a possible cut.
|
|
113
|
+
|
|
114
|
+
Git only takes the cut line as one when a line feed follows it, so a cut line that ends the file is message text.
|
|
115
|
+
"""
|
|
116
|
+
cut_line = comment_prefix + SCISSORS_LINE_SUFFIX
|
|
117
|
+
last_index = len(lines) - 1
|
|
118
|
+
for index, line in enumerate(lines):
|
|
119
|
+
if line == cut_line and index < last_index:
|
|
120
|
+
return lines[:index]
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_commit_message_setting(output: bytes) -> CommitMessageRule:
|
|
125
|
+
"""Build the rule from the settings git config listed, with Git's defaults for the ones not set.
|
|
126
|
+
|
|
127
|
+
Git lists the settings in the order it reads them, the command line last, so the last value of a key wins, and the
|
|
128
|
+
later of core.commentChar and core.commentString wins as it does for Git. The output is decoded with replacement
|
|
129
|
+
characters: a value that is not UTF-8 must not stop the user from editing the message.
|
|
130
|
+
"""
|
|
131
|
+
comment_prefix: str | None = DEFAULT_COMMENT_PREFIX
|
|
132
|
+
cleanup = CleanupMode.DEFAULT
|
|
133
|
+
commit_encoding = ""
|
|
134
|
+
for line in output.decode(SETTING_OUTPUT_CODEC, errors="replace").split(MESSAGE_LINE_SEPARATOR):
|
|
135
|
+
key, separator, value = line.partition(SETTING_KEY_VALUE_SEPARATOR)
|
|
136
|
+
if not separator:
|
|
137
|
+
continue
|
|
138
|
+
if key in COMMENT_KEY_SET:
|
|
139
|
+
comment_prefix = None if value == AUTOMATIC_COMMENT_PREFIX else value
|
|
140
|
+
elif key == CLEANUP_KEY:
|
|
141
|
+
cleanup = cleanup_mode(value)
|
|
142
|
+
elif key == COMMIT_ENCODING_KEY:
|
|
143
|
+
commit_encoding = value
|
|
144
|
+
return CommitMessageRule(comment_prefix, cleanup, commit_encoding)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_commit_message_rule() -> Generator[GitCommand, bytes, CommitMessageRule]:
|
|
148
|
+
"""Read the comment prefix, the cleanup and the commit encoding of the repository in one Git process.
|
|
149
|
+
|
|
150
|
+
git config exits with 1 when none of the settings is set, which is the usual case, so that status is accepted and
|
|
151
|
+
gives Git's defaults.
|
|
152
|
+
"""
|
|
153
|
+
output = yield GitCommand(["config", "--get-regexp", COMMIT_MESSAGE_SETTING_PATTERN], accepted_codes=(0, 1))
|
|
154
|
+
return parse_commit_message_setting(output)
|