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,266 @@
|
|
|
1
|
+
"""Append literal exclusion rules without changing any byte already in the ignore file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING, Self
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
13
|
+
|
|
14
|
+
from gitpane.config import IGNORE_RULE_LIMIT_BYTES
|
|
15
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
16
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
17
|
+
from gitpane.operation.repository_operation import OperationAction
|
|
18
|
+
from gitpane.system.path_identity import canonical_path
|
|
19
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError
|
|
20
|
+
from gitpane.worktree.worktree_file import FILE_STABLE_IDENTITY_FIELDS, PRIVATE_FILE_MODE, FileImage, creation_permission_mode, stat_identity, validate_destination
|
|
21
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, read_validated_size
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Generator
|
|
25
|
+
|
|
26
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
27
|
+
from gitpane.worktree.mutation import MutationService
|
|
28
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
29
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeKey, WorktreeSnapshot
|
|
30
|
+
|
|
31
|
+
CHECK_IGNORE_RECORD_FIELDS = 4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IgnoreDestination(Enum):
|
|
35
|
+
"""The file an ignore rule is added to, with the label of its menu entry."""
|
|
36
|
+
|
|
37
|
+
WORKTREE = ("worktree", "Add file to .gitignore")
|
|
38
|
+
EXCLUDE = ("exclude", "Add file to .git/info/exclude")
|
|
39
|
+
|
|
40
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
41
|
+
"""Keep the name of the destination as the value, and its menu label beside it."""
|
|
42
|
+
member = object.__new__(cls)
|
|
43
|
+
member._value_ = value
|
|
44
|
+
member.label = label
|
|
45
|
+
return member
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class IgnoreOutcome:
|
|
50
|
+
"""Retain publication and effective-pattern results even after the initiating view closes."""
|
|
51
|
+
|
|
52
|
+
key: WorktreeKey
|
|
53
|
+
status: OperationStatus
|
|
54
|
+
destination_filename: str
|
|
55
|
+
added_paths: tuple[bytes, ...] = ()
|
|
56
|
+
existing_paths: tuple[bytes, ...] = ()
|
|
57
|
+
overridden_paths: tuple[bytes, ...] = ()
|
|
58
|
+
diagnostic: PreparedDiagnostic | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def literal_rule(path: bytes) -> bytes:
|
|
62
|
+
"""Anchor one byte path at the repository root, escaping Git's pattern grammar."""
|
|
63
|
+
if not path or any(character in path for character in (b"\r", b"\n", b"\0")):
|
|
64
|
+
raise ValueError("Ignore rules cannot represent paths containing CR, LF or NUL")
|
|
65
|
+
if any(part in (b"", b".", b"..") for part in path.split(b"/")):
|
|
66
|
+
raise ValueError("Ignore rules require a relative Git file path")
|
|
67
|
+
escaped = bytearray(b"/")
|
|
68
|
+
for character in path:
|
|
69
|
+
if character in b"!#\\*?[] ":
|
|
70
|
+
escaped.extend(b"\\")
|
|
71
|
+
escaped.append(character)
|
|
72
|
+
return bytes(escaped)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def capture_rule_image(filename: str) -> FileImage:
|
|
76
|
+
"""Capture a service-authorized regular rule file and every parent without following links."""
|
|
77
|
+
parent_dir = os.path.dirname(filename)
|
|
78
|
+
parents: list[tuple[str, tuple[int, int]]] = []
|
|
79
|
+
while True:
|
|
80
|
+
metadata = os.lstat(parent_dir)
|
|
81
|
+
if not stat.S_ISDIR(metadata.st_mode) or os.path.islink(parent_dir) or os.path.isjunction(parent_dir):
|
|
82
|
+
raise ValueError("The ignore destination traverses a linked or invalid directory")
|
|
83
|
+
parents.append((parent_dir, (metadata.st_dev, metadata.st_ino)))
|
|
84
|
+
ancestor_dir = os.path.dirname(parent_dir)
|
|
85
|
+
if ancestor_dir == parent_dir:
|
|
86
|
+
break
|
|
87
|
+
parent_dir = ancestor_dir
|
|
88
|
+
try:
|
|
89
|
+
metadata = os.lstat(filename)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return FileImage(False, 0, parents=tuple(parents))
|
|
92
|
+
if not stat.S_ISREG(metadata.st_mode) or os.path.isjunction(filename):
|
|
93
|
+
raise ValueError("The ignore destination must be a regular file")
|
|
94
|
+
with open(filename, "rb") as stream:
|
|
95
|
+
opened = os.fstat(stream.fileno())
|
|
96
|
+
if stat_identity(opened)[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(metadata)[:FILE_STABLE_IDENTITY_FIELDS]:
|
|
97
|
+
raise ValueError("Ignore rules changed while reading")
|
|
98
|
+
if opened.st_size > IGNORE_RULE_LIMIT_BYTES:
|
|
99
|
+
raise ValueError("The ignore file exceeds the supported read limit")
|
|
100
|
+
content = read_validated_size(stream, opened.st_size)
|
|
101
|
+
if stat_identity(os.fstat(stream.fileno()))[:FILE_STABLE_IDENTITY_FIELDS] != stat_identity(opened)[:FILE_STABLE_IDENTITY_FIELDS]:
|
|
102
|
+
raise ValueError("Ignore rules changed while reading")
|
|
103
|
+
if len(content) != opened.st_size:
|
|
104
|
+
raise ValueError("Ignore rules changed while reading")
|
|
105
|
+
if stat_identity(os.lstat(filename)) != stat_identity(metadata):
|
|
106
|
+
raise ValueError("Ignore rules changed while reading")
|
|
107
|
+
return FileImage(True, metadata.st_mode, content=content, identity=stat_identity(metadata), parents=tuple(parents))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def publish_rule_image(filename: str, before: FileImage, content: bytes) -> None:
|
|
111
|
+
"""Publish under an owned lock only while bytes, type and parent identities still agree."""
|
|
112
|
+
if capture_rule_image(filename) != before:
|
|
113
|
+
raise ValueError("Ignore rules or their parent changed; refresh and try again")
|
|
114
|
+
permission_mode = stat.S_IMODE(before.mode) if before.exists else creation_permission_mode(os.path.dirname(filename))
|
|
115
|
+
lock_filename = filename + ".lock"
|
|
116
|
+
descriptor = os.open(lock_filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, PRIVATE_FILE_MODE)
|
|
117
|
+
lock_metadata = os.fstat(descriptor)
|
|
118
|
+
lock_identity = (lock_metadata.st_dev, lock_metadata.st_ino)
|
|
119
|
+
try:
|
|
120
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
121
|
+
stream.write(content)
|
|
122
|
+
stream.flush()
|
|
123
|
+
os.fsync(stream.fileno())
|
|
124
|
+
if capture_rule_image(filename) != before:
|
|
125
|
+
raise ValueError("Ignore rules or their parent changed; refresh and try again")
|
|
126
|
+
if before.exists and not before.mode & stat.S_IWUSR:
|
|
127
|
+
raise PermissionError("The ignore file is read-only")
|
|
128
|
+
metadata = os.lstat(lock_filename)
|
|
129
|
+
if (metadata.st_dev, metadata.st_ino) != lock_identity:
|
|
130
|
+
raise ValueError("The ignore publication lock changed externally")
|
|
131
|
+
os.chmod(lock_filename, permission_mode)
|
|
132
|
+
if before.exists:
|
|
133
|
+
os.replace(lock_filename, filename)
|
|
134
|
+
else:
|
|
135
|
+
# A hard link prevents overwriting a rule file created since the absence check.
|
|
136
|
+
os.link(lock_filename, filename)
|
|
137
|
+
finally:
|
|
138
|
+
if os.path.lexists(lock_filename):
|
|
139
|
+
metadata = os.lstat(lock_filename)
|
|
140
|
+
if (metadata.st_dev, metadata.st_ino) == lock_identity:
|
|
141
|
+
os.unlink(lock_filename)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IgnoreRuleService(QObject):
|
|
145
|
+
"""Reserve each worktree and canonical rule file across all injected session services."""
|
|
146
|
+
|
|
147
|
+
finished = Signal(object)
|
|
148
|
+
changed = Signal(object)
|
|
149
|
+
|
|
150
|
+
def __init__(self, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None) -> None:
|
|
151
|
+
"""Share write reservations and asynchronous filesystem work with the existing services."""
|
|
152
|
+
super().__init__(parent)
|
|
153
|
+
self.worktree_service = worktree_service
|
|
154
|
+
self.mutation = mutation
|
|
155
|
+
self.active = False
|
|
156
|
+
|
|
157
|
+
def submit(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], destination: IgnoreDestination) -> None:
|
|
158
|
+
"""Capture selected paths and report a terminal result for every submission.
|
|
159
|
+
|
|
160
|
+
A refused reservation writes no rule and gives the real reason, worked out right away so it describes the state
|
|
161
|
+
that refused it.
|
|
162
|
+
"""
|
|
163
|
+
operation_id = uuid.uuid4().hex
|
|
164
|
+
if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
|
|
165
|
+
cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
|
|
166
|
+
diagnostic = Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare()
|
|
167
|
+
QTimer.singleShot(0, lambda: self.finished.emit(IgnoreOutcome(snapshot.key, OperationStatus.REFUSED, "", diagnostic=diagnostic)))
|
|
168
|
+
return
|
|
169
|
+
self.active = True
|
|
170
|
+
|
|
171
|
+
def complete(outcome: IgnoreOutcome | None, error: Exception | None) -> None:
|
|
172
|
+
"""Release reservations before notifying all affected views to reread."""
|
|
173
|
+
self.mutation.release(snapshot.key, operation_id)
|
|
174
|
+
self.active = False
|
|
175
|
+
if error is not None:
|
|
176
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
177
|
+
outcome = IgnoreOutcome(snapshot.key, OperationStatus.REFUSED, "", diagnostic=diagnostic)
|
|
178
|
+
self.finished.emit(outcome)
|
|
179
|
+
self.changed.emit(snapshot.context)
|
|
180
|
+
|
|
181
|
+
self.worktree_service.run(snapshot.context, snapshot.git_filename, self._append(snapshot, entries, destination), complete, operation_id=operation_id)
|
|
182
|
+
|
|
183
|
+
def _append(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], destination: IgnoreDestination) -> Generator:
|
|
184
|
+
"""Resolve the single authorized administrative exception and append without normalizing old rules.
|
|
185
|
+
|
|
186
|
+
Only a member names a destination: the text of a destination is refused like an unknown one.
|
|
187
|
+
"""
|
|
188
|
+
if not isinstance(destination, IgnoreDestination) or not snapshot.fresh or not entries:
|
|
189
|
+
raise ValueError("Choose current Unstaged files and a supported ignore destination")
|
|
190
|
+
paths: list[bytes] = []
|
|
191
|
+
rules: list[bytes] = []
|
|
192
|
+
for entry in entries:
|
|
193
|
+
if entry not in snapshot.entries or entry.side != StagingSide.UNSTAGED or entry.conflict_stages or entry.skip_worktree or entry.old_mode == "160000":
|
|
194
|
+
raise ValueError("Choose current ordinary Unstaged files")
|
|
195
|
+
validate_destination(snapshot.context, entry.path, allow_missing_parents=True)
|
|
196
|
+
if entry.path not in paths:
|
|
197
|
+
paths.append(entry.path)
|
|
198
|
+
rules.append(literal_rule(entry.path))
|
|
199
|
+
if destination == IgnoreDestination.EXCLUDE:
|
|
200
|
+
output = yield GitCommand(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"])
|
|
201
|
+
filename = os.fsdecode(output.removesuffix(b"\n").removesuffix(b"\r"))
|
|
202
|
+
if not os.path.isabs(filename):
|
|
203
|
+
raise ValueError("Git did not resolve an absolute exclude file")
|
|
204
|
+
else:
|
|
205
|
+
filename, _parents = validate_destination(snapshot.context, b".gitignore")
|
|
206
|
+
resource = canonical_path(filename)
|
|
207
|
+
if resource in self.mutation.active_rule_filename_set:
|
|
208
|
+
raise ValueError("This ignore file is being edited in another worktree")
|
|
209
|
+
self.mutation.active_rule_filename_set.add(resource)
|
|
210
|
+
added_paths: list[bytes] = []
|
|
211
|
+
existing_paths: list[bytes] = []
|
|
212
|
+
published = False
|
|
213
|
+
try:
|
|
214
|
+
before = yield FileTask(lambda: capture_rule_image(filename))
|
|
215
|
+
content = before.read()
|
|
216
|
+
newline = b"\r\n" if b"\r\n" in content else b"\n"
|
|
217
|
+
existing_rule_set: set[bytes] = set()
|
|
218
|
+
for line in content.split(b"\n"):
|
|
219
|
+
existing_rule_set.add(line.removesuffix(b"\r"))
|
|
220
|
+
for path, rule in zip(paths, rules, strict=True):
|
|
221
|
+
if rule in existing_rule_set:
|
|
222
|
+
existing_paths.append(path)
|
|
223
|
+
continue
|
|
224
|
+
if content and not content.endswith(b"\n"):
|
|
225
|
+
content += newline
|
|
226
|
+
content += rule + newline
|
|
227
|
+
added_paths.append(path)
|
|
228
|
+
existing_rule_set.add(rule)
|
|
229
|
+
if len(content) > IGNORE_RULE_LIMIT_BYTES:
|
|
230
|
+
raise ValueError("The resulting ignore file exceeds the supported read limit")
|
|
231
|
+
# Git compares HEAD, index, configuration, attributes and the chosen files with the selection; the rule
|
|
232
|
+
# file itself is compared with its capture again under the publication lock.
|
|
233
|
+
yield from self.worktree_service.verify(snapshot, tuple(paths))
|
|
234
|
+
if added_paths:
|
|
235
|
+
def publish() -> None:
|
|
236
|
+
"""Check native authority in the worker immediately before saving rules."""
|
|
237
|
+
self.mutation.validate_write(snapshot.key)
|
|
238
|
+
publish_rule_image(filename, before, content)
|
|
239
|
+
|
|
240
|
+
yield FileTask(publish)
|
|
241
|
+
published = True
|
|
242
|
+
# check-ignore reads literal NUL paths itself and rejects the runner's pathspec-magic option.
|
|
243
|
+
output = yield GitCommand(["--no-literal-pathspecs", "check-ignore", "--no-index", "-z", "--stdin", "--verbose", "--non-matching"],
|
|
244
|
+
stdin=b"\0".join(paths) + b"\0", accepted_codes=(0, 1))
|
|
245
|
+
fields = output.split(b"\0")
|
|
246
|
+
if fields.pop() != b"" or len(fields) != len(paths) * CHECK_IGNORE_RECORD_FIELDS:
|
|
247
|
+
raise ValueError("Git returned incomplete ignore verification")
|
|
248
|
+
overridden_paths: list[bytes] = []
|
|
249
|
+
for offset in range(0, len(fields), CHECK_IGNORE_RECORD_FIELDS):
|
|
250
|
+
_source, _line, pattern, path = fields[offset:offset + CHECK_IGNORE_RECORD_FIELDS]
|
|
251
|
+
if path not in paths:
|
|
252
|
+
raise ValueError("Git verified a different ignore path")
|
|
253
|
+
if not pattern or pattern.startswith(b"!"):
|
|
254
|
+
overridden_paths.append(path)
|
|
255
|
+
cause = ""
|
|
256
|
+
if overridden_paths:
|
|
257
|
+
cause = "Rules saved, but another ignore rule overrides them: " + ", ".join(os.fsdecode(path) for path in overridden_paths)
|
|
258
|
+
elif any(entry.old_blob_oid is not None for entry in entries):
|
|
259
|
+
cause = "Rules saved. Already tracked files remain tracked; use Stop tracking this file separately if intended."
|
|
260
|
+
diagnostic = Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], cause).prepare() if cause else None
|
|
261
|
+
return IgnoreOutcome(snapshot.key, OperationStatus.SUCCESS, filename, tuple(added_paths), tuple(existing_paths), tuple(overridden_paths), diagnostic)
|
|
262
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
263
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic("Add ignore rule", snapshot.context.repository_dir, snapshot.git_filename, [], str(error)).prepare()
|
|
264
|
+
return IgnoreOutcome(snapshot.key, OperationStatus.UNKNOWN if published else OperationStatus.REFUSED, filename, tuple(added_paths) if published else (), tuple(existing_paths), diagnostic=diagnostic)
|
|
265
|
+
finally:
|
|
266
|
+
self.mutation.active_rule_filename_set.discard(resource)
|