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,730 @@
|
|
|
1
|
+
"""Run native stash commands, keeping their observed effects with the authority they started from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from gitpane.config import GIT_MUTATION_TIMEOUT_MS, GIT_PATH_SEPARATOR, SUBMODULE_RECURSION_OFF_ARGUMENTS
|
|
13
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
14
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
15
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessStatus
|
|
16
|
+
from gitpane.system.path_identity import canonical_path
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable, Generator
|
|
20
|
+
|
|
21
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
22
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
23
|
+
from gitpane.operation.launch_signature import LaunchSignature
|
|
24
|
+
from gitpane.operation.repository_operation import OperationIntent, OperationOutcome, RepositoryOperationService
|
|
25
|
+
from gitpane.reference.stash_list import StashEntry, StashList
|
|
26
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
27
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
|
|
28
|
+
|
|
29
|
+
STASH_INCLUDE_UNTRACKED_KEY = "stash_include_untracked"
|
|
30
|
+
STASH_KEEP_INDEX_KEY = "stash_keep_index"
|
|
31
|
+
STASH_DEFAULT_BY_KEY = {STASH_INCLUDE_UNTRACKED_KEY: True, STASH_KEEP_INDEX_KEY: False}
|
|
32
|
+
STASH_REFERENCE = "refs/stash"
|
|
33
|
+
# Where the files backend keeps the stash reflog, relative to the shared Git directory Git already resolved.
|
|
34
|
+
STASH_REFLOG_DIR_PARTS = ("logs", "refs")
|
|
35
|
+
STASH_REFLOG_BASENAME = "stash"
|
|
36
|
+
STASH_CHANGED_CAUSE = "Stash changed during reading; refresh the repository"
|
|
37
|
+
NEW_CHANGE_CAUSE = "Working files, index or HEAD changed; reopen the form after refreshing"
|
|
38
|
+
CREATE_CHANGED_CAUSE = "Working files, index, HEAD or stash authority changed before launch; reopen the form"
|
|
39
|
+
POP_CHANGED_CAUSE = "Stash, working files, index or authority changed before launch; refresh the repository"
|
|
40
|
+
DROP_CHANGED_CAUSE = "The stash list changed since it was read; refresh the repository and try again"
|
|
41
|
+
DROP_AMBIGUOUS_CAUSE = "Several stashes hold this same stash commit; drop the right one from the left panel"
|
|
42
|
+
DROP_CONFLICT_CAUSE = "Merge conflicts remain in the working tree; the stash is kept"
|
|
43
|
+
DROP_OPERATION_LABEL = "Drop stash"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stash_reflog_filename(context: RepositoryContext) -> str:
|
|
47
|
+
"""Name the shared stash reflog of the files backend; a reftable store keeps it in its tables instead."""
|
|
48
|
+
return os.path.join(context.common_git_dir, *STASH_REFLOG_DIR_PARTS, STASH_REFLOG_BASENAME)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def capture_stash_signature(context: RepositoryContext, filenames: tuple[str, ...] = (), watched_dirs: tuple[str, ...] = (),
|
|
52
|
+
parent_dirs: tuple[str, ...] = ()) -> LaunchSignature:
|
|
53
|
+
"""Take the metadata, without any content, of the shared stash storage plus the given paths.
|
|
54
|
+
|
|
55
|
+
The stash storage is every file and folder the reference signature covers (loose, packed and table references,
|
|
56
|
+
configuration, linked worktrees) and the stash reflog with each of its folders, present or absent, so a reflog
|
|
57
|
+
that appears, grows or is replaced changes the signature. Runs on a worker: it walks the reference folders.
|
|
58
|
+
"""
|
|
59
|
+
from gitpane.operation.launch_signature import sign_launch_paths
|
|
60
|
+
from gitpane.reference.reference_edit import reference_signature_paths
|
|
61
|
+
|
|
62
|
+
reference_filenames, reference_dirs = reference_signature_paths(context)
|
|
63
|
+
reflog_filename = stash_reflog_filename(context)
|
|
64
|
+
log_refs_dir = os.path.dirname(reflog_filename)
|
|
65
|
+
all_filenames = [*reference_filenames, reflog_filename, *filenames]
|
|
66
|
+
all_watched_dirs = [*reference_dirs, os.path.dirname(log_refs_dir), log_refs_dir, *watched_dirs]
|
|
67
|
+
all_parent_dirs = [context.common_git_dir, context.git_dir, *parent_dirs]
|
|
68
|
+
return sign_launch_paths(all_filenames, all_watched_dirs, all_parent_dirs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def refuse_new_changes(snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> None:
|
|
72
|
+
"""Refuse a fresh status that shows a path the form's read did not: a new change or a new untracked file."""
|
|
73
|
+
for entry in entries:
|
|
74
|
+
for path in entry.paths:
|
|
75
|
+
if path not in snapshot.fingerprint_by_path:
|
|
76
|
+
raise ValueError(NEW_CHANGE_CAUSE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class StashLaunchBaseline:
|
|
81
|
+
"""The first look one stash write relies on, taken on a worker before its policy and content checks.
|
|
82
|
+
|
|
83
|
+
``signature`` holds only metadata of the checked files and folders. The other fields are the exact contents a
|
|
84
|
+
worker compares again once every check has passed, just before the command is handed to the launcher. Nothing
|
|
85
|
+
here authorizes a write by itself.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
signature: LaunchSignature
|
|
89
|
+
reference_state: tuple
|
|
90
|
+
working_state: tuple
|
|
91
|
+
reflog_content: bytes | None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stash_launch_paths(context: RepositoryContext, paths: tuple[bytes, ...], untracked_dirs: tuple[str, ...], native_git_dirs: tuple[str, ...],
|
|
95
|
+
index_filename: str) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
|
96
|
+
"""List the files, watched folders and containing folders one stash write is checked against, besides the stash storage.
|
|
97
|
+
|
|
98
|
+
Files: the index, every path the native stash reads, the linked worktree pointers and the pause markers of every
|
|
99
|
+
related Git directory, so a rebase, cherry-pick, revert or merge started elsewhere is noticed. Watched folders:
|
|
100
|
+
the worktree root, each folder holding a checked path and the untracked folders a creation found, where a new
|
|
101
|
+
entry would change what Git stashes. Containing folders are compared by identity only. Runs on a worker.
|
|
102
|
+
"""
|
|
103
|
+
from gitpane.operation.launch_signature import LINKED_COMMON_DIR_BASENAME, WORKTREE_GIT_ENTRY_BASENAME
|
|
104
|
+
from gitpane.operation.repository_operation import PAUSE_PROBE_GIT_PATHS
|
|
105
|
+
from gitpane.reference.reference_edit import reference_git_dirs
|
|
106
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
107
|
+
|
|
108
|
+
worktree_dir = context.worktree_dir
|
|
109
|
+
if worktree_dir is None:
|
|
110
|
+
raise ValueError("A stash requires a working tree")
|
|
111
|
+
filenames = [index_filename, os.path.join(context.git_dir, LINKED_COMMON_DIR_BASENAME)]
|
|
112
|
+
watched_dirs = [worktree_dir, *untracked_dirs]
|
|
113
|
+
git_entry = os.path.join(worktree_dir, WORKTREE_GIT_ENTRY_BASENAME)
|
|
114
|
+
parent_dirs = [os.path.dirname(index_filename), git_entry]
|
|
115
|
+
if os.path.isfile(git_entry):
|
|
116
|
+
filenames.append(git_entry)
|
|
117
|
+
root_dir = os.path.normcase(os.path.abspath(worktree_dir))
|
|
118
|
+
for path in paths:
|
|
119
|
+
filename = worktree_filename(context, path)
|
|
120
|
+
filenames.append(filename)
|
|
121
|
+
directory = os.path.dirname(filename)
|
|
122
|
+
while os.path.normcase(directory) != root_dir:
|
|
123
|
+
watched_dirs.append(directory)
|
|
124
|
+
parent_dir = os.path.dirname(directory)
|
|
125
|
+
if parent_dir == directory:
|
|
126
|
+
raise ValueError("A stashed path is outside the working tree")
|
|
127
|
+
directory = parent_dir
|
|
128
|
+
for git_dir in sorted({*native_git_dirs, *reference_git_dirs(context)}):
|
|
129
|
+
parent_dirs.append(git_dir)
|
|
130
|
+
for git_path in PAUSE_PROBE_GIT_PATHS:
|
|
131
|
+
filenames.append(os.path.join(git_dir, *git_path.split(GIT_PATH_SEPARATOR)))
|
|
132
|
+
return tuple(filenames), tuple(watched_dirs), tuple(parent_dirs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def capture_stash_launch_baseline(context: RepositoryContext, index_filename: str, index_content: bytes | None, paths: tuple[bytes, ...],
|
|
136
|
+
untracked_dirs: tuple[str, ...], native_git_dirs: tuple[str, ...], changed_cause: str) -> StashLaunchBaseline:
|
|
137
|
+
"""Take the first look of a stash write: the contents it will compare again, between two identical metadata looks.
|
|
138
|
+
|
|
139
|
+
The index must still be the one the write was asked with. Runs on a worker: it walks folders and reads contents.
|
|
140
|
+
"""
|
|
141
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
142
|
+
from gitpane.reference.reference_edit import reference_signature, working_signature
|
|
143
|
+
from gitpane.worktree.worktree_snapshot import read_index
|
|
144
|
+
|
|
145
|
+
filenames, watched_dirs, parent_dirs = stash_launch_paths(context, paths, untracked_dirs, native_git_dirs, index_filename)
|
|
146
|
+
signature = capture_stash_signature(context, filenames, watched_dirs, parent_dirs)
|
|
147
|
+
if read_index(index_filename) != index_content:
|
|
148
|
+
raise ValueError(changed_cause)
|
|
149
|
+
reference_state = reference_signature(context)
|
|
150
|
+
working_state = working_signature(context, paths)
|
|
151
|
+
reflog_content = read_index(stash_reflog_filename(context))
|
|
152
|
+
verify_launch_signature(signature, changed_cause)
|
|
153
|
+
return StashLaunchBaseline(signature, reference_state, working_state, reflog_content)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def prepare_stash_launch(context: RepositoryContext, baseline: StashLaunchBaseline, index_filename: str, index_content: bytes | None, paths: tuple[bytes, ...],
|
|
157
|
+
untracked_signature: tuple[tuple[str, tuple[int, ...]], ...], changed_cause: str) -> LaunchSignature:
|
|
158
|
+
"""Check, once every other check has passed, that nothing moved since the first look, and answer the signature the launch compares.
|
|
159
|
+
|
|
160
|
+
The contents are compared with the first look, between two metadata looks that must both match it: a change made
|
|
161
|
+
by another program meanwhile is refused, never adopted. Runs on a worker.
|
|
162
|
+
"""
|
|
163
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
164
|
+
from gitpane.reference.reference_edit import reference_signature, working_signature
|
|
165
|
+
from gitpane.worktree.worktree_snapshot import read_index
|
|
166
|
+
|
|
167
|
+
verify_launch_signature(baseline.signature, changed_cause)
|
|
168
|
+
if (read_index(index_filename) != index_content or reference_signature(context) != baseline.reference_state
|
|
169
|
+
or working_signature(context, paths) != baseline.working_state or read_index(stash_reflog_filename(context)) != baseline.reflog_content):
|
|
170
|
+
raise ValueError(changed_cause)
|
|
171
|
+
untracked_dirs = []
|
|
172
|
+
for directory, _identity in untracked_signature:
|
|
173
|
+
untracked_dirs.append(directory)
|
|
174
|
+
if _directory_signature(tuple(untracked_dirs)) != untracked_signature:
|
|
175
|
+
raise ValueError(changed_cause)
|
|
176
|
+
verify_launch_signature(baseline.signature, changed_cause)
|
|
177
|
+
return baseline.signature
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_paths(snapshot: WorktreeSnapshot) -> tuple[bytes, ...]:
|
|
181
|
+
"""Include clean tracked files because native stash also reads changes made while queued."""
|
|
182
|
+
path_set = set(snapshot.fingerprint_by_path)
|
|
183
|
+
for record in (snapshot.index_entries or b"").split(b"\0"):
|
|
184
|
+
if record:
|
|
185
|
+
_metadata, _separator, path = record.partition(b"\t")
|
|
186
|
+
path_set.add(path)
|
|
187
|
+
return tuple(sorted(path_set))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _directory_signature(dirs: tuple[str, ...]) -> tuple[tuple[str, tuple[int, ...]], ...]:
|
|
191
|
+
"""Detect new nested files through parent metadata without rereading ignored content."""
|
|
192
|
+
from gitpane.worktree.worktree_file import stat_identity
|
|
193
|
+
|
|
194
|
+
records = []
|
|
195
|
+
for directory in dirs:
|
|
196
|
+
metadata = os.lstat(directory)
|
|
197
|
+
if not stat.S_ISDIR(metadata.st_mode) or getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
|
|
198
|
+
raise ValueError("An untracked directory changed type; reopen the stash form")
|
|
199
|
+
records.append((directory, stat_identity(metadata)))
|
|
200
|
+
return tuple(records)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _capture_untracked_directory_signature(context: RepositoryContext) -> Generator:
|
|
204
|
+
"""Include initially empty directories while leaving Git-ignored trees unvisited."""
|
|
205
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
206
|
+
from gitpane.worktree.worktree_file import validate_destination
|
|
207
|
+
|
|
208
|
+
worktree_dir = context.worktree_dir
|
|
209
|
+
if worktree_dir is None:
|
|
210
|
+
raise ValueError("Creating a stash requires a working tree")
|
|
211
|
+
root_signature = yield FileTask(lambda: _directory_signature((worktree_dir,)))
|
|
212
|
+
untracked = yield GitCommand(["ls-files", "--others", "--directory", "--exclude-standard", "-z"])
|
|
213
|
+
ignored = yield GitCommand(["ls-files", "--others", "--ignored", "--directory", "--exclude-standard", "-z"])
|
|
214
|
+
|
|
215
|
+
def capture() -> tuple[tuple[str, tuple[int, ...]], ...]:
|
|
216
|
+
"""Enumerate directory metadata in the filesystem worker before capturing file content."""
|
|
217
|
+
ignored_dir_set = set()
|
|
218
|
+
for path in ignored.split(b"\0"):
|
|
219
|
+
if path.endswith(b"/"):
|
|
220
|
+
filename, _parents = validate_destination(context, path.rstrip(b"/"))
|
|
221
|
+
ignored_dir_set.add(os.path.normcase(filename))
|
|
222
|
+
pending_dirs = []
|
|
223
|
+
for path in untracked.split(b"\0"):
|
|
224
|
+
if path.endswith(b"/"):
|
|
225
|
+
filename, _parents = validate_destination(context, path.rstrip(b"/"))
|
|
226
|
+
pending_dirs.append(filename)
|
|
227
|
+
directory_set = set()
|
|
228
|
+
while pending_dirs:
|
|
229
|
+
directory = pending_dirs.pop()
|
|
230
|
+
if directory in directory_set or os.path.normcase(directory) in ignored_dir_set:
|
|
231
|
+
continue
|
|
232
|
+
_directory_signature((directory,))
|
|
233
|
+
directory_set.add(directory)
|
|
234
|
+
with os.scandir(directory) as entries:
|
|
235
|
+
for entry in entries:
|
|
236
|
+
if entry.name.casefold() != ".git" and entry.is_dir(follow_symlinks=False):
|
|
237
|
+
pending_dirs.append(entry.path)
|
|
238
|
+
return root_signature + _directory_signature(tuple(sorted(directory_set)))
|
|
239
|
+
|
|
240
|
+
return (yield FileTask(capture))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class StashOption:
|
|
245
|
+
"""Keep creation options separate from the literal user message."""
|
|
246
|
+
|
|
247
|
+
include_untracked: bool = STASH_DEFAULT_BY_KEY[STASH_INCLUDE_UNTRACKED_KEY]
|
|
248
|
+
keep_index: bool = STASH_DEFAULT_BY_KEY[STASH_KEEP_INDEX_KEY]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class StashIntent:
|
|
253
|
+
"""Bind one submission to the exact worktree read presented by its form."""
|
|
254
|
+
|
|
255
|
+
operation: OperationIntent
|
|
256
|
+
snapshot: WorktreeSnapshot
|
|
257
|
+
message: str = ""
|
|
258
|
+
option: StashOption = StashOption()
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def capture(cls, snapshot: WorktreeSnapshot, message: str = "", option: StashOption | None = None, *, session_id: str | None = None) -> StashIntent:
|
|
262
|
+
"""Assign a fresh submission identity without changing its captured repository."""
|
|
263
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, WriteScope
|
|
264
|
+
|
|
265
|
+
operation = OperationIntent.capture(uuid.uuid4().hex, snapshot.context, snapshot.git_filename, generation=snapshot.generation,
|
|
266
|
+
session_id=session_id, operation="Create stash", action=OperationAction.STASH,
|
|
267
|
+
scope=WriteScope.capture(snapshot.context, (STASH_REFERENCE,)))
|
|
268
|
+
return cls(operation, snapshot, message, option if option is not None else StashOption())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class StashObservation:
|
|
273
|
+
"""Preserve reflog entries even when two consecutive stashes share an object ID."""
|
|
274
|
+
|
|
275
|
+
oid: str | None
|
|
276
|
+
entries: tuple[bytes, ...]
|
|
277
|
+
unmerged_paths: tuple[bytes, ...] | None = field(default=None, compare=False)
|
|
278
|
+
retained_oid: str | None = field(default=None, compare=False)
|
|
279
|
+
retained: bool | None = field(default=None, compare=False)
|
|
280
|
+
observed_at: datetime | None = field(default=None, compare=False)
|
|
281
|
+
|
|
282
|
+
def retention_of(self, previous: StashObservation) -> StashObservation:
|
|
283
|
+
"""Attach a dated observation of the original stash object without claiming future retention."""
|
|
284
|
+
retained = previous.oid is not None and previous.oid.encode("ascii") in self.entries
|
|
285
|
+
return replace(self, retained_oid=previous.oid, retained=retained, observed_at=datetime.now(UTC))
|
|
286
|
+
|
|
287
|
+
def added_after(self, previous: StashObservation) -> bool:
|
|
288
|
+
"""Recognize a single prepended entry while retaining the older reflog exactly."""
|
|
289
|
+
first_entry_index, preceding_entries_index = 0, 1
|
|
290
|
+
return (self.oid is not None and len(self.entries) == len(previous.entries) + 1
|
|
291
|
+
and self.entries[preceding_entries_index:] == previous.entries and self.entries[first_entry_index].decode("ascii") == self.oid)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class StashDropIntent:
|
|
296
|
+
"""One request to drop one exact stash, named by the stash commit GitPane read for it.
|
|
297
|
+
|
|
298
|
+
Git drops a stash by its position only (``stash@{N}``), and every position moves when another stash is created
|
|
299
|
+
or dropped. The stash commit is what names the stash: the drop reads the list again and refuses when that commit
|
|
300
|
+
is no longer where it was read. ``index`` None means the position is unknown and the commit is looked for in the
|
|
301
|
+
whole list, as for the stash a Stash pop kept; it must then appear exactly once. ``require_resolved`` refuses the
|
|
302
|
+
drop while the index still holds an unresolved conflict.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
operation: OperationIntent
|
|
306
|
+
oid: str
|
|
307
|
+
index: int | None = None
|
|
308
|
+
require_resolved: bool = False
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def of_entry(cls, operation: OperationIntent, entry: StashEntry) -> StashDropIntent:
|
|
312
|
+
"""Drop the stash the user chose in the left panel: it must still be at the position the list showed."""
|
|
313
|
+
return cls(_stash_write_operation(operation, DROP_OPERATION_LABEL), entry.oid, entry.index)
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def of_retained(cls, operation: OperationIntent, oid: str) -> StashDropIntent:
|
|
317
|
+
"""Drop the stash a Stash pop kept, wherever it now stands in the list, and only once no conflict remains."""
|
|
318
|
+
return cls(_stash_write_operation(operation, DROP_OPERATION_LABEL), oid, None, require_resolved=True)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _stash_write_operation(operation: OperationIntent, label: str) -> OperationIntent:
|
|
322
|
+
"""Give a captured write the stash authority and the stash reference as its only written reference."""
|
|
323
|
+
from gitpane.operation.repository_operation import OperationAction, WriteScope
|
|
324
|
+
|
|
325
|
+
return replace(operation, operation=label, action=OperationAction.STASH, scope=WriteScope.capture(operation.context, (STASH_REFERENCE,)))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def locate_stash(stash_list: StashList, intent: StashDropIntent) -> int:
|
|
329
|
+
"""Answer the position of the stash a drop names, or refuse when it moved, vanished or cannot be told apart."""
|
|
330
|
+
if stash_list.cause:
|
|
331
|
+
raise ValueError(stash_list.cause)
|
|
332
|
+
positions = []
|
|
333
|
+
for entry in stash_list.entries:
|
|
334
|
+
if entry.oid == intent.oid:
|
|
335
|
+
positions.append(entry.index)
|
|
336
|
+
if intent.index is not None:
|
|
337
|
+
if intent.index not in positions:
|
|
338
|
+
raise ValueError(DROP_CHANGED_CAUSE)
|
|
339
|
+
return intent.index
|
|
340
|
+
if not positions:
|
|
341
|
+
raise ValueError(DROP_CHANGED_CAUSE)
|
|
342
|
+
if len(positions) > 1:
|
|
343
|
+
raise ValueError(DROP_AMBIGUOUS_CAUSE)
|
|
344
|
+
only_position_index = 0
|
|
345
|
+
return positions[only_position_index]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def stash_oids(stash_list: StashList | None) -> tuple[str, ...]:
|
|
349
|
+
"""List the stash commits of a read list, newest first, to compare two reads without their dates or messages."""
|
|
350
|
+
if stash_list is None:
|
|
351
|
+
return ()
|
|
352
|
+
oids = []
|
|
353
|
+
for entry in stash_list.entries:
|
|
354
|
+
oids.append(entry.oid)
|
|
355
|
+
return tuple(oids)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def pop_stopped_on_conflict(outcome: OperationOutcome) -> bool:
|
|
359
|
+
"""Tell whether a Stash pop stopped on merge conflicts, an expected pause that waits for the user rather than a failure.
|
|
360
|
+
|
|
361
|
+
Git applied what it could, left the conflicting files unmerged, kept the stash and ended with its conflict exit
|
|
362
|
+
status. Any other ending (a refusal, an error, a result that could not be read) stays a failure.
|
|
363
|
+
"""
|
|
364
|
+
from gitpane.history.repository_value import GIT_CONFLICT_RETURN_CODE
|
|
365
|
+
|
|
366
|
+
if outcome.status != OperationStatus.PARTIAL:
|
|
367
|
+
return False
|
|
368
|
+
for step in outcome.steps:
|
|
369
|
+
if isinstance(step.after, StashObservation) and step.after.unmerged_paths and step.return_code == GIT_CONFLICT_RETURN_CODE:
|
|
370
|
+
return True
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class StashService:
|
|
375
|
+
"""Run native stash writes under the shared repository owner and always reread."""
|
|
376
|
+
|
|
377
|
+
def __init__(self, worktree_service: WorktreeService, repository_operation: RepositoryOperationService) -> None:
|
|
378
|
+
"""Inject existing process execution and common repository reservations."""
|
|
379
|
+
self.worktree_service = worktree_service
|
|
380
|
+
self.repository_operation = repository_operation
|
|
381
|
+
self.outcome_by_id: dict[str, OperationOutcome] = {}
|
|
382
|
+
self.cancelled_id_set: set[str] = set()
|
|
383
|
+
|
|
384
|
+
def pop(self, operation: OperationIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
385
|
+
"""Apply the latest stash once after acquiring the common repository reservation."""
|
|
386
|
+
from gitpane.operation.repository_operation import OperationAction, WriteScope
|
|
387
|
+
|
|
388
|
+
operation = replace(operation, action=OperationAction.STASH, scope=WriteScope.capture(operation.context, (STASH_REFERENCE,)))
|
|
389
|
+
return self.repository_operation.submit(operation, self._pop(operation, is_current))
|
|
390
|
+
|
|
391
|
+
def cancel_pop(self, operation_id: str) -> None:
|
|
392
|
+
"""Stop current native work while allowing final observation to complete.
|
|
393
|
+
|
|
394
|
+
The Stash pop console no longer offers Abort (a pop is a local write that must finish), so the application
|
|
395
|
+
does not call this any more. It stays as the service-level way to stop a queued or running pop: the stash tests
|
|
396
|
+
use it to check that a stopped pop still rereads and reports the retained stash instead of losing it.
|
|
397
|
+
"""
|
|
398
|
+
if operation_id in self.outcome_by_id:
|
|
399
|
+
return
|
|
400
|
+
self.cancelled_id_set.add(operation_id)
|
|
401
|
+
runner = self.worktree_service.runner
|
|
402
|
+
for active in tuple(runner.active_by_id.values()):
|
|
403
|
+
if active.request.operation_id == operation_id:
|
|
404
|
+
runner.cancel(active.request.request_id)
|
|
405
|
+
for request in tuple(runner.pending_requests):
|
|
406
|
+
if request.operation_id == operation_id:
|
|
407
|
+
runner.cancel(request.request_id)
|
|
408
|
+
|
|
409
|
+
def _require_authority(self, operation: OperationIntent, is_current: Callable[[], bool] | None, changed_cause: str) -> None:
|
|
410
|
+
"""Refuse a write whose live authority is gone, reading nothing from disk beyond what the application policy checks.
|
|
411
|
+
|
|
412
|
+
The service must be open and still hold this very intention, with management capability; the application must
|
|
413
|
+
still allow it and consider it current, and a pop must not have been cancelled.
|
|
414
|
+
"""
|
|
415
|
+
from gitpane.history.file_history import WindowCapability
|
|
416
|
+
|
|
417
|
+
owner = self.repository_operation
|
|
418
|
+
allows = owner.allows
|
|
419
|
+
if (owner.closed or owner.active_by_id.get(operation.operation_id) is not operation or operation.capability != WindowCapability.MANAGE
|
|
420
|
+
or operation.operation_id in self.cancelled_id_set or allows is not None and not allows(operation)
|
|
421
|
+
or is_current is not None and not is_current()):
|
|
422
|
+
raise ValueError(changed_cause)
|
|
423
|
+
|
|
424
|
+
def _related_git_dirs(self, operation: OperationIntent) -> tuple[str, ...]:
|
|
425
|
+
"""Freeze, on the Qt thread, the Git directories whose pause state the write policy examines for this shared storage."""
|
|
426
|
+
git_dirs = [operation.context.git_dir]
|
|
427
|
+
for context in operation.reserved_contexts:
|
|
428
|
+
git_dirs.append(context.git_dir)
|
|
429
|
+
for context in tuple(self.repository_operation.context_by_key.values()):
|
|
430
|
+
if canonical_path(context.common_git_dir) == operation.common_git_dir:
|
|
431
|
+
git_dirs.append(context.git_dir)
|
|
432
|
+
return tuple(dict.fromkeys(git_dirs))
|
|
433
|
+
|
|
434
|
+
def _pop(self, operation: OperationIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
435
|
+
"""Recheck the captured stash at native launch and reread even on a rejected command."""
|
|
436
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
437
|
+
from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
|
|
438
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
439
|
+
|
|
440
|
+
def read_failure_diagnostic(error: Exception) -> PreparedDiagnostic:
|
|
441
|
+
"""Describe a failed read of the pop's result, naming the Git process it comes from when there is one.
|
|
442
|
+
|
|
443
|
+
A console that already ended that process with its own line then does not repeat the failure. The block
|
|
444
|
+
written to the log is the same as without the process, which is not part of its text.
|
|
445
|
+
"""
|
|
446
|
+
return Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error),
|
|
447
|
+
process_id=error.diagnostic.process_id if isinstance(error, GitCommandError) else "").prepare()
|
|
448
|
+
|
|
449
|
+
before = after = snapshot = refreshed = diagnostic = process_status = return_code = None
|
|
450
|
+
native_started = False
|
|
451
|
+
status, effect = OperationStatus.REFUSED, "not observed"
|
|
452
|
+
try:
|
|
453
|
+
if operation.context.is_bare:
|
|
454
|
+
raise ValueError("This bare repository has no working tree for Stash pop")
|
|
455
|
+
before = yield from self.observe(operation.context)
|
|
456
|
+
# A pop is asked without a worktree read, so this complete capture is what its result is compared with.
|
|
457
|
+
snapshot = yield from self.worktree_service.capture(operation.context, operation.git_filename, operation.generation)
|
|
458
|
+
if before.oid is None:
|
|
459
|
+
raise ValueError("No stash to apply. Create a stash before using Stash pop.")
|
|
460
|
+
paths = _write_paths(snapshot)
|
|
461
|
+
git_dirs = self._related_git_dirs(operation)
|
|
462
|
+
baseline = yield FileTask(lambda: capture_stash_launch_baseline(operation.context, snapshot.index_filename, snapshot.index_content, paths, (), git_dirs,
|
|
463
|
+
POP_CHANGED_CAUSE))
|
|
464
|
+
self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
|
|
465
|
+
self.repository_operation.validate(operation)
|
|
466
|
+
if (yield from self.observe(operation.context)) != before:
|
|
467
|
+
raise ValueError("Stash changed before launch; refresh the repository")
|
|
468
|
+
signature = yield FileTask(lambda: prepare_stash_launch(operation.context, baseline, snapshot.index_filename, snapshot.index_content, paths, (),
|
|
469
|
+
POP_CHANGED_CAUSE))
|
|
470
|
+
self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
|
|
471
|
+
|
|
472
|
+
def validate_launch() -> None:
|
|
473
|
+
"""Refuse a changed authority or any metadata change after queue waiting; no content is read here."""
|
|
474
|
+
nonlocal native_started
|
|
475
|
+
self._require_authority(operation, is_current, POP_CHANGED_CAUSE)
|
|
476
|
+
verify_launch_signature(signature, POP_CHANGED_CAUSE)
|
|
477
|
+
native_started = True
|
|
478
|
+
|
|
479
|
+
# Stash invokes Git subcommands internally; no user pathspec is passed to this native command. Submodule
|
|
480
|
+
# recursion is turned off so the files of every submodule stay as they are, even with submodule.recurse set.
|
|
481
|
+
yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "--no-literal-pathspecs", "stash", "pop"], timeout_ms=GIT_MUTATION_TIMEOUT_MS,
|
|
482
|
+
idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
|
|
483
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
484
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
485
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
486
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
487
|
+
return_code = error.return_code if isinstance(error, GitCommandError) else None
|
|
488
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
489
|
+
try:
|
|
490
|
+
after = yield from self.observe(operation.context)
|
|
491
|
+
if before is not None:
|
|
492
|
+
after = after.retention_of(before)
|
|
493
|
+
if process_status == ProcessStatus.SUCCESS:
|
|
494
|
+
preceding_entries_index = 1
|
|
495
|
+
removed = before is not None and after.entries == before.entries[preceding_entries_index:]
|
|
496
|
+
status, effect = (OperationStatus.SUCCESS, "applied") if removed else (OperationStatus.UNKNOWN, "unknown")
|
|
497
|
+
if not removed:
|
|
498
|
+
diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [],
|
|
499
|
+
"Stash history changed unexpectedly; inspect the repository before trying again").prepare()
|
|
500
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
501
|
+
status, effect = OperationStatus.UNKNOWN, "unknown"
|
|
502
|
+
diagnostic = read_failure_diagnostic(error)
|
|
503
|
+
try:
|
|
504
|
+
if not operation.context.is_bare:
|
|
505
|
+
refreshed = yield from self.worktree_service.capture(operation.context, operation.git_filename, operation.generation)
|
|
506
|
+
if after is not None:
|
|
507
|
+
unmerged_paths = []
|
|
508
|
+
for entry in refreshed.entries:
|
|
509
|
+
if entry.conflict_stages:
|
|
510
|
+
unmerged_paths.append(entry.path)
|
|
511
|
+
after = replace(after, unmerged_paths=tuple(sorted(set(unmerged_paths))))
|
|
512
|
+
if status != OperationStatus.UNKNOWN and native_started:
|
|
513
|
+
changed = before != after or (snapshot is not None and (snapshot.head_oid != refreshed.head_oid or snapshot.index_entries != refreshed.index_entries
|
|
514
|
+
or snapshot.fingerprint_by_path != refreshed.fingerprint_by_path))
|
|
515
|
+
if after is not None and after.unmerged_paths or process_status != ProcessStatus.SUCCESS and changed:
|
|
516
|
+
status, effect = OperationStatus.PARTIAL, "partially applied"
|
|
517
|
+
elif process_status != ProcessStatus.SUCCESS:
|
|
518
|
+
effect = "unchanged"
|
|
519
|
+
if after is not None and after.unmerged_paths:
|
|
520
|
+
names = []
|
|
521
|
+
for path in after.unmerged_paths:
|
|
522
|
+
names.append(path.decode("utf-8", "replace"))
|
|
523
|
+
cause = "Unresolved files:\n" + "\n".join(names)
|
|
524
|
+
diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
|
|
525
|
+
if after is not None and after.retained:
|
|
526
|
+
cause = diagnostic.cause + "\n" if diagnostic is not None else ""
|
|
527
|
+
cause += "Stash retained: " + after.retained_oid + " (observed " + after.observed_at.isoformat() + ")"
|
|
528
|
+
diagnostic = Diagnostic("Stash pop", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
|
|
529
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
530
|
+
status = OperationStatus.UNKNOWN
|
|
531
|
+
diagnostic = read_failure_diagnostic(error)
|
|
532
|
+
step = OperationStepResult("Stash pop", status, before, after, process_status, effect, diagnostic, return_code)
|
|
533
|
+
refresh = OperationStepResult("Read working tree", OperationStatus.SUCCESS if refreshed is not None else OperationStatus.UNKNOWN, before=snapshot, after=refreshed)
|
|
534
|
+
outcome = OperationOutcome(operation.operation_id, status, (step, refresh), diagnostic)
|
|
535
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
536
|
+
self.cancelled_id_set.discard(operation.operation_id)
|
|
537
|
+
return outcome
|
|
538
|
+
|
|
539
|
+
def drop(self, intent: StashDropIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
540
|
+
"""Drop one exact stash once, under the common repository reservation, and report what the list shows afterwards."""
|
|
541
|
+
return self.repository_operation.submit(intent.operation, self._drop(intent, is_current))
|
|
542
|
+
|
|
543
|
+
def _drop(self, intent: StashDropIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
544
|
+
"""Find the stash again by its commit, check nothing moved, run `git stash drop stash@{N}`, then read the list again.
|
|
545
|
+
|
|
546
|
+
The stash reflog is read as a file between two identical metadata looks at the stash storage, and the same
|
|
547
|
+
look is compared again just before Git starts: a stash created or dropped by another program meanwhile makes
|
|
548
|
+
the drop refuse instead of removing whatever now stands at that position. Only the stash reference and its
|
|
549
|
+
reflog are written; working files, the index and submodules are never read for writing nor touched.
|
|
550
|
+
"""
|
|
551
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
552
|
+
from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
|
|
553
|
+
from gitpane.reference.stash_list import read_stash_list, stash_selector
|
|
554
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
555
|
+
|
|
556
|
+
operation = intent.operation
|
|
557
|
+
context = operation.context
|
|
558
|
+
before = after = diagnostic = process_status = return_code = None
|
|
559
|
+
native_started = False
|
|
560
|
+
status, effect = OperationStatus.REFUSED, "not observed"
|
|
561
|
+
position = None
|
|
562
|
+
try:
|
|
563
|
+
if context.is_bare:
|
|
564
|
+
raise ValueError("This bare repository has no stash to drop")
|
|
565
|
+
signature = yield FileTask(lambda: capture_stash_signature(context))
|
|
566
|
+
before = yield FileTask(lambda: read_stash_list(context))
|
|
567
|
+
position = locate_stash(before, intent)
|
|
568
|
+
if intent.require_resolved:
|
|
569
|
+
unmerged = yield GitCommand(["ls-files", "--unmerged", "-z"])
|
|
570
|
+
if unmerged:
|
|
571
|
+
raise ValueError(DROP_CONFLICT_CAUSE)
|
|
572
|
+
yield FileTask(lambda: verify_launch_signature(signature, DROP_CHANGED_CAUSE))
|
|
573
|
+
self._require_authority(operation, is_current, DROP_CHANGED_CAUSE)
|
|
574
|
+
self.repository_operation.validate(operation)
|
|
575
|
+
|
|
576
|
+
def validate_launch() -> None:
|
|
577
|
+
"""Refuse a changed authority or any change of the stash storage after queue waiting; no content is read here."""
|
|
578
|
+
nonlocal native_started
|
|
579
|
+
self._require_authority(operation, is_current, DROP_CHANGED_CAUSE)
|
|
580
|
+
verify_launch_signature(signature, DROP_CHANGED_CAUSE)
|
|
581
|
+
native_started = True
|
|
582
|
+
|
|
583
|
+
yield GitCommand(["stash", "drop", stash_selector(position)], timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS,
|
|
584
|
+
before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
|
|
585
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
586
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
587
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(DROP_OPERATION_LABEL, context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
588
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
589
|
+
return_code = error.return_code if isinstance(error, GitCommandError) else None
|
|
590
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
591
|
+
if native_started:
|
|
592
|
+
try:
|
|
593
|
+
after = yield FileTask(lambda: read_stash_list(context))
|
|
594
|
+
expected = list(stash_oids(before))
|
|
595
|
+
del expected[position]
|
|
596
|
+
if not after.cause and stash_oids(after) == tuple(expected):
|
|
597
|
+
status, effect = OperationStatus.SUCCESS, "dropped"
|
|
598
|
+
elif process_status == ProcessStatus.SUCCESS or stash_oids(after) != stash_oids(before):
|
|
599
|
+
status, effect = OperationStatus.UNKNOWN, "unknown"
|
|
600
|
+
diagnostic = Diagnostic(DROP_OPERATION_LABEL, context.repository_dir, operation.git_filename, [],
|
|
601
|
+
"Stash list changed unexpectedly; inspect the repository before trying again").prepare()
|
|
602
|
+
else:
|
|
603
|
+
effect = "unchanged"
|
|
604
|
+
except (ValueError, OSError) as error:
|
|
605
|
+
status, effect = OperationStatus.UNKNOWN, "unknown"
|
|
606
|
+
diagnostic = Diagnostic("Read stash result", context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
607
|
+
step = OperationStepResult(DROP_OPERATION_LABEL, status, before, after, process_status, effect, diagnostic, return_code)
|
|
608
|
+
outcome = OperationOutcome(operation.operation_id, status, (step,), diagnostic)
|
|
609
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
610
|
+
return outcome
|
|
611
|
+
|
|
612
|
+
def observe(self, context: RepositoryContext) -> Generator:
|
|
613
|
+
"""Read the stash reference and reflog of the context's shared storage, without copying messages or file contents to diagnostics."""
|
|
614
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
615
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
616
|
+
|
|
617
|
+
# Two identical metadata looks at the stash storage, before and after the Git reads, prove those reads saw one
|
|
618
|
+
# state; a second rev-parse could not see a reflog entry added with the same object ID, a packing or a new table.
|
|
619
|
+
signature = yield FileTask(lambda: capture_stash_signature(context))
|
|
620
|
+
# Exit code 1 with no output means the reference is absent; a timeout or a cancellation stays an error.
|
|
621
|
+
output = yield GitCommand(["rev-parse", "--verify", "--quiet", STASH_REFERENCE], accepted_codes=(0, 1))
|
|
622
|
+
oid_output = output.strip()
|
|
623
|
+
entries: tuple[bytes, ...] = ()
|
|
624
|
+
if oid_output:
|
|
625
|
+
# The reference and its reflog are separate facts: a reference can exist without a log, or point elsewhere
|
|
626
|
+
# than its latest entry, and both are kept as Git reports them.
|
|
627
|
+
reflog_output = yield GitCommand(["reflog", "show", "--format=%H", STASH_REFERENCE])
|
|
628
|
+
entries = tuple(reflog_output.splitlines())
|
|
629
|
+
yield FileTask(lambda: verify_launch_signature(signature, STASH_CHANGED_CAUSE))
|
|
630
|
+
return StashObservation(oid_output.decode("ascii") or None, entries)
|
|
631
|
+
|
|
632
|
+
def create(self, intent: StashIntent, *, is_current: Callable[[], bool] | None = None) -> str:
|
|
633
|
+
"""Submit one native creation once without replaying an uncertain result."""
|
|
634
|
+
return self.repository_operation.submit(intent.operation, self._create(intent, is_current))
|
|
635
|
+
|
|
636
|
+
def _create(self, intent: StashIntent, is_current: Callable[[], bool] | None) -> Generator:
|
|
637
|
+
"""Validate captured state, execute Git, then separate its status from observed effects."""
|
|
638
|
+
# Preferences import the option definitions; load execution dependencies only when running a stash.
|
|
639
|
+
from gitpane.operation.launch_signature import verify_launch_signature
|
|
640
|
+
from gitpane.operation.repository_operation import OperationOutcome, OperationStepResult
|
|
641
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
642
|
+
from gitpane.worktree.worktree_service import STATUS_ARGUMENTS
|
|
643
|
+
from gitpane.worktree.worktree_snapshot import parse_status
|
|
644
|
+
|
|
645
|
+
operation, snapshot = intent.operation, intent.snapshot
|
|
646
|
+
before = after = refreshed = diagnostic = process_status = return_code = None
|
|
647
|
+
native_started = False
|
|
648
|
+
status, effect = OperationStatus.REFUSED, "not observed"
|
|
649
|
+
try:
|
|
650
|
+
if (snapshot.context.is_bare or not snapshot.fresh or snapshot.key != operation.key or snapshot.context != operation.context
|
|
651
|
+
or snapshot.generation != operation.generation or snapshot.git_filename != operation.git_filename):
|
|
652
|
+
raise ValueError("The stash request does not match its captured working tree")
|
|
653
|
+
if "\0" in intent.message:
|
|
654
|
+
raise ValueError("The stash message cannot contain a NUL character")
|
|
655
|
+
if is_current is not None and not is_current():
|
|
656
|
+
raise ValueError("The stash request is no longer current; reopen the form")
|
|
657
|
+
before = yield from self.observe(snapshot.context)
|
|
658
|
+
untracked_directory_signature = ()
|
|
659
|
+
if intent.option.include_untracked:
|
|
660
|
+
untracked_directory_signature = yield from _capture_untracked_directory_signature(snapshot.context)
|
|
661
|
+
untracked_dirs = []
|
|
662
|
+
for directory, _identity in untracked_directory_signature:
|
|
663
|
+
untracked_dirs.append(directory)
|
|
664
|
+
paths = _write_paths(snapshot)
|
|
665
|
+
git_dirs = self._related_git_dirs(operation)
|
|
666
|
+
# The exact bytes of every path the native stash reads, clean tracked files included, are taken here and
|
|
667
|
+
# compared again after the checks below, between metadata looks that must not move: the checks then
|
|
668
|
+
# describe the state that will be stashed.
|
|
669
|
+
baseline = yield FileTask(lambda: capture_stash_launch_baseline(snapshot.context, snapshot.index_filename, snapshot.index_content, paths, tuple(untracked_dirs),
|
|
670
|
+
git_dirs, CREATE_CHANGED_CAUSE))
|
|
671
|
+
self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
|
|
672
|
+
self.repository_operation.validate(operation)
|
|
673
|
+
# verify compares the paths the form read (bytes, index, HEAD, identity, configuration and attributes); only
|
|
674
|
+
# the paths with a fingerprint are passed, a clean path listed by the index has none to compare with.
|
|
675
|
+
yield from self.worktree_service.verify(snapshot, tuple(snapshot.fingerprint_by_path))
|
|
676
|
+
# A fresh status shows what verify cannot: a change to a file that was clean, or a new untracked file.
|
|
677
|
+
status_output = yield GitCommand(list(STATUS_ARGUMENTS), profile=READ_PROFILE)
|
|
678
|
+
refuse_new_changes(snapshot, parse_status(status_output))
|
|
679
|
+
signature = yield FileTask(lambda: prepare_stash_launch(snapshot.context, baseline, snapshot.index_filename, snapshot.index_content, paths,
|
|
680
|
+
untracked_directory_signature, CREATE_CHANGED_CAUSE))
|
|
681
|
+
self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
|
|
682
|
+
|
|
683
|
+
def validate_launch() -> None:
|
|
684
|
+
"""Refuse a changed authority or any metadata change after queue waiting; no content is read here."""
|
|
685
|
+
nonlocal native_started
|
|
686
|
+
self._require_authority(operation, is_current, CREATE_CHANGED_CAUSE)
|
|
687
|
+
verify_launch_signature(signature, CREATE_CHANGED_CAUSE)
|
|
688
|
+
native_started = True
|
|
689
|
+
|
|
690
|
+
# The shared reader enables literal pathspecs, but stash internally checks out :/ for Keep index.
|
|
691
|
+
# No user path is supplied here; restore Git's native pathspec handling for its own subcommands.
|
|
692
|
+
# Submodule recursion is turned off so the files of every submodule stay as they are, even with submodule.recurse set.
|
|
693
|
+
arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, "--no-literal-pathspecs", "stash", "push"]
|
|
694
|
+
if intent.option.include_untracked:
|
|
695
|
+
arguments.append("--include-untracked")
|
|
696
|
+
if intent.option.keep_index:
|
|
697
|
+
arguments.append("--keep-index")
|
|
698
|
+
if intent.message:
|
|
699
|
+
arguments.extend(["--message", intent.message])
|
|
700
|
+
yield GitCommand(arguments, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=GIT_MUTATION_TIMEOUT_MS, before_start=validate_launch, profile=LOCAL_WRITE_PROFILE)
|
|
701
|
+
process_status, return_code = ProcessStatus.SUCCESS, 0
|
|
702
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
703
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Create stash", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
704
|
+
process_status = error.process_status if isinstance(error, GitCommandError) else None
|
|
705
|
+
return_code = error.return_code if isinstance(error, GitCommandError) else None
|
|
706
|
+
status = OperationStatus.FAILED if native_started else OperationStatus.REFUSED
|
|
707
|
+
try:
|
|
708
|
+
after = yield from self.observe(snapshot.context)
|
|
709
|
+
if native_started and before is not None and after.added_after(before):
|
|
710
|
+
effect = "created"
|
|
711
|
+
status = OperationStatus.SUCCESS if process_status == ProcessStatus.SUCCESS else OperationStatus.PARTIAL
|
|
712
|
+
elif process_status == ProcessStatus.SUCCESS:
|
|
713
|
+
status = OperationStatus.NO_CHANGE if after == before else OperationStatus.UNKNOWN
|
|
714
|
+
effect = "unchanged" if after == before else "unknown"
|
|
715
|
+
cause = "No changes to stash. Change a file, then try again." if status == OperationStatus.NO_CHANGE else "Stash changed unexpectedly; inspect the repository before trying again"
|
|
716
|
+
diagnostic = Diagnostic("Create stash", operation.context.repository_dir, operation.git_filename, [], cause).prepare()
|
|
717
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
718
|
+
status, effect = OperationStatus.UNKNOWN, "unknown"
|
|
719
|
+
diagnostic = Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
720
|
+
try:
|
|
721
|
+
if not snapshot.context.is_bare:
|
|
722
|
+
refreshed = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
723
|
+
except (ValueError, OSError, GitCommandError) as error:
|
|
724
|
+
status = OperationStatus.UNKNOWN
|
|
725
|
+
diagnostic = Diagnostic("Read stash result", operation.context.repository_dir, operation.git_filename, [], str(error)).prepare()
|
|
726
|
+
step = OperationStepResult("Create stash", status, before, after, process_status, effect, diagnostic, return_code)
|
|
727
|
+
refresh = OperationStepResult("Read working tree", OperationStatus.SUCCESS if refreshed is not None else OperationStatus.UNKNOWN, after=refreshed)
|
|
728
|
+
outcome = OperationOutcome(operation.operation_id, status, (step, refresh), diagnostic)
|
|
729
|
+
self.outcome_by_id[operation.operation_id] = outcome
|
|
730
|
+
return outcome
|