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,1539 @@
|
|
|
1
|
+
"""Publish privately prepared Git indexes under an owned lock with recovery records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict, dataclass, replace
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
15
|
+
|
|
16
|
+
from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS, MUTATION_RECORD_LIMIT_BYTES
|
|
17
|
+
from gitpane.history.repository_value import FileStatus
|
|
18
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
19
|
+
from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OWNED_REPOSITORY_TEXT, OperationAction, OperationIntent, RepositoryOperationService, WriteScope, native_rebase_capture
|
|
20
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
|
|
21
|
+
from gitpane.system.path_identity import canonical_path
|
|
22
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
23
|
+
from gitpane.worktree.staged_reset import may_carry_split_index
|
|
24
|
+
from gitpane.worktree.worktree_file import (
|
|
25
|
+
FILE_MODE_IDENTITY_FIELD,
|
|
26
|
+
FILE_OBJECT_IDENTITY_FIELDS,
|
|
27
|
+
FILE_STABLE_IDENTITY_FIELDS,
|
|
28
|
+
MISSING_PARENT_IDENTITY,
|
|
29
|
+
PRIVATE_DIRECTORY_MODE,
|
|
30
|
+
PRIVATE_FILE_MODE,
|
|
31
|
+
FileImage,
|
|
32
|
+
capture_image,
|
|
33
|
+
clean_created_parents,
|
|
34
|
+
clean_owned,
|
|
35
|
+
create_missing_parents,
|
|
36
|
+
matches_after_announced_mode,
|
|
37
|
+
materialize_blob,
|
|
38
|
+
owned_inventory,
|
|
39
|
+
prepare_publication_image,
|
|
40
|
+
private_file_dir,
|
|
41
|
+
publication_image,
|
|
42
|
+
publication_permission,
|
|
43
|
+
publish_image_mode,
|
|
44
|
+
respell_single_entry,
|
|
45
|
+
stat_identity,
|
|
46
|
+
validate_destination,
|
|
47
|
+
validate_image,
|
|
48
|
+
write_image,
|
|
49
|
+
)
|
|
50
|
+
from gitpane.worktree.worktree_snapshot import SUBMODULE_NO_COMMIT_CHANGE, StagingSide, WorktreeKey, file_fingerprint, fingerprint, read_index, verify_local_state
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Callable, Generator
|
|
54
|
+
|
|
55
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
56
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
57
|
+
from gitpane.process.process_runner import ProcessRunner
|
|
58
|
+
from gitpane.sequencer.conflict import ConflictResolutionIntent
|
|
59
|
+
from gitpane.worktree.partial_patch import RawLineSelection, RawPatch
|
|
60
|
+
from gitpane.worktree.staged_reset import ResetCandidate
|
|
61
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
62
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
|
|
63
|
+
|
|
64
|
+
JOURNAL_SCHEMA_VERSION = 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class JournalKind(Enum):
|
|
68
|
+
"""The kinds of change a recovery journal describes; the index journal of a staging change carries no kind at all."""
|
|
69
|
+
|
|
70
|
+
RESET = "reset"
|
|
71
|
+
"""A Reset of whole files, with one entry per file it replaces."""
|
|
72
|
+
FILE = "file"
|
|
73
|
+
"""A change of one working file, such as a Reset of selected lines or a historical restore."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JournalState(Enum):
|
|
77
|
+
"""The moments of a journaled change, written as their value in record.json and read back at recovery."""
|
|
78
|
+
|
|
79
|
+
PREPARING = "preparing"
|
|
80
|
+
"""The private copies are being written; nothing visible has changed."""
|
|
81
|
+
PREPARED = "prepared"
|
|
82
|
+
"""The candidates and backups are complete; nothing visible has changed yet."""
|
|
83
|
+
PUBLISHING = "publishing"
|
|
84
|
+
"""The visible replacements are about to start."""
|
|
85
|
+
FILE_PUBLISHED = "file_published"
|
|
86
|
+
"""At least one working file holds its new content."""
|
|
87
|
+
INDEX_PUBLISHED = "index_published"
|
|
88
|
+
"""The index holds its new content."""
|
|
89
|
+
COMPLETE = "complete"
|
|
90
|
+
"""Every replacement finished and was cleaned up."""
|
|
91
|
+
ABANDONED = "abandoned"
|
|
92
|
+
"""The change stopped before anything visible changed."""
|
|
93
|
+
RECOVERED_PRE = "recovered_pre"
|
|
94
|
+
"""A recovery found or restored the state from before the change."""
|
|
95
|
+
RECOVERED_POST = "recovered_post"
|
|
96
|
+
"""A recovery found the change fully applied."""
|
|
97
|
+
ROLLED_BACK = "rolled_back"
|
|
98
|
+
"""The change put the working file back after a failure."""
|
|
99
|
+
RESTORED = "restored"
|
|
100
|
+
"""A recovery put this file of a Reset back to its content from before the change."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# The journal states a recovery leaves alone: the change ended, either way.
|
|
104
|
+
FINISHED_JOURNAL_STATE_SET = frozenset((JournalState.COMPLETE, JournalState.ABANDONED, JournalState.RECOVERED_PRE, JournalState.RECOVERED_POST, JournalState.ROLLED_BACK))
|
|
105
|
+
# Causes of a journal this version cannot read, shown after "Cannot inspect interrupted operation: ".
|
|
106
|
+
UNKNOWN_JOURNAL_STATE_CAUSE = "Unknown recovery record state"
|
|
107
|
+
UNKNOWN_JOURNAL_KIND_CAUSE = "Unknown recovery record kind"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_journal_state(record_by_name: dict[str, object]) -> JournalState:
|
|
111
|
+
"""Read the state a recovery journal records, refusing a word this version does not know, an empty one or none.
|
|
112
|
+
|
|
113
|
+
The refusal is a plain sentence the user reads, never Python's own message about the enum.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
return JournalState(record_by_name.get("state"))
|
|
117
|
+
except ValueError as error:
|
|
118
|
+
raise ValueError(UNKNOWN_JOURNAL_STATE_CAUSE) from error
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_journal_kind(record_by_name: dict[str, object]) -> JournalKind | None:
|
|
122
|
+
"""Read the kind of change a recovery journal records, or None when it records none, as the index journal of a staging change does.
|
|
123
|
+
|
|
124
|
+
A kind this version does not know is refused with a plain sentence the user reads.
|
|
125
|
+
"""
|
|
126
|
+
if "kind" not in record_by_name:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
return JournalKind(record_by_name["kind"])
|
|
130
|
+
except ValueError as error:
|
|
131
|
+
raise ValueError(UNKNOWN_JOURNAL_KIND_CAUSE) from error
|
|
132
|
+
|
|
133
|
+
# The operation named by a diagnostic about restoring interrupted changes; the staging view keeps it shown as a state.
|
|
134
|
+
RECOVER_CHANGES_OPERATION = "Recover changes"
|
|
135
|
+
# Said when the snapshot's repository folder no longer resolves to the Git directory it was read from, for example after a move.
|
|
136
|
+
REPOSITORY_FOLDER_CHANGED_TEXT = "The repository folder changed since it was read. Refresh before writing."
|
|
137
|
+
# Said when an identifier already prepared is asked again for another repository, Git program, action or scope.
|
|
138
|
+
PREPARED_ACTION_CHANGED_TEXT = "This action no longer matches what was prepared. Refresh and try again."
|
|
139
|
+
# Introduces the folder where an interrupted change keeps its files, so the user can inspect them.
|
|
140
|
+
RECOVERY_FILES_LABEL = "Recovery files: "
|
|
141
|
+
# Ends a sentence of a cause; a cause that already ends with it needs no other one before the next sentence.
|
|
142
|
+
SENTENCE_END = "."
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def recovery_files_cause(cause: str, operation_dir: str) -> str:
|
|
146
|
+
"""Follow a failure cause with the folder that keeps the recovery files, as the next sentence of the same text.
|
|
147
|
+
|
|
148
|
+
Many refusal texts already end their sentence, so the full stop is added only when the cause has none.
|
|
149
|
+
"""
|
|
150
|
+
separator = " " if cause.endswith(SENTENCE_END) else SENTENCE_END + " "
|
|
151
|
+
return cause + separator + RECOVERY_FILES_LABEL + operation_dir
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reset_recovery_authority(context: RepositoryContext) -> dict[str, str]:
|
|
155
|
+
"""Bind durable recovery to native refs and pause identity without including Reset's own effects."""
|
|
156
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
157
|
+
|
|
158
|
+
observations = []
|
|
159
|
+
for observation in native_rebase_capture(context):
|
|
160
|
+
observations.append(asdict(observation) if observation is not None else None)
|
|
161
|
+
return {
|
|
162
|
+
"references": fingerprint(json.dumps(reference_signature(context), default=bytes.hex).encode("utf-8")),
|
|
163
|
+
"rebase": fingerprint(json.dumps(observations, sort_keys=True).encode("utf-8")),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def require_unchanged_authority(context: RepositoryContext, expected: dict[str, str]) -> None:
|
|
168
|
+
"""Refuse when references, configuration files or the native rebase moved since the expected authority was read.
|
|
169
|
+
|
|
170
|
+
The authority is read from local files only, so bracketing Git's verification and the live replacements with it
|
|
171
|
+
costs no Git process, and a reference moved after that verification cannot pass for the verified state.
|
|
172
|
+
"""
|
|
173
|
+
if reset_recovery_authority(context) != expected:
|
|
174
|
+
raise ValueError("HEAD, references or the native rebase changed before publication; refresh the changes")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class MutationOutcome:
|
|
179
|
+
"""Report the proven index result even when its original view has closed."""
|
|
180
|
+
|
|
181
|
+
operation_id: str
|
|
182
|
+
key: WorktreeKey
|
|
183
|
+
status: str
|
|
184
|
+
completed_paths: tuple[bytes, ...]
|
|
185
|
+
failed_paths: tuple[bytes, ...]
|
|
186
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
187
|
+
recovery_dir: str | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class PreparedLineReset:
|
|
192
|
+
"""Own one exact calculated candidate and backup until publication or cancellation."""
|
|
193
|
+
|
|
194
|
+
operation_id: str
|
|
195
|
+
model: RawPatch
|
|
196
|
+
selection: RawLineSelection
|
|
197
|
+
before: FileImage
|
|
198
|
+
after: FileImage
|
|
199
|
+
operation_dir: str
|
|
200
|
+
private_dir: str
|
|
201
|
+
candidate_filename: str
|
|
202
|
+
backup_filename: str
|
|
203
|
+
record_by_name: dict[str, object]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def announced_candidate_mode(candidate_identity: tuple[int, ...] | None, after: FileImage) -> int | None:
|
|
207
|
+
"""Predict the mode a candidate reports after publish_image_mode, recorded before the change so recovery recognizes it.
|
|
208
|
+
|
|
209
|
+
The prediction is the POSIX result: file type kept, permission bits replaced. Where a system reports permissions
|
|
210
|
+
differently, the prediction simply does not match and the recorded identity stays the only accepted one.
|
|
211
|
+
"""
|
|
212
|
+
permission = publication_permission(after)
|
|
213
|
+
if candidate_identity is None or permission is None:
|
|
214
|
+
return None
|
|
215
|
+
return stat.S_IFMT(candidate_identity[FILE_MODE_IDENTITY_FIELD]) | permission
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def write_record(operation_dir: str, record_by_name: dict[str, object]) -> None:
|
|
219
|
+
"""Flush a private recovery record before making its described step visible."""
|
|
220
|
+
filename = os.path.join(operation_dir, "record.json")
|
|
221
|
+
temporary_filename = os.path.join(operation_dir, "record.next")
|
|
222
|
+
with open(temporary_filename, "w", encoding="utf-8") as stream:
|
|
223
|
+
os.chmod(temporary_filename, PRIVATE_FILE_MODE)
|
|
224
|
+
json.dump(record_by_name, stream, sort_keys=True)
|
|
225
|
+
stream.flush()
|
|
226
|
+
os.fsync(stream.fileno())
|
|
227
|
+
os.replace(temporary_filename, filename)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fill_owned_lock(descriptor: int, content: bytes) -> None:
|
|
231
|
+
"""Write a candidate into the lock file this operation created, and make it durable before anyone checks it."""
|
|
232
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
233
|
+
stream.write(content)
|
|
234
|
+
stream.flush()
|
|
235
|
+
os.fsync(stream.fileno())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def release_owned_index_lock(lock_filename: str, descriptor: int | None, identity: tuple[int, int] | None) -> None:
|
|
239
|
+
"""Close a publication handle and remove its lock only while its recorded ownership still agrees."""
|
|
240
|
+
if descriptor is not None:
|
|
241
|
+
os.close(descriptor)
|
|
242
|
+
if identity is None:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
metadata = os.lstat(lock_filename)
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
return
|
|
248
|
+
if not stat.S_ISREG(metadata.st_mode) or stat_identity(metadata)[:FILE_OBJECT_IDENTITY_FIELDS] != identity:
|
|
249
|
+
raise ValueError("The publication lock changed; cleanup refused")
|
|
250
|
+
os.unlink(lock_filename)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class MutationService(QObject):
|
|
254
|
+
"""Serialize index changes by worktree and keep all candidate files outside working trees."""
|
|
255
|
+
|
|
256
|
+
finished = Signal(object)
|
|
257
|
+
reread_requested = Signal(object)
|
|
258
|
+
|
|
259
|
+
def __init__(self, runner: ProcessRunner, worktree_service: WorktreeService, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None) -> None:
|
|
260
|
+
"""Inject command execution and reads rather than constructing hidden services."""
|
|
261
|
+
super().__init__(parent)
|
|
262
|
+
self.runner = runner
|
|
263
|
+
self.worktree_service = worktree_service
|
|
264
|
+
self.repository_operation = repository_operation if repository_operation is not None else RepositoryOperationService(worktree_service, self)
|
|
265
|
+
self.operation_id_by_key: dict[WorktreeKey, str] = {}
|
|
266
|
+
self.intent_by_id: dict[str, OperationIntent] = {}
|
|
267
|
+
self.active_key_set: set[WorktreeKey] = set()
|
|
268
|
+
self.active_rule_filename_set: set[str] = set()
|
|
269
|
+
self.configuration_busy = False
|
|
270
|
+
self.outcome_by_id: dict[str, MutationOutcome] = {}
|
|
271
|
+
self.prepared_reset_by_id: dict[str, PreparedLineReset] = {}
|
|
272
|
+
self.prepared_file_reset_by_id: dict[str, ResetCandidate] = {}
|
|
273
|
+
self.live_reset_id_set: set[str] = set()
|
|
274
|
+
|
|
275
|
+
def acquire(self, snapshot: WorktreeSnapshot, operation_id: str, *, action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None,
|
|
276
|
+
operation: str | None = None) -> bool:
|
|
277
|
+
"""Reserve the worktree and its shared repository before any candidate or live write."""
|
|
278
|
+
intent, _reason = self._acquisition_check(snapshot.context, snapshot.git_filename, operation_id, snapshot.generation, action, scope, snapshot.key, operation)
|
|
279
|
+
return self._reserve_acquisition(intent)
|
|
280
|
+
|
|
281
|
+
def acquire_context(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int = 0, *,
|
|
282
|
+
action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None) -> bool:
|
|
283
|
+
"""Protect preparation that must capture a snapshot after acquiring repository ownership."""
|
|
284
|
+
intent, _reason = self._acquisition_check(context, git_filename, operation_id, generation, action, scope)
|
|
285
|
+
return self._reserve_acquisition(intent)
|
|
286
|
+
|
|
287
|
+
def acquire_refusal_cause(self, snapshot: WorktreeSnapshot, operation_id: str, *, action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None,
|
|
288
|
+
operation: str | None = None) -> str:
|
|
289
|
+
"""Say why ``acquire`` refused this write, with the same arguments, in the same event-loop turn as the refusal.
|
|
290
|
+
|
|
291
|
+
Another operation is named only when a write really holds the repository, so the user waits only when waiting helps.
|
|
292
|
+
"""
|
|
293
|
+
return self._acquisition_refusal_cause(snapshot.context, snapshot.git_filename, operation_id, snapshot.generation, action, scope, snapshot.key, operation)
|
|
294
|
+
|
|
295
|
+
def acquire_context_refusal_cause(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int = 0, *,
|
|
296
|
+
action: OperationAction = OperationAction.UNKNOWN, scope: WriteScope | None = None) -> str:
|
|
297
|
+
"""Say why ``acquire_context`` refused this write, with the same arguments, in the same event-loop turn as the refusal."""
|
|
298
|
+
return self._acquisition_refusal_cause(context, git_filename, operation_id, generation, action, scope)
|
|
299
|
+
|
|
300
|
+
def _acquisition_check(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int, action: OperationAction, scope: WriteScope | None,
|
|
301
|
+
read_key: WorktreeKey | None = None, operation: str | None = None) -> tuple[OperationIntent | None, str]:
|
|
302
|
+
"""Run the checks of an acquisition that belong to this service, without reserving anything.
|
|
303
|
+
|
|
304
|
+
Answers the intent to reserve, or no intent and the reason that prevents it. Both the acquisition and the
|
|
305
|
+
cause of its refusal use this one list of checks, so the cause cannot drift from what was really refused.
|
|
306
|
+
"""
|
|
307
|
+
key = WorktreeKey.from_context(context)
|
|
308
|
+
if read_key is not None and read_key != key:
|
|
309
|
+
return None, REPOSITORY_FOLDER_CHANGED_TEXT
|
|
310
|
+
# A global Git setting being saved or another change of the same worktree really holds the repository.
|
|
311
|
+
if self.configuration_busy or key in self.active_key_set:
|
|
312
|
+
return None, OWNED_REPOSITORY_TEXT
|
|
313
|
+
intent = OperationIntent.capture(operation_id, context, git_filename, generation=generation, action=action,
|
|
314
|
+
**({"scope": scope} if scope is not None else {}), **({"operation": operation} if operation is not None else {}))
|
|
315
|
+
previous = self.intent_by_id.get(operation_id)
|
|
316
|
+
if previous is None:
|
|
317
|
+
return intent, ""
|
|
318
|
+
if previous.context != context or previous.git_filename != git_filename or previous.action != action or previous.scope != intent.scope or previous.operation != intent.operation:
|
|
319
|
+
return None, PREPARED_ACTION_CHANGED_TEXT
|
|
320
|
+
return previous, ""
|
|
321
|
+
|
|
322
|
+
def _reserve_acquisition(self, intent: OperationIntent | None) -> bool:
|
|
323
|
+
"""Reserve an intent the acquisition checks accepted and record this service's ownership of its worktree."""
|
|
324
|
+
if intent is None or not self.repository_operation.reserve(intent):
|
|
325
|
+
return False
|
|
326
|
+
self.intent_by_id[intent.operation_id] = intent
|
|
327
|
+
self.operation_id_by_key[intent.key] = intent.operation_id
|
|
328
|
+
self.active_key_set.add(intent.key)
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
def _acquisition_refusal_cause(self, context: RepositoryContext, git_filename: str, operation_id: str, generation: int, action: OperationAction,
|
|
332
|
+
scope: WriteScope | None, read_key: WorktreeKey | None = None, operation: str | None = None) -> str:
|
|
333
|
+
"""Give the cause of a refused acquisition: closing first, then this service's checks, then the repository operation service's reason."""
|
|
334
|
+
if self.repository_operation.closed:
|
|
335
|
+
return GITPANE_CLOSING_TEXT
|
|
336
|
+
intent, reason = self._acquisition_check(context, git_filename, operation_id, generation, action, scope, read_key, operation)
|
|
337
|
+
if reason:
|
|
338
|
+
return reason
|
|
339
|
+
return self.repository_operation.refusal_cause(intent)
|
|
340
|
+
|
|
341
|
+
def validate_write(self, key: WorktreeKey) -> None:
|
|
342
|
+
"""Recheck the native operation and exact owner before publishing local content."""
|
|
343
|
+
operation_id = self.operation_id_by_key.get(key)
|
|
344
|
+
intent = self.repository_operation.active_by_id.get(operation_id)
|
|
345
|
+
if intent is None:
|
|
346
|
+
raise ValueError("This action no longer owns the worktree")
|
|
347
|
+
self.repository_operation.validate(intent)
|
|
348
|
+
|
|
349
|
+
def release(self, key: WorktreeKey, operation_id: str) -> None:
|
|
350
|
+
"""Release only matching ownership after its processes and recovery work finish."""
|
|
351
|
+
if self.operation_id_by_key.get(key) != operation_id:
|
|
352
|
+
return
|
|
353
|
+
self.operation_id_by_key.pop(key)
|
|
354
|
+
self.active_key_set.discard(key)
|
|
355
|
+
self.repository_operation.release(operation_id)
|
|
356
|
+
|
|
357
|
+
def prepare_reset_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], callback: Callable[[ResetCandidate | None, Exception | None], None]) -> str:
|
|
358
|
+
"""Compute a whole-file result, releasing the reservation while confirmation is open.
|
|
359
|
+
|
|
360
|
+
A refused reservation hands the callback the real cause of the refusal.
|
|
361
|
+
"""
|
|
362
|
+
operation_id = uuid.uuid4().hex
|
|
363
|
+
if not self.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
364
|
+
cause = self.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
365
|
+
QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
|
|
366
|
+
return operation_id
|
|
367
|
+
self.live_reset_id_set.add(operation_id)
|
|
368
|
+
|
|
369
|
+
def complete(prepared: ResetCandidate | None, error: Exception | None) -> None:
|
|
370
|
+
"""Retain only successful candidates and always release calculation ownership."""
|
|
371
|
+
self.release(snapshot.key, operation_id)
|
|
372
|
+
self.live_reset_id_set.discard(operation_id)
|
|
373
|
+
if error is None:
|
|
374
|
+
self.prepared_file_reset_by_id[operation_id] = prepared
|
|
375
|
+
else:
|
|
376
|
+
self.reread_requested.emit(snapshot.key)
|
|
377
|
+
callback(prepared, error)
|
|
378
|
+
|
|
379
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare_file_reset(operation_id, snapshot, entries), complete, operation_id=operation_id)
|
|
380
|
+
return operation_id
|
|
381
|
+
|
|
382
|
+
def cancel_reset_file(self, prepared: ResetCandidate) -> None:
|
|
383
|
+
"""Invalidate a declined confirmation and clean only its owned unpublished files."""
|
|
384
|
+
if self.prepared_file_reset_by_id.pop(prepared.operation_id, None) is not prepared:
|
|
385
|
+
return
|
|
386
|
+
prepared.record_by_name["state"] = JournalState.ABANDONED.value
|
|
387
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
388
|
+
self._clean_reset_content(prepared.record_by_name)
|
|
389
|
+
self.reread_requested.emit(prepared.snapshot.key)
|
|
390
|
+
|
|
391
|
+
def reset_file(self, prepared: ResetCandidate) -> str:
|
|
392
|
+
"""Reacquire the worktree and publish only the exact confirmed file/index candidate.
|
|
393
|
+
|
|
394
|
+
A refused reservation abandons the candidate and reports the real cause of the refusal.
|
|
395
|
+
"""
|
|
396
|
+
if self.prepared_file_reset_by_id.pop(prepared.operation_id, None) is not prepared:
|
|
397
|
+
raise ValueError("This reset confirmation is no longer current")
|
|
398
|
+
snapshot = prepared.snapshot
|
|
399
|
+
if not self.acquire(snapshot, prepared.operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
400
|
+
cause = self.acquire_refusal_cause(snapshot, prepared.operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
401
|
+
prepared.record_by_name["state"] = JournalState.ABANDONED.value
|
|
402
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
403
|
+
diagnostic = Diagnostic("Reset", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
|
|
404
|
+
outcome = MutationOutcome(prepared.operation_id, snapshot.key, "refused", (), tuple(change.path for change in prepared.changes), diagnostic)
|
|
405
|
+
QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
|
|
406
|
+
return prepared.operation_id
|
|
407
|
+
self.live_reset_id_set.add(prepared.operation_id)
|
|
408
|
+
|
|
409
|
+
def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
|
|
410
|
+
"""Keep terminal results after view closure and request a complete reread."""
|
|
411
|
+
self.release(snapshot.key, prepared.operation_id)
|
|
412
|
+
self.live_reset_id_set.discard(prepared.operation_id)
|
|
413
|
+
if error is not None:
|
|
414
|
+
outcome = self._reset_failure(prepared, error)
|
|
415
|
+
self._publish_outcome(outcome)
|
|
416
|
+
|
|
417
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._publish_reset(prepared), complete, operation_id=prepared.operation_id)
|
|
418
|
+
return prepared.operation_id
|
|
419
|
+
|
|
420
|
+
def _prepare_file_reset(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], *, resolution: ConflictResolutionIntent | None = None) -> Generator:
|
|
421
|
+
"""Prepare every file and durable backup before any live path may change."""
|
|
422
|
+
from gitpane.worktree.staged_reset import ResetCandidate, calculate_reset
|
|
423
|
+
|
|
424
|
+
recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
|
|
425
|
+
if recovery:
|
|
426
|
+
raise ValueError(recovery)
|
|
427
|
+
paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
|
|
428
|
+
recovery_authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
|
|
429
|
+
yield from self._check_reset_preparation(snapshot, paths, resolution)
|
|
430
|
+
root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
|
|
431
|
+
os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
|
432
|
+
operation_dir = os.path.join(root_dir, operation_id)
|
|
433
|
+
os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
|
|
434
|
+
record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.RESET.value, "operation_id": operation_id,
|
|
435
|
+
"index_filename": snapshot.index_filename, "before": snapshot.index_fingerprint, "after": None, "state": JournalState.PREPARING.value, "files": [],
|
|
436
|
+
"git_filename": snapshot.git_filename, "recovery_authority": recovery_authority,
|
|
437
|
+
"operation_dir": operation_dir, "operation_identity": stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS]}
|
|
438
|
+
write_record(operation_dir, record_by_name)
|
|
439
|
+
try:
|
|
440
|
+
side = next(iter(entries)).side
|
|
441
|
+
operation = "Reset"
|
|
442
|
+
explanation = "Remove staged changes from the index and working files. Independent unstaged changes will be preserved." if side == StagingSide.STAGED else "Replace unstaged changes with the index version. Staged content will be preserved."
|
|
443
|
+
if resolution is None:
|
|
444
|
+
changes, before_entries, after_entries, candidate = yield from calculate_reset(snapshot, entries, operation_dir)
|
|
445
|
+
else:
|
|
446
|
+
from gitpane.sequencer.conflict import prepare_conflict_deletion
|
|
447
|
+
|
|
448
|
+
# Conflict deletion needs the same durable file/index publication and recovery as a staged reset.
|
|
449
|
+
changes, before_entries, after_entries, candidate = yield from prepare_conflict_deletion(resolution, operation_dir)
|
|
450
|
+
side = StagingSide.STAGED
|
|
451
|
+
operation = "Delete conflicted file"
|
|
452
|
+
explanation = "Delete the working file and stage its deletion as the conflict resolution."
|
|
453
|
+
owned_files: list[dict[str, object]] = []
|
|
454
|
+
normalized_changes = []
|
|
455
|
+
for number, change in enumerate(changes):
|
|
456
|
+
private_dir = private_file_dir(snapshot.context, change.path, operation_dir, allow_missing_parents=True)
|
|
457
|
+
backup_filename = os.path.join(private_dir, str(number) + "-before")
|
|
458
|
+
candidate_filename = os.path.join(private_dir, str(number) + "-after")
|
|
459
|
+
if change.before.exists:
|
|
460
|
+
yield FileTask(lambda change=change, backup_filename=backup_filename: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, change.before.read())))
|
|
461
|
+
after = yield FileTask(lambda change=change, candidate_filename=candidate_filename: prepare_publication_image(candidate_filename, change.after, change.before))
|
|
462
|
+
change = replace(change, after=after)
|
|
463
|
+
normalized_changes.append(change)
|
|
464
|
+
candidate_identity = stat_identity(os.lstat(candidate_filename)) if change.after.exists else None
|
|
465
|
+
candidate_published_mode = announced_candidate_mode(candidate_identity, change.after)
|
|
466
|
+
owned_files.append({"path_hex": change.path.hex(), "before": change.before.digest(), "after": change.after.digest(),
|
|
467
|
+
"before_mode": change.before.mode, "after_mode": change.after.mode, "before_absent": not change.before.exists, "after_absent": not change.after.exists,
|
|
468
|
+
"before_identity": change.before.identity, "parents": change.before.parents, "private_dir": private_dir,
|
|
469
|
+
"private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS], "backup_filename": backup_filename,
|
|
470
|
+
"backup_identity": stat_identity(os.lstat(backup_filename)) if change.before.exists else None,
|
|
471
|
+
"candidate_filename": candidate_filename, "candidate_identity": candidate_identity, "candidate_published_mode": candidate_published_mode,
|
|
472
|
+
"state": JournalState.PREPARED.value, "published_identity": None, "original_path_hex": (change.original_path or change.path).hex()})
|
|
473
|
+
changes = tuple(normalized_changes)
|
|
474
|
+
backup_index_filename = os.path.join(operation_dir, "before-index")
|
|
475
|
+
if snapshot.index_content is not None:
|
|
476
|
+
yield FileTask(lambda: write_image(backup_index_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, snapshot.index_content)))
|
|
477
|
+
record_by_name.update({"after": fingerprint(candidate), "state": JournalState.PREPARED.value, "files": owned_files,
|
|
478
|
+
"owned_files": owned_inventory(operation_dir), "side": side.value})
|
|
479
|
+
yield from self._check_reset_preparation(snapshot, paths, resolution)
|
|
480
|
+
for change in changes:
|
|
481
|
+
yield FileTask(lambda change=change: validate_image(snapshot.context, change.path, change.before))
|
|
482
|
+
write_record(operation_dir, record_by_name)
|
|
483
|
+
return ResetCandidate(operation_id, snapshot, side, changes, before_entries, after_entries, candidate, operation_dir, record_by_name, explanation, operation)
|
|
484
|
+
except (OSError, ValueError, RuntimeError):
|
|
485
|
+
record_by_name["state"] = JournalState.ABANDONED.value
|
|
486
|
+
write_record(operation_dir, record_by_name)
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
def _check_reset_preparation(self, snapshot: WorktreeSnapshot, paths: tuple[bytes, ...], resolution: ConflictResolutionIntent | None) -> Generator:
|
|
490
|
+
"""Refuse stale selected bytes at a preparation boundary.
|
|
491
|
+
|
|
492
|
+
A normal Reset only builds private files here, so the process-free check of the index bytes and selected paths
|
|
493
|
+
is enough; Git verifies HEAD, repository identity and conversion once, under the publication lock. A conflict
|
|
494
|
+
deletion asks Git to verify the index, HEAD, configuration, attributes and chosen path it was written against;
|
|
495
|
+
its complete capture stays at publication, under the lock, where it protects the file replacement.
|
|
496
|
+
"""
|
|
497
|
+
if resolution is None:
|
|
498
|
+
yield FileTask(lambda: verify_local_state(snapshot, paths))
|
|
499
|
+
return
|
|
500
|
+
yield from self.worktree_service.verify(snapshot, paths)
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _reset_verified_paths(prepared: ResetCandidate) -> tuple[bytes, ...]:
|
|
504
|
+
"""Name every selected path the capture fingerprinted, both sides of a rename included.
|
|
505
|
+
|
|
506
|
+
A destination the capture never listed has no fingerprint to compare; its before image still proves it.
|
|
507
|
+
"""
|
|
508
|
+
paths: list[bytes] = []
|
|
509
|
+
for change in prepared.changes:
|
|
510
|
+
for path in (change.path, change.original_path):
|
|
511
|
+
if path is not None and path in prepared.snapshot.fingerprint_by_path and path not in paths:
|
|
512
|
+
paths.append(path)
|
|
513
|
+
return tuple(paths)
|
|
514
|
+
|
|
515
|
+
def _clean_reset_content(self, record_by_name: dict[str, object]) -> None:
|
|
516
|
+
"""Remove confirmed owned backup and candidate files without following directory substitutions.
|
|
517
|
+
|
|
518
|
+
A candidate may also carry the final permissions its record announced: publication changes them just before
|
|
519
|
+
recording the new identity, and an interruption between the two must not make GitPane refuse its own file.
|
|
520
|
+
Any other difference, including another mode, still refuses the cleanup.
|
|
521
|
+
"""
|
|
522
|
+
operation_dir = record_by_name["operation_dir"]
|
|
523
|
+
if os.path.islink(operation_dir) or os.path.isjunction(operation_dir) or stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record_by_name["operation_identity"]):
|
|
524
|
+
raise ValueError("The operation directory changed; cleanup refused")
|
|
525
|
+
for record in record_by_name["files"]:
|
|
526
|
+
private_dir = record["private_dir"]
|
|
527
|
+
if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
|
|
528
|
+
raise ValueError("Private recovery directory changed; cleanup refused")
|
|
529
|
+
for name in ("backup", "candidate"):
|
|
530
|
+
filename = record[name + "_filename"]
|
|
531
|
+
if os.path.dirname(filename) != private_dir:
|
|
532
|
+
raise ValueError("Recovery filename is outside its owned directory")
|
|
533
|
+
if not os.path.lexists(filename):
|
|
534
|
+
continue
|
|
535
|
+
actual_identity = stat_identity(os.lstat(filename))
|
|
536
|
+
recorded_identity = tuple(record[name + "_identity"])
|
|
537
|
+
announced_mode = record.get("candidate_published_mode") if name == "candidate" else None
|
|
538
|
+
if actual_identity != recorded_identity and not matches_after_announced_mode(actual_identity, recorded_identity, announced_mode):
|
|
539
|
+
raise ValueError("Recovery content changed; cleanup refused")
|
|
540
|
+
os.unlink(filename)
|
|
541
|
+
if private_dir != operation_dir:
|
|
542
|
+
os.rmdir(private_dir)
|
|
543
|
+
clean_owned(operation_dir, record_by_name.get("owned_files", []))
|
|
544
|
+
|
|
545
|
+
def _publish_reset(self, prepared: ResetCandidate, *, validate: Callable[[], None] | None = None) -> Generator:
|
|
546
|
+
"""Publish files then index, recording each real transition and retaining partial results.
|
|
547
|
+
|
|
548
|
+
Each candidate's final permissions were recorded at preparation, before this changes them: on POSIX the change
|
|
549
|
+
also gives the candidate a new identity, and a crash before the next record write must still let recovery
|
|
550
|
+
recognize and clean the candidate.
|
|
551
|
+
|
|
552
|
+
A normal Reset fills its owned lock with the candidate index first, asks Git to verify once, then proves with
|
|
553
|
+
local reads that no reference moved, before the first replacement and again after the last one. A caller that
|
|
554
|
+
supplies its own validation keeps the complete capture and HEAD read it was written against.
|
|
555
|
+
"""
|
|
556
|
+
snapshot = prepared.snapshot
|
|
557
|
+
complete_check = validate is not None
|
|
558
|
+
validate_owner = validate or (lambda: self.validate_write(snapshot.key))
|
|
559
|
+
paths = tuple(change.path for change in prepared.changes)
|
|
560
|
+
lock_filename = snapshot.index_filename + ".lock"
|
|
561
|
+
lock_descriptor: int | None = None
|
|
562
|
+
lock_identity: tuple[int, int] | None = None
|
|
563
|
+
published_path_hexes: list[str] = []
|
|
564
|
+
created_dirs: list[tuple[str, tuple[int, int]]] = []
|
|
565
|
+
prepared.record_by_name["created_dirs"] = created_dirs
|
|
566
|
+
prepared.record_by_name["published_path_hexes"] = published_path_hexes
|
|
567
|
+
try:
|
|
568
|
+
lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
569
|
+
lock_identity = stat_identity(os.fstat(lock_descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
|
|
570
|
+
if complete_check:
|
|
571
|
+
current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename)
|
|
572
|
+
self._validate(snapshot, current, paths)
|
|
573
|
+
else:
|
|
574
|
+
if prepared.index_content is not None:
|
|
575
|
+
descriptor, lock_descriptor = lock_descriptor, None
|
|
576
|
+
fill_owned_lock(descriptor, prepared.index_content)
|
|
577
|
+
yield from self.worktree_service.verify(snapshot, self._reset_verified_paths(prepared))
|
|
578
|
+
for change in prepared.changes:
|
|
579
|
+
yield FileTask(lambda change=change: validate_image(snapshot.context, change.path, change.before))
|
|
580
|
+
for change, record in zip(prepared.changes, prepared.record_by_name["files"], strict=True):
|
|
581
|
+
yield FileTask(lambda change=change, record=record: self._validate_reset_storage(change.before, change.after, record))
|
|
582
|
+
if complete_check and prepared.index_content is not None:
|
|
583
|
+
descriptor, lock_descriptor = lock_descriptor, None
|
|
584
|
+
fill_owned_lock(descriptor, prepared.index_content)
|
|
585
|
+
if not complete_check:
|
|
586
|
+
yield FileTask(lambda: require_unchanged_authority(snapshot.context, prepared.record_by_name["recovery_authority"]))
|
|
587
|
+
prepared.record_by_name["state"] = JournalState.PUBLISHING.value
|
|
588
|
+
prepared.record_by_name["lock_identity"] = lock_identity
|
|
589
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
590
|
+
for change, record in zip(prepared.changes, prepared.record_by_name["files"], strict=True):
|
|
591
|
+
expected = publication_image(change.before, created_dirs)
|
|
592
|
+
yield FileTask(lambda change=change, expected=expected: validate_image(snapshot.context, change.path, expected))
|
|
593
|
+
if change.after.exists:
|
|
594
|
+
create_missing_parents(snapshot.context, change.path, change.before, created_dirs, lambda: write_record(prepared.operation_dir, prepared.record_by_name))
|
|
595
|
+
filename, _parents = validate_destination(snapshot.context, change.path, allow_missing_parents=True)
|
|
596
|
+
candidate_filename = record["candidate_filename"]
|
|
597
|
+
validate_owner()
|
|
598
|
+
self._validate_reset_storage(change.before, change.after, record)
|
|
599
|
+
if change.after.exists:
|
|
600
|
+
publish_image_mode(candidate_filename, change.after)
|
|
601
|
+
record["candidate_identity"] = stat_identity(os.lstat(candidate_filename))
|
|
602
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
603
|
+
validate_image(snapshot.context, change.path, publication_image(change.before, created_dirs))
|
|
604
|
+
os.replace(candidate_filename, filename)
|
|
605
|
+
if change.original_path is not None:
|
|
606
|
+
respell_single_entry(snapshot.context, change.original_path, change.path)
|
|
607
|
+
elif change.before.exists:
|
|
608
|
+
os.unlink(filename)
|
|
609
|
+
published_path_hexes.append(change.path.hex())
|
|
610
|
+
actual = capture_image(snapshot.context, change.path, allow_missing_parents=True)
|
|
611
|
+
record.update({"state": JournalState.FILE_PUBLISHED.value, "published_identity": actual.identity})
|
|
612
|
+
prepared.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
|
|
613
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
614
|
+
if complete_check:
|
|
615
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
616
|
+
raise ValueError("The index changed before publication")
|
|
617
|
+
final_head = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
618
|
+
if (final_head.strip().decode("ascii") or None) != snapshot.head_oid:
|
|
619
|
+
raise ValueError("HEAD changed before publication")
|
|
620
|
+
else:
|
|
621
|
+
yield FileTask(lambda: require_unchanged_authority(snapshot.context, prepared.record_by_name["recovery_authority"]))
|
|
622
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
623
|
+
raise ValueError("The index changed before publication")
|
|
624
|
+
for change in prepared.changes:
|
|
625
|
+
if capture_image(snapshot.context, change.path, allow_missing_parents=True).digest() != change.after.digest():
|
|
626
|
+
raise ValueError("A working file changed during " + prepared.operation)
|
|
627
|
+
if stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
|
|
628
|
+
lock_identity = None
|
|
629
|
+
raise ValueError("The index publication lock changed")
|
|
630
|
+
if prepared.side == StagingSide.STAGED:
|
|
631
|
+
if read_index(lock_filename) != prepared.index_content:
|
|
632
|
+
raise ValueError("The candidate index changed")
|
|
633
|
+
validate_owner()
|
|
634
|
+
os.replace(lock_filename, snapshot.index_filename)
|
|
635
|
+
lock_identity = None
|
|
636
|
+
prepared.record_by_name["index_published"] = True
|
|
637
|
+
prepared.record_by_name["state"] = JournalState.INDEX_PUBLISHED.value
|
|
638
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
639
|
+
outcome = MutationOutcome(prepared.operation_id, snapshot.key, "success", paths, ())
|
|
640
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
641
|
+
outcome = self._reset_failure(prepared, error)
|
|
642
|
+
finally:
|
|
643
|
+
clean_created_parents(created_dirs)
|
|
644
|
+
try:
|
|
645
|
+
release_owned_index_lock(lock_filename, lock_descriptor, lock_identity)
|
|
646
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
647
|
+
outcome = self._reset_failure(prepared, error)
|
|
648
|
+
if outcome.diagnostic is None:
|
|
649
|
+
try:
|
|
650
|
+
# Backups remain available if releasing the live publication lock fails.
|
|
651
|
+
prepared.record_by_name["state"] = JournalState.COMPLETE.value
|
|
652
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
653
|
+
self._clean_reset_content(prepared.record_by_name)
|
|
654
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
655
|
+
outcome = self._reset_failure(prepared, error)
|
|
656
|
+
return outcome
|
|
657
|
+
|
|
658
|
+
def _reset_failure(self, prepared: ResetCandidate, error: Exception) -> MutationOutcome:
|
|
659
|
+
"""Retain known published paths even if cleanup or a later editor obscures their current bytes."""
|
|
660
|
+
snapshot = prepared.snapshot
|
|
661
|
+
published_paths = []
|
|
662
|
+
for path_hex in prepared.record_by_name.get("published_path_hexes", []):
|
|
663
|
+
published_paths.append(bytes.fromhex(path_hex))
|
|
664
|
+
index_published = bool(prepared.record_by_name.get("index_published"))
|
|
665
|
+
# A filesystem replacement can succeed before raising; keep both recorded facts and observed results.
|
|
666
|
+
for change in prepared.changes:
|
|
667
|
+
try:
|
|
668
|
+
actual = capture_image(snapshot.context, change.path)
|
|
669
|
+
if actual.digest() == change.after.digest() and actual.digest() != change.before.digest() and change.path not in published_paths:
|
|
670
|
+
published_paths.append(change.path)
|
|
671
|
+
except (OSError, ValueError):
|
|
672
|
+
pass
|
|
673
|
+
if prepared.side == StagingSide.STAGED and prepared.index_content != snapshot.index_content:
|
|
674
|
+
try:
|
|
675
|
+
index_published = index_published or read_index(snapshot.index_filename) == prepared.index_content
|
|
676
|
+
except (OSError, ValueError):
|
|
677
|
+
pass
|
|
678
|
+
published_path_hexes = []
|
|
679
|
+
for path in published_paths:
|
|
680
|
+
published_path_hexes.append(path.hex())
|
|
681
|
+
prepared.record_by_name["published_path_hexes"] = published_path_hexes
|
|
682
|
+
prepared.record_by_name["index_published"] = index_published
|
|
683
|
+
status = "unknown" if index_published else "partial" if published_paths else "refused"
|
|
684
|
+
journal_state = JournalState.INDEX_PUBLISHED if index_published else JournalState.FILE_PUBLISHED if published_paths else JournalState.ABANDONED
|
|
685
|
+
prepared.record_by_name["state"] = journal_state.value
|
|
686
|
+
try:
|
|
687
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
688
|
+
except OSError:
|
|
689
|
+
pass
|
|
690
|
+
diagnostic = Diagnostic(prepared.operation, snapshot.context.repository_dir, snapshot.git_filename, [], recovery_files_cause(str(error), prepared.operation_dir)).prepare()
|
|
691
|
+
failed_paths = tuple(change.path for change in prepared.changes if change.path not in published_paths)
|
|
692
|
+
return MutationOutcome(prepared.operation_id, snapshot.key, status, tuple(published_paths), failed_paths, diagnostic, prepared.operation_dir)
|
|
693
|
+
|
|
694
|
+
def _validate_reset_storage(self, before: FileImage, after: FileImage, record: dict[str, object]) -> None:
|
|
695
|
+
"""Prove both backup and candidate ownership before any member of a batch is replaced."""
|
|
696
|
+
private_dir = record["private_dir"]
|
|
697
|
+
if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
|
|
698
|
+
raise ValueError("The owned Reset directory changed")
|
|
699
|
+
for name, image in (("backup", before), ("candidate", after)):
|
|
700
|
+
filename = record[name + "_filename"]
|
|
701
|
+
if os.path.dirname(filename) != private_dir:
|
|
702
|
+
raise ValueError("The owned Reset file is outside its private directory")
|
|
703
|
+
if not image.exists:
|
|
704
|
+
if os.path.lexists(filename):
|
|
705
|
+
raise ValueError("An unexpected file occupies the owned Reset path")
|
|
706
|
+
continue
|
|
707
|
+
if stat_identity(os.lstat(filename)) != tuple(record[name + "_identity"]):
|
|
708
|
+
raise ValueError("The owned Reset " + name + " changed")
|
|
709
|
+
if name == "candidate" and stat.S_ISLNK(image.mode):
|
|
710
|
+
content = os.fsencode(os.readlink(filename))
|
|
711
|
+
else:
|
|
712
|
+
if os.path.islink(filename):
|
|
713
|
+
raise ValueError("The owned Reset backup is a symbolic link")
|
|
714
|
+
with open(filename, "rb") as stream:
|
|
715
|
+
content = stream.read()
|
|
716
|
+
if content != image.read():
|
|
717
|
+
raise ValueError("The owned Reset " + name + " changed")
|
|
718
|
+
|
|
719
|
+
def _recover_reset(self, context: RepositoryContext, operation_dir: str, record_by_name: dict[str, object], operation_id: str | None) -> str:
|
|
720
|
+
"""Own recovery separately or borrow an explicit caller while retaining original native authority.
|
|
721
|
+
|
|
722
|
+
A recovery that cannot reserve the worktree gives the real cause of the refusal, then where its files are kept.
|
|
723
|
+
"""
|
|
724
|
+
if not record_by_name.get("recovery_authority") or not record_by_name.get("git_filename"):
|
|
725
|
+
return recovery_files_cause("Interrupted Reset has no native authority proof; inspect its retained files before restoring manually.", operation_dir)
|
|
726
|
+
key = WorktreeKey.from_context(context)
|
|
727
|
+
recovery_id = uuid.uuid4().hex
|
|
728
|
+
borrowed = operation_id is not None
|
|
729
|
+
if borrowed:
|
|
730
|
+
owner = self.repository_operation.active_by_id.get(operation_id)
|
|
731
|
+
if owner is None or owner.key != key or self.operation_id_by_key.get(key) != operation_id:
|
|
732
|
+
return recovery_files_cause("Reset recovery does not own this worktree.", operation_dir)
|
|
733
|
+
else:
|
|
734
|
+
if not self.acquire_context(context, record_by_name["git_filename"], recovery_id, action=OperationAction.WORKTREE_EDIT):
|
|
735
|
+
cause = self.acquire_context_refusal_cause(context, record_by_name["git_filename"], recovery_id, action=OperationAction.WORKTREE_EDIT)
|
|
736
|
+
return recovery_files_cause(cause, operation_dir)
|
|
737
|
+
owner = self.repository_operation.active_by_id[recovery_id]
|
|
738
|
+
recovery_intent = replace(owner, action=OperationAction.WORKTREE_EDIT, scope=WriteScope.capture(context))
|
|
739
|
+
lock_filename = record_by_name["index_filename"] + ".lock"
|
|
740
|
+
lock_identity = None
|
|
741
|
+
restored_paths: list[bytes] = []
|
|
742
|
+
diagnostic = ""
|
|
743
|
+
|
|
744
|
+
def validate_recovery() -> None:
|
|
745
|
+
"""Recheck the caller, recovery action and durable proof immediately before publication."""
|
|
746
|
+
if self.operation_id_by_key.get(key) != owner.operation_id:
|
|
747
|
+
raise ValueError("Reset recovery no longer owns this worktree")
|
|
748
|
+
self.repository_operation.validate(owner)
|
|
749
|
+
reason = self.repository_operation.policy_diagnostic(recovery_intent)
|
|
750
|
+
if reason:
|
|
751
|
+
raise ValueError(reason)
|
|
752
|
+
allows = self.repository_operation.allows
|
|
753
|
+
if allows is not None and not allows(recovery_intent):
|
|
754
|
+
raise ValueError("Working-file recovery is no longer authorized")
|
|
755
|
+
if reset_recovery_authority(context) != record_by_name["recovery_authority"]:
|
|
756
|
+
raise ValueError("HEAD, references or the native rebase changed; inspect retained files before restoring manually")
|
|
757
|
+
if lock_identity is not None and stat_identity(os.lstat(lock_filename))[:FILE_OBJECT_IDENTITY_FIELDS] != lock_identity:
|
|
758
|
+
raise ValueError("The recovery index lock changed; remaining backups retained")
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
validate_recovery()
|
|
762
|
+
if os.path.lexists(lock_filename):
|
|
763
|
+
raise ValueError("An interrupted Reset has an index lock; process termination and lock ownership cannot be proven")
|
|
764
|
+
descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
765
|
+
lock_identity = stat_identity(os.fstat(descriptor))[:FILE_OBJECT_IDENTITY_FIELDS]
|
|
766
|
+
os.close(descriptor)
|
|
767
|
+
diagnostic = self._reconcile_reset(context, operation_dir, record_by_name, validate_recovery, restored_paths)
|
|
768
|
+
except (OSError, ValueError, TypeError, KeyError) as error:
|
|
769
|
+
diagnostic = recovery_files_cause(str(error), operation_dir)
|
|
770
|
+
finally:
|
|
771
|
+
try:
|
|
772
|
+
release_owned_index_lock(lock_filename, None, lock_identity)
|
|
773
|
+
except (OSError, ValueError) as error:
|
|
774
|
+
diagnostic = (diagnostic + "\n" if diagnostic else "") + recovery_files_cause(str(error), operation_dir)
|
|
775
|
+
if not borrowed:
|
|
776
|
+
self.release(key, recovery_id)
|
|
777
|
+
if restored_paths:
|
|
778
|
+
failure = Diagnostic(RECOVER_CHANGES_OPERATION, context.repository_dir, owner.git_filename, [], diagnostic).prepare() if diagnostic else None
|
|
779
|
+
restored_path_set = set(restored_paths)
|
|
780
|
+
failed_paths = []
|
|
781
|
+
if diagnostic:
|
|
782
|
+
for record in record_by_name["files"]:
|
|
783
|
+
path = bytes.fromhex(record["path_hex"])
|
|
784
|
+
if path not in restored_path_set:
|
|
785
|
+
failed_paths.append(path)
|
|
786
|
+
self._publish_outcome(MutationOutcome(recovery_id, key, "partial" if diagnostic else "rolled_back", tuple(restored_paths), tuple(failed_paths), failure,
|
|
787
|
+
operation_dir if diagnostic else None))
|
|
788
|
+
return diagnostic
|
|
789
|
+
|
|
790
|
+
def _reconcile_reset(self, context: RepositoryContext, operation_dir: str, record_by_name: dict[str, object],
|
|
791
|
+
validate_recovery: Callable[[], None], restored_paths: list[bytes]) -> str:
|
|
792
|
+
"""Check every image under the owned index lock before restoring any interrupted file."""
|
|
793
|
+
if record_by_name.get("operation_dir") != operation_dir:
|
|
794
|
+
raise ValueError("Recovery operation directory does not match its record")
|
|
795
|
+
if stat_identity(os.lstat(operation_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record_by_name["operation_identity"]):
|
|
796
|
+
raise ValueError("Recovery operation directory changed")
|
|
797
|
+
index_filename = record_by_name["index_filename"]
|
|
798
|
+
current_index = fingerprint(read_index(index_filename))
|
|
799
|
+
before_index, after_index = record_by_name["before"], record_by_name["after"]
|
|
800
|
+
if current_index not in (before_index, after_index):
|
|
801
|
+
return recovery_files_cause("The index changed outside the interrupted Reset.", operation_dir)
|
|
802
|
+
records = record_by_name["files"]
|
|
803
|
+
states: list[str] = []
|
|
804
|
+
images: list[FileImage] = []
|
|
805
|
+
for record in records:
|
|
806
|
+
path = bytes.fromhex(record["path_hex"])
|
|
807
|
+
image = capture_image(context, path, allow_missing_parents=True)
|
|
808
|
+
parents = tuple((directory, tuple(identity)) for directory, identity in record["parents"])
|
|
809
|
+
actual_identity_by_directory = dict(image.parents)
|
|
810
|
+
created_dirs = []
|
|
811
|
+
for directory, identity in record_by_name.get("created_dirs", []):
|
|
812
|
+
if actual_identity_by_directory.get(directory) != MISSING_PARENT_IDENTITY:
|
|
813
|
+
created_dirs.append((directory, tuple(identity)))
|
|
814
|
+
parents = publication_image(FileImage(False, 0, parents=parents), created_dirs).parents
|
|
815
|
+
if image.parents != parents:
|
|
816
|
+
return recovery_files_cause("A working parent changed outside the interrupted Reset.", operation_dir)
|
|
817
|
+
before_identity = record.get("restored_identity") or record["before_identity"]
|
|
818
|
+
published_identity = record["published_identity"] or record["candidate_identity"]
|
|
819
|
+
is_before = image.digest() == record["before"] and (image.identity is None or before_identity is not None and image.identity == tuple(before_identity))
|
|
820
|
+
is_after = image.digest() == record["after"] and (image.identity is None or published_identity is not None and image.identity[:FILE_STABLE_IDENTITY_FIELDS] == tuple(published_identity)[:FILE_STABLE_IDENTITY_FIELDS])
|
|
821
|
+
if not is_before and not is_after:
|
|
822
|
+
return recovery_files_cause("Working file changed outside the interrupted Reset: " + os.fsdecode(path) + ".", operation_dir)
|
|
823
|
+
states.append("before" if is_before else "after")
|
|
824
|
+
images.append(image)
|
|
825
|
+
private_dir = record["private_dir"]
|
|
826
|
+
if os.path.islink(private_dir) or os.path.isjunction(private_dir) or stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(record["private_identity"]):
|
|
827
|
+
raise ValueError("Private recovery directory changed")
|
|
828
|
+
backup_filename = record["backup_filename"]
|
|
829
|
+
if os.path.dirname(backup_filename) != private_dir:
|
|
830
|
+
raise ValueError("Recovery backup is outside its owned directory")
|
|
831
|
+
if not record["before_absent"]:
|
|
832
|
+
if stat_identity(os.lstat(backup_filename)) != tuple(record["backup_identity"]) or os.path.islink(backup_filename):
|
|
833
|
+
raise ValueError("Recovery backup changed")
|
|
834
|
+
with open(backup_filename, "rb") as stream:
|
|
835
|
+
backup = FileImage(True, record["before_mode"], stream.read())
|
|
836
|
+
if backup.digest() != record["before"]:
|
|
837
|
+
raise ValueError("Recovery backup has different bytes")
|
|
838
|
+
fully_published = bool(records) and all(state == "after" or record["before"] == record["after"] for state, record in zip(states, records, strict=True))
|
|
839
|
+
if current_index == after_index and (before_index != after_index or fully_published):
|
|
840
|
+
if not fully_published:
|
|
841
|
+
return recovery_files_cause("The index was published but working files no longer match the Reset result.", operation_dir)
|
|
842
|
+
validate_recovery()
|
|
843
|
+
record_by_name["state"] = JournalState.RECOVERED_POST.value
|
|
844
|
+
write_record(operation_dir, record_by_name)
|
|
845
|
+
self._clean_reset_content(record_by_name)
|
|
846
|
+
return ""
|
|
847
|
+
# Check all paths first so an external edit in the last file prevents restoration of the first.
|
|
848
|
+
for record, state, image in zip(records, states, images, strict=True):
|
|
849
|
+
if state == "before":
|
|
850
|
+
continue
|
|
851
|
+
if fingerprint(read_index(index_filename)) != current_index:
|
|
852
|
+
raise ValueError("The index changed during recovery; remaining backups retained")
|
|
853
|
+
path = bytes.fromhex(record["path_hex"])
|
|
854
|
+
validate_image(context, path, image)
|
|
855
|
+
filename, _parents = validate_destination(context, bytes.fromhex(record.get("original_path_hex", record["path_hex"])))
|
|
856
|
+
if record["before_absent"]:
|
|
857
|
+
validate_recovery()
|
|
858
|
+
os.unlink(filename)
|
|
859
|
+
else:
|
|
860
|
+
with open(record["backup_filename"], "rb") as stream:
|
|
861
|
+
before = FileImage(True, record["before_mode"], stream.read())
|
|
862
|
+
rollback_filename = record["candidate_filename"] + "-rollback"
|
|
863
|
+
write_image(rollback_filename, before)
|
|
864
|
+
record["restored_identity"] = stat_identity(os.lstat(rollback_filename))
|
|
865
|
+
write_record(operation_dir, record_by_name)
|
|
866
|
+
validate_image(context, path, image)
|
|
867
|
+
validate_recovery()
|
|
868
|
+
os.replace(rollback_filename, filename)
|
|
869
|
+
if "original_path_hex" in record:
|
|
870
|
+
respell_single_entry(context, path, bytes.fromhex(record["original_path_hex"]))
|
|
871
|
+
record["restored_identity"] = capture_image(context, path).identity
|
|
872
|
+
restored_paths.append(path)
|
|
873
|
+
record["state"] = JournalState.RESTORED.value
|
|
874
|
+
write_record(operation_dir, record_by_name)
|
|
875
|
+
validate_recovery()
|
|
876
|
+
record_by_name["state"] = JournalState.RECOVERED_PRE.value
|
|
877
|
+
clean_created_parents(record_by_name.get("created_dirs", []))
|
|
878
|
+
write_record(operation_dir, record_by_name)
|
|
879
|
+
self._clean_reset_content(record_by_name)
|
|
880
|
+
return ""
|
|
881
|
+
|
|
882
|
+
def stage_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
|
|
883
|
+
"""Prepare complete selected working files without modifying their bytes."""
|
|
884
|
+
return self._submit(snapshot, entries, "stage")
|
|
885
|
+
|
|
886
|
+
def resolve_conflict(self, intent: ConflictResolutionIntent) -> str:
|
|
887
|
+
"""Reserve one manual resolution until its candidate is published and reported.
|
|
888
|
+
|
|
889
|
+
A refused resolution says why: GitPane closing first, then another operation when a write already holds the
|
|
890
|
+
repository, otherwise the reason the repository operation service finds for refusing the reservation.
|
|
891
|
+
"""
|
|
892
|
+
owner, snapshot = intent.operation, intent.snapshot
|
|
893
|
+
operation_id = owner.operation_id
|
|
894
|
+
# A global Git setting being saved, another change of the same worktree, or this same resolution still running
|
|
895
|
+
# all hold the repository for real. They are named here because refusal_cause skips an operation with this
|
|
896
|
+
# resolution's identifier and does not know about a setting save.
|
|
897
|
+
write_holds_repository = self.configuration_busy or snapshot.key in self.active_key_set or operation_id in self.repository_operation.active_by_id
|
|
898
|
+
if write_holds_repository or not self.repository_operation.reserve(owner):
|
|
899
|
+
if self.repository_operation.closed:
|
|
900
|
+
cause = GITPANE_CLOSING_TEXT
|
|
901
|
+
elif write_holds_repository:
|
|
902
|
+
cause = OWNED_REPOSITORY_TEXT
|
|
903
|
+
else:
|
|
904
|
+
cause = self.repository_operation.refusal_cause(owner)
|
|
905
|
+
diagnostic = Diagnostic(owner.operation, snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
|
|
906
|
+
outcome = MutationOutcome(operation_id, snapshot.key, "refused", (), (intent.path,), diagnostic)
|
|
907
|
+
QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
|
|
908
|
+
return operation_id
|
|
909
|
+
self.operation_id_by_key[snapshot.key] = operation_id
|
|
910
|
+
self.active_key_set.add(snapshot.key)
|
|
911
|
+
|
|
912
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
913
|
+
"""Release only this manual owner after native execution has finished."""
|
|
914
|
+
if self.repository_operation.active_by_id.get(operation_id) is owner:
|
|
915
|
+
self.release(snapshot.key, operation_id)
|
|
916
|
+
else:
|
|
917
|
+
self.operation_id_by_key.pop(snapshot.key, None)
|
|
918
|
+
self.active_key_set.discard(snapshot.key)
|
|
919
|
+
if error is not None:
|
|
920
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(owner.operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
921
|
+
value = MutationOutcome(operation_id, snapshot.key, "refused", (), (intent.path,), diagnostic)
|
|
922
|
+
self._publish_outcome(value)
|
|
923
|
+
|
|
924
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self.resolve_conflict_sequence(intent), complete, operation_id=operation_id)
|
|
925
|
+
return operation_id
|
|
926
|
+
|
|
927
|
+
def resolve_conflict_sequence(self, intent: ConflictResolutionIntent) -> Generator:
|
|
928
|
+
"""Stage a result using the exact existing owner without reserving or releasing it."""
|
|
929
|
+
from gitpane.sequencer.conflict import has_deleted_side
|
|
930
|
+
|
|
931
|
+
self._validate_conflict_owner(intent)
|
|
932
|
+
snapshot = intent.snapshot
|
|
933
|
+
entries = tuple(entry for entry in snapshot.entries if entry.path == intent.path and entry.status == FileStatus.UNMERGED)
|
|
934
|
+
if len(entries) != 1 or next(iter(entries)).conflict_sides != intent.sides:
|
|
935
|
+
raise ValueError("The selected conflict stages changed; rescan merge conflicts")
|
|
936
|
+
if (not intent.delete and not intent.image.exists or any(side.mode == "160000" for side in intent.sides)
|
|
937
|
+
or intent.delete and intent.image.exists and not has_deleted_side(intent.sides)):
|
|
938
|
+
raise ValueError("Choose an existing file result or explicitly confirm its deletion; resolve submodules externally")
|
|
939
|
+
yield FileTask(lambda: validate_image(snapshot.context, intent.path, intent.image))
|
|
940
|
+
if intent.delete and intent.image.exists:
|
|
941
|
+
operation_id = intent.operation.operation_id
|
|
942
|
+
self.live_reset_id_set.add(operation_id)
|
|
943
|
+
try:
|
|
944
|
+
prepared = yield from self._prepare_file_reset(operation_id, snapshot, entries, resolution=intent)
|
|
945
|
+
self._validate_conflict_owner(intent)
|
|
946
|
+
return (yield from self._publish_reset(prepared, validate=lambda: self._validate_conflict_owner(intent)))
|
|
947
|
+
finally:
|
|
948
|
+
self.live_reset_id_set.discard(operation_id)
|
|
949
|
+
return (yield from self._prepare(uuid.uuid4().hex, snapshot, entries, "resolve conflict", resolution=intent))
|
|
950
|
+
|
|
951
|
+
def _validate_conflict_owner(self, intent: ConflictResolutionIntent) -> None:
|
|
952
|
+
"""Reject forged ownership and stale session authority before any publication."""
|
|
953
|
+
owner, snapshot = intent.operation, intent.snapshot
|
|
954
|
+
service = self.repository_operation
|
|
955
|
+
service.validate(owner)
|
|
956
|
+
if (service.active_by_id.get(owner.operation_id) is not owner or service.closed or service.allows is not None and not service.allows(owner)
|
|
957
|
+
or owner.key != snapshot.key or owner.context != snapshot.context or owner.git_filename != snapshot.git_filename):
|
|
958
|
+
raise ValueError("The conflict resolution no longer owns this repository or session")
|
|
959
|
+
|
|
960
|
+
def unstage_file(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
|
|
961
|
+
"""Restore selected index entries to HEAD while retaining every working byte."""
|
|
962
|
+
return self._submit(snapshot, entries, "unstage")
|
|
963
|
+
|
|
964
|
+
def stop_tracking(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> str:
|
|
965
|
+
"""Remove selected index entries with Git's native safety checks and preserve disk content."""
|
|
966
|
+
return self._submit(snapshot, entries, "stop tracking")
|
|
967
|
+
|
|
968
|
+
def stage_lines(self, model: RawPatch, selection: RawLineSelection) -> str:
|
|
969
|
+
"""Publish selected canonical lines using the same index reservation and validation."""
|
|
970
|
+
return self._submit(model.snapshot, (model.entry,), "stage", model, selection)
|
|
971
|
+
|
|
972
|
+
def prepare_reset_lines(self, model: RawPatch, selection: RawLineSelection, callback: Callable[[PreparedLineReset | None, Exception | None], None]) -> str:
|
|
973
|
+
"""Reserve the worktree and compute the exact selected-line result before publication.
|
|
974
|
+
|
|
975
|
+
A refused reservation hands the callback the real cause of the refusal.
|
|
976
|
+
"""
|
|
977
|
+
operation_id = uuid.uuid4().hex
|
|
978
|
+
if not self.acquire(model.snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
979
|
+
cause = self.acquire_refusal_cause(model.snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
980
|
+
QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
|
|
981
|
+
return operation_id
|
|
982
|
+
|
|
983
|
+
def complete(prepared: PreparedLineReset | None, error: Exception | None) -> None:
|
|
984
|
+
"""Keep successful candidates reserved while releasing failed computations."""
|
|
985
|
+
if error is not None:
|
|
986
|
+
self.release(model.snapshot.key, operation_id)
|
|
987
|
+
else:
|
|
988
|
+
self.prepared_reset_by_id[operation_id] = prepared
|
|
989
|
+
callback(prepared, error)
|
|
990
|
+
|
|
991
|
+
self.worktree_service.run(model.snapshot.context, model.snapshot.git_filename, self._prepare_reset(operation_id, model, selection), complete, operation_id=operation_id)
|
|
992
|
+
return operation_id
|
|
993
|
+
|
|
994
|
+
def cancel_reset_lines(self, prepared: PreparedLineReset) -> None:
|
|
995
|
+
"""Discard only an owned unpublished candidate when its source view or selection expires."""
|
|
996
|
+
if self.prepared_reset_by_id.get(prepared.operation_id) is not prepared:
|
|
997
|
+
return
|
|
998
|
+
self.prepared_reset_by_id.pop(prepared.operation_id)
|
|
999
|
+
self.release(prepared.model.snapshot.key, prepared.operation_id)
|
|
1000
|
+
prepared.record_by_name["state"] = JournalState.ABANDONED.value
|
|
1001
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1002
|
+
self._clean_file_content(prepared)
|
|
1003
|
+
|
|
1004
|
+
def reset_lines(self, prepared: PreparedLineReset) -> str:
|
|
1005
|
+
"""Publish the exact calculated candidate; a changed target requires a fresh selection."""
|
|
1006
|
+
if self.prepared_reset_by_id.pop(prepared.operation_id, None) is not prepared:
|
|
1007
|
+
raise ValueError("This reset selection is no longer current")
|
|
1008
|
+
|
|
1009
|
+
def complete(outcome: MutationOutcome | None, error: Exception | None) -> None:
|
|
1010
|
+
"""Release the worktree after the last process and recovery action have finished."""
|
|
1011
|
+
self.release(prepared.model.snapshot.key, prepared.operation_id)
|
|
1012
|
+
if error is not None:
|
|
1013
|
+
outcome = self._line_reset_failure(prepared, error)
|
|
1014
|
+
self._publish_outcome(outcome)
|
|
1015
|
+
|
|
1016
|
+
self.worktree_service.run(prepared.model.snapshot.context, prepared.model.snapshot.git_filename, self._publish_file(prepared), complete, operation_id=prepared.operation_id)
|
|
1017
|
+
return prepared.operation_id
|
|
1018
|
+
|
|
1019
|
+
def _prepare_reset(self, operation_id: str, model: RawPatch, selection: RawLineSelection) -> Generator:
|
|
1020
|
+
"""Materialize outside user content and record the exact before/after file images.
|
|
1021
|
+
|
|
1022
|
+
Nothing live changes here, so both boundaries check the index bytes and the selected file without Git; Git
|
|
1023
|
+
verifies once, under the publication lock.
|
|
1024
|
+
"""
|
|
1025
|
+
from gitpane.worktree.partial_patch import reset_content
|
|
1026
|
+
|
|
1027
|
+
snapshot, path = model.snapshot, model.entry.path
|
|
1028
|
+
model.selected(selection)
|
|
1029
|
+
recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
|
|
1030
|
+
if recovery:
|
|
1031
|
+
raise ValueError(recovery)
|
|
1032
|
+
yield FileTask(lambda: verify_local_state(snapshot, (path,)))
|
|
1033
|
+
before = yield FileTask(lambda: capture_image(snapshot.context, path, allow_missing_parents=True))
|
|
1034
|
+
if before.exists and not stat.S_ISREG(before.mode):
|
|
1035
|
+
raise ValueError("Partial reset requires a regular working file")
|
|
1036
|
+
root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
|
|
1037
|
+
os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
|
1038
|
+
operation_dir = os.path.join(root_dir, operation_id)
|
|
1039
|
+
os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
|
|
1040
|
+
private_dir = private_file_dir(snapshot.context, path, operation_dir, allow_missing_parents=True)
|
|
1041
|
+
candidate_filename = os.path.join(private_dir, "after-file")
|
|
1042
|
+
backup_filename = os.path.join(private_dir, "before-file")
|
|
1043
|
+
record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "kind": JournalKind.FILE.value, "operation_id": operation_id,
|
|
1044
|
+
"index_filename": snapshot.index_filename, "path_hex": path.hex(), "before": before.digest(), "before_mode": before.mode, "before_absent": not before.exists,
|
|
1045
|
+
"before_identity": before.identity, "after": None, "state": JournalState.PREPARING.value, "private_dir": private_dir,
|
|
1046
|
+
"private_identity": stat_identity(os.lstat(private_dir))[:FILE_OBJECT_IDENTITY_FIELDS], "candidate_filename": candidate_filename, "backup_filename": backup_filename}
|
|
1047
|
+
write_record(operation_dir, record_by_name)
|
|
1048
|
+
try:
|
|
1049
|
+
if before.exists:
|
|
1050
|
+
yield FileTask(lambda: write_image(backup_filename, FileImage(True, stat.S_IFREG | PRIVATE_FILE_MODE, before.read())))
|
|
1051
|
+
attributes = yield GitCommand(["check-attr", "-z", "filter", "working-tree-encoding", "--stdin"], stdin=path + b"\0")
|
|
1052
|
+
attribute_by_name: dict[bytes, bytes] = {}
|
|
1053
|
+
fields = iter(attributes.removesuffix(b"\0").split(b"\0"))
|
|
1054
|
+
for _path in fields:
|
|
1055
|
+
name, value = next(fields), next(fields)
|
|
1056
|
+
attribute_by_name[name] = value
|
|
1057
|
+
encoding_value = attribute_by_name.get(b"working-tree-encoding", b"unspecified")
|
|
1058
|
+
encoding = "" if encoding_value in (b"unspecified", b"unset") else encoding_value.decode("ascii")
|
|
1059
|
+
filter_value = attribute_by_name.get(b"filter", b"unspecified")
|
|
1060
|
+
has_filter = filter_value not in (b"unspecified", b"unset")
|
|
1061
|
+
restored = FileImage(False, 0)
|
|
1062
|
+
if model.before_oid:
|
|
1063
|
+
restored = yield from materialize_blob(model.before_oid, path, model.mode, os.path.join(private_dir, "materialized-index"))
|
|
1064
|
+
materialized_working = FileImage(False, 0)
|
|
1065
|
+
if has_filter and model.after_oid:
|
|
1066
|
+
materialized_working = yield from materialize_blob(model.after_oid, path, model.mode, os.path.join(private_dir, "materialized-working"))
|
|
1067
|
+
content = yield from reset_content(model, selection, before, restored, materialized_working, encoding, has_filter)
|
|
1068
|
+
mode = before.mode if before.exists else stat.S_IFREG | (0o755 if model.mode == "100755" else 0o644)
|
|
1069
|
+
after = FileImage(content is not None, mode, content)
|
|
1070
|
+
after = yield FileTask(lambda: prepare_publication_image(candidate_filename, after, before))
|
|
1071
|
+
if after.exists:
|
|
1072
|
+
metadata = os.lstat(candidate_filename)
|
|
1073
|
+
after = replace(after, identity=stat_identity(metadata), parents=before.parents)
|
|
1074
|
+
else:
|
|
1075
|
+
after = FileImage(False, 0, parents=before.parents)
|
|
1076
|
+
yield FileTask(lambda: verify_local_state(snapshot, (path,)))
|
|
1077
|
+
yield FileTask(lambda: validate_image(snapshot.context, path, before))
|
|
1078
|
+
identity_by_basename: dict[str, tuple[int, ...]] = {}
|
|
1079
|
+
for basename in ("after-file", "before-file", "materialized-index", "materialized-working"):
|
|
1080
|
+
filename = os.path.join(private_dir, basename)
|
|
1081
|
+
if os.path.exists(filename):
|
|
1082
|
+
identity_by_basename[basename] = stat_identity(os.lstat(filename))
|
|
1083
|
+
record_by_name.update({"after": after.digest(), "after_identity": after.identity, "state": JournalState.PREPARED.value, "owned_identity_by_basename": identity_by_basename})
|
|
1084
|
+
write_record(operation_dir, record_by_name)
|
|
1085
|
+
return PreparedLineReset(operation_id, model, selection, before, after, operation_dir, private_dir, candidate_filename, backup_filename, record_by_name)
|
|
1086
|
+
except (OSError, ValueError, RuntimeError):
|
|
1087
|
+
record_by_name["state"] = JournalState.ABANDONED.value
|
|
1088
|
+
write_record(operation_dir, record_by_name)
|
|
1089
|
+
raise
|
|
1090
|
+
|
|
1091
|
+
def _publish_file(self, prepared: PreparedLineReset) -> Generator:
|
|
1092
|
+
"""Replace only an unchanged target, preserving recovery bytes after any publication failure.
|
|
1093
|
+
|
|
1094
|
+
The index lock stays empty: it only excludes other index writers while the working file changes. Git verifies
|
|
1095
|
+
once under it, after the private candidate is proven, and local reference reads taken before and after that
|
|
1096
|
+
verification replace a separate HEAD read.
|
|
1097
|
+
"""
|
|
1098
|
+
snapshot, path = prepared.model.snapshot, prepared.model.entry.path
|
|
1099
|
+
filename, _parents = validate_destination(snapshot.context, path, allow_missing_parents=True)
|
|
1100
|
+
lock_filename = snapshot.index_filename + ".lock"
|
|
1101
|
+
lock_descriptor: int | None = None
|
|
1102
|
+
lock_identity: tuple[int, int] | None = None
|
|
1103
|
+
published = False
|
|
1104
|
+
created_dirs: list[tuple[str, tuple[int, int]]] = []
|
|
1105
|
+
prepared.record_by_name["created_dirs"] = created_dirs
|
|
1106
|
+
try:
|
|
1107
|
+
lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
1108
|
+
metadata = os.fstat(lock_descriptor)
|
|
1109
|
+
lock_identity = (metadata.st_dev, metadata.st_ino)
|
|
1110
|
+
yield FileTask(lambda: validate_image(snapshot.context, path, prepared.before))
|
|
1111
|
+
private_identity = stat_identity(os.lstat(prepared.private_dir))[:FILE_OBJECT_IDENTITY_FIELDS]
|
|
1112
|
+
if tuple(prepared.record_by_name["private_identity"]) != private_identity or os.path.islink(prepared.private_dir) or os.path.isjunction(prepared.private_dir):
|
|
1113
|
+
raise ValueError("Private preparation directory changed")
|
|
1114
|
+
if prepared.after.exists:
|
|
1115
|
+
metadata = os.lstat(prepared.candidate_filename)
|
|
1116
|
+
if stat_identity(metadata) != prepared.after.identity or not stat.S_ISREG(metadata.st_mode):
|
|
1117
|
+
raise ValueError("Private reset candidate changed")
|
|
1118
|
+
with open(prepared.candidate_filename, "rb") as stream:
|
|
1119
|
+
if stream.read() != prepared.after.read():
|
|
1120
|
+
raise ValueError("Private reset candidate changed")
|
|
1121
|
+
prepared.record_by_name["state"] = JournalState.PREPARED.value
|
|
1122
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1123
|
+
authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
|
|
1124
|
+
yield from self.worktree_service.verify(snapshot, (path,))
|
|
1125
|
+
yield FileTask(lambda: require_unchanged_authority(snapshot.context, authority))
|
|
1126
|
+
metadata = os.lstat(lock_filename)
|
|
1127
|
+
if not stat.S_ISREG(metadata.st_mode) or (metadata.st_dev, metadata.st_ino) != lock_identity or metadata.st_size:
|
|
1128
|
+
lock_identity = None
|
|
1129
|
+
raise ValueError("The publication lock was replaced externally")
|
|
1130
|
+
# Recheck after the journal flush because an external editor is not blocked by Git's index lock.
|
|
1131
|
+
validate_image(snapshot.context, path, prepared.before)
|
|
1132
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
1133
|
+
raise ValueError("The index changed after confirmation")
|
|
1134
|
+
self.validate_write(snapshot.key)
|
|
1135
|
+
if prepared.after.exists:
|
|
1136
|
+
create_missing_parents(snapshot.context, path, prepared.before, created_dirs, lambda: write_record(prepared.operation_dir, prepared.record_by_name))
|
|
1137
|
+
publish_image_mode(prepared.candidate_filename, prepared.after)
|
|
1138
|
+
identity = stat_identity(os.lstat(prepared.candidate_filename))
|
|
1139
|
+
prepared.record_by_name["after_identity"] = identity
|
|
1140
|
+
prepared.record_by_name["owned_identity_by_basename"][os.path.basename(prepared.candidate_filename)] = identity
|
|
1141
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1142
|
+
validate_image(snapshot.context, path, publication_image(prepared.before, created_dirs))
|
|
1143
|
+
os.replace(prepared.candidate_filename, filename)
|
|
1144
|
+
elif prepared.before.exists:
|
|
1145
|
+
os.unlink(filename)
|
|
1146
|
+
published = True
|
|
1147
|
+
prepared.record_by_name["working_file_published"] = True
|
|
1148
|
+
prepared.record_by_name["state"] = JournalState.FILE_PUBLISHED.value
|
|
1149
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1150
|
+
actual = capture_image(snapshot.context, path)
|
|
1151
|
+
if actual.digest() != prepared.after.digest():
|
|
1152
|
+
raise ValueError("Working file changed immediately after publication")
|
|
1153
|
+
outcome = MutationOutcome(prepared.operation_id, snapshot.key, "success", (path,), ())
|
|
1154
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
1155
|
+
status = "refused"
|
|
1156
|
+
if not published:
|
|
1157
|
+
try:
|
|
1158
|
+
actual = capture_image(snapshot.context, path)
|
|
1159
|
+
published = actual.digest() == prepared.after.digest() and actual.digest() != prepared.before.digest()
|
|
1160
|
+
prepared.record_by_name["working_file_published"] = published
|
|
1161
|
+
except (OSError, ValueError):
|
|
1162
|
+
pass
|
|
1163
|
+
if published:
|
|
1164
|
+
status = "unknown"
|
|
1165
|
+
try:
|
|
1166
|
+
actual = capture_image(snapshot.context, path)
|
|
1167
|
+
expected_identity = prepared.record_by_name["after_identity"]
|
|
1168
|
+
# Some filesystems update ctime on rename; content, mode and inode still identify our publication.
|
|
1169
|
+
same_identity = not actual.exists and not prepared.after.exists
|
|
1170
|
+
if actual.exists and actual.identity is not None and expected_identity is not None:
|
|
1171
|
+
same_identity = actual.identity[:FILE_STABLE_IDENTITY_FIELDS] == tuple(expected_identity)[:FILE_STABLE_IDENTITY_FIELDS]
|
|
1172
|
+
if actual.digest() == prepared.after.digest() and actual.parents == publication_image(prepared.before, created_dirs).parents and same_identity:
|
|
1173
|
+
if prepared.before.exists:
|
|
1174
|
+
rollback_filename = os.path.join(prepared.private_dir, "rollback-file")
|
|
1175
|
+
write_image(rollback_filename, prepared.before)
|
|
1176
|
+
validate_image(snapshot.context, path, actual)
|
|
1177
|
+
os.replace(rollback_filename, filename)
|
|
1178
|
+
elif actual.exists:
|
|
1179
|
+
validate_image(snapshot.context, path, actual)
|
|
1180
|
+
os.unlink(filename)
|
|
1181
|
+
status = "rolled_back"
|
|
1182
|
+
prepared.record_by_name["working_file_published"] = False
|
|
1183
|
+
except (OSError, ValueError):
|
|
1184
|
+
pass
|
|
1185
|
+
journal_state = JournalState.ROLLED_BACK if status == "rolled_back" else JournalState.FILE_PUBLISHED if published else JournalState.ABANDONED
|
|
1186
|
+
prepared.record_by_name["state"] = journal_state.value
|
|
1187
|
+
try:
|
|
1188
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1189
|
+
except OSError:
|
|
1190
|
+
pass
|
|
1191
|
+
cause = recovery_files_cause(str(error), prepared.operation_dir) if published else str(error)
|
|
1192
|
+
diagnostic = Diagnostic("Reset selected lines", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
|
|
1193
|
+
outcome = MutationOutcome(prepared.operation_id, snapshot.key, status, (path,) if status == "unknown" else (), (path,), diagnostic, prepared.operation_dir)
|
|
1194
|
+
finally:
|
|
1195
|
+
clean_created_parents(created_dirs)
|
|
1196
|
+
try:
|
|
1197
|
+
release_owned_index_lock(lock_filename, lock_descriptor, lock_identity)
|
|
1198
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
1199
|
+
outcome = self._line_reset_failure(prepared, error)
|
|
1200
|
+
if outcome.diagnostic is None:
|
|
1201
|
+
try:
|
|
1202
|
+
# Lock cleanup is part of publication, so recovery files outlive it.
|
|
1203
|
+
prepared.record_by_name["state"] = JournalState.COMPLETE.value
|
|
1204
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1205
|
+
self._clean_file_content(prepared)
|
|
1206
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
1207
|
+
outcome = self._line_reset_failure(prepared, error)
|
|
1208
|
+
return outcome
|
|
1209
|
+
|
|
1210
|
+
def _line_reset_failure(self, prepared: PreparedLineReset, error: Exception) -> MutationOutcome:
|
|
1211
|
+
"""Report cleanup faults from recorded publication facts without rolling back later local content."""
|
|
1212
|
+
snapshot, path = prepared.model.snapshot, prepared.model.entry.path
|
|
1213
|
+
published = bool(prepared.record_by_name.get("working_file_published"))
|
|
1214
|
+
# The word this process wrote is compared as text: reading it back as a member could raise inside this failure handler.
|
|
1215
|
+
rolled_back = prepared.record_by_name.get("state") == JournalState.ROLLED_BACK.value
|
|
1216
|
+
status = "unknown" if published else "rolled_back" if rolled_back else "refused"
|
|
1217
|
+
journal_state = JournalState.FILE_PUBLISHED if published else JournalState.ROLLED_BACK if rolled_back else JournalState.ABANDONED
|
|
1218
|
+
prepared.record_by_name["state"] = journal_state.value
|
|
1219
|
+
try:
|
|
1220
|
+
write_record(prepared.operation_dir, prepared.record_by_name)
|
|
1221
|
+
except OSError:
|
|
1222
|
+
pass
|
|
1223
|
+
diagnostic = Diagnostic("Reset selected lines", snapshot.context.repository_dir, snapshot.git_filename, [], recovery_files_cause(str(error), prepared.operation_dir)).prepare()
|
|
1224
|
+
return MutationOutcome(prepared.operation_id, snapshot.key, status, (path,) if published else (), (path,), diagnostic, prepared.operation_dir)
|
|
1225
|
+
|
|
1226
|
+
def _clean_file_content(self, prepared: PreparedLineReset) -> None:
|
|
1227
|
+
"""Remove only files owned by a completed or cancelled operation."""
|
|
1228
|
+
actual = os.lstat(prepared.private_dir)
|
|
1229
|
+
if os.path.islink(prepared.private_dir) or os.path.isjunction(prepared.private_dir) or stat_identity(actual)[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(prepared.record_by_name["private_identity"]):
|
|
1230
|
+
raise ValueError("Private cleanup directory changed; recovery files retained")
|
|
1231
|
+
for basename, identity in prepared.record_by_name["owned_identity_by_basename"].items():
|
|
1232
|
+
filename = os.path.join(prepared.private_dir, basename)
|
|
1233
|
+
if not os.path.lexists(filename):
|
|
1234
|
+
continue
|
|
1235
|
+
if os.path.islink(filename) or stat_identity(os.lstat(filename)) != tuple(identity):
|
|
1236
|
+
raise ValueError("Owned recovery file changed; cleanup refused")
|
|
1237
|
+
os.unlink(filename)
|
|
1238
|
+
if prepared.private_dir != prepared.operation_dir:
|
|
1239
|
+
os.rmdir(prepared.private_dir)
|
|
1240
|
+
|
|
1241
|
+
def _submit(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation: str, model: RawPatch | None = None, selection: RawLineSelection | None = None) -> str:
|
|
1242
|
+
"""Reserve one worktree until computation, publication and terminal reporting finish.
|
|
1243
|
+
|
|
1244
|
+
A refused reservation is reported with the real cause of the refusal.
|
|
1245
|
+
"""
|
|
1246
|
+
operation_id = uuid.uuid4().hex
|
|
1247
|
+
paths = tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
|
|
1248
|
+
action = OperationAction.UNSTAGE if operation == "unstage" else OperationAction.STAGE
|
|
1249
|
+
if not self.acquire(snapshot, operation_id, action=action):
|
|
1250
|
+
diagnostic = Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], self.acquire_refusal_cause(snapshot, operation_id, action=action)).prepare()
|
|
1251
|
+
outcome = MutationOutcome(operation_id, snapshot.key, "refused", (), paths, diagnostic)
|
|
1252
|
+
QTimer.singleShot(0, lambda: self._publish_outcome(outcome))
|
|
1253
|
+
return operation_id
|
|
1254
|
+
|
|
1255
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
1256
|
+
"""Retain failures as terminal results, releasing the reservation only now."""
|
|
1257
|
+
self.release(snapshot.key, operation_id)
|
|
1258
|
+
if error is not None:
|
|
1259
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1260
|
+
value = MutationOutcome(operation_id, snapshot.key, "refused", (), paths, diagnostic)
|
|
1261
|
+
self._publish_outcome(value)
|
|
1262
|
+
|
|
1263
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare(operation_id, snapshot, entries, operation, model, selection), complete, operation_id=operation_id)
|
|
1264
|
+
return operation_id
|
|
1265
|
+
|
|
1266
|
+
def _publish_outcome(self, outcome: MutationOutcome) -> None:
|
|
1267
|
+
"""Preserve the result for reopening and request authoritative state after every issue."""
|
|
1268
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
1269
|
+
self.reread_requested.emit(outcome.key)
|
|
1270
|
+
self.finished.emit(outcome)
|
|
1271
|
+
|
|
1272
|
+
def recover(self, context: RepositoryContext, index_filename: str, *, operation_id: str | None = None) -> str:
|
|
1273
|
+
"""Classify interrupted operations and restore proven Reset effects under explicit authority."""
|
|
1274
|
+
root_dir = os.path.join(context.git_dir, "gitpane-operation")
|
|
1275
|
+
if not os.path.exists(root_dir):
|
|
1276
|
+
return ""
|
|
1277
|
+
if os.path.islink(root_dir) or os.path.isjunction(root_dir) or not os.path.isdir(root_dir):
|
|
1278
|
+
return "The recovery directory is not a private directory"
|
|
1279
|
+
diagnostics: list[str] = []
|
|
1280
|
+
with os.scandir(root_dir) as iterator:
|
|
1281
|
+
operation_dirs = sorted(iterator, key=lambda entry: entry.name)
|
|
1282
|
+
for entry in operation_dirs:
|
|
1283
|
+
if entry.name in self.prepared_file_reset_by_id or entry.name in self.live_reset_id_set:
|
|
1284
|
+
continue
|
|
1285
|
+
if not entry.is_dir(follow_symlinks=False) or os.path.isjunction(entry.path) or len(entry.name) != len(uuid.uuid4().hex) or any(character not in "0123456789abcdef" for character in entry.name):
|
|
1286
|
+
diagnostics.append("Unrecognized recovery data requires inspection")
|
|
1287
|
+
continue
|
|
1288
|
+
record_filename = os.path.join(entry.path, "record.json")
|
|
1289
|
+
try:
|
|
1290
|
+
if os.path.islink(record_filename):
|
|
1291
|
+
raise ValueError("Linked recovery record")
|
|
1292
|
+
if os.stat(record_filename).st_size > MUTATION_RECORD_LIMIT_BYTES:
|
|
1293
|
+
raise ValueError("Recovery record exceeds the supported limit")
|
|
1294
|
+
with open(record_filename, encoding="utf-8") as stream:
|
|
1295
|
+
record_by_name = json.load(stream)
|
|
1296
|
+
if record_by_name.get("schema_version") != JOURNAL_SCHEMA_VERSION or record_by_name.get("index_filename") != index_filename:
|
|
1297
|
+
raise ValueError("Unrecognized recovery record")
|
|
1298
|
+
# Both words are read once here, so a word this version does not know, or an empty one, stops the
|
|
1299
|
+
# inspection of this journal and leaves it untouched for the user.
|
|
1300
|
+
state = read_journal_state(record_by_name)
|
|
1301
|
+
kind = read_journal_kind(record_by_name)
|
|
1302
|
+
if state in FINISHED_JOURNAL_STATE_SET:
|
|
1303
|
+
continue
|
|
1304
|
+
if kind == JournalKind.RESET:
|
|
1305
|
+
diagnostic = self._recover_reset(context, entry.path, record_by_name, operation_id)
|
|
1306
|
+
if diagnostic:
|
|
1307
|
+
diagnostics.append(diagnostic)
|
|
1308
|
+
continue
|
|
1309
|
+
if kind == JournalKind.FILE:
|
|
1310
|
+
path = bytes.fromhex(record_by_name["path_hex"])
|
|
1311
|
+
image = capture_image(context, path, allow_missing_parents=True)
|
|
1312
|
+
if image.digest() == record_by_name.get("before"):
|
|
1313
|
+
record_by_name["state"] = JournalState.RECOVERED_PRE.value
|
|
1314
|
+
elif image.digest() == record_by_name.get("after"):
|
|
1315
|
+
record_by_name["state"] = JournalState.RECOVERED_POST.value
|
|
1316
|
+
else:
|
|
1317
|
+
diagnostics.append(recovery_files_cause("Working file changed outside the interrupted operation: " + os.fsdecode(path) + ".", entry.path))
|
|
1318
|
+
continue
|
|
1319
|
+
write_record(entry.path, record_by_name)
|
|
1320
|
+
continue
|
|
1321
|
+
if os.path.lexists(index_filename + ".lock"):
|
|
1322
|
+
diagnostics.append("An interrupted operation has an index lock; ownership cannot be proven")
|
|
1323
|
+
continue
|
|
1324
|
+
current = fingerprint(read_index(index_filename))
|
|
1325
|
+
if current == record_by_name.get("before"):
|
|
1326
|
+
record_by_name["state"] = JournalState.RECOVERED_PRE.value
|
|
1327
|
+
elif current == record_by_name.get("after"):
|
|
1328
|
+
record_by_name["state"] = JournalState.RECOVERED_POST.value
|
|
1329
|
+
else:
|
|
1330
|
+
diagnostics.append("The index changed outside the interrupted operation; recovery data retained")
|
|
1331
|
+
continue
|
|
1332
|
+
write_record(entry.path, record_by_name)
|
|
1333
|
+
except (OSError, ValueError, TypeError, KeyError) as error:
|
|
1334
|
+
diagnostics.append("Cannot inspect interrupted operation: " + str(error))
|
|
1335
|
+
return "\n".join(diagnostics)
|
|
1336
|
+
|
|
1337
|
+
def _prepare(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], operation: str,
|
|
1338
|
+
model: RawPatch | None = None, selection: RawLineSelection | None = None,
|
|
1339
|
+
resolution: ConflictResolutionIntent | None = None) -> Generator[GitCommand, bytes, MutationOutcome]:
|
|
1340
|
+
"""Compute outside the real index, recheck under exclusive lock, then publish once.
|
|
1341
|
+
|
|
1342
|
+
Every form, a conflict resolution included, writes its candidate into the owned lock and makes it durable
|
|
1343
|
+
before Git's single final verification, which reads HEAD itself. Local reads of references, configuration
|
|
1344
|
+
files and the native rebase are taken before that verification and compared again just before publication,
|
|
1345
|
+
so a change made after Git answered cannot pass for the verified state. A resolution also rechecks the
|
|
1346
|
+
identity of its accepted file and its exact owner at that last point.
|
|
1347
|
+
"""
|
|
1348
|
+
if not snapshot.fresh or snapshot.context.is_bare or snapshot.context.worktree_dir is None:
|
|
1349
|
+
raise ValueError("A complete current working tree read is required")
|
|
1350
|
+
side = StagingSide.UNSTAGED if operation in ("stage", "stop tracking", "resolve conflict") else StagingSide.STAGED
|
|
1351
|
+
if not entries or any(entry not in snapshot.entries or entry.side != side for entry in entries):
|
|
1352
|
+
raise ValueError("Choose current " + side.label + " files")
|
|
1353
|
+
if operation == "stage":
|
|
1354
|
+
entries = tuple(entry for entry in entries if entry.stageable)
|
|
1355
|
+
if not entries:
|
|
1356
|
+
raise ValueError(SUBMODULE_NO_COMMIT_CHANGE)
|
|
1357
|
+
if resolution is None and any(entry.conflict_stages for entry in snapshot.entries):
|
|
1358
|
+
intent = self.repository_operation.active_by_id.get(operation_id)
|
|
1359
|
+
# Preparing unrelated files is allowed only under the captured native rebase authority.
|
|
1360
|
+
if any(entry.conflict_stages for entry in entries) or intent is None or not any(intent.rebase_capture or ()):
|
|
1361
|
+
raise ValueError("Resolve all conflicts before preparing changes")
|
|
1362
|
+
if any(entry.skip_worktree for entry in entries):
|
|
1363
|
+
raise ValueError("This file is marked skip-worktree; expand it before preparing changes")
|
|
1364
|
+
if operation == "stop tracking" and any(entry.old_mode not in ("100644", "100755", "120000") for entry in entries):
|
|
1365
|
+
raise ValueError("Stop tracking requires tracked ordinary files")
|
|
1366
|
+
paths = (resolution.path,) if resolution is not None else tuple(dict.fromkeys(path for entry in entries for path in entry.paths))
|
|
1367
|
+
outcome_id = resolution.operation.operation_id if resolution is not None else operation_id
|
|
1368
|
+
recovery = self.recover(snapshot.context, snapshot.index_filename, operation_id=operation_id)
|
|
1369
|
+
if recovery:
|
|
1370
|
+
raise ValueError(recovery)
|
|
1371
|
+
# Only a private candidate is built before locking. Defer the complete Git
|
|
1372
|
+
# metadata check until publication, while rejecting already-stale bytes now.
|
|
1373
|
+
yield FileTask(lambda: verify_local_state(snapshot, paths))
|
|
1374
|
+
root_dir = os.path.join(snapshot.context.git_dir, "gitpane-operation")
|
|
1375
|
+
os.makedirs(root_dir, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
|
1376
|
+
operation_dir = os.path.join(root_dir, operation_id)
|
|
1377
|
+
os.mkdir(operation_dir, PRIVATE_DIRECTORY_MODE)
|
|
1378
|
+
candidate_filename = os.path.join(operation_dir, "candidate-index")
|
|
1379
|
+
backup_filename = os.path.join(operation_dir, "before-index")
|
|
1380
|
+
record_by_name: dict[str, object] = {"schema_version": JOURNAL_SCHEMA_VERSION, "operation_id": operation_id, "index_filename": snapshot.index_filename,
|
|
1381
|
+
"before": snapshot.index_fingerprint, "after": None, "index_absent": snapshot.index_content is None, "state": JournalState.PREPARING.value}
|
|
1382
|
+
write_record(operation_dir, record_by_name)
|
|
1383
|
+
environment_by_name = {"GIT_INDEX_FILE": candidate_filename}
|
|
1384
|
+
lock_filename = snapshot.index_filename + ".lock"
|
|
1385
|
+
lock_descriptor: int | None = None
|
|
1386
|
+
lock_identity: tuple[int, int] | None = None
|
|
1387
|
+
published = False
|
|
1388
|
+
try:
|
|
1389
|
+
if snapshot.index_content is not None:
|
|
1390
|
+
# Preserve the index timestamp: changing it can make Git trust a same-size racy stat entry.
|
|
1391
|
+
shutil.copy2(snapshot.index_filename, candidate_filename)
|
|
1392
|
+
os.chmod(candidate_filename, PRIVATE_FILE_MODE)
|
|
1393
|
+
if read_index(candidate_filename) != snapshot.index_content:
|
|
1394
|
+
raise ValueError("The index changed while preparing its private copy")
|
|
1395
|
+
with open(backup_filename, "xb") as stream:
|
|
1396
|
+
os.chmod(backup_filename, PRIVATE_FILE_MODE)
|
|
1397
|
+
stream.write(snapshot.index_content)
|
|
1398
|
+
stream.flush()
|
|
1399
|
+
os.fsync(stream.fileno())
|
|
1400
|
+
else:
|
|
1401
|
+
yield GitCommand(["read-tree", "--empty"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
1402
|
+
# Ordinary indexes avoid an unnecessary process; only a possible split index is flattened.
|
|
1403
|
+
if may_carry_split_index(snapshot.index_content):
|
|
1404
|
+
yield GitCommand(["update-index", "--no-split-index"], environment_by_name=environment_by_name, profile=LOCAL_WRITE_PROFILE)
|
|
1405
|
+
before_entries = snapshot.index_entries
|
|
1406
|
+
if before_entries is None:
|
|
1407
|
+
before_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
|
|
1408
|
+
expected_line_record: bytes | None = None
|
|
1409
|
+
if model is not None:
|
|
1410
|
+
from gitpane.worktree.partial_patch import stage_candidate
|
|
1411
|
+
expected_line_record = yield from stage_candidate(model, selection, environment_by_name)
|
|
1412
|
+
arguments = None
|
|
1413
|
+
elif operation in ("stage", "resolve conflict"):
|
|
1414
|
+
arguments = ["add", "--pathspec-from-file=-", "--pathspec-file-nul"]
|
|
1415
|
+
elif operation == "stop tracking":
|
|
1416
|
+
arguments = ["rm", "--cached", "--pathspec-from-file=-", "--pathspec-file-nul"]
|
|
1417
|
+
elif snapshot.head_oid is not None:
|
|
1418
|
+
arguments = ["restore", "--staged", "--source=" + snapshot.head_oid, "--pathspec-from-file=-", "--pathspec-file-nul"]
|
|
1419
|
+
else:
|
|
1420
|
+
arguments = ["update-index", "--force-remove", "-z", "--stdin"]
|
|
1421
|
+
if arguments is not None:
|
|
1422
|
+
# Every form of this command rewrites the candidate index, and add also runs the user's clean filters, which can outlive it.
|
|
1423
|
+
# It declares its own supervised profile: the merge tool publishes through a sequence started without one, whose metadata reads stay lightweight.
|
|
1424
|
+
yield GitCommand(arguments, stdin=b"\0".join(paths) + b"\0", environment_by_name=environment_by_name,
|
|
1425
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
1426
|
+
after_entries = yield GitCommand(["ls-files", "--stage", "-z"], environment_by_name=environment_by_name)
|
|
1427
|
+
unchanged_records = []
|
|
1428
|
+
path_set = set(paths)
|
|
1429
|
+
for output in (before_entries, after_entries):
|
|
1430
|
+
if output and not output.endswith(b"\0"):
|
|
1431
|
+
raise ValueError("Incomplete candidate index entries")
|
|
1432
|
+
records = []
|
|
1433
|
+
for record in output.split(b"\0"):
|
|
1434
|
+
_metadata, separator, path = record.partition(b"\t")
|
|
1435
|
+
if record and not separator:
|
|
1436
|
+
raise ValueError("Malformed candidate index entry")
|
|
1437
|
+
if record and path not in path_set:
|
|
1438
|
+
records.append(record)
|
|
1439
|
+
unchanged_records.append(sorted(records))
|
|
1440
|
+
before_unchanged, after_unchanged = unchanged_records
|
|
1441
|
+
if before_unchanged != after_unchanged:
|
|
1442
|
+
raise ValueError("The candidate changed files outside the selection")
|
|
1443
|
+
if model is not None:
|
|
1444
|
+
# The listing that proves the neighbors unchanged also proves the staged lines: exactly the expected
|
|
1445
|
+
# record at the selected path, or no record at all after a total removal.
|
|
1446
|
+
line_records = []
|
|
1447
|
+
for record in after_entries.split(b"\0"):
|
|
1448
|
+
if record and record.partition(b"\t")[2] == model.entry.path:
|
|
1449
|
+
line_records.append(record + b"\0")
|
|
1450
|
+
if line_records != ([] if expected_line_record is None else [expected_line_record]):
|
|
1451
|
+
raise ValueError("The candidate index does not hold the selected lines")
|
|
1452
|
+
if resolution is not None:
|
|
1453
|
+
selected_records = []
|
|
1454
|
+
for record in after_entries.split(b"\0"):
|
|
1455
|
+
metadata, _separator, path = record.partition(b"\t")
|
|
1456
|
+
if path == resolution.path:
|
|
1457
|
+
selected_records.append(metadata)
|
|
1458
|
+
if resolution.delete:
|
|
1459
|
+
valid_result = not selected_records
|
|
1460
|
+
else:
|
|
1461
|
+
valid_result = len(selected_records) == 1 and next(iter(selected_records)).endswith(b" 0")
|
|
1462
|
+
if not valid_result:
|
|
1463
|
+
raise ValueError("The candidate did not resolve the selected conflict")
|
|
1464
|
+
candidate = read_index(candidate_filename)
|
|
1465
|
+
if candidate is None:
|
|
1466
|
+
raise ValueError("Git did not produce a candidate index")
|
|
1467
|
+
record_by_name["after"] = fingerprint(candidate)
|
|
1468
|
+
record_by_name["state"] = JournalState.PREPARED.value
|
|
1469
|
+
write_record(operation_dir, record_by_name)
|
|
1470
|
+
lock_descriptor = os.open(lock_filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_FILE_MODE)
|
|
1471
|
+
metadata = os.fstat(lock_descriptor)
|
|
1472
|
+
lock_identity = (metadata.st_dev, metadata.st_ino)
|
|
1473
|
+
# The durable prepared record already describes both possible index images.
|
|
1474
|
+
# Recovery classifies those bytes and any remaining lock without intermediate records.
|
|
1475
|
+
descriptor, lock_descriptor = lock_descriptor, None
|
|
1476
|
+
fill_owned_lock(descriptor, candidate)
|
|
1477
|
+
authority = yield FileTask(lambda: reset_recovery_authority(snapshot.context))
|
|
1478
|
+
yield from self.worktree_service.verify(snapshot, paths)
|
|
1479
|
+
# Git's lock protects the index; an editor can still change working bytes independently.
|
|
1480
|
+
for path in paths:
|
|
1481
|
+
actual = yield FileTask(lambda path=path: file_fingerprint(snapshot.context, path))
|
|
1482
|
+
expected = snapshot.fingerprint_by_path[path]
|
|
1483
|
+
if expected.startswith(actual + ":"):
|
|
1484
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
1485
|
+
submodule_head = yield GitCommand(["-C", worktree_filename(snapshot.context, path), "rev-parse", "--verify", "HEAD"])
|
|
1486
|
+
actual += ":" + submodule_head.strip().decode("ascii")
|
|
1487
|
+
if actual != expected:
|
|
1488
|
+
raise ValueError("Working file changed before publication; refresh the changes")
|
|
1489
|
+
if resolution is not None:
|
|
1490
|
+
# The accepted result is bound to its file identity and parents, which a byte fingerprint alone does not prove.
|
|
1491
|
+
yield FileTask(lambda: validate_image(snapshot.context, resolution.path, resolution.image))
|
|
1492
|
+
yield FileTask(lambda: require_unchanged_authority(snapshot.context, authority))
|
|
1493
|
+
if read_index(snapshot.index_filename) != snapshot.index_content:
|
|
1494
|
+
raise ValueError("The index changed before publication")
|
|
1495
|
+
metadata = os.lstat(lock_filename)
|
|
1496
|
+
if (metadata.st_dev, metadata.st_ino) != lock_identity or read_index(lock_filename) != candidate:
|
|
1497
|
+
lock_identity = None
|
|
1498
|
+
raise ValueError("The publication lock was replaced externally")
|
|
1499
|
+
if resolution is None:
|
|
1500
|
+
self.validate_write(snapshot.key)
|
|
1501
|
+
else:
|
|
1502
|
+
self._validate_conflict_owner(resolution)
|
|
1503
|
+
os.replace(lock_filename, snapshot.index_filename)
|
|
1504
|
+
published = True
|
|
1505
|
+
lock_identity = None
|
|
1506
|
+
record_by_name["state"] = JournalState.COMPLETE.value
|
|
1507
|
+
write_record(operation_dir, record_by_name)
|
|
1508
|
+
return MutationOutcome(outcome_id, snapshot.key, "success", paths, ())
|
|
1509
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
1510
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(operation, snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
1511
|
+
if operation == "stop tracking":
|
|
1512
|
+
diagnostic = replace(diagnostic, cause=diagnostic.cause + " Review the staged version before retrying; no files were removed from disk.")
|
|
1513
|
+
if not published:
|
|
1514
|
+
record_by_name["state"] = JournalState.ABANDONED.value
|
|
1515
|
+
try:
|
|
1516
|
+
write_record(operation_dir, record_by_name)
|
|
1517
|
+
except OSError:
|
|
1518
|
+
pass
|
|
1519
|
+
return MutationOutcome(outcome_id, snapshot.key, "unknown" if published else "refused", paths if published else (), () if published else paths, diagnostic, operation_dir)
|
|
1520
|
+
finally:
|
|
1521
|
+
if lock_descriptor is not None:
|
|
1522
|
+
os.close(lock_descriptor)
|
|
1523
|
+
if lock_identity is not None:
|
|
1524
|
+
try:
|
|
1525
|
+
metadata = os.lstat(lock_filename)
|
|
1526
|
+
if stat.S_ISREG(metadata.st_mode) and (metadata.st_dev, metadata.st_ino) == lock_identity:
|
|
1527
|
+
os.unlink(lock_filename)
|
|
1528
|
+
except FileNotFoundError:
|
|
1529
|
+
pass
|
|
1530
|
+
|
|
1531
|
+
def _validate(self, snapshot: WorktreeSnapshot, current: WorktreeSnapshot, paths: tuple[bytes, ...]) -> None:
|
|
1532
|
+
"""Refuse stale index, HEAD, conversion settings or selected working bytes."""
|
|
1533
|
+
if snapshot.head_oid != current.head_oid or snapshot.index_content != current.index_content or snapshot.conversion != current.conversion:
|
|
1534
|
+
raise ValueError("Repository changed since selection; refresh the changes")
|
|
1535
|
+
if snapshot.key != current.key or canonical_path(snapshot.index_filename) != canonical_path(current.index_filename):
|
|
1536
|
+
raise ValueError("The selected index does not belong to this worktree")
|
|
1537
|
+
for path in paths:
|
|
1538
|
+
if snapshot.fingerprint_by_path.get(path) != current.fingerprint_by_path.get(path):
|
|
1539
|
+
raise ValueError("Working file changed since selection; refresh the changes")
|