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,523 @@
|
|
|
1
|
+
"""Read native rebase metadata without owning or advancing the Git operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from gitpane.config import LOCAL_PREFIX, REBASE_CAPTURE_ATTEMPTS, REBASE_CAPTURE_TIMEOUT_MS, REBASE_FILE_LIMIT_BYTES, REBASE_STEP_LIMIT, REBASE_TOTAL_LIMIT_BYTES, SHORT_OID_LENGTH
|
|
13
|
+
from gitpane.process.process_runner import READ_PROFILE
|
|
14
|
+
from gitpane.system.path_identity import canonical_path
|
|
15
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
|
|
16
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey, read_validated_size
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable, Generator
|
|
20
|
+
|
|
21
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
22
|
+
from gitpane.worktree.worktree_snapshot import PassiveObservationSource
|
|
23
|
+
|
|
24
|
+
# The two folders Git keeps a stopped rebase in, one per backend.
|
|
25
|
+
MERGE_STATE_DIR_BASENAME = "rebase-merge"
|
|
26
|
+
REBASE_STATE_DIR_BASENAMES = (MERGE_STATE_DIR_BASENAME, "rebase-apply")
|
|
27
|
+
DONE_BASENAME = "done"
|
|
28
|
+
REBASE_BASENAMES = ("orig-head", "onto", "head-name", DONE_BASENAME, "git-rebase-todo", "stopped-sha", "amend", "msgnum", "end", "next", "last")
|
|
29
|
+
REBASE_ACTION_BY_ALIAS = {"p": "pick", "r": "reword", "e": "edit", "s": "squash", "f": "fixup", "d": "drop"}
|
|
30
|
+
REBASE_COMMIT_ACTION_SET = frozenset((*REBASE_ACTION_BY_ALIAS.values(),))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RebaseStep:
|
|
35
|
+
"""Retain the native instruction, including opaque actions we cannot classify."""
|
|
36
|
+
|
|
37
|
+
action: str
|
|
38
|
+
oid: str
|
|
39
|
+
message: str
|
|
40
|
+
raw: str
|
|
41
|
+
opaque: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RebaseIdentity:
|
|
46
|
+
"""Identify a native operation independently of its moving replay HEAD."""
|
|
47
|
+
|
|
48
|
+
backend: str
|
|
49
|
+
state_dir: str
|
|
50
|
+
original_oid: str
|
|
51
|
+
onto_oid: str
|
|
52
|
+
head_name: str
|
|
53
|
+
creation_fingerprint: tuple[int, int, int]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RebaseCommit:
|
|
58
|
+
"""Describe an original commit for display without changing its native instruction."""
|
|
59
|
+
|
|
60
|
+
oid: str
|
|
61
|
+
author_name: str
|
|
62
|
+
author_timestamp: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# States of a rebase stopped and waiting for the user. Its instructions can be edited then, and only the writes that help
|
|
66
|
+
# finish the rebase are allowed: staging, conflict tools, Continue, Abort, and Amend on an edit stop.
|
|
67
|
+
REBASE_STOPPED_STATE_SET = frozenset(("edit", "conflict", "paused", "intervention_required"))
|
|
68
|
+
# Stopped states Continue can resume. A conflict is left out: it must be resolved and staged first, which turns it into
|
|
69
|
+
# "paused".
|
|
70
|
+
REBASE_CONTINUABLE_STATE_SET = frozenset(("edit", "paused", "intervention_required"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class RebaseSnapshot:
|
|
75
|
+
"""Publish immutable native facts bound to a worktree and observation generation.
|
|
76
|
+
|
|
77
|
+
`state` is one of: absent, starting, edit, conflict, paused (conflicts resolved and staged, Continue expected), intervention_required,
|
|
78
|
+
unknown or external-change.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
key: WorktreeKey
|
|
82
|
+
generation: int
|
|
83
|
+
identity: RebaseIdentity | None = None
|
|
84
|
+
head_oid: str | None = None
|
|
85
|
+
original_branch: str = ""
|
|
86
|
+
state: str = "absent"
|
|
87
|
+
fresh: bool = True
|
|
88
|
+
diagnostic: str = ""
|
|
89
|
+
done_steps: tuple[RebaseStep, ...] = ()
|
|
90
|
+
current_step: RebaseStep | None = None
|
|
91
|
+
remaining_steps: tuple[RebaseStep, ...] = ()
|
|
92
|
+
opaque_steps: tuple[RebaseStep, ...] = ()
|
|
93
|
+
# Whether the index still held a conflict when this state was captured.
|
|
94
|
+
unmerged: bool = False
|
|
95
|
+
# The native files of each rebase state folder (None for an absent folder) that this capture proved stable. It only
|
|
96
|
+
# lets a display tell later whether those files are still the same; it never authorizes a command. None means no
|
|
97
|
+
# such proof is available: a failed, stale or rebuilt state never carries one.
|
|
98
|
+
native_observations: tuple[NativeMetadata | None, ...] | None = field(default=None, hash=False)
|
|
99
|
+
# True only on the first absent state read after a rebase GitPane knew ended without a command of GitPane (a quit,
|
|
100
|
+
# an abort or a continue made in a terminal): the views follow the new state once, without a banner.
|
|
101
|
+
ended_outside: bool = False
|
|
102
|
+
# The paths still in conflict when this state was captured, sorted and without repeats, so a stop can name them.
|
|
103
|
+
conflict_paths: tuple[bytes, ...] = ()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# The one line the views show when a rebase ended without a command of GitPane.
|
|
107
|
+
REBASE_ENDED_OUTSIDE_MESSAGE = "Rebase ended outside GitPane."
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ended_outside_state(state: RebaseSnapshot) -> RebaseSnapshot:
|
|
111
|
+
"""Mark an absent state that follows a rebase GitPane knew as ended elsewhere, for its one publication."""
|
|
112
|
+
return replace(state, diagnostic=REBASE_ENDED_OUTSIDE_MESSAGE, ended_outside=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_rebase_commits(steps: tuple[RebaseStep, ...]) -> Generator[GitCommand, bytes, tuple[RebaseCommit, ...]]:
|
|
116
|
+
"""Read original authors and dates independently of native state and recovery commands."""
|
|
117
|
+
oid_set = set()
|
|
118
|
+
for step in steps:
|
|
119
|
+
if step.oid:
|
|
120
|
+
oid_set.add(step.oid)
|
|
121
|
+
if not oid_set:
|
|
122
|
+
return ()
|
|
123
|
+
try:
|
|
124
|
+
# Stdin avoids Windows command-length limits for large instruction lists.
|
|
125
|
+
output = yield GitCommand(["log", "--no-walk", "--ignore-missing", "--no-decorate", "--no-notes", "--no-show-signature", "--encoding=UTF-8",
|
|
126
|
+
"-z", "--format=%H%x00%an%x00%at", "--stdin"], stdin=("\n".join(sorted(oid_set)) + "\n").encode("ascii"))
|
|
127
|
+
fields = output.decode("utf-8", errors="replace").rstrip("\0").split("\0")
|
|
128
|
+
field_count = 3
|
|
129
|
+
oid_offset, author_offset, timestamp_offset = 0, 1, 2
|
|
130
|
+
commits = []
|
|
131
|
+
for index in range(0, len(fields) - field_count + 1, field_count):
|
|
132
|
+
commits.append(RebaseCommit(fields[index + oid_offset], fields[index + author_offset], int(fields[index + timestamp_offset])))
|
|
133
|
+
return tuple(commits)
|
|
134
|
+
except (GitCommandError, ValueError):
|
|
135
|
+
return ()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def can_abort_rebase(snapshot: RebaseSnapshot) -> bool:
|
|
139
|
+
"""Permit native abandonment only when the backend and original checkout are readable."""
|
|
140
|
+
identity = snapshot.identity
|
|
141
|
+
return (snapshot.fresh and identity is not None and identity.backend in REBASE_STATE_DIR_BASENAMES
|
|
142
|
+
and snapshot.state not in ("absent", "external-change")
|
|
143
|
+
and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", identity.original_oid) is not None
|
|
144
|
+
and (identity.head_name.startswith(LOCAL_PREFIX) or identity.head_name == "detached HEAD"))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Recent Git (2.53 on Ubuntu 26) writes "pick <oid> # <subject>" where Git for Windows 2.47 writes "pick <oid> <subject>".
|
|
148
|
+
# Everything after the hash is a comment for Git either way; only the marker is removed from the displayed subject.
|
|
149
|
+
REBASE_SUBJECT_COMMENT_MARKER = "# "
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_steps(content: str) -> tuple[RebaseStep, ...]:
|
|
153
|
+
"""Preserve native ordering and expose unsupported instructions as plain text.
|
|
154
|
+
|
|
155
|
+
The subject shown for a step is read in both instruction formats Git writes, with or without a comment marker
|
|
156
|
+
between the hash and the subject.
|
|
157
|
+
"""
|
|
158
|
+
steps = []
|
|
159
|
+
for raw in content.splitlines():
|
|
160
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
161
|
+
continue
|
|
162
|
+
first_index, oid_index, message_index = 0, 1, 2
|
|
163
|
+
parts = raw.split(maxsplit=message_index)
|
|
164
|
+
action = parts[first_index]
|
|
165
|
+
action = REBASE_ACTION_BY_ALIAS.get(action, action)
|
|
166
|
+
if action == "fixup" and len(parts) > oid_index and parts[oid_index] in ("-C", "-c"):
|
|
167
|
+
# Git's fixup message flags precede the hash but do not change its identity.
|
|
168
|
+
parts = (parts[first_index] + " " + (parts[message_index] if len(parts) > message_index else "")).split(maxsplit=message_index)
|
|
169
|
+
oid = parts[oid_index] if len(parts) > oid_index else ""
|
|
170
|
+
message = parts[message_index] if len(parts) > message_index else ""
|
|
171
|
+
message = message.removeprefix(REBASE_SUBJECT_COMMENT_MARKER)
|
|
172
|
+
opaque = action not in REBASE_COMMIT_ACTION_SET or re.fullmatch(r"[0-9a-f]{4,64}", oid) is None
|
|
173
|
+
steps.append(RebaseStep(action, "" if opaque else oid, raw if opaque else message, raw, opaque))
|
|
174
|
+
if len(steps) > REBASE_STEP_LIMIT:
|
|
175
|
+
raise ValueError("Native rebase exceeds the supported instruction limit")
|
|
176
|
+
return tuple(steps)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Git lists in this file the commits a squash or fixup chain has folded so far, one instruction per line without its
|
|
180
|
+
# subject, and removes it once the chain is committed. It is read only to show which commits a message is about, so it
|
|
181
|
+
# stays out of REBASE_BASENAMES: that list is the signature of the observation, and this file comes and goes during
|
|
182
|
+
# every squash.
|
|
183
|
+
CURRENT_FIXUPS_BASENAME = "current-fixups"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class RebaseMessageCommit:
|
|
188
|
+
"""One commit a message Git asks for during a rebase is about, as the header of the message window shows it."""
|
|
189
|
+
|
|
190
|
+
short_oid: str
|
|
191
|
+
subject: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _read_display_text(filename: str) -> str:
|
|
195
|
+
"""Read one of Git's rebase files for display: empty when absent, refused when it is a link, a special file or too large.
|
|
196
|
+
|
|
197
|
+
The bytes are decoded with replacement characters, since nothing is decided on this text.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
metadata = checked_metadata(filename)
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return ""
|
|
203
|
+
if metadata.st_size > REBASE_FILE_LIMIT_BYTES:
|
|
204
|
+
raise ValueError("Native rebase metadata exceeds the supported file limit")
|
|
205
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
206
|
+
with os.fdopen(os.open(filename, flags), "rb") as stream:
|
|
207
|
+
data = stream.read(REBASE_FILE_LIMIT_BYTES + 1)
|
|
208
|
+
if len(data) > REBASE_FILE_LIMIT_BYTES:
|
|
209
|
+
raise ValueError("Native rebase metadata exceeds the supported file limit")
|
|
210
|
+
return data.decode("utf-8", errors="replace")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def read_message_context(git_dir: str) -> tuple[RebaseMessageCommit, ...]:
|
|
214
|
+
"""Name the commits a commit message Git asks for right now is about, from Git's own rebase files.
|
|
215
|
+
|
|
216
|
+
rebase-merge/done ends with the instruction being applied. Without current-fixups the message is a reword's: its
|
|
217
|
+
commit is the last instruction. With current-fixups, its N lines are the commits a squash or fixup chain folded,
|
|
218
|
+
the last N instructions, and the instruction before them is the commit they fold into. The comments Git writes in
|
|
219
|
+
the message are never read: their count does not follow the chain. Nothing is named when done is absent, unreadable
|
|
220
|
+
or holds no commit, so the header falls back to the repository alone.
|
|
221
|
+
"""
|
|
222
|
+
state_dir = os.path.join(git_dir, MERGE_STATE_DIR_BASENAME)
|
|
223
|
+
try:
|
|
224
|
+
steps = parse_steps(_read_display_text(os.path.join(state_dir, DONE_BASENAME)))
|
|
225
|
+
fixup_count = 0
|
|
226
|
+
for line in _read_display_text(os.path.join(state_dir, CURRENT_FIXUPS_BASENAME)).splitlines():
|
|
227
|
+
if line.strip():
|
|
228
|
+
fixup_count += 1
|
|
229
|
+
except (OSError, ValueError):
|
|
230
|
+
return ()
|
|
231
|
+
base_count = 1
|
|
232
|
+
commits = []
|
|
233
|
+
for step in steps[-(fixup_count + base_count):]:
|
|
234
|
+
if step.oid:
|
|
235
|
+
commits.append(RebaseMessageCommit(step.oid[:SHORT_OID_LENGTH], step.message))
|
|
236
|
+
return tuple(commits)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class NativeMetadata:
|
|
241
|
+
"""Retain file identity as well as bytes so replacements invalidate a capture."""
|
|
242
|
+
|
|
243
|
+
creation_fingerprint: tuple[int, int, int]
|
|
244
|
+
content_by_basename: dict[str, str]
|
|
245
|
+
signatures: tuple[tuple[int, ...] | None, ...]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def metadata_signature(metadata: os.stat_result, *, include_change_time: bool = True) -> tuple[int, ...]:
|
|
249
|
+
"""Compare file identity, size and timestamps across the whole observation."""
|
|
250
|
+
return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_mtime_ns,
|
|
251
|
+
metadata.st_ctime_ns if include_change_time else 0, metadata.st_nlink)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def checked_metadata(filename: str, *, directory: bool = False) -> os.stat_result:
|
|
255
|
+
"""Reject links, special files and Windows reparse points before reading bytes."""
|
|
256
|
+
metadata = os.lstat(filename)
|
|
257
|
+
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
|
258
|
+
if not expected_type(metadata.st_mode) or getattr(metadata, "st_reparse_tag", 0) or not directory and metadata.st_nlink != 1:
|
|
259
|
+
raise ValueError("Native rebase metadata has an unsupported file type or link")
|
|
260
|
+
return metadata
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_metadata(state_dir: str) -> NativeMetadata | None:
|
|
264
|
+
"""Bound native file reads and verify their opened identity before consuming bytes."""
|
|
265
|
+
try:
|
|
266
|
+
metadata = checked_metadata(state_dir, directory=True)
|
|
267
|
+
except FileNotFoundError:
|
|
268
|
+
return None
|
|
269
|
+
content_by_basename = {}
|
|
270
|
+
signatures = [metadata_signature(metadata)]
|
|
271
|
+
total_bytes = 0
|
|
272
|
+
started = time.monotonic()
|
|
273
|
+
for basename in REBASE_BASENAMES:
|
|
274
|
+
filename = os.path.join(state_dir, basename)
|
|
275
|
+
try:
|
|
276
|
+
before = checked_metadata(filename)
|
|
277
|
+
except FileNotFoundError:
|
|
278
|
+
content_by_basename[basename] = ""
|
|
279
|
+
signatures.append(None)
|
|
280
|
+
continue
|
|
281
|
+
if before.st_size > REBASE_FILE_LIMIT_BYTES:
|
|
282
|
+
raise ValueError("Native rebase metadata exceeds the supported file limit")
|
|
283
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
284
|
+
descriptor = os.open(filename, flags)
|
|
285
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
286
|
+
opened = os.fstat(stream.fileno())
|
|
287
|
+
# Windows path stat and descriptor stat can expose different ctime
|
|
288
|
+
# meanings; compare that timestamp only within the same API.
|
|
289
|
+
if (metadata_signature(opened, include_change_time=False) != metadata_signature(before, include_change_time=False)
|
|
290
|
+
or metadata_signature(checked_metadata(filename)) != metadata_signature(before)
|
|
291
|
+
or metadata_signature(checked_metadata(state_dir, directory=True)) != metadata_signature(metadata)):
|
|
292
|
+
raise ValueError("Native rebase metadata changed before reading")
|
|
293
|
+
# The size is the one of the descriptor whose identity was just proven, and it is within the limit.
|
|
294
|
+
content = read_validated_size(stream, opened.st_size)
|
|
295
|
+
if metadata_signature(os.fstat(stream.fileno())) != metadata_signature(opened):
|
|
296
|
+
raise ValueError("Native rebase metadata changed while reading")
|
|
297
|
+
if len(content) != opened.st_size:
|
|
298
|
+
raise ValueError("Native rebase metadata changed while reading")
|
|
299
|
+
total_bytes += len(content)
|
|
300
|
+
if len(content) > REBASE_FILE_LIMIT_BYTES or total_bytes > REBASE_TOTAL_LIMIT_BYTES or (time.monotonic() - started) * 1000 > REBASE_CAPTURE_TIMEOUT_MS:
|
|
301
|
+
raise ValueError("Native rebase metadata exceeds the supported read budget")
|
|
302
|
+
signatures.append(metadata_signature(before))
|
|
303
|
+
try:
|
|
304
|
+
content_by_basename[basename] = content.decode("utf-8")
|
|
305
|
+
except UnicodeDecodeError as error:
|
|
306
|
+
raise ValueError("Native rebase metadata uses unsupported UTF-8 encoding. Correct it externally, then Refresh.") from error
|
|
307
|
+
if metadata_signature(checked_metadata(state_dir, directory=True)) != metadata_signature(metadata):
|
|
308
|
+
raise ValueError("Native rebase directory changed while reading")
|
|
309
|
+
# Unix ctime changes as Git edits directory entries; only birth time, when
|
|
310
|
+
# supplied by the OS, can supplement the stable device/inode identity.
|
|
311
|
+
creation = (metadata.st_dev, metadata.st_ino, getattr(metadata, "st_birthtime_ns", 0))
|
|
312
|
+
return NativeMetadata(creation, content_by_basename, tuple(signatures))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _checked_state_dir(key: WorktreeKey, backend: str, state_dir: str) -> str:
|
|
316
|
+
"""Refuse a rebase state folder that is not the asked one in this worktree's own Git directory."""
|
|
317
|
+
if canonical_path(os.path.dirname(state_dir)) != key.git_dir or os.path.basename(state_dir) != backend:
|
|
318
|
+
raise ValueError("Native rebase path does not belong to this worktree")
|
|
319
|
+
return state_dir
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _read_state_dirs(key: WorktreeKey) -> Generator[GitCommand | ReadBatch, object, list[str]]:
|
|
323
|
+
"""Ask Git where this worktree keeps both rebase state folders, and check each answer."""
|
|
324
|
+
arguments = ["rev-parse", "--path-format=absolute"]
|
|
325
|
+
for backend in REBASE_STATE_DIR_BASENAMES:
|
|
326
|
+
arguments.extend(["--git-path", backend])
|
|
327
|
+
# One process answers for both folders; a newline is valid inside a folder name, so a count that does not match
|
|
328
|
+
# asks each folder alone to keep every path whole.
|
|
329
|
+
output = yield GitCommand(arguments, profile=READ_PROFILE)
|
|
330
|
+
outputs = output.removesuffix(b"\n").split(b"\n")
|
|
331
|
+
if len(outputs) != len(REBASE_STATE_DIR_BASENAMES):
|
|
332
|
+
commands = []
|
|
333
|
+
for backend in REBASE_STATE_DIR_BASENAMES:
|
|
334
|
+
commands.append(GitCommand(["rev-parse", "--path-format=absolute", "--git-path", backend], profile=READ_PROFILE))
|
|
335
|
+
outputs = yield ReadBatch(tuple(commands))
|
|
336
|
+
state_dirs = []
|
|
337
|
+
for backend, path_output in zip(REBASE_STATE_DIR_BASENAMES, outputs, strict=True):
|
|
338
|
+
state_dirs.append(_checked_state_dir(key, backend, os.fsdecode(path_output).strip()))
|
|
339
|
+
return state_dirs
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def state_dirs_from_pause_paths(key: WorktreeKey, filename_by_git_path: dict[str, str]) -> list[str]:
|
|
343
|
+
"""Take both rebase state folders from the paths Git gave the session's pause probe, checking that each is this worktree's own."""
|
|
344
|
+
state_dirs = []
|
|
345
|
+
for backend in REBASE_STATE_DIR_BASENAMES:
|
|
346
|
+
state_dir = filename_by_git_path.get(backend)
|
|
347
|
+
if state_dir is None:
|
|
348
|
+
raise ValueError("Native rebase path does not belong to this worktree")
|
|
349
|
+
state_dirs.append(_checked_state_dir(key, backend, state_dir))
|
|
350
|
+
return state_dirs
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def native_observations_unchanged(state_dirs: list[str], observations: tuple[NativeMetadata | None, ...]) -> bool:
|
|
354
|
+
"""Tell whether the native rebase files are still exactly the ones a stable capture proved, absences included.
|
|
355
|
+
|
|
356
|
+
Runs on a worker. A read that fails is propagated, never taken for a match.
|
|
357
|
+
"""
|
|
358
|
+
current = []
|
|
359
|
+
for state_dir in state_dirs:
|
|
360
|
+
current.append(read_metadata(state_dir))
|
|
361
|
+
return tuple(current) == observations
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def staged_change_command() -> GitCommand:
|
|
365
|
+
"""Ask whether the index differs from HEAD, which tells a stop whose conflicts were resolved and staged from one that needs a correction."""
|
|
366
|
+
return GitCommand(["diff-index", "--cached", "--name-only", "-z", "HEAD"], accepted_codes=(0, 128))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _capture_rebase_attempts(key: WorktreeKey, generation: int, state_dirs: list[str],
|
|
370
|
+
read_facts: Callable[[], Generator[GitCommand | ReadBatch, object, tuple[bytes, bytes, bytes]]], *,
|
|
371
|
+
confirm_facts: bool) -> Generator[GitCommand | FileTask | ReadBatch, object, RebaseSnapshot]:
|
|
372
|
+
"""Read the native files around the HEAD, conflict and index facts until two looks agree, within bounded attempts and time.
|
|
373
|
+
|
|
374
|
+
With `confirm_facts` the facts are read a second time after the files and must not have moved either.
|
|
375
|
+
"""
|
|
376
|
+
started = time.monotonic()
|
|
377
|
+
for _attempt in range(REBASE_CAPTURE_ATTEMPTS):
|
|
378
|
+
try:
|
|
379
|
+
before = yield FileTask(lambda: tuple(read_metadata(directory) for directory in state_dirs))
|
|
380
|
+
if not any(before):
|
|
381
|
+
after = yield FileTask(lambda: tuple(read_metadata(directory) for directory in state_dirs))
|
|
382
|
+
if before == after:
|
|
383
|
+
return RebaseSnapshot(key, generation, native_observations=before)
|
|
384
|
+
continue
|
|
385
|
+
first_facts = yield from read_facts()
|
|
386
|
+
after = yield FileTask(lambda: tuple(read_metadata(directory) for directory in state_dirs))
|
|
387
|
+
last_facts = first_facts
|
|
388
|
+
if confirm_facts:
|
|
389
|
+
last_facts = yield from read_facts()
|
|
390
|
+
if before == after and first_facts == last_facts and (time.monotonic() - started) * 1000 <= REBASE_CAPTURE_TIMEOUT_MS:
|
|
391
|
+
head_output, unmerged, staged = first_facts
|
|
392
|
+
path_set = set(unmerged.split(b"\0"))
|
|
393
|
+
path_set.discard(b"")
|
|
394
|
+
snapshot = snapshot_from_metadata(key, generation, state_dirs, before, head_output.decode().strip() or None, bool(unmerged), staged_change=bool(staged))
|
|
395
|
+
return replace(snapshot, conflict_paths=tuple(sorted(path_set)))
|
|
396
|
+
except (OSError, ValueError):
|
|
397
|
+
if _attempt == REBASE_CAPTURE_ATTEMPTS - 1:
|
|
398
|
+
raise
|
|
399
|
+
if (time.monotonic() - started) * 1000 > REBASE_CAPTURE_TIMEOUT_MS:
|
|
400
|
+
break
|
|
401
|
+
raise ValueError("Native rebase changed during observation. Refresh to try again.")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def unmerged_paths_output(listing: bytes) -> bytes:
|
|
405
|
+
"""Keep only the paths of an `ls-files --unmerged -z` listing, separated by NUL, in the form the automatic refresh hands over.
|
|
406
|
+
|
|
407
|
+
Each record is the mode, object ID and stage, a tab, then the path; a record without its tab is refused.
|
|
408
|
+
"""
|
|
409
|
+
paths = []
|
|
410
|
+
for record in listing.split(b"\0"):
|
|
411
|
+
if not record:
|
|
412
|
+
continue
|
|
413
|
+
_stage, separator, path = record.partition(b"\t")
|
|
414
|
+
if not separator:
|
|
415
|
+
raise ValueError("Native rebase conflict data is malformed.")
|
|
416
|
+
paths.append(path)
|
|
417
|
+
return b"\0".join(paths)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def capture_rebase(context: RepositoryContext, generation: int) -> Generator[GitCommand | FileTask | ReadBatch, object, RebaseSnapshot]:
|
|
421
|
+
"""Retry changing native captures within bounded attempts and total elapsed time.
|
|
422
|
+
|
|
423
|
+
Everything is read again through Git: this is the capture operations and the observations a user asks for rely on.
|
|
424
|
+
"""
|
|
425
|
+
key = WorktreeKey.from_context(context)
|
|
426
|
+
if context.is_bare:
|
|
427
|
+
return RebaseSnapshot(key, generation)
|
|
428
|
+
state_dirs = yield from _read_state_dirs(key)
|
|
429
|
+
|
|
430
|
+
def read_facts() -> Generator[ReadBatch, object, tuple[bytes, bytes, bytes]]:
|
|
431
|
+
"""Read HEAD, the unmerged paths and whether the index differs from HEAD, in one batch."""
|
|
432
|
+
head_output, unmerged_listing, staged = yield ReadBatch((GitCommand(["rev-parse", "--verify", "HEAD"], accepted_codes=(0, 128)),
|
|
433
|
+
GitCommand(["ls-files", "--unmerged", "-z"]), staged_change_command()))
|
|
434
|
+
return head_output, unmerged_paths_output(unmerged_listing), staged
|
|
435
|
+
|
|
436
|
+
return (yield from _capture_rebase_attempts(key, generation, state_dirs, read_facts, confirm_facts=True))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def capture_rebase_passive(context: RepositoryContext, generation: int,
|
|
440
|
+
source: PassiveObservationSource) -> Generator[GitCommand | FileTask | ReadBatch, object, RebaseSnapshot]:
|
|
441
|
+
"""Follow the rebase state for the banner with what the automatic refresh has just read; never called by an operation."""
|
|
442
|
+
key = WorktreeKey.from_context(context)
|
|
443
|
+
if context.is_bare:
|
|
444
|
+
return RebaseSnapshot(key, generation)
|
|
445
|
+
state_dirs = state_dirs_from_pause_paths(key, source.filename_by_git_path)
|
|
446
|
+
|
|
447
|
+
def read_facts() -> Generator[GitCommand, object, tuple[bytes, bytes, bytes]]:
|
|
448
|
+
"""Answer HEAD and the conflicts from the status, and ask Git only whether the index differs from HEAD."""
|
|
449
|
+
staged = b""
|
|
450
|
+
# This fact follows the submodule settings while the status of the display reads every submodule, so it is
|
|
451
|
+
# asked of Git as the other capture does, and only without a conflict: the only case the classification reads it.
|
|
452
|
+
if not source.unmerged_paths:
|
|
453
|
+
staged = yield staged_change_command()
|
|
454
|
+
return (source.head_oid or "").encode("ascii"), b"\0".join(source.unmerged_paths), staged
|
|
455
|
+
|
|
456
|
+
return (yield from _capture_rebase_attempts(key, generation, state_dirs, read_facts, confirm_facts=False))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def snapshot_from_metadata(key: WorktreeKey, generation: int, state_dirs: list[str], observations: tuple[NativeMetadata | None, ...],
|
|
460
|
+
head_oid: str | None, unmerged: bool, *, staged_change: bool = False) -> RebaseSnapshot:
|
|
461
|
+
"""Classify a coherent native capture without inventing progress for opaque lines.
|
|
462
|
+
|
|
463
|
+
`staged_change` tells whether the index differs from HEAD; without it a stop whose conflicts were resolved and staged
|
|
464
|
+
cannot be told apart from one that still needs a manual correction.
|
|
465
|
+
"""
|
|
466
|
+
present_count = sum(observed is not None for observed in observations)
|
|
467
|
+
for state_dir, observed in zip(state_dirs, observations, strict=True):
|
|
468
|
+
if observed is None:
|
|
469
|
+
continue
|
|
470
|
+
content_by_basename = observed.content_by_basename
|
|
471
|
+
backend = os.path.basename(state_dir)
|
|
472
|
+
identity = RebaseIdentity(backend, os.path.normcase(state_dir), content_by_basename["orig-head"].strip(),
|
|
473
|
+
content_by_basename["onto"].strip(), content_by_basename["head-name"].strip(), observed.creation_fingerprint)
|
|
474
|
+
done_steps = parse_steps(content_by_basename["done"])
|
|
475
|
+
remaining_steps = parse_steps(content_by_basename["git-rebase-todo"])
|
|
476
|
+
opaque_steps = []
|
|
477
|
+
for step in (*done_steps, *remaining_steps):
|
|
478
|
+
if step.opaque:
|
|
479
|
+
opaque_steps.append(step)
|
|
480
|
+
current_step = None
|
|
481
|
+
state = "unknown"
|
|
482
|
+
diagnostic = "Native rebase is present; refresh to inspect its current state."
|
|
483
|
+
complete_identity = (re.fullmatch(r"[0-9a-f]{40,64}", identity.original_oid) is not None
|
|
484
|
+
and re.fullmatch(r"[0-9a-f]{40,64}", identity.onto_oid) is not None and bool(identity.head_name))
|
|
485
|
+
todo_signature_index = REBASE_BASENAMES.index("git-rebase-todo") + 1
|
|
486
|
+
missing_todo = backend == "rebase-merge" and observed.signatures[todo_signature_index] is None
|
|
487
|
+
if backend == "rebase-merge" and present_count == 1 and complete_identity:
|
|
488
|
+
state = "intervention_required"
|
|
489
|
+
diagnostic = "Git needs intervention. Review the Git output; edit the instructions or correct the repository externally, then Refresh. You can also abort the rebase."
|
|
490
|
+
if backend == "rebase-merge" and done_steps and present_count == 1:
|
|
491
|
+
last_step_index = len(done_steps) - 1
|
|
492
|
+
candidate = done_steps[last_step_index]
|
|
493
|
+
stopped_oid = content_by_basename["stopped-sha"].strip()
|
|
494
|
+
amend_oid = content_by_basename["amend"].strip()
|
|
495
|
+
if candidate.oid and (stopped_oid.startswith(candidate.oid) or (amend_oid and head_oid == amend_oid) or unmerged):
|
|
496
|
+
current_step = candidate
|
|
497
|
+
done_steps = done_steps[:last_step_index]
|
|
498
|
+
if unmerged:
|
|
499
|
+
state, diagnostic = "conflict", ""
|
|
500
|
+
elif candidate.action == "edit" and amend_oid:
|
|
501
|
+
state, diagnostic = "edit", ""
|
|
502
|
+
elif stopped_oid.startswith(candidate.oid) and staged_change:
|
|
503
|
+
# The stopped pick's conflicts were resolved and staged: Git only waits for Continue, so an intervention message would contradict the expected action.
|
|
504
|
+
state, diagnostic = "paused", "Conflicts are resolved and staged. Continue the rebase, or abort it."
|
|
505
|
+
else:
|
|
506
|
+
state = "intervention_required"
|
|
507
|
+
if backend == "rebase-apply":
|
|
508
|
+
diagnostic = "The apply backend is present. This view cannot classify its native steps."
|
|
509
|
+
if present_count != 1:
|
|
510
|
+
state = "unknown"
|
|
511
|
+
diagnostic = "Multiple native rebase directories are present. The current operation is unknown."
|
|
512
|
+
if not complete_identity:
|
|
513
|
+
state = "unknown"
|
|
514
|
+
diagnostic = "Native rebase metadata is incomplete. Restore it externally, then Refresh."
|
|
515
|
+
elif missing_todo:
|
|
516
|
+
state = "unknown"
|
|
517
|
+
diagnostic = "Native rebase instructions are missing. Refresh after correcting them externally, or abort the rebase."
|
|
518
|
+
known_done_steps = tuple(step for step in done_steps if not step.opaque)
|
|
519
|
+
known_remaining_steps = tuple(step for step in remaining_steps if not step.opaque)
|
|
520
|
+
fresh = complete_identity and present_count == 1
|
|
521
|
+
return RebaseSnapshot(key, generation, identity, head_oid, identity.head_name, state, fresh, diagnostic,
|
|
522
|
+
known_done_steps, current_step, known_remaining_steps, tuple(opaque_steps), unmerged, tuple(observations) if fresh else None)
|
|
523
|
+
return RebaseSnapshot(key, generation, unmerged=unmerged, native_observations=tuple(observations))
|