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,406 @@
|
|
|
1
|
+
"""Handle owned file images (capture, materialization, replacement) without following working-tree links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from gitpane.config import GIT_MUTATION_TIMEOUT_MS, MUTATION_IDLE_TIMEOUT_MS, PROCESS_CHUNK_BYTES
|
|
14
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE
|
|
15
|
+
from gitpane.system.path_identity import canonical_path
|
|
16
|
+
from gitpane.tool.system_tool import worktree_filename
|
|
17
|
+
from gitpane.worktree.command_sequence import GitCommand
|
|
18
|
+
from gitpane.worktree.worktree_snapshot import fingerprint
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Generator
|
|
22
|
+
from typing import BinaryIO
|
|
23
|
+
|
|
24
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
25
|
+
|
|
26
|
+
PRIVATE_DIRECTORY_MODE = 0o700
|
|
27
|
+
PRIVATE_FILE_MODE = 0o600
|
|
28
|
+
REGULAR_FILE_MODE = 0o666
|
|
29
|
+
BATCH_TERMINATOR_BYTES = 1
|
|
30
|
+
FILE_OBJECT_IDENTITY_FIELDS = 2
|
|
31
|
+
# Position of st_mode in stat_identity; the change time is the only field after the stable ones.
|
|
32
|
+
FILE_MODE_IDENTITY_FIELD = 2
|
|
33
|
+
FILE_STABLE_IDENTITY_FIELDS = 5
|
|
34
|
+
MISSING_PARENT_IDENTITY = (0, 0)
|
|
35
|
+
# A file reachable under two names while it has a single link can only be one directory entry seen twice.
|
|
36
|
+
SINGLE_LINK_COUNT = 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class FileImage:
|
|
41
|
+
"""Distinguish absence, file type, exact bytes and filesystem identity for safe replacement."""
|
|
42
|
+
|
|
43
|
+
exists: bool
|
|
44
|
+
mode: int
|
|
45
|
+
content: bytes | None = None
|
|
46
|
+
content_filename: str | None = None
|
|
47
|
+
identity: tuple[int, ...] | None = None
|
|
48
|
+
parents: tuple[tuple[str, tuple[int, int]], ...] = ()
|
|
49
|
+
publication_mode: int | None = None
|
|
50
|
+
|
|
51
|
+
def read(self) -> bytes:
|
|
52
|
+
"""Read owned content while keeping missing files distinct from empty files."""
|
|
53
|
+
if not self.exists:
|
|
54
|
+
return b""
|
|
55
|
+
if self.content is not None:
|
|
56
|
+
return self.content
|
|
57
|
+
if self.content_filename is None:
|
|
58
|
+
raise ValueError("The file image has no owned content")
|
|
59
|
+
with open(self.content_filename, "rb") as stream:
|
|
60
|
+
return stream.read()
|
|
61
|
+
|
|
62
|
+
def digest(self) -> str:
|
|
63
|
+
"""Describe content and type without putting private file bytes in the journal."""
|
|
64
|
+
if not self.exists:
|
|
65
|
+
return "absent"
|
|
66
|
+
if self.content is not None or self.content_filename is None:
|
|
67
|
+
return str(self.mode) + ":" + fingerprint(self.read())
|
|
68
|
+
digest = hashlib.sha256()
|
|
69
|
+
with open(self.content_filename, "rb") as stream:
|
|
70
|
+
while chunk := stream.read(PROCESS_CHUNK_BYTES):
|
|
71
|
+
digest.update(chunk)
|
|
72
|
+
return str(self.mode) + ":" + digest.hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def stat_identity(metadata: os.stat_result) -> tuple[int, ...]:
|
|
76
|
+
"""Recognize a replaced file even when its content and size were preserved."""
|
|
77
|
+
return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def matches_after_announced_mode(actual: tuple[int, ...], recorded: tuple[int, ...], announced_mode: int | None) -> bool:
|
|
81
|
+
"""Recognize a recorded file after the permission change its owner announced, and nothing else.
|
|
82
|
+
|
|
83
|
+
Changing permissions also moves the change time, so the file only differs from its recorded identity in those two
|
|
84
|
+
fields. Device, inode, size and modification time must still match and the mode must be exactly the announced one,
|
|
85
|
+
so a file another program rewrote, replaced or gave other permissions is still a different file.
|
|
86
|
+
"""
|
|
87
|
+
if announced_mode is None:
|
|
88
|
+
return False
|
|
89
|
+
expected = list(recorded)
|
|
90
|
+
expected[FILE_MODE_IDENTITY_FIELD] = announced_mode
|
|
91
|
+
return tuple(actual)[:FILE_STABLE_IDENTITY_FIELDS] == tuple(expected)[:FILE_STABLE_IDENTITY_FIELDS]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_destination(context: RepositoryContext, path: bytes, allow_missing_parents: bool = False) -> tuple[str, tuple[tuple[str, tuple[int, int]], ...]]:
|
|
95
|
+
"""Reject administrative and linked paths, optionally capturing missing historical parents."""
|
|
96
|
+
filename = worktree_filename(context, path)
|
|
97
|
+
for part in path.split(b"/"):
|
|
98
|
+
if part.rstrip(b" .").lower() in (b".git", b"git~1"):
|
|
99
|
+
raise ValueError("Git administrative paths cannot be replaced")
|
|
100
|
+
# Git names its folders as the disk spells them, while the file is built from the folder the user opened, which on
|
|
101
|
+
# macOS may be typed in another letter case or Unicode form: both sides take the same identity before comparing.
|
|
102
|
+
normalized_filename = canonical_path(filename)
|
|
103
|
+
for protected_dir in (context.git_dir, context.common_git_dir):
|
|
104
|
+
normalized_dir = canonical_path(protected_dir)
|
|
105
|
+
try:
|
|
106
|
+
ancestor_dir = os.path.commonpath((normalized_dir, normalized_filename))
|
|
107
|
+
except ValueError:
|
|
108
|
+
# Windows worktrees can live on a different drive from their shared Git directory.
|
|
109
|
+
continue
|
|
110
|
+
if ancestor_dir == normalized_dir:
|
|
111
|
+
raise ValueError("Git administrative paths cannot be replaced")
|
|
112
|
+
root_dir = os.path.normcase(os.path.abspath(context.worktree_dir))
|
|
113
|
+
parent_dir = os.path.dirname(filename)
|
|
114
|
+
parents: list[tuple[str, tuple[int, int]]] = []
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
metadata = os.lstat(parent_dir)
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
if not allow_missing_parents or os.path.normcase(parent_dir) == root_dir:
|
|
120
|
+
raise
|
|
121
|
+
parents.append((parent_dir, MISSING_PARENT_IDENTITY))
|
|
122
|
+
else:
|
|
123
|
+
if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
|
|
124
|
+
raise ValueError("A selected path traverses a linked or invalid directory")
|
|
125
|
+
parents.append((parent_dir, (metadata.st_dev, metadata.st_ino)))
|
|
126
|
+
if os.path.normcase(parent_dir) == root_dir:
|
|
127
|
+
break
|
|
128
|
+
ancestor_dir = os.path.dirname(parent_dir)
|
|
129
|
+
if ancestor_dir == parent_dir:
|
|
130
|
+
raise ValueError("A selected path is outside the working tree")
|
|
131
|
+
parent_dir = ancestor_dir
|
|
132
|
+
return filename, tuple(parents)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def capture_image(context: RepositoryContext, path: bytes, allow_missing_parents: bool = False) -> FileImage:
|
|
136
|
+
"""Capture exact file bytes and lstat identity, refusing links and replacement during reading."""
|
|
137
|
+
filename, parents = validate_destination(context, path, allow_missing_parents)
|
|
138
|
+
try:
|
|
139
|
+
metadata = os.lstat(filename)
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
return FileImage(False, 0, parents=parents)
|
|
142
|
+
if os.path.isjunction(filename):
|
|
143
|
+
raise ValueError("A junction cannot be replaced as a file")
|
|
144
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
145
|
+
content = os.fsencode(os.readlink(filename))
|
|
146
|
+
elif stat.S_ISREG(metadata.st_mode):
|
|
147
|
+
with open(filename, "rb") as stream:
|
|
148
|
+
# Windows fstat and lstat can report different ctime meanings; compare handle identity, mode, size and mtime here.
|
|
149
|
+
if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
|
|
150
|
+
raise ValueError("Working file changed while reading")
|
|
151
|
+
content = stream.read()
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError("This working file type cannot be replaced")
|
|
154
|
+
if stat_identity(os.lstat(filename)) != stat_identity(metadata):
|
|
155
|
+
raise ValueError("Working file changed while reading")
|
|
156
|
+
return FileImage(True, metadata.st_mode, content, identity=stat_identity(metadata), parents=parents)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_image(context: RepositoryContext, path: bytes, expected: FileImage) -> None:
|
|
160
|
+
"""Require unchanged parents, type, identity and bytes immediately before publication."""
|
|
161
|
+
current = capture_image(context, path, allow_missing_parents=any(identity == MISSING_PARENT_IDENTITY for _parent_dir, identity in expected.parents))
|
|
162
|
+
if (current.exists, current.identity, current.parents, current.digest()) != (expected.exists, expected.identity, expected.parents, expected.digest()):
|
|
163
|
+
raise ValueError("Working file or parent changed after confirmation; select the changes again")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def private_file_dir(context: RepositoryContext, path: bytes, operation_dir: str, allow_missing_parents: bool = False) -> str:
|
|
167
|
+
"""Choose a private directory on the target volume, never a temporary file inside user content."""
|
|
168
|
+
_filename, parents = validate_destination(context, path, allow_missing_parents)
|
|
169
|
+
existing_parent_dir = next(parent_dir for parent_dir, identity in parents if identity != MISSING_PARENT_IDENTITY)
|
|
170
|
+
target_device = os.stat(existing_parent_dir).st_dev
|
|
171
|
+
if os.stat(operation_dir).st_dev == target_device:
|
|
172
|
+
return operation_dir
|
|
173
|
+
parent_dir = os.path.dirname(os.path.abspath(context.worktree_dir))
|
|
174
|
+
if os.path.islink(parent_dir) or os.path.isjunction(parent_dir) or os.stat(parent_dir).st_dev != target_device:
|
|
175
|
+
raise ValueError("No private directory on the working file's volume is available")
|
|
176
|
+
private_dir = tempfile.mkdtemp(prefix=".gitpane-operation-", dir=parent_dir)
|
|
177
|
+
os.chmod(private_dir, PRIVATE_DIRECTORY_MODE)
|
|
178
|
+
root_dir = canonical_path(context.worktree_dir)
|
|
179
|
+
if os.path.commonpath((root_dir, canonical_path(private_dir))) == root_dir:
|
|
180
|
+
os.rmdir(private_dir)
|
|
181
|
+
raise ValueError("Private file preparation would enter the working tree")
|
|
182
|
+
return private_dir
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def publication_image(expected: FileImage, created_dirs: list[tuple[str, tuple[int, int]]]) -> FileImage:
|
|
186
|
+
"""Account only for parents created and still owned by this publication."""
|
|
187
|
+
identity_by_directory = dict(created_dirs)
|
|
188
|
+
parents = []
|
|
189
|
+
for directory, identity in expected.parents:
|
|
190
|
+
parents.append((directory, identity_by_directory.get(directory, identity) if identity == MISSING_PARENT_IDENTITY else identity))
|
|
191
|
+
return replace(expected, parents=tuple(parents))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def create_missing_parents(context: RepositoryContext, path: bytes, expected: FileImage, created_dirs: list[tuple[str, tuple[int, int]]], persist: Callable[[], None]) -> FileImage:
|
|
195
|
+
"""Revalidate each ancestor before creating and journaling missing destination directories."""
|
|
196
|
+
validate_image(context, path, publication_image(expected, created_dirs))
|
|
197
|
+
for directory, identity in reversed(expected.parents):
|
|
198
|
+
if identity != MISSING_PARENT_IDENTITY or directory in dict(created_dirs):
|
|
199
|
+
continue
|
|
200
|
+
validate_image(context, path, publication_image(expected, created_dirs))
|
|
201
|
+
os.mkdir(directory)
|
|
202
|
+
created_dirs.append((directory, stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS]))
|
|
203
|
+
persist()
|
|
204
|
+
current = publication_image(expected, created_dirs)
|
|
205
|
+
validate_image(context, path, current)
|
|
206
|
+
return current
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clean_created_parents(created_dirs: list[tuple[str, tuple[int, int]]]) -> None:
|
|
210
|
+
"""Remove only owned empty parents, retaining external files and replacement directories."""
|
|
211
|
+
for directory, identity in reversed(created_dirs):
|
|
212
|
+
try:
|
|
213
|
+
if not os.path.islink(directory) and not os.path.isjunction(directory) and stat_identity(os.lstat(directory))[:FILE_OBJECT_IDENTITY_FIELDS] == tuple(identity):
|
|
214
|
+
os.rmdir(directory)
|
|
215
|
+
except OSError:
|
|
216
|
+
# Another writer may now own content inside this directory.
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class FilteredBlobStream:
|
|
221
|
+
"""Validate one NUL-framed filtered blob while retaining only its trailing separator."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, stream: BinaryIO, oid: str) -> None:
|
|
224
|
+
"""Bind an owned destination to the exact requested Git object and blob type."""
|
|
225
|
+
self.stream = stream
|
|
226
|
+
self.expected_header = oid.encode("ascii") + b" blob"
|
|
227
|
+
self.header = bytearray()
|
|
228
|
+
self.header_complete = False
|
|
229
|
+
self.tail = b""
|
|
230
|
+
|
|
231
|
+
def consume(self, chunk: bytes) -> None:
|
|
232
|
+
"""Write bounded body chunks, rejecting unexpected headers before any result is usable."""
|
|
233
|
+
if not self.header_complete:
|
|
234
|
+
header, separator, remainder = chunk.partition(b"\0")
|
|
235
|
+
self.header.extend(header)
|
|
236
|
+
if len(self.header) > len(self.expected_header):
|
|
237
|
+
raise ValueError("Git returned an invalid filtered blob header")
|
|
238
|
+
if not separator:
|
|
239
|
+
return
|
|
240
|
+
if bytes(self.header) != self.expected_header:
|
|
241
|
+
raise ValueError("Git returned a different filtered object")
|
|
242
|
+
self.header_complete = True
|
|
243
|
+
chunk = remainder
|
|
244
|
+
content = self.tail + chunk
|
|
245
|
+
if content:
|
|
246
|
+
self.stream.write(content[:-BATCH_TERMINATOR_BYTES])
|
|
247
|
+
self.tail = content[-BATCH_TERMINATOR_BYTES:]
|
|
248
|
+
|
|
249
|
+
def finish(self) -> None:
|
|
250
|
+
"""Require the terminal NUL only after Git has exited successfully and stdout reached EOF."""
|
|
251
|
+
if not self.header_complete or self.tail != b"\0":
|
|
252
|
+
raise ValueError("Git returned an incomplete filtered blob stream")
|
|
253
|
+
self.stream.flush()
|
|
254
|
+
os.fsync(self.stream.fileno())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def materialize_blob(oid: str, path: bytes, mode: str, destination_filename: str, symlinks: bool = True) -> Generator[GitCommand, bytes, FileImage]:
|
|
258
|
+
"""Stream Git's actual working representation into an owned file outside the working tree."""
|
|
259
|
+
if mode == "160000":
|
|
260
|
+
raise ValueError("A submodule cannot be materialized as a file")
|
|
261
|
+
if mode not in ("100644", "100755", "120000"):
|
|
262
|
+
raise ValueError("Unsupported Git file mode")
|
|
263
|
+
with open(destination_filename, "xb") as stream:
|
|
264
|
+
os.chmod(destination_filename, PRIVATE_FILE_MODE)
|
|
265
|
+
if mode == "120000":
|
|
266
|
+
yield GitCommand(["cat-file", "blob", oid], stdout_limit_bytes=None, consume=stream.write,
|
|
267
|
+
timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS)
|
|
268
|
+
else:
|
|
269
|
+
consumer = FilteredBlobStream(stream, oid)
|
|
270
|
+
yield GitCommand(["cat-file", "--batch=%(objectname) %(objecttype)", "--filters", "-Z"], stdin=oid.encode("ascii") + b" ./" + path + b"\0",
|
|
271
|
+
stdout_limit_bytes=None, consume=consumer.consume, timeout_ms=GIT_MUTATION_TIMEOUT_MS, idle_timeout_ms=MUTATION_IDLE_TIMEOUT_MS, profile=LOCAL_WRITE_PROFILE)
|
|
272
|
+
consumer.finish()
|
|
273
|
+
stream.flush()
|
|
274
|
+
os.fsync(stream.fileno())
|
|
275
|
+
file_mode = stat.S_IFLNK if mode == "120000" and symlinks else stat.S_IFREG
|
|
276
|
+
permission_mode = 0o755 if mode == "100755" else REGULAR_FILE_MODE
|
|
277
|
+
return FileImage(True, file_mode | permission_mode, content_filename=destination_filename)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def write_image(filename: str, image: FileImage) -> None:
|
|
281
|
+
"""Create an owned publication candidate with the requested regular-file bytes and mode."""
|
|
282
|
+
if not image.exists:
|
|
283
|
+
return
|
|
284
|
+
if stat.S_ISLNK(image.mode):
|
|
285
|
+
os.symlink(os.fsdecode(image.read()), filename)
|
|
286
|
+
return
|
|
287
|
+
with open(filename, "xb") as stream:
|
|
288
|
+
os.chmod(filename, PRIVATE_FILE_MODE)
|
|
289
|
+
if image.content_filename is not None and image.content is None:
|
|
290
|
+
with open(image.content_filename, "rb") as source:
|
|
291
|
+
while chunk := source.read(PROCESS_CHUNK_BYTES):
|
|
292
|
+
stream.write(chunk)
|
|
293
|
+
else:
|
|
294
|
+
stream.write(image.read())
|
|
295
|
+
stream.flush()
|
|
296
|
+
os.fsync(stream.fileno())
|
|
297
|
+
os.chmod(filename, stat.S_IMODE(image.mode))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def creation_permission_mode(directory: str, executable: bool = False) -> int:
|
|
301
|
+
"""Observe kernel-granted creation permissions using an empty file, without changing umask."""
|
|
302
|
+
filename = os.path.join(directory, "creation-mode-" + uuid.uuid4().hex)
|
|
303
|
+
requested_mode = REGULAR_FILE_MODE | (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) if executable else REGULAR_FILE_MODE
|
|
304
|
+
descriptor = os.open(filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, requested_mode)
|
|
305
|
+
metadata: os.stat_result | None = None
|
|
306
|
+
try:
|
|
307
|
+
metadata = os.fstat(descriptor)
|
|
308
|
+
return stat.S_IMODE(metadata.st_mode)
|
|
309
|
+
finally:
|
|
310
|
+
os.close(descriptor)
|
|
311
|
+
current = os.lstat(filename)
|
|
312
|
+
if metadata is not None and (current.st_dev, current.st_ino) == (metadata.st_dev, metadata.st_ino):
|
|
313
|
+
os.unlink(filename)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def prepare_publication_image(filename: str, image: FileImage, before: FileImage) -> FileImage:
|
|
317
|
+
"""Keep content private while recording final permissions separately from the candidate's mode."""
|
|
318
|
+
if not image.exists or stat.S_ISLNK(image.mode):
|
|
319
|
+
write_image(filename, image)
|
|
320
|
+
return replace(image, mode=os.lstat(filename).st_mode) if image.exists else image
|
|
321
|
+
permission_mode = stat.S_IMODE(image.mode)
|
|
322
|
+
if not before.exists or not stat.S_ISREG(before.mode):
|
|
323
|
+
permission_mode = creation_permission_mode(os.path.dirname(filename), bool(image.mode & stat.S_IXUSR))
|
|
324
|
+
write_image(filename, replace(image, mode=stat.S_IFREG | PRIVATE_FILE_MODE))
|
|
325
|
+
# Windows chmod controls only the read-only flag, so the image digest must describe its actual file mode.
|
|
326
|
+
mode = stat.S_IFREG | permission_mode
|
|
327
|
+
if os.name == "nt":
|
|
328
|
+
write_mask = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
|
|
329
|
+
mode = stat.S_IFREG | (REGULAR_FILE_MODE if permission_mode & stat.S_IWUSR else REGULAR_FILE_MODE & ~write_mask)
|
|
330
|
+
return replace(image, mode=mode, publication_mode=permission_mode)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def publication_permission(image: FileImage) -> int | None:
|
|
334
|
+
"""Name the permissions publish_image_mode gives a candidate, or None when it leaves the file as it is.
|
|
335
|
+
|
|
336
|
+
A recovery journal records this before the change happens, so an interruption right after it is still recognizable.
|
|
337
|
+
"""
|
|
338
|
+
if not image.exists or stat.S_ISLNK(image.mode):
|
|
339
|
+
return None
|
|
340
|
+
return image.publication_mode if image.publication_mode is not None else stat.S_IMODE(image.mode)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def publish_image_mode(filename: str, image: FileImage) -> None:
|
|
344
|
+
"""Apply accepted permissions only at publication, leaving symbolic links themselves untouched."""
|
|
345
|
+
permission = publication_permission(image)
|
|
346
|
+
if permission is not None:
|
|
347
|
+
os.chmod(filename, permission)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def respell_single_entry(context: RepositoryContext, spelled_path: bytes, wanted_path: bytes) -> None:
|
|
351
|
+
"""Give one directory entry the spelling Git expects when the filesystem finds it under another letter case or Unicode form.
|
|
352
|
+
|
|
353
|
+
Replacing a file through another spelling of its name keeps the spelling already on disk on macOS, so the Reset of
|
|
354
|
+
a case-only rename would leave the restored file under the renamed spelling. Renaming the entry onto itself under
|
|
355
|
+
the wanted spelling changes only its name. Both names must still lead to the same single-link file: anything else
|
|
356
|
+
is a second file, which is left untouched rather than replaced.
|
|
357
|
+
"""
|
|
358
|
+
if spelled_path == wanted_path:
|
|
359
|
+
return
|
|
360
|
+
spelled_filename, _parents = validate_destination(context, spelled_path)
|
|
361
|
+
wanted_filename, _parents = validate_destination(context, wanted_path)
|
|
362
|
+
spelled_metadata = os.lstat(spelled_filename)
|
|
363
|
+
wanted_metadata = os.lstat(wanted_filename)
|
|
364
|
+
if (spelled_metadata.st_dev, spelled_metadata.st_ino) != (wanted_metadata.st_dev, wanted_metadata.st_ino) or wanted_metadata.st_nlink != SINGLE_LINK_COUNT:
|
|
365
|
+
return
|
|
366
|
+
os.replace(spelled_filename, wanted_filename)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def owned_inventory(directory: str) -> list[tuple[str, tuple[int, ...]]]:
|
|
370
|
+
"""Record private files and directories without following any symbolic links."""
|
|
371
|
+
owned_files: list[tuple[str, tuple[int, ...]]] = []
|
|
372
|
+
with os.scandir(directory) as iterator:
|
|
373
|
+
entries = sorted(iterator, key=lambda entry: entry.name)
|
|
374
|
+
for entry in entries:
|
|
375
|
+
if entry.name in ("record.json", "record.next"):
|
|
376
|
+
continue
|
|
377
|
+
if entry.is_dir(follow_symlinks=False) and not os.path.isjunction(entry.path):
|
|
378
|
+
owned_files.extend(owned_inventory(entry.path))
|
|
379
|
+
owned_files.append((entry.path, stat_identity(os.lstat(entry.path))))
|
|
380
|
+
return owned_files
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def clean_owned(directory: str, owned_files: list[tuple[str, tuple[int, ...]]]) -> None:
|
|
384
|
+
"""Delete only inventoried private artifacts whose paths and filesystem identities still agree."""
|
|
385
|
+
root_dir = os.path.normcase(os.path.abspath(directory))
|
|
386
|
+
for filename, expected in owned_files:
|
|
387
|
+
normalized_filename = os.path.normcase(os.path.abspath(filename))
|
|
388
|
+
if os.path.commonpath((root_dir, normalized_filename)) != root_dir or normalized_filename == root_dir:
|
|
389
|
+
raise ValueError("Recovery cleanup path escapes its private directory")
|
|
390
|
+
parent_dir = os.path.dirname(filename)
|
|
391
|
+
while os.path.normcase(os.path.abspath(parent_dir)) != root_dir:
|
|
392
|
+
if os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
|
|
393
|
+
raise ValueError("Recovery cleanup path traverses a replaced directory")
|
|
394
|
+
parent_dir = os.path.dirname(parent_dir)
|
|
395
|
+
if not os.path.lexists(filename):
|
|
396
|
+
continue
|
|
397
|
+
metadata = os.lstat(filename)
|
|
398
|
+
actual = stat_identity(metadata)
|
|
399
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
400
|
+
if actual[:FILE_OBJECT_IDENTITY_FIELDS] != tuple(expected)[:FILE_OBJECT_IDENTITY_FIELDS] or os.path.isjunction(filename):
|
|
401
|
+
raise ValueError("An owned directory changed; cleanup refused")
|
|
402
|
+
os.rmdir(filename)
|
|
403
|
+
else:
|
|
404
|
+
if actual != tuple(expected):
|
|
405
|
+
raise ValueError("An owned file changed; cleanup refused")
|
|
406
|
+
os.unlink(filename)
|