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,481 @@
|
|
|
1
|
+
"""Reconcile durable evidence of an interrupted history publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from dataclasses import asdict, dataclass, fields, replace
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from gitpane.config import LOCAL_PREFIX, MUTATION_RECORD_LIMIT_BYTES
|
|
15
|
+
from gitpane.history.file_history import WindowCapability
|
|
16
|
+
from gitpane.history.repository_value import validate_oid
|
|
17
|
+
from gitpane.history_rewrite.guard import GIT_STORAGE_ARGUMENTS, git_storage_matches, other_worktree_dir_by_branch, parse_worktree_listing
|
|
18
|
+
from gitpane.history_rewrite.index import index_digest
|
|
19
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
20
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
21
|
+
from gitpane.operation.repository_operation import OperationAction, WriteScope
|
|
22
|
+
from gitpane.process.process_runner import READ_PROFILE
|
|
23
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
24
|
+
from gitpane.system.path_identity import canonical_path
|
|
25
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
26
|
+
from gitpane.worktree.mutation import write_record
|
|
27
|
+
from gitpane.worktree.worktree_file import FILE_MODE_IDENTITY_FIELD, FILE_OBJECT_IDENTITY_FIELDS, PRIVATE_DIRECTORY_MODE
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Generator
|
|
31
|
+
|
|
32
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
33
|
+
from gitpane.history_rewrite.publish import RewritePublicationOutcome
|
|
34
|
+
from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
|
|
35
|
+
|
|
36
|
+
HISTORY_REWRITE_STORAGE_BASENAME = "history-rewrite"
|
|
37
|
+
RECOVERY_SCHEMA_VERSION = 1
|
|
38
|
+
PRIVATE_REF_PREFIX = "refs/gitpane/history-rewrite/"
|
|
39
|
+
RECOVERY_REF_FORMAT = "%(refname)%00%(objectname)%00%(symref)"
|
|
40
|
+
EMPTY_LOCK_DIGEST = hashlib.sha256(b"").hexdigest()
|
|
41
|
+
LOCK_IDENTITY_FIELD_COUNT = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RewritePublicationState(Enum):
|
|
45
|
+
"""Name durable transitions without claiming a transaction across refs and index."""
|
|
46
|
+
|
|
47
|
+
PREPARED = "prepared"
|
|
48
|
+
IMPORTING = "importing"
|
|
49
|
+
IMPORTED = "imported"
|
|
50
|
+
REFS_PENDING = "refs_pending"
|
|
51
|
+
REFS_COMMITTED = "refs_committed"
|
|
52
|
+
INDEX_PENDING = "index_pending"
|
|
53
|
+
INDEX_PUBLISHED = "index_published"
|
|
54
|
+
COMPENSATING = "compensating"
|
|
55
|
+
RESTORED = "restored"
|
|
56
|
+
COMPLETE = "complete"
|
|
57
|
+
NEEDS_RECOVERY = "needs_recovery"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RewriteRecoveryRecord:
|
|
62
|
+
"""Keep exact before/after identities outside Git for a single publication token."""
|
|
63
|
+
|
|
64
|
+
schema_version: int
|
|
65
|
+
operation_id: str
|
|
66
|
+
operation_dir: str
|
|
67
|
+
context_identity: tuple[str, str, str]
|
|
68
|
+
git_filename: str
|
|
69
|
+
head_ref: str
|
|
70
|
+
old_oid_by_ref: dict[str, str]
|
|
71
|
+
new_oid_by_ref: dict[str, str]
|
|
72
|
+
original_index_identity: str | None
|
|
73
|
+
candidate_index_identity: str | None
|
|
74
|
+
owned_lock_identity: tuple[int, int, int] | None
|
|
75
|
+
state: RewritePublicationState
|
|
76
|
+
private_ref_by_name: dict[str, str]
|
|
77
|
+
detail: str = ""
|
|
78
|
+
owned_lock_digest: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RecoveryObservation:
|
|
83
|
+
"""Report observed public state independently of the last journal transition."""
|
|
84
|
+
|
|
85
|
+
ref_oid_by_name: dict[str, str]
|
|
86
|
+
index_identity: str | None
|
|
87
|
+
restorable: bool
|
|
88
|
+
detail: str
|
|
89
|
+
head_ref: str | None
|
|
90
|
+
lock_identity: tuple[int, int, int] | None
|
|
91
|
+
private_ref_by_name: dict[str, str]
|
|
92
|
+
authority: tuple = ()
|
|
93
|
+
worktrees: tuple[bytes, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class RecoveryDiscovery:
|
|
98
|
+
"""Retain unreadable operation folders as visible diagnostics during discovery."""
|
|
99
|
+
|
|
100
|
+
operation_dir: str
|
|
101
|
+
record: RewriteRecoveryRecord | None
|
|
102
|
+
detail: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RecoveryContextMismatch(ValueError):
|
|
106
|
+
"""Distinguish another repository's valid record from corrupt recovery data."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def context_identity(context: RepositoryContext) -> tuple[str, str, str]:
|
|
110
|
+
"""Bind a record to the exact active repository and its two storage directories."""
|
|
111
|
+
return canonical_path(context.repository_dir), canonical_path(context.git_dir), canonical_path(context.common_git_dir)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def digest_text(identity: bytes | None) -> str | None:
|
|
115
|
+
"""Serialize an absent index distinctly from every byte digest."""
|
|
116
|
+
return identity.hex() if identity is not None else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lock_identity(filename: str) -> tuple[int, int, int] | None:
|
|
120
|
+
"""Identify a lock entry without following a replacement symlink."""
|
|
121
|
+
try:
|
|
122
|
+
information = os.lstat(filename)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return None
|
|
125
|
+
return information.st_dev, information.st_ino, stat.S_IFMT(information.st_mode)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sync_directory(directory: str) -> None:
|
|
129
|
+
"""Persist renamed entries on systems that support opening directory descriptors."""
|
|
130
|
+
if not hasattr(os, "O_DIRECTORY"):
|
|
131
|
+
return
|
|
132
|
+
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
133
|
+
try:
|
|
134
|
+
os.fsync(descriptor)
|
|
135
|
+
finally:
|
|
136
|
+
os.close(descriptor)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_storage_root(storage_root_dir: str, context: RepositoryContext) -> str:
|
|
140
|
+
"""Require recovery storage outside the worktree and all of its Git storage."""
|
|
141
|
+
storage_root_dir = canonical_path(storage_root_dir)
|
|
142
|
+
for source_dir in context_identity(context):
|
|
143
|
+
try:
|
|
144
|
+
inside = os.path.commonpath((source_dir, storage_root_dir)) == source_dir
|
|
145
|
+
except ValueError:
|
|
146
|
+
inside = False
|
|
147
|
+
if inside:
|
|
148
|
+
raise ValueError("History recovery storage must be outside the repository and its Git storage")
|
|
149
|
+
return storage_root_dir
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_recovery_directory(storage_root_dir: str, operation_id: str, context: RepositoryContext) -> str:
|
|
153
|
+
"""Claim a private durable operation folder without reusing an earlier publication."""
|
|
154
|
+
if re.fullmatch(r"[0-9a-f]{32}", operation_id) is None:
|
|
155
|
+
raise ValueError("Invalid history publication identifier; build a new preview")
|
|
156
|
+
storage_root_dir = validate_storage_root(storage_root_dir, context)
|
|
157
|
+
os.makedirs(storage_root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
|
158
|
+
operation_dir = os.path.join(storage_root_dir, operation_id)
|
|
159
|
+
os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
|
|
160
|
+
os.mkdir(os.path.join(operation_dir, "hooks"), PRIVATE_DIRECTORY_MODE)
|
|
161
|
+
sync_directory(storage_root_dir)
|
|
162
|
+
return operation_dir
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_recovery_record(record: RewriteRecoveryRecord) -> None:
|
|
166
|
+
"""Flush a bounded private JSON record before performing its announced action."""
|
|
167
|
+
if os.path.islink(record.operation_dir) or not os.path.isdir(record.operation_dir):
|
|
168
|
+
raise ValueError("Recovery directory changed; retain the recovery files for inspection")
|
|
169
|
+
record_by_name = asdict(record)
|
|
170
|
+
record_by_name["state"] = record.state.value
|
|
171
|
+
if len(json.dumps(record_by_name, sort_keys=True).encode("utf-8")) > MUTATION_RECORD_LIMIT_BYTES:
|
|
172
|
+
raise ValueError("History recovery record exceeds its size limit; retain the recovery files")
|
|
173
|
+
for basename in ("record.json", "record.next"):
|
|
174
|
+
filename = os.path.join(record.operation_dir, basename)
|
|
175
|
+
if os.path.lexists(filename):
|
|
176
|
+
information = os.lstat(filename)
|
|
177
|
+
if not stat.S_ISREG(information.st_mode) or information.st_nlink != 1:
|
|
178
|
+
raise ValueError("Linked or nonregular recovery record; inspect the recovery files")
|
|
179
|
+
write_record(record.operation_dir, record_by_name)
|
|
180
|
+
sync_directory(record.operation_dir)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def transition_record(record: RewriteRecoveryRecord, state: RewritePublicationState, **changes: object) -> RewriteRecoveryRecord:
|
|
184
|
+
"""Persist a transition before returning it to the caller."""
|
|
185
|
+
record = replace(record, state=state, **changes)
|
|
186
|
+
write_recovery_record(record)
|
|
187
|
+
return record
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _unique_record_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
191
|
+
"""Refuse duplicate JSON fields instead of silently trusting the last occurrence."""
|
|
192
|
+
value_by_name = {}
|
|
193
|
+
for name, value in pairs:
|
|
194
|
+
if name in value_by_name:
|
|
195
|
+
raise ValueError("Duplicate recovery record field; inspect it before continuing")
|
|
196
|
+
value_by_name[name] = value
|
|
197
|
+
return value_by_name
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_record_reference(reference: str, prefix: str) -> None:
|
|
201
|
+
"""Check the native reference namespace using Git's ASCII restrictions, preserving non-ASCII bytes."""
|
|
202
|
+
forbidden_byte_set = set(b" ~^:?*[\\")
|
|
203
|
+
encoded = os.fsencode(reference)
|
|
204
|
+
if (not reference.startswith(prefix) or any(value <= 32 or value == 127 or value in forbidden_byte_set for value in encoded)
|
|
205
|
+
or ".." in reference or "@{" in reference or reference.endswith(".")):
|
|
206
|
+
raise ValueError("Recovery reference is outside its allowed namespace")
|
|
207
|
+
for part in reference.split("/"):
|
|
208
|
+
if not part or part.startswith(".") or part.endswith(".lock"):
|
|
209
|
+
raise ValueError("Invalid recovery reference name")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def read_recovery_record(operation_dir: str, context: RepositoryContext) -> RewriteRecoveryRecord:
|
|
213
|
+
"""Read only a supported journal bound to this repository and operation folder."""
|
|
214
|
+
validate_storage_root(os.path.dirname(operation_dir), context)
|
|
215
|
+
filename = os.path.join(operation_dir, "record.json")
|
|
216
|
+
if os.path.islink(operation_dir):
|
|
217
|
+
raise ValueError("Linked recovery directory; inspect it before continuing")
|
|
218
|
+
information = os.lstat(filename)
|
|
219
|
+
if not stat.S_ISREG(information.st_mode) or information.st_nlink != 1 or information.st_size > MUTATION_RECORD_LIMIT_BYTES:
|
|
220
|
+
raise ValueError("Linked, oversized or nonregular recovery record; inspect it before continuing")
|
|
221
|
+
with open(filename, "rb") as stream:
|
|
222
|
+
opened = os.fstat(stream.fileno())
|
|
223
|
+
current = os.lstat(filename)
|
|
224
|
+
if (opened.st_dev, opened.st_ino) != (information.st_dev, information.st_ino) or not stat.S_ISREG(current.st_mode):
|
|
225
|
+
raise ValueError("Recovery record was replaced while opening it; inspect it before continuing")
|
|
226
|
+
content = stream.read(MUTATION_RECORD_LIMIT_BYTES + 1)
|
|
227
|
+
if len(content) > MUTATION_RECORD_LIMIT_BYTES:
|
|
228
|
+
raise ValueError("Oversized recovery record; inspect it before continuing")
|
|
229
|
+
record_by_name = json.loads(content, object_pairs_hook=_unique_record_pairs)
|
|
230
|
+
if not isinstance(record_by_name, dict) or set(record_by_name) != {field.name for field in fields(RewriteRecoveryRecord)}:
|
|
231
|
+
raise ValueError("Unrecognized recovery record fields; inspect it before continuing")
|
|
232
|
+
if type(record_by_name["schema_version"]) is not int or record_by_name["schema_version"] != RECOVERY_SCHEMA_VERSION:
|
|
233
|
+
raise ValueError("Unsupported recovery record version; inspect it before continuing")
|
|
234
|
+
if record_by_name["operation_id"] != os.path.basename(operation_dir) or re.fullmatch(r"[0-9a-f]{32}", record_by_name["operation_id"]) is None:
|
|
235
|
+
raise ValueError("Recovery operation identifier does not match its folder")
|
|
236
|
+
# The folder is recorded with its canonical spelling, lower case under Windows: compare folders, not spellings.
|
|
237
|
+
if not isinstance(record_by_name["operation_dir"], str) or canonical_path(record_by_name["operation_dir"]) != canonical_path(operation_dir):
|
|
238
|
+
raise ValueError("Recovery record does not belong to this repository")
|
|
239
|
+
if record_by_name["context_identity"] != list(context_identity(context)):
|
|
240
|
+
raise RecoveryContextMismatch("Recovery record belongs to another repository")
|
|
241
|
+
record_by_name["context_identity"] = tuple(record_by_name["context_identity"])
|
|
242
|
+
record_by_name["state"] = RewritePublicationState(record_by_name["state"])
|
|
243
|
+
identity = record_by_name["owned_lock_identity"]
|
|
244
|
+
if identity is not None:
|
|
245
|
+
if (not isinstance(identity, list) or len(identity) != LOCK_IDENTITY_FIELD_COUNT or any(type(value) is not int or value < 0 for value in identity)
|
|
246
|
+
or identity[FILE_MODE_IDENTITY_FIELD] != stat.S_IFREG):
|
|
247
|
+
raise ValueError("Invalid recorded index lock identity")
|
|
248
|
+
record_by_name["owned_lock_identity"] = tuple(identity)
|
|
249
|
+
for name in ("original_index_identity", "candidate_index_identity", "owned_lock_digest"):
|
|
250
|
+
identity = record_by_name[name]
|
|
251
|
+
if identity is not None and (not isinstance(identity, str) or re.fullmatch(r"[0-9a-f]{64}", identity) is None):
|
|
252
|
+
raise ValueError("Invalid recorded index digest")
|
|
253
|
+
for name in ("old_oid_by_ref", "new_oid_by_ref", "private_ref_by_name"):
|
|
254
|
+
oid_by_ref = record_by_name[name]
|
|
255
|
+
if not isinstance(oid_by_ref, dict):
|
|
256
|
+
raise ValueError("Invalid recovery reference map") # noqa: TRY004 — malformed persisted JSON is a recoverable data error.
|
|
257
|
+
prefix = PRIVATE_REF_PREFIX + record_by_name["operation_id"] + "/" if name == "private_ref_by_name" else LOCAL_PREFIX
|
|
258
|
+
for reference, oid in oid_by_ref.items():
|
|
259
|
+
_validate_record_reference(reference, prefix)
|
|
260
|
+
validate_oid(oid)
|
|
261
|
+
if set(record_by_name["old_oid_by_ref"]) != set(record_by_name["new_oid_by_ref"]) or record_by_name["head_ref"] not in record_by_name["old_oid_by_ref"]:
|
|
262
|
+
raise ValueError("Recovery reference sets do not describe one captured repository")
|
|
263
|
+
return RewriteRecoveryRecord(**record_by_name)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def observe_recovery(record: RewriteRecoveryRecord, context: RepositoryContext) -> Generator[GitCommand | FileTask, object, RecoveryObservation]:
|
|
267
|
+
"""Inspect actual refs, HEAD and index instead of trusting a pending journal state."""
|
|
268
|
+
if record.context_identity != context_identity(context):
|
|
269
|
+
raise ValueError("Recovery record belongs to another repository")
|
|
270
|
+
authority = yield FileTask(lambda: reference_signature(context))
|
|
271
|
+
locations = yield GitCommand(list(GIT_STORAGE_ARGUMENTS), profile=READ_PROFILE)
|
|
272
|
+
if not git_storage_matches(context, locations):
|
|
273
|
+
raise ValueError("Repository storage changed; reopen the repository before recovery")
|
|
274
|
+
output = yield GitCommand(["for-each-ref", "--format=" + RECOVERY_REF_FORMAT, LOCAL_PREFIX, PRIVATE_REF_PREFIX + record.operation_id + "/"])
|
|
275
|
+
ref_oid_by_name = {}
|
|
276
|
+
private_ref_by_name = {}
|
|
277
|
+
detail = ""
|
|
278
|
+
for line in output.splitlines():
|
|
279
|
+
name, oid, symbolic = line.split(b"\0")
|
|
280
|
+
name = os.fsdecode(name)
|
|
281
|
+
if symbolic:
|
|
282
|
+
detail = "A reference became symbolic; inspect references before recovery"
|
|
283
|
+
if name.startswith(LOCAL_PREFIX):
|
|
284
|
+
ref_oid_by_name[name] = validate_oid(oid.decode("ascii"))
|
|
285
|
+
else:
|
|
286
|
+
private_ref_by_name[name] = validate_oid(oid.decode("ascii"))
|
|
287
|
+
head = (yield GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))).removesuffix(b"\n")
|
|
288
|
+
head_ref = os.fsdecode(head) if head else None
|
|
289
|
+
worktrees = parse_worktree_listing((yield GitCommand(["worktree", "list", "--porcelain", "-z"])))
|
|
290
|
+
index_filename = os.path.join(context.git_dir, "index")
|
|
291
|
+
identity = yield FileTask(lambda: digest_text(index_digest(index_filename)))
|
|
292
|
+
actual_lock_identity = yield FileTask(lambda: lock_identity(index_filename + ".lock"))
|
|
293
|
+
if head_ref != record.head_ref:
|
|
294
|
+
detail = "HEAD changed outside the operation; inspect the active branch before recovery"
|
|
295
|
+
if actual_lock_identity is not None and actual_lock_identity != record.owned_lock_identity:
|
|
296
|
+
detail = "An index lock belongs to another writer; wait for that writer before recovery"
|
|
297
|
+
elif actual_lock_identity is not None:
|
|
298
|
+
lock_digest = yield FileTask(lambda: digest_text(index_digest(index_filename + ".lock")))
|
|
299
|
+
if lock_digest != record.owned_lock_digest:
|
|
300
|
+
detail = "Index lock content changed outside the operation; inspect it before recovery"
|
|
301
|
+
restorable = (not detail and record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED)
|
|
302
|
+
and ref_oid_by_name == record.new_oid_by_ref and identity == record.original_index_identity and identity != record.candidate_index_identity)
|
|
303
|
+
if restorable:
|
|
304
|
+
worktree_dir_by_branch = other_worktree_dir_by_branch(context, worktrees)
|
|
305
|
+
for name in sorted(record.old_oid_by_ref):
|
|
306
|
+
if record.old_oid_by_ref[name] != record.new_oid_by_ref[name] and name in worktree_dir_by_branch:
|
|
307
|
+
detail = (f"{name} is checked out in another worktree: {worktree_dir_by_branch[name]}. Restoring it would change that checkout's branch without its index. "
|
|
308
|
+
"Release that branch in the other worktree, then refresh recovery. No branches were restored.")
|
|
309
|
+
restorable = False
|
|
310
|
+
break
|
|
311
|
+
if (yield FileTask(lambda: reference_signature(context))) != authority:
|
|
312
|
+
raise ValueError("References, HEAD or worktrees changed during recovery inspection; refresh recovery")
|
|
313
|
+
return RecoveryObservation(ref_oid_by_name, identity, restorable, detail, head_ref, actual_lock_identity, private_ref_by_name, authority, worktrees)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def coherent_recovery_state(record: RewriteRecoveryRecord, observation: RecoveryObservation) -> RewritePublicationState | None:
|
|
317
|
+
"""Identify already consistent public data that needs no branch restoration."""
|
|
318
|
+
if observation.detail:
|
|
319
|
+
return None
|
|
320
|
+
if observation.ref_oid_by_name == record.old_oid_by_ref and observation.index_identity == record.original_index_identity:
|
|
321
|
+
return RewritePublicationState.RESTORED
|
|
322
|
+
if observation.ref_oid_by_name == record.new_oid_by_ref and observation.index_identity == record.candidate_index_identity:
|
|
323
|
+
return RewritePublicationState.COMPLETE
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def validate_recovery_observation(record: RewriteRecoveryRecord, observation: RecoveryObservation, operation: OperationIntent,
|
|
328
|
+
repository_operation: RepositoryOperationService) -> None:
|
|
329
|
+
"""Reject changed evidence immediately before cleanup or a terminal journal write."""
|
|
330
|
+
validate_recovery_authority(record, operation, repository_operation)
|
|
331
|
+
if read_recovery_record(record.operation_dir, operation.context) != record or reference_signature(operation.context) != observation.authority:
|
|
332
|
+
raise ValueError("Recovery evidence changed; refresh recovery before continuing")
|
|
333
|
+
index_filename = os.path.join(operation.context.git_dir, "index")
|
|
334
|
+
actual_lock = lock_identity(index_filename + ".lock")
|
|
335
|
+
if digest_text(index_digest(index_filename)) != observation.index_identity or actual_lock != observation.lock_identity:
|
|
336
|
+
raise ValueError("Index or lock changed during recovery; retain recovery data")
|
|
337
|
+
if actual_lock is not None and (actual_lock != record.owned_lock_identity or digest_text(index_digest(index_filename + ".lock")) != record.owned_lock_digest):
|
|
338
|
+
raise ValueError("Index lock content changed; retain it for inspection")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def discover_recovery(storage_root_dir: str, context: RepositoryContext) -> tuple[RecoveryDiscovery, ...]:
|
|
342
|
+
"""List unfinished operations without deleting malformed or unknown recovery evidence."""
|
|
343
|
+
storage_root_dir = validate_storage_root(storage_root_dir, context)
|
|
344
|
+
if not os.path.exists(storage_root_dir):
|
|
345
|
+
return ()
|
|
346
|
+
discoveries = []
|
|
347
|
+
with os.scandir(storage_root_dir) as entries:
|
|
348
|
+
for entry in entries:
|
|
349
|
+
try:
|
|
350
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
351
|
+
raise ValueError("Unexpected entry in recovery storage; inspect it before continuing")
|
|
352
|
+
record = read_recovery_record(entry.path, context)
|
|
353
|
+
if record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED):
|
|
354
|
+
discoveries.append(RecoveryDiscovery(entry.path, record))
|
|
355
|
+
except RecoveryContextMismatch:
|
|
356
|
+
continue
|
|
357
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError) as error:
|
|
358
|
+
discoveries.append(RecoveryDiscovery(entry.path, None, "Cannot inspect history recovery: " + str(error)))
|
|
359
|
+
return tuple(sorted(discoveries, key=lambda discovery: discovery.operation_dir))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def validate_recovery_authority(record: RewriteRecoveryRecord, operation: OperationIntent, repository_operation: RepositoryOperationService) -> None:
|
|
363
|
+
"""Require the same executable, context and full reference scope for every recovery write."""
|
|
364
|
+
repository_operation.validate(operation)
|
|
365
|
+
if (record.context_identity != context_identity(operation.context) or record.git_filename != operation.git_filename
|
|
366
|
+
or operation.action != OperationAction.REF_WRITE or operation.capability != WindowCapability.MANAGE
|
|
367
|
+
or operation.scope != WriteScope.capture(operation.context, (LOCAL_PREFIX + "*",))):
|
|
368
|
+
raise ValueError("History recovery authority changed; reopen the repository before continuing")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def release_recovery_lock(record: RewriteRecoveryRecord, context: RepositoryContext) -> None:
|
|
372
|
+
"""Release an interrupted writer's exact regular lock, never a replacement entry."""
|
|
373
|
+
from gitpane.worktree.mutation import release_owned_index_lock
|
|
374
|
+
|
|
375
|
+
filename = os.path.join(context.git_dir, "index.lock")
|
|
376
|
+
actual_identity = lock_identity(filename)
|
|
377
|
+
if actual_identity is None:
|
|
378
|
+
return
|
|
379
|
+
if actual_identity != record.owned_lock_identity:
|
|
380
|
+
raise ValueError("Index lock belongs to another writer; wait before retrying recovery")
|
|
381
|
+
if digest_text(index_digest(filename)) != record.owned_lock_digest:
|
|
382
|
+
raise ValueError("Index lock content changed; retain it for inspection")
|
|
383
|
+
release_owned_index_lock(filename, None, record.owned_lock_identity[:FILE_OBJECT_IDENTITY_FIELDS])
|
|
384
|
+
sync_directory(context.git_dir)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def finish_coherent_recovery(record: RewriteRecoveryRecord, observation: RecoveryObservation, state: RewritePublicationState,
|
|
388
|
+
operation: OperationIntent, repository_operation: RepositoryOperationService, *, detail: str = "") -> Generator[GitCommand | FileTask, object, RewriteRecoveryRecord]:
|
|
389
|
+
"""Close recovery only while fresh public state, journal and owned resources remain coherent."""
|
|
390
|
+
from gitpane.history_rewrite.publish import cleanup_private_refs
|
|
391
|
+
|
|
392
|
+
if coherent_recovery_state(record, observation) != state:
|
|
393
|
+
raise ValueError("Repository is not coherent; retain recovery data")
|
|
394
|
+
|
|
395
|
+
def validate() -> None:
|
|
396
|
+
"""Use the latest complete observation at each write boundary."""
|
|
397
|
+
validate_recovery_observation(record, observation, operation, repository_operation)
|
|
398
|
+
|
|
399
|
+
yield from cleanup_private_refs(record, observation, before_start=validate)
|
|
400
|
+
observation = yield from observe_recovery(record, operation.context)
|
|
401
|
+
if coherent_recovery_state(record, observation) != state or observation.private_ref_by_name:
|
|
402
|
+
raise ValueError("Repository changed during recovery cleanup; retain recovery data")
|
|
403
|
+
|
|
404
|
+
def release_lock() -> None:
|
|
405
|
+
"""Revalidate before releasing only the interrupted operation's lock."""
|
|
406
|
+
validate()
|
|
407
|
+
release_recovery_lock(record, operation.context)
|
|
408
|
+
|
|
409
|
+
yield FileTask(release_lock)
|
|
410
|
+
observation = yield from observe_recovery(record, operation.context)
|
|
411
|
+
if coherent_recovery_state(record, observation) != state or observation.private_ref_by_name or observation.lock_identity is not None:
|
|
412
|
+
raise ValueError("Repository changed before recovery finished; retain recovery data")
|
|
413
|
+
|
|
414
|
+
def finish() -> RewriteRecoveryRecord:
|
|
415
|
+
"""Record completion only while the final public state still matches."""
|
|
416
|
+
validate()
|
|
417
|
+
return transition_record(record, state, detail=detail)
|
|
418
|
+
|
|
419
|
+
return (yield FileTask(finish))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def recover_rewrite(record: RewriteRecoveryRecord, operation: OperationIntent, repository_operation: RepositoryOperationService,
|
|
423
|
+
*, restore: bool = False, expected_observation: RecoveryObservation | None = None) -> Generator[GitCommand | FileTask, object, RewritePublicationOutcome]:
|
|
424
|
+
"""Reobserve pending work, restoring incomplete publication only when its caller authorizes it."""
|
|
425
|
+
from gitpane.history_rewrite.publish import RewritePublicationOutcome, compensate_rewrite
|
|
426
|
+
|
|
427
|
+
trusted = False
|
|
428
|
+
try:
|
|
429
|
+
fresh_record = yield FileTask(lambda: read_recovery_record(record.operation_dir, operation.context))
|
|
430
|
+
if fresh_record != record:
|
|
431
|
+
raise ValueError("Recovery record changed; refresh recovery before continuing")
|
|
432
|
+
trusted = True
|
|
433
|
+
validate_recovery_authority(record, operation, repository_operation)
|
|
434
|
+
observation = yield from observe_recovery(record, operation.context)
|
|
435
|
+
if expected_observation is not None and observation != expected_observation:
|
|
436
|
+
raise ValueError("Recovery state changed since observation; refresh recovery")
|
|
437
|
+
if observation.detail:
|
|
438
|
+
raise ValueError(observation.detail)
|
|
439
|
+
state = coherent_recovery_state(record, observation)
|
|
440
|
+
if state is None and observation.restorable and restore:
|
|
441
|
+
return (yield from compensate_rewrite(record, observation, operation, repository_operation))
|
|
442
|
+
if state is None:
|
|
443
|
+
detail = "Branches were published but the index is original. Review recovery and confirm Restore previous branches."
|
|
444
|
+
if not observation.restorable:
|
|
445
|
+
changed_refs = []
|
|
446
|
+
for name in sorted(set(observation.ref_oid_by_name) | set(record.old_oid_by_ref)):
|
|
447
|
+
observed_oid = observation.ref_oid_by_name.get(name, "absent")
|
|
448
|
+
if observed_oid not in (record.old_oid_by_ref.get(name), record.new_oid_by_ref.get(name)):
|
|
449
|
+
changed_refs.append(f"{name}={observed_oid}")
|
|
450
|
+
detail = "References or index differ from a complete before/after state; inspect recovery without overwriting external changes."
|
|
451
|
+
if changed_refs:
|
|
452
|
+
detail += " Changed references: " + ", ".join(changed_refs)
|
|
453
|
+
raise ValueError(detail)
|
|
454
|
+
record = yield from finish_coherent_recovery(record, observation, state, operation, repository_operation)
|
|
455
|
+
status = OperationStatus.SUCCESS if state == RewritePublicationState.COMPLETE else OperationStatus.FAILED
|
|
456
|
+
return RewritePublicationOutcome(operation.operation_id, status, record=record)
|
|
457
|
+
except (OSError, ValueError, RuntimeError, TypeError, KeyError) as error:
|
|
458
|
+
if trusted:
|
|
459
|
+
return (yield from retain_recovery_failure(record, operation, error))
|
|
460
|
+
diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
461
|
+
return RewritePublicationOutcome(operation.operation_id, OperationStatus.UNKNOWN, diagnostic=diagnostic, record=record)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def retain_recovery_failure(record: RewriteRecoveryRecord, operation: OperationIntent, error: Exception) -> Generator[FileTask, object, RewritePublicationOutcome]:
|
|
465
|
+
"""Persist failure against the caller's latest journal without replacing an external edit."""
|
|
466
|
+
from gitpane.history_rewrite.publish import RewritePublicationOutcome
|
|
467
|
+
|
|
468
|
+
detail = str(error)
|
|
469
|
+
try:
|
|
470
|
+
def retain_failure() -> RewriteRecoveryRecord:
|
|
471
|
+
"""Change only the exact journal last written or inspected by this operation."""
|
|
472
|
+
current = read_recovery_record(record.operation_dir, operation.context)
|
|
473
|
+
if current != record:
|
|
474
|
+
return current
|
|
475
|
+
return transition_record(record, RewritePublicationState.NEEDS_RECOVERY, detail=detail)
|
|
476
|
+
|
|
477
|
+
record = yield FileTask(retain_failure)
|
|
478
|
+
except (OSError, ValueError, RuntimeError, TypeError, KeyError):
|
|
479
|
+
detail += "; cannot persist recovery status, retain the existing record for inspection"
|
|
480
|
+
diagnostic = Diagnostic(operation.operation, operation.context.repository_dir, operation.git_filename, [], detail).prepare()
|
|
481
|
+
return RewritePublicationOutcome(operation.operation_id, OperationStatus.UNKNOWN, diagnostic=diagnostic, record=record)
|