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,830 @@
|
|
|
1
|
+
"""Create native Git commits, reporting success only once their actual effects are checked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
14
|
+
|
|
15
|
+
from gitpane.config import COMMIT_IDLE_TIMEOUT_MS, DETACHED_HEAD_NAME, GIT_COMMIT_TIMEOUT_MS, REFERENCE_ROOT_PREFIX, SENSITIVE_VALUE_MIN_LENGTH, SUBMODULE_RECURSION_OFF_ARGUMENTS, SharedText
|
|
16
|
+
from gitpane.history.history_parse import parse_commit
|
|
17
|
+
from gitpane.history.repository_value import validate_oid
|
|
18
|
+
from gitpane.operation.diagnostic import MASKED_VALUE, Diagnostic
|
|
19
|
+
from gitpane.operation.launch_signature import LaunchSignature, sign_launch_paths, verify_launch_signature, worktree_location_paths
|
|
20
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
21
|
+
from gitpane.operation.repository_operation import GITPANE_CLOSING_TEXT, OperationAction, OperationIntent
|
|
22
|
+
from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, READ_PROFILE, ProcessRequest, ProcessResult, ProcessStatus
|
|
23
|
+
from gitpane.sequencer.conflict import conflict_path_label
|
|
24
|
+
from gitpane.sequencer.merge_state import read_merge_in_progress
|
|
25
|
+
from gitpane.text.typed_text import typed_value
|
|
26
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand, GitCommandError, ReadBatch
|
|
27
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, read_fingerprints, read_index
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Generator
|
|
31
|
+
|
|
32
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
33
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
34
|
+
from gitpane.process.process_runner import ProcessRunner
|
|
35
|
+
from gitpane.sequencer.rebase_state import NativeMetadata
|
|
36
|
+
from gitpane.worktree.mutation import MutationService
|
|
37
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
38
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
39
|
+
|
|
40
|
+
HOOK_SAMPLE_SUFFIX = ".sample"
|
|
41
|
+
POST_COMMIT_HOOK_BASENAME = "post-commit"
|
|
42
|
+
PRE_COMMIT_HOOK_BASENAME = "pre-commit"
|
|
43
|
+
# Git for Windows also runs a hook saved as a program with this suffix.
|
|
44
|
+
WINDOWS_PROGRAM_SUFFIX = ".exe"
|
|
45
|
+
# Windows has no executable permission, so Git for Windows runs any hook file that exists; elsewhere Git skips a hook
|
|
46
|
+
# file without it.
|
|
47
|
+
HOOK_EXECUTABLE_BIT_USED = os.name != "nt"
|
|
48
|
+
# The neutral notice a commit leaves in the repository windows when files changed during it without any active commit hook.
|
|
49
|
+
COMMIT_CREATED_NOTICE_PREFIX = "Commit created. "
|
|
50
|
+
CHANGED_AFTER_COMMIT_NOTICE_TEMPLATE = "{subject} changed right after the commit; another program may have modified {pronoun}."
|
|
51
|
+
INDEX_NOTICE_SUBJECT = "The index"
|
|
52
|
+
COUNTED_FILES_NOTICE_SUBJECT_TEMPLATE = "{count} files"
|
|
53
|
+
SINGLE_NOTICE_PRONOUN = "it"
|
|
54
|
+
PLURAL_NOTICE_PRONOUN = "them"
|
|
55
|
+
# Beyond this many files, the notice counts them instead of naming them, so it stays one line.
|
|
56
|
+
NOTICE_NAMED_FILE_LIMIT = 3
|
|
57
|
+
# The grouped HEAD read ends with the branch HEAD names, or with the detached HEAD word. That read gives bytes, so the
|
|
58
|
+
# two values are encoded from their one text form instead of being written again here.
|
|
59
|
+
DETACHED_HEAD_NAME_BYTES = DETACHED_HEAD_NAME.encode("ascii")
|
|
60
|
+
REFERENCE_ROOT_PREFIX_BYTES = REFERENCE_ROOT_PREFIX.encode("ascii")
|
|
61
|
+
# The lines of the grouped read: HEAD, its tree, one line per parent (none for a root commit), then the branch line.
|
|
62
|
+
GROUPED_HEAD_MINIMUM_LINE_COUNT = 3
|
|
63
|
+
HEAD_DESCRIPTION_ERROR = "Git described HEAD in an unexpected form"
|
|
64
|
+
COMMIT_LAUNCH_CHANGED_CAUSE = "HEAD, references, configuration or the index changed before commit. Refresh before writing."
|
|
65
|
+
COMMIT_LAUNCH_UNREADABLE_CAUSE = "The files checked before commit could not be read again. Refresh before writing."
|
|
66
|
+
# The plain first sentences of a commit result that is not the requested commit; the object IDs and change flags go to
|
|
67
|
+
# the details only.
|
|
68
|
+
COMMIT_CHANGED_ELSEWHERE_CAUSE = "Commit created, but it also contains changes made by a hook or another program."
|
|
69
|
+
# The plain first sentence of a commit Git did not create while files, the index or the branch changed: it says who
|
|
70
|
+
# refused it when an active pre-commit hook did, and what changed. Files are named when few, otherwise counted.
|
|
71
|
+
PRE_COMMIT_HOOK_REFUSED_NAMED_FILES_TEMPLATE = "Commit refused by your pre-commit hook. It also changed: {subject}."
|
|
72
|
+
PRE_COMMIT_HOOK_REFUSED_TEMPLATE = "Commit refused by your pre-commit hook. It also changed {subject}."
|
|
73
|
+
COMMIT_FAILED_NAMED_FILES_TEMPLATE = "Commit failed, and files changed: {subject}. Click {details} for full information."
|
|
74
|
+
COMMIT_FAILED_TEMPLATE = "Commit failed, and {subject} changed. Click {details} for full information."
|
|
75
|
+
NO_COMMIT_INDEX_SUBJECT = "the index"
|
|
76
|
+
NO_COMMIT_BRANCH_SUBJECT = "the current branch"
|
|
77
|
+
NO_COMMIT_FOUND_CAUSE = "Git reported success, but no new commit was found."
|
|
78
|
+
SUCCESS_WITH_CHANGES_LOG_CAUSE = "Commit created as requested; files or the index changed around it."
|
|
79
|
+
PRE_COMMIT_HOOK_CONTENT_LOG_CAUSE = "Commit created with files an active pre-commit hook added or changed."
|
|
80
|
+
REVIEW_BEFORE_RETRY_TEXT = "Review the changes before retrying."
|
|
81
|
+
GIT_CAUSE_DETAIL_PREFIX = "Git: "
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class CommitHeadFacts:
|
|
86
|
+
"""HEAD, its tree, its parents in their order and the branch HEAD names, as read together by Git.
|
|
87
|
+
|
|
88
|
+
The branch keeps the exact bytes symbolic-ref prints, line end included; a detached HEAD has empty bytes. Without
|
|
89
|
+
any commit, HEAD has no object ID, the tree is empty and there are no parents.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
head_oid: str | None
|
|
93
|
+
tree_oid: str
|
|
94
|
+
parent_oids: tuple[str, ...]
|
|
95
|
+
ref_target: bytes
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_commit_head(output: bytes) -> CommitHeadFacts:
|
|
99
|
+
"""Split the grouped HEAD read by position, accepting only full object IDs and a branch name or the detached marker.
|
|
100
|
+
|
|
101
|
+
Every line but the last is an object ID, so the last line alone can be the branch; an answer where it is not one
|
|
102
|
+
is refused rather than guessed, and the caller then reads each fact separately.
|
|
103
|
+
"""
|
|
104
|
+
lines = output.removesuffix(b"\n").split(b"\n")
|
|
105
|
+
if len(lines) < GROUPED_HEAD_MINIMUM_LINE_COUNT:
|
|
106
|
+
raise ValueError(HEAD_DESCRIPTION_ERROR)
|
|
107
|
+
head_line, tree_line, *parent_lines, reference_line = lines
|
|
108
|
+
try:
|
|
109
|
+
head_oid = validate_oid(head_line.decode("ascii"))
|
|
110
|
+
tree_oid = validate_oid(tree_line.decode("ascii"))
|
|
111
|
+
parent_oids = []
|
|
112
|
+
for parent_line in parent_lines:
|
|
113
|
+
parent_oids.append(validate_oid(parent_line.decode("ascii")))
|
|
114
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
115
|
+
raise ValueError(HEAD_DESCRIPTION_ERROR) from error
|
|
116
|
+
if reference_line == DETACHED_HEAD_NAME_BYTES:
|
|
117
|
+
ref_target = b""
|
|
118
|
+
elif reference_line.startswith(REFERENCE_ROOT_PREFIX_BYTES):
|
|
119
|
+
# Restore the line end symbolic-ref prints, so both reads give the same bytes for the same branch.
|
|
120
|
+
ref_target = reference_line + b"\n"
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(HEAD_DESCRIPTION_ERROR)
|
|
123
|
+
return CommitHeadFacts(head_oid, tree_oid, tuple(parent_oids), ref_target)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_commit_head() -> Generator[GitCommand | ReadBatch, object, CommitHeadFacts]:
|
|
127
|
+
"""Read HEAD, its tree, its parents and its branch with one Git process on the normal path.
|
|
128
|
+
|
|
129
|
+
When Git refuses the grouped read, as it does without any commit, or answers in a form that cannot be split with
|
|
130
|
+
certainty, the facts are read again separately. A read that timed out, was cancelled or could not start stays an
|
|
131
|
+
error: its partial output is never taken for an answer.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
output = yield GitCommand(["rev-parse", "HEAD", "HEAD^{tree}", "HEAD^@", "--symbolic-full-name", "HEAD"])
|
|
135
|
+
except GitCommandError as error:
|
|
136
|
+
# Only a failed process ran to its end and refused: timeouts, cancellations and failed starts say nothing about
|
|
137
|
+
# the repository and must never be answered by another read.
|
|
138
|
+
if error.process_status != ProcessStatus.FAILED:
|
|
139
|
+
raise
|
|
140
|
+
return (yield from _read_commit_head_separately())
|
|
141
|
+
try:
|
|
142
|
+
return parse_commit_head(output)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return (yield from _read_commit_head_separately())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_commit_head_separately() -> Generator[GitCommand | ReadBatch, object, CommitHeadFacts]:
|
|
148
|
+
"""Verify HEAD on its own, read its tree and parents by that object ID, and read the branch HEAD names.
|
|
149
|
+
|
|
150
|
+
A HEAD without a commit is accepted only when HEAD names a branch, which is the only way Git can have no commit
|
|
151
|
+
yet: a detached HEAD always points at an object, so an empty answer for both is an error.
|
|
152
|
+
"""
|
|
153
|
+
head_output, ref_target = yield ReadBatch((GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1)),
|
|
154
|
+
GitCommand(["symbolic-ref", "--quiet", "HEAD"], accepted_codes=(0, 1))))
|
|
155
|
+
head_text = head_output.strip().decode("ascii", "replace")
|
|
156
|
+
if not head_text:
|
|
157
|
+
if not ref_target:
|
|
158
|
+
raise ValueError("HEAD names neither a commit nor a branch; Refresh to inspect the repository")
|
|
159
|
+
return CommitHeadFacts(None, "", (), ref_target)
|
|
160
|
+
head_oid = validate_oid(head_text)
|
|
161
|
+
identity_output = yield GitCommand(["rev-parse", head_oid + "^{tree}", head_oid + "^@"])
|
|
162
|
+
tree_line, *parent_lines = identity_output.removesuffix(b"\n").split(b"\n")
|
|
163
|
+
tree_oid = validate_oid(tree_line.decode("ascii", "replace"))
|
|
164
|
+
parent_oids = []
|
|
165
|
+
for parent_line in parent_lines:
|
|
166
|
+
parent_oids.append(validate_oid(parent_line.decode("ascii", "replace")))
|
|
167
|
+
return CommitHeadFacts(head_oid, tree_oid, tuple(parent_oids), ref_target)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def reduced_merge_parent_oids(parent_oids: tuple[str, ...]) -> Generator[GitCommand, object, tuple[str, ...]]:
|
|
171
|
+
"""Answer the parents Git records when it concludes a merge that may drop redundant ones, in Git's order.
|
|
172
|
+
|
|
173
|
+
Without --no-ff, git commit removes every parent that another parent already contains, and duplicates, keeping the
|
|
174
|
+
others in their order. Git lists the parents to keep; their order is taken from ``parent_oids``, never from its output.
|
|
175
|
+
"""
|
|
176
|
+
output = yield GitCommand(["merge-base", "--independent", *parent_oids], profile=READ_PROFILE)
|
|
177
|
+
independent_set = set()
|
|
178
|
+
for line in output.decode("ascii").split():
|
|
179
|
+
independent_set.add(validate_oid(line))
|
|
180
|
+
reduced_oids = []
|
|
181
|
+
for oid in parent_oids:
|
|
182
|
+
if oid in independent_set and oid not in reduced_oids:
|
|
183
|
+
reduced_oids.append(oid)
|
|
184
|
+
return tuple(reduced_oids)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def capture_commit_launch_signature(context: RepositoryContext, index_filename: str) -> LaunchSignature:
|
|
188
|
+
"""Sign the index, every file and folder the reference signature covers, and the files and folders that name this worktree.
|
|
189
|
+
|
|
190
|
+
The reference files include HEAD, the branches, packed and table references and the configuration of every
|
|
191
|
+
related worktree. The Git directories, the index folder and the worktree root are containing folders, compared
|
|
192
|
+
by identity only; a linked worktree's pointer files are signed like any other file.
|
|
193
|
+
"""
|
|
194
|
+
from gitpane.reference.reference_edit import reference_signature_paths
|
|
195
|
+
|
|
196
|
+
reference_filenames, watched_dirs = reference_signature_paths(context)
|
|
197
|
+
location_filenames, location_parent_dirs = worktree_location_paths(context)
|
|
198
|
+
filenames = [*reference_filenames, index_filename, *location_filenames]
|
|
199
|
+
parent_dirs = [os.path.dirname(index_filename), *location_parent_dirs]
|
|
200
|
+
return sign_launch_paths(filenames, watched_dirs, parent_dirs)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def prove_commit_launch(context: RepositoryContext, index_filename: str, expected_index_content: bytes | None, expected_reference_signature: tuple) -> LaunchSignature:
|
|
204
|
+
"""Compare the index and reference contents with the checked ones between two signatures of the same paths.
|
|
205
|
+
|
|
206
|
+
The reference contents are compared with the signature taken before the checks started, so a change made by
|
|
207
|
+
another program meanwhile is refused instead of adopted. The signature handed back describes exactly the state
|
|
208
|
+
these comparisons accepted; it is the only thing the launch callback checks again.
|
|
209
|
+
"""
|
|
210
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
211
|
+
|
|
212
|
+
before = capture_commit_launch_signature(context, index_filename)
|
|
213
|
+
if read_index(index_filename) != expected_index_content:
|
|
214
|
+
raise ValueError("The index changed before commit. Refresh before writing.")
|
|
215
|
+
if reference_signature(context) != expected_reference_signature:
|
|
216
|
+
raise ValueError("HEAD or repository references changed before commit. Refresh before writing.")
|
|
217
|
+
after = capture_commit_launch_signature(context, index_filename)
|
|
218
|
+
if after != before:
|
|
219
|
+
raise ValueError("HEAD, references, configuration or the index changed while the commit was checked. Refresh before writing.")
|
|
220
|
+
return after
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def commit_hook_exists(hooks_dir: str) -> bool:
|
|
224
|
+
"""Tell whether the hooks directory Git resolved for a worktree holds anything that could run during a commit.
|
|
225
|
+
|
|
226
|
+
Every file except Git's own samples counts, whatever its name or mode: Git runs more hooks than the commit ones,
|
|
227
|
+
and a helper could live under any name. Only a path that is not a directory, or a complete listing without such a
|
|
228
|
+
file, answers False; any error raised while inspecting it answers True, so a doubt keeps the full byte checks.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
if not os.path.isdir(hooks_dir):
|
|
232
|
+
return False
|
|
233
|
+
with os.scandir(hooks_dir) as entries:
|
|
234
|
+
for entry in entries:
|
|
235
|
+
if entry.name.endswith(HOOK_SAMPLE_SUFFIX):
|
|
236
|
+
continue
|
|
237
|
+
if entry.is_file():
|
|
238
|
+
return True
|
|
239
|
+
except OSError:
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def git_hook_active(hooks_dir: str, hook_basename: str, *, executable_bit_used: bool = HOOK_EXECUTABLE_BIT_USED) -> bool:
|
|
245
|
+
"""Tell whether Git would run the hook with this name from the hooks directory it resolved for a worktree.
|
|
246
|
+
|
|
247
|
+
Git looks for a file with the hook's name and, on Linux and macOS, runs it only when it may be executed. Git for
|
|
248
|
+
Windows does not use that permission: the file, or the same name with .exe, only has to exist. A folder with
|
|
249
|
+
that name is not run, and a directory that cannot be read answers False: the commit result then only loses the
|
|
250
|
+
knowledge that the user's own hook explains a change.
|
|
251
|
+
"""
|
|
252
|
+
candidate_basenames = [hook_basename]
|
|
253
|
+
if not executable_bit_used:
|
|
254
|
+
candidate_basenames.append(hook_basename + WINDOWS_PROGRAM_SUFFIX)
|
|
255
|
+
for basename in candidate_basenames:
|
|
256
|
+
hook_filename = os.path.join(hooks_dir, basename)
|
|
257
|
+
if not os.path.isfile(hook_filename):
|
|
258
|
+
continue
|
|
259
|
+
if not executable_bit_used or os.access(hook_filename, os.X_OK):
|
|
260
|
+
return True
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def changed_working_paths(before: CommitState, after: CommitState) -> list[bytes]:
|
|
265
|
+
"""List, sorted, the files hashed at both commit boundaries whose bytes differ between them."""
|
|
266
|
+
paths = []
|
|
267
|
+
for path, fingerprint in before.working_by_path.items():
|
|
268
|
+
if after.working_by_path.get(path) != fingerprint:
|
|
269
|
+
paths.append(path)
|
|
270
|
+
return sorted(paths)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def noticed_paths(before: CommitState, after: CommitState) -> list[bytes]:
|
|
274
|
+
"""List, sorted, the files a commit notice names: changed bytes, and files Git lists after the commit whose earlier bytes are unknown.
|
|
275
|
+
|
|
276
|
+
A file hashed for the first time after the commit only because a hook appeared is not named: nothing says it changed.
|
|
277
|
+
"""
|
|
278
|
+
listed_path_set: set[bytes] = set()
|
|
279
|
+
for entry in after.snapshot.entries:
|
|
280
|
+
listed_path_set.update(entry.paths)
|
|
281
|
+
paths = changed_working_paths(before, after)
|
|
282
|
+
for path in after.working_by_path.keys() - before.working_by_path.keys():
|
|
283
|
+
if path in listed_path_set:
|
|
284
|
+
paths.append(path)
|
|
285
|
+
return sorted(paths)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def changed_files_subject(paths: list[bytes]) -> tuple[str, bool]:
|
|
289
|
+
"""Name the changed files the way every commit sentence does, and tell whether the answer names them.
|
|
290
|
+
|
|
291
|
+
Up to three files are named in sorted order (« a.txt », « a.txt and b.txt », « a.txt, b.txt and c.txt »), more
|
|
292
|
+
are counted (« 4 files ») so the sentence stays one line. Without any file the subject is empty.
|
|
293
|
+
"""
|
|
294
|
+
labels = []
|
|
295
|
+
for path in sorted(paths):
|
|
296
|
+
labels.append(conflict_path_label(path))
|
|
297
|
+
if not labels:
|
|
298
|
+
return "", False
|
|
299
|
+
if len(labels) == 1:
|
|
300
|
+
return labels[0], True
|
|
301
|
+
if len(labels) <= NOTICE_NAMED_FILE_LIMIT:
|
|
302
|
+
last_label = labels.pop()
|
|
303
|
+
return ", ".join(labels) + " and " + last_label, True
|
|
304
|
+
return COUNTED_FILES_NOTICE_SUBJECT_TEMPLATE.format(count=len(labels)), False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def commit_change_notice(paths: list[bytes], index_changed: bool) -> str:
|
|
308
|
+
"""Build the one neutral sentence telling the user which files, or the index, changed right after a commit.
|
|
309
|
+
|
|
310
|
+
Up to three files are named in order, more are counted; the index is named only when no file changed. Without
|
|
311
|
+
any change the answer is empty.
|
|
312
|
+
"""
|
|
313
|
+
subject, _names_files = changed_files_subject(paths)
|
|
314
|
+
if not subject and not index_changed:
|
|
315
|
+
return ""
|
|
316
|
+
if not subject:
|
|
317
|
+
subject, pronoun = INDEX_NOTICE_SUBJECT, SINGLE_NOTICE_PRONOUN
|
|
318
|
+
elif len(paths) == 1:
|
|
319
|
+
pronoun = SINGLE_NOTICE_PRONOUN
|
|
320
|
+
else:
|
|
321
|
+
pronoun = PLURAL_NOTICE_PRONOUN
|
|
322
|
+
return COMMIT_CREATED_NOTICE_PREFIX + CHANGED_AFTER_COMMIT_NOTICE_TEMPLATE.format(subject=subject, pronoun=pronoun)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def no_commit_change_cause(paths: list[bytes], index_changed: bool, refused_by_pre_commit_hook: bool) -> str:
|
|
326
|
+
"""Build the warning of a commit Git did not create while something changed: who refused it, and what changed.
|
|
327
|
+
|
|
328
|
+
A user who clicked Commit must not read that nothing happened. When Git refused and an active pre-commit hook
|
|
329
|
+
explains the refusal, the sentence blames that hook; otherwise it says the commit failed and points at the details,
|
|
330
|
+
where Git's own message stays. Files are named or counted; with no file to name, the index or the current branch is.
|
|
331
|
+
"""
|
|
332
|
+
subject, names_files = changed_files_subject(paths)
|
|
333
|
+
if not subject:
|
|
334
|
+
subject = NO_COMMIT_INDEX_SUBJECT if index_changed else NO_COMMIT_BRANCH_SUBJECT
|
|
335
|
+
if refused_by_pre_commit_hook:
|
|
336
|
+
template = PRE_COMMIT_HOOK_REFUSED_NAMED_FILES_TEMPLATE if names_files else PRE_COMMIT_HOOK_REFUSED_TEMPLATE
|
|
337
|
+
else:
|
|
338
|
+
template = COMMIT_FAILED_NAMED_FILES_TEMPLATE if names_files else COMMIT_FAILED_TEMPLATE
|
|
339
|
+
# The label of the Show details button of Prepare changes, read from the one shared value so the sentence names the real button.
|
|
340
|
+
return template.format(subject=subject, details=SharedText.RESULT_DETAILS.value)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def index_records_by_path(index_entries: bytes) -> dict[bytes, list[bytes]]:
|
|
344
|
+
"""Group the records of ls-files --stage -z by path, so a path with several conflict stages keeps all of them."""
|
|
345
|
+
records_by_path: dict[bytes, list[bytes]] = {}
|
|
346
|
+
for record in index_entries.split(b"\0"):
|
|
347
|
+
if not record:
|
|
348
|
+
continue
|
|
349
|
+
_metadata, _separator, path = record.partition(b"\t")
|
|
350
|
+
records_by_path.setdefault(path, []).append(record)
|
|
351
|
+
return records_by_path
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def changed_staged_paths(before: CommitState, after: CommitState) -> list[bytes]:
|
|
355
|
+
"""List, sorted, the paths whose index entries differ between two commit boundaries: added, changed or removed.
|
|
356
|
+
|
|
357
|
+
It tells which files a hook staged during the commit, without asking Git again.
|
|
358
|
+
"""
|
|
359
|
+
before_records_by_path = index_records_by_path(before.index_entries)
|
|
360
|
+
after_records_by_path = index_records_by_path(after.index_entries)
|
|
361
|
+
paths = []
|
|
362
|
+
for path in before_records_by_path.keys() | after_records_by_path.keys():
|
|
363
|
+
if sorted(before_records_by_path.get(path, [])) != sorted(after_records_by_path.get(path, [])):
|
|
364
|
+
paths.append(path)
|
|
365
|
+
return sorted(paths)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def describe_commit_boundaries(before: CommitState, after: CommitState) -> str:
|
|
369
|
+
"""Describe in English, for the details and the --log file, what changed around a commit, with the Git identities.
|
|
370
|
+
|
|
371
|
+
The commit message is never part of it; the caller still masks it, as for any text a commit diagnostic shows.
|
|
372
|
+
"""
|
|
373
|
+
changed_labels = []
|
|
374
|
+
for path in changed_working_paths(before, after):
|
|
375
|
+
changed_labels.append(conflict_path_label(path))
|
|
376
|
+
unverified_labels = []
|
|
377
|
+
for path in sorted(after.working_by_path.keys() - before.working_by_path.keys()):
|
|
378
|
+
unverified_labels.append(conflict_path_label(path))
|
|
379
|
+
post_commit_hook_active = before.post_commit_hook_active or after.post_commit_hook_active
|
|
380
|
+
pre_commit_hook_active = before.pre_commit_hook_active or after.pre_commit_hook_active
|
|
381
|
+
lines = [
|
|
382
|
+
"A post-commit hook is active." if post_commit_hook_active else "No active post-commit hook was found.",
|
|
383
|
+
"A pre-commit hook is active." if pre_commit_hook_active else "No active pre-commit hook was found.",
|
|
384
|
+
f"Index changed: {'yes' if before.index_entries != after.index_entries else 'no'}",
|
|
385
|
+
]
|
|
386
|
+
staged_labels = []
|
|
387
|
+
for path in changed_staged_paths(before, after):
|
|
388
|
+
staged_labels.append(conflict_path_label(path))
|
|
389
|
+
if staged_labels:
|
|
390
|
+
lines.append(f"Staged files changed during the commit: {', '.join(staged_labels)}")
|
|
391
|
+
lines.append(f"Working files changed: {', '.join(changed_labels) or 'none'}")
|
|
392
|
+
if unverified_labels:
|
|
393
|
+
lines.append(f"Files whose previous bytes were not captured ({len(unverified_labels)}): {', '.join(unverified_labels)}")
|
|
394
|
+
lines.append(f"HEAD before: {before.snapshot.head_oid or 'unborn'}; HEAD after: {after.snapshot.head_oid or 'unborn'}")
|
|
395
|
+
lines.append(f"Tree: {after.tree_oid or 'none'}; parents: {', '.join(after.parent_oids) or 'none'}")
|
|
396
|
+
return "\n".join(lines)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def mask_commit_message(message: str, text: str) -> str:
|
|
400
|
+
"""Hide every line of the commit message, and the whole message, wherever it appears in ``text``.
|
|
401
|
+
|
|
402
|
+
A line shorter than the length diagnostics ever hide is left alone, and a line is only hidden where it is not glued
|
|
403
|
+
to a letter or digit on either side: otherwise a message line such as "a" or "12" would also be replaced inside the
|
|
404
|
+
commit IDs the details show, which are exactly what lets the user find the commit again. Each line is also hidden
|
|
405
|
+
without its trailing blanks, the form Git writes once its cleanup has run on the message.
|
|
406
|
+
"""
|
|
407
|
+
hidden_set = {message}
|
|
408
|
+
for line in message.splitlines():
|
|
409
|
+
hidden_set.add(line)
|
|
410
|
+
hidden_set.add(line.rstrip())
|
|
411
|
+
for line in sorted(hidden_set, key=len, reverse=True):
|
|
412
|
+
if len(line.strip()) < SENSITIVE_VALUE_MIN_LENGTH:
|
|
413
|
+
continue
|
|
414
|
+
text = re.sub(r"(?<![0-9A-Za-z])" + re.escape(line) + r"(?![0-9A-Za-z])", MASKED_VALUE, text)
|
|
415
|
+
return text
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass(frozen=True)
|
|
419
|
+
class CommitRequest:
|
|
420
|
+
"""Freeze the intended index, HEAD and complete UTF-8 message for one explicit action."""
|
|
421
|
+
|
|
422
|
+
operation_id: str
|
|
423
|
+
key: WorktreeKey
|
|
424
|
+
expected_head_oid: str | None
|
|
425
|
+
index_fingerprint: str
|
|
426
|
+
amend: bool
|
|
427
|
+
message: bytes
|
|
428
|
+
snapshot: WorktreeSnapshot
|
|
429
|
+
expected_head_ref: str | None = None
|
|
430
|
+
rebase_capture: tuple[NativeMetadata | None, ...] | None = None
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def capture(cls, snapshot: WorktreeSnapshot, message: str, amend: bool = False) -> CommitRequest:
|
|
434
|
+
"""Capture the currently displayed preparation without including working files.
|
|
435
|
+
|
|
436
|
+
The message is sent exactly as typed and Git applies the repository's ``commit.cleanup`` setting: by default it
|
|
437
|
+
drops blank lines at both ends and trailing blanks of each line while keeping the indentation of the first line,
|
|
438
|
+
and ``verbatim`` keeps the message exactly. Cleaning the message in GitPane before Git would override that
|
|
439
|
+
setting. Commit and Commit & push both capture here, and the draft the caller holds stays as typed. Diagnostics
|
|
440
|
+
mask the sent message and its lines as Git writes them.
|
|
441
|
+
"""
|
|
442
|
+
operation_id = uuid.uuid4().hex
|
|
443
|
+
intent = OperationIntent.capture(operation_id, snapshot.context, snapshot.git_filename)
|
|
444
|
+
return cls(operation_id, snapshot.key, snapshot.head_oid, snapshot.index_fingerprint, amend, message.encode("utf-8"), snapshot,
|
|
445
|
+
rebase_capture=intent.rebase_capture)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass(frozen=True)
|
|
449
|
+
class CommitState:
|
|
450
|
+
"""Keep observed Git identities and working fingerprints separate from the user's intent.
|
|
451
|
+
|
|
452
|
+
``post_commit_hook_active`` and ``pre_commit_hook_active`` tell whether Git would run that hook from the hooks
|
|
453
|
+
directory of this boundary; both are False when that directory is unknown.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
snapshot: WorktreeSnapshot
|
|
457
|
+
tree_oid: str
|
|
458
|
+
parent_oids: tuple[str, ...]
|
|
459
|
+
index_entries: bytes
|
|
460
|
+
working_by_path: dict[bytes, str]
|
|
461
|
+
ref_target: bytes
|
|
462
|
+
post_commit_hook_active: bool = False
|
|
463
|
+
pre_commit_hook_active: bool = False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass(frozen=True)
|
|
467
|
+
class CommitOutcome:
|
|
468
|
+
"""Retain observed before/after state even when the native command reports failure.
|
|
469
|
+
|
|
470
|
+
``commit_created`` tells that the read after the write proved a new commit, whatever the status. ``notice`` is a
|
|
471
|
+
neutral sentence for the repository windows, and ``log_diagnostic`` the details written only to the --log file,
|
|
472
|
+
for a commit that succeeded although files changed around it.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
operation_id: str
|
|
476
|
+
key: WorktreeKey
|
|
477
|
+
status: OperationStatus
|
|
478
|
+
before: CommitState | None
|
|
479
|
+
after: CommitState | None
|
|
480
|
+
diagnostic: PreparedDiagnostic | None
|
|
481
|
+
process_status: ProcessStatus | None = None
|
|
482
|
+
commit_created: bool = False
|
|
483
|
+
notice: str = ""
|
|
484
|
+
log_diagnostic: PreparedDiagnostic | None = None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass
|
|
488
|
+
class _CommitOperation:
|
|
489
|
+
"""Own the reservation and captured message until reconciliation has completed.
|
|
490
|
+
|
|
491
|
+
``merge_parent_oids`` are the commits a merge waiting at launch adds after HEAD, as Git listed them then; they are
|
|
492
|
+
the only parents beyond HEAD a Commit accepts. ``merge_expected_parent_oids`` are the parents the commit concluding
|
|
493
|
+
that merge must have: HEAD then those commits, less the ones Git drops as already contained in another parent.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
request: CommitRequest
|
|
497
|
+
before: CommitState | None = None
|
|
498
|
+
expected_tree_oid: str = ""
|
|
499
|
+
merge_parent_oids: tuple[str, ...] = ()
|
|
500
|
+
merge_expected_parent_oids: tuple[str, ...] = ()
|
|
501
|
+
reference_signature: tuple = ()
|
|
502
|
+
launch_signature: LaunchSignature | None = None
|
|
503
|
+
reconciling: bool = False
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class CommitService(QObject):
|
|
507
|
+
"""Reserve GitPane writes while letting native Git own its own index and ref locks."""
|
|
508
|
+
|
|
509
|
+
finished = Signal(object)
|
|
510
|
+
waiting = Signal(object)
|
|
511
|
+
|
|
512
|
+
def __init__(self, runner: ProcessRunner, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None) -> None:
|
|
513
|
+
"""Inject the existing process, read and mutation owners for shared lifetime control."""
|
|
514
|
+
super().__init__(parent)
|
|
515
|
+
self.runner = runner
|
|
516
|
+
self.worktree_service = worktree_service
|
|
517
|
+
self.mutation = mutation
|
|
518
|
+
self.repository_operation = mutation.repository_operation
|
|
519
|
+
self.active_by_id: dict[str, _CommitOperation] = {}
|
|
520
|
+
self.outcome_by_id: dict[str, CommitOutcome] = {}
|
|
521
|
+
runner.finished.connect(self._process_finished)
|
|
522
|
+
runner.stop_requested.connect(self._stopping)
|
|
523
|
+
|
|
524
|
+
def submit(self, request: CommitRequest) -> str:
|
|
525
|
+
"""Validate and reserve exactly one user request without retrying rejected writes.
|
|
526
|
+
|
|
527
|
+
A refusal says first that GitPane is closing, since no write is accepted any more whatever else is wrong; then it
|
|
528
|
+
gives the write policy's reason, or else the real reason the reservation was refused, worked out right away so it
|
|
529
|
+
describes the state that refused it.
|
|
530
|
+
"""
|
|
531
|
+
if request.operation_id in self.active_by_id or request.operation_id in self.outcome_by_id:
|
|
532
|
+
return request.operation_id
|
|
533
|
+
commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
|
|
534
|
+
action = OperationAction.COMMIT_PUSH if request.expected_head_ref is not None else commit_action
|
|
535
|
+
intent = OperationIntent.capture(request.operation_id, request.snapshot.context, request.snapshot.git_filename, action=action, rebase_capture=request.rebase_capture,
|
|
536
|
+
operation=commit_action.commit_label)
|
|
537
|
+
reason = GITPANE_CLOSING_TEXT if self.repository_operation.closed else self.repository_operation.policy_diagnostic(intent)
|
|
538
|
+
if reason or not self.mutation.acquire(request.snapshot, request.operation_id, action=action, operation=commit_action.commit_label):
|
|
539
|
+
diagnostic = self._diagnostic(request, reason or self.mutation.acquire_refusal_cause(request.snapshot, request.operation_id, action=action, operation=commit_action.commit_label))
|
|
540
|
+
outcome = CommitOutcome(request.operation_id, request.key, OperationStatus.REFUSED, None, None, diagnostic)
|
|
541
|
+
self.outcome_by_id[request.operation_id] = outcome
|
|
542
|
+
QTimer.singleShot(0, lambda: self.finished.emit(outcome))
|
|
543
|
+
return request.operation_id
|
|
544
|
+
operation = _CommitOperation(request)
|
|
545
|
+
self.active_by_id[request.operation_id] = operation
|
|
546
|
+
|
|
547
|
+
def prepared(value: object, error: Exception | None) -> None:
|
|
548
|
+
"""Launch the native write only after every captured precondition still matches."""
|
|
549
|
+
if error is not None:
|
|
550
|
+
self._finish(operation, OperationStatus.REFUSED, None, self._diagnostic(request, str(error)))
|
|
551
|
+
return
|
|
552
|
+
snapshot = request.snapshot
|
|
553
|
+
arguments = [*SUBMODULE_RECURSION_OFF_ARGUMENTS, "commit", "--file=-"]
|
|
554
|
+
if request.amend:
|
|
555
|
+
arguments.append("--amend")
|
|
556
|
+
|
|
557
|
+
def validate_launch() -> None:
|
|
558
|
+
"""Recheck the current owner, write policy and authority, then only the metadata the launch proof covered.
|
|
559
|
+
|
|
560
|
+
The index and reference contents were compared by a file worker when the preparation ended; here, on
|
|
561
|
+
the Qt thread, their metadata alone says whether anything moved while the commit waited to start.
|
|
562
|
+
"""
|
|
563
|
+
self.mutation.validate_write(request.key)
|
|
564
|
+
if operation.launch_signature is None:
|
|
565
|
+
raise ValueError("The commit launch was not checked. Refresh before writing.")
|
|
566
|
+
verify_launch_signature(operation.launch_signature, COMMIT_LAUNCH_CHANGED_CAUSE, COMMIT_LAUNCH_UNREADABLE_CAUSE)
|
|
567
|
+
|
|
568
|
+
process_request = ProcessRequest(request.operation_id, snapshot.git_filename, arguments, snapshot.context.repository_dir, GIT_COMMIT_TIMEOUT_MS,
|
|
569
|
+
session_id="commit:" + request.key.git_dir, stdin=request.message, idle_timeout_ms=COMMIT_IDLE_TIMEOUT_MS,
|
|
570
|
+
operation="Amend" if request.amend else "Commit", wait_for_exit_on_stop=True, profile=LOCAL_WRITE_PROFILE, operation_id=request.operation_id,
|
|
571
|
+
before_start=validate_launch)
|
|
572
|
+
try:
|
|
573
|
+
self.runner.start(process_request)
|
|
574
|
+
except (ValueError, RuntimeError) as failure:
|
|
575
|
+
self._process_finished(ProcessResult(process_request, ProcessStatus.START_FAILED, None, str(failure), b""))
|
|
576
|
+
|
|
577
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, self._prepare(operation), prepared, operation_id=request.operation_id)
|
|
578
|
+
return request.operation_id
|
|
579
|
+
|
|
580
|
+
def _prepare(self, operation: _CommitOperation) -> Generator:
|
|
581
|
+
"""Check freshness, compute the expected tree with a private index copy, then prove what the launch will check.
|
|
582
|
+
|
|
583
|
+
The displayed read is checked against fresh Git reads rather than read again in full. Once the tree is known,
|
|
584
|
+
a file worker compares the index and reference contents with the checked ones and leaves a metadata signature,
|
|
585
|
+
which is all the launch callback reads again on the Qt thread.
|
|
586
|
+
|
|
587
|
+
A merge waiting for its commit is read from its files by a file worker, not by Git: its commits become the
|
|
588
|
+
expected parents after HEAD, and it may be concluded with nothing staged, since keeping HEAD's side of every
|
|
589
|
+
file is a valid resolution Git accepts.
|
|
590
|
+
"""
|
|
591
|
+
from gitpane.reference.reference_edit import reference_signature
|
|
592
|
+
|
|
593
|
+
request = operation.request
|
|
594
|
+
snapshot = request.snapshot
|
|
595
|
+
if not snapshot.fresh or request.key != snapshot.key or request.key != WorktreeKey.from_context(snapshot.context):
|
|
596
|
+
raise ValueError("Preparation is stale; refresh the changes")
|
|
597
|
+
if request.expected_head_oid != snapshot.head_oid or request.index_fingerprint != snapshot.index_fingerprint:
|
|
598
|
+
raise ValueError("Commit intention does not match the displayed preparation")
|
|
599
|
+
if not typed_value(request.message.decode("utf-8")) or b"\0" in request.message:
|
|
600
|
+
raise ValueError("Enter a nonblank commit message without NUL characters")
|
|
601
|
+
if request.amend and request.expected_head_oid is None:
|
|
602
|
+
raise ValueError("There is no HEAD commit to amend")
|
|
603
|
+
# Capture before the native identity reads so a queued launch cannot adopt newer ref storage.
|
|
604
|
+
operation.reference_signature = yield FileTask(lambda: reference_signature(snapshot.context))
|
|
605
|
+
before = yield from self._read_state(snapshot)
|
|
606
|
+
if request.expected_head_ref is not None and before.ref_target.decode("utf-8", "surrogateescape").strip() != request.expected_head_ref:
|
|
607
|
+
raise ValueError("The current branch changed after Commit & push was captured; refresh the changes")
|
|
608
|
+
# The state before the write carries the displayed read itself, which _read_state has just proven current: the
|
|
609
|
+
# identity, the index path and bytes, HEAD and the configuration and attributes were compared with fresh Git
|
|
610
|
+
# reads. Equal index bytes and HEAD give the same staged entries and the same conflict stages, so the entries
|
|
611
|
+
# below describe the index the commit will record.
|
|
612
|
+
if any(entry.conflict_stages for entry in before.snapshot.entries):
|
|
613
|
+
raise ValueError("Resolve all conflicts before committing")
|
|
614
|
+
if not request.amend:
|
|
615
|
+
git_dir = snapshot.context.git_dir
|
|
616
|
+
merge = yield FileTask(lambda: read_merge_in_progress(git_dir))
|
|
617
|
+
if merge is not None:
|
|
618
|
+
operation.merge_parent_oids = merge.parent_oids
|
|
619
|
+
head_parents = (request.expected_head_oid,) if request.expected_head_oid else ()
|
|
620
|
+
operation.merge_expected_parent_oids = (*head_parents, *merge.parent_oids)
|
|
621
|
+
if not merge.keeps_every_parent and len(operation.merge_expected_parent_oids) > 1:
|
|
622
|
+
operation.merge_expected_parent_oids = yield from reduced_merge_parent_oids(operation.merge_expected_parent_oids)
|
|
623
|
+
if not request.amend and not operation.merge_parent_oids and not any(entry.side == StagingSide.STAGED for entry in before.snapshot.entries):
|
|
624
|
+
raise ValueError("Stage changes before committing")
|
|
625
|
+
recovery = self.mutation.recover(snapshot.context, snapshot.index_filename, operation_id=request.operation_id)
|
|
626
|
+
if recovery:
|
|
627
|
+
raise ValueError(recovery)
|
|
628
|
+
operation.before = before
|
|
629
|
+
# write-tree may update cached index metadata, so compute against a private copy.
|
|
630
|
+
with tempfile.TemporaryDirectory(prefix="gitpane-commit-", dir=snapshot.context.git_dir) as temporary_dir:
|
|
631
|
+
index_filename = os.path.join(temporary_dir, "index")
|
|
632
|
+
if before.snapshot.index_content is not None:
|
|
633
|
+
yield FileTask(lambda: shutil.copy2(snapshot.index_filename, index_filename))
|
|
634
|
+
tree = yield GitCommand([*SUBMODULE_RECURSION_OFF_ARGUMENTS, "write-tree"], environment_by_name={"GIT_INDEX_FILE": index_filename}, profile=LOCAL_WRITE_PROFILE)
|
|
635
|
+
operation.expected_tree_oid = tree.strip().decode("ascii")
|
|
636
|
+
# HEAD and the branch are among the reference files, so this proof also covers what separate HEAD reads checked.
|
|
637
|
+
reference = operation.reference_signature
|
|
638
|
+
operation.launch_signature = yield FileTask(lambda: prove_commit_launch(snapshot.context, snapshot.index_filename, snapshot.index_content, reference))
|
|
639
|
+
|
|
640
|
+
def _read_state(self, snapshot: WorktreeSnapshot, previous: CommitState | None = None) -> Generator:
|
|
641
|
+
"""Establish Git identities and the working bytes a commit could change, at one commit boundary.
|
|
642
|
+
|
|
643
|
+
Before the write, without a previous state, the displayed snapshot is not read again: WorktreeService.verify
|
|
644
|
+
compares its identity, index path and bytes, HEAD, configuration and attributes with fresh Git reads, and the
|
|
645
|
+
state carries that same snapshot. After the write, with the previous state, a complete capture is read.
|
|
646
|
+
|
|
647
|
+
HEAD, its tree, its parents and its branch are read at both ends of the boundary, around the file reads, and
|
|
648
|
+
must not move, nor may the index bytes. Files Git lists, files of the displayed preparation and files already
|
|
649
|
+
hashed at the previous boundary are always hashed. Every other tracked file, hidden index entries included, is
|
|
650
|
+
hashed only when the hooks directory of this boundary holds a hook, since only a hook runs inside the commit and
|
|
651
|
+
can rewrite a file Git still reports as clean. A snapshot that did not resolve its hooks directory keeps the
|
|
652
|
+
full hashing.
|
|
653
|
+
"""
|
|
654
|
+
if previous is None:
|
|
655
|
+
yield from self.worktree_service.verify(snapshot, ())
|
|
656
|
+
current = snapshot
|
|
657
|
+
else:
|
|
658
|
+
current = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename, snapshot.generation)
|
|
659
|
+
facts = yield from read_commit_head()
|
|
660
|
+
hook_present = True
|
|
661
|
+
post_commit_hook_active = False
|
|
662
|
+
pre_commit_hook_active = False
|
|
663
|
+
if current.hooks_dir is not None:
|
|
664
|
+
hooks_dir = current.hooks_dir
|
|
665
|
+
hook_present, post_commit_hook_active, pre_commit_hook_active = yield FileTask(lambda: (commit_hook_exists(hooks_dir), git_hook_active(hooks_dir, POST_COMMIT_HOOK_BASENAME),
|
|
666
|
+
git_hook_active(hooks_dir, PRE_COMMIT_HOOK_BASENAME)))
|
|
667
|
+
index_entries = current.index_entries
|
|
668
|
+
if index_entries is None:
|
|
669
|
+
index_entries = yield GitCommand(["ls-files", "--stage", "-z"])
|
|
670
|
+
paths = list(snapshot.fingerprint_by_path)
|
|
671
|
+
tracked_paths = []
|
|
672
|
+
for record in index_entries.split(b"\0"):
|
|
673
|
+
if record:
|
|
674
|
+
_metadata, separator, path = record.partition(b"\t")
|
|
675
|
+
if not separator:
|
|
676
|
+
raise ValueError("Malformed Git index entry output")
|
|
677
|
+
tracked_paths.append(path)
|
|
678
|
+
if hook_present:
|
|
679
|
+
# Equal metadata can hide a same-size rewrite with restored timestamps, even when Git reports a clean file.
|
|
680
|
+
paths.extend(current.hidden_index_paths)
|
|
681
|
+
paths.extend(tracked_paths)
|
|
682
|
+
if previous is not None:
|
|
683
|
+
paths.extend(previous.working_by_path)
|
|
684
|
+
for entry in current.entries:
|
|
685
|
+
paths.extend(entry.paths)
|
|
686
|
+
working_by_path = yield from read_fingerprints(snapshot.context, tuple(paths))
|
|
687
|
+
after_facts = yield from read_commit_head()
|
|
688
|
+
index = yield FileTask(lambda: read_index(current.index_filename))
|
|
689
|
+
if facts.head_oid != current.head_oid or after_facts != facts or index != current.index_content:
|
|
690
|
+
raise ValueError("Repository changed during commit verification; Refresh to inspect the result")
|
|
691
|
+
return CommitState(current, facts.tree_oid, facts.parent_oids, index_entries, working_by_path, facts.ref_target, post_commit_hook_active, pre_commit_hook_active)
|
|
692
|
+
|
|
693
|
+
def _stopping(self, process_request: ProcessRequest, cause: str) -> None:
|
|
694
|
+
"""Explain an exceeded budget while the native Git process still owns its hooks."""
|
|
695
|
+
operation = self.active_by_id.get(process_request.request_id)
|
|
696
|
+
if operation is not None:
|
|
697
|
+
self.waiting.emit(self._diagnostic(operation.request, cause + ". Waiting for Git and its hooks to exit; no second commit will start"))
|
|
698
|
+
|
|
699
|
+
def _process_finished(self, result: ProcessResult) -> None:
|
|
700
|
+
"""Reconcile a single terminal process response while retaining the write reservation."""
|
|
701
|
+
operation = self.active_by_id.get(result.request.request_id)
|
|
702
|
+
if operation is None or operation.reconciling:
|
|
703
|
+
return
|
|
704
|
+
operation.reconciling = True
|
|
705
|
+
request = operation.request
|
|
706
|
+
|
|
707
|
+
def inspected(after: CommitState | None, error: Exception | None) -> None:
|
|
708
|
+
"""Classify the result from the states read before and after the write.
|
|
709
|
+
|
|
710
|
+
The commit that was asked for, with its expected tree, parents and branch, is a success even when files or
|
|
711
|
+
the index changed around it: a hook or another program changed them, not the commit. Those details go to
|
|
712
|
+
the --log file; only when neither a pre-commit nor a post-commit hook is active to explain them does a
|
|
713
|
+
neutral notice also name the changed files. A new commit with the expected parents and branch whose tree differs only because an active
|
|
714
|
+
pre-commit hook added or changed files is the user's own program at work: a success with nothing on screen,
|
|
715
|
+
the details in the --log file. Any other new commit, or a change without one, is partial and starts with a plain sentence:
|
|
716
|
+
Git's own cause when Git did not succeed but a commit exists, or when Git never started, the object IDs
|
|
717
|
+
always in the details. A HEAD that moved while Git never started is not a commit this request created.
|
|
718
|
+
When Git ran but no commit exists while something changed, the sentence names the changed files and blames
|
|
719
|
+
the pre-commit hook only when Git ended with a refusal and such a hook is active; the hook's own message
|
|
720
|
+
stays in the details.
|
|
721
|
+
"""
|
|
722
|
+
before = operation.before
|
|
723
|
+
if error is not None:
|
|
724
|
+
cause = result.cause + ". " if result.cause else ""
|
|
725
|
+
cause += "Commit result could not be verified; Refresh. " + str(error)
|
|
726
|
+
self._finish(operation, OperationStatus.UNKNOWN, None, self._diagnostic(request, cause, result.stderr, result.return_code), result.status)
|
|
727
|
+
return
|
|
728
|
+
head_parents = (request.expected_head_oid,) if request.expected_head_oid else ()
|
|
729
|
+
expected_parents = operation.merge_expected_parent_oids if operation.merge_parent_oids else head_parents
|
|
730
|
+
if request.amend:
|
|
731
|
+
expected_parents = before.parent_oids
|
|
732
|
+
new_head_with_expected_parents = after.snapshot.head_oid is not None and (request.amend or after.snapshot.head_oid != request.expected_head_oid) and after.parent_oids == expected_parents
|
|
733
|
+
expected_tree = after.tree_oid == operation.expected_tree_oid
|
|
734
|
+
same_branch = before.ref_target == after.ref_target
|
|
735
|
+
unchanged_local = before.index_entries == after.index_entries and before.working_by_path == after.working_by_path and same_branch
|
|
736
|
+
# Whatever moved in the repository while Git never started was not this commit's doing.
|
|
737
|
+
git_ran = result.status != ProcessStatus.START_FAILED
|
|
738
|
+
commit_created = git_ran and after.snapshot.head_oid is not None and after.snapshot.head_oid != before.snapshot.head_oid
|
|
739
|
+
requested = new_head_with_expected_parents and expected_tree and same_branch
|
|
740
|
+
pre_commit_hook_active = before.pre_commit_hook_active or after.pre_commit_hook_active
|
|
741
|
+
if result.status == ProcessStatus.SUCCESS and requested:
|
|
742
|
+
log_diagnostic = None
|
|
743
|
+
notice = ""
|
|
744
|
+
if not unchanged_local:
|
|
745
|
+
log_diagnostic = self._diagnostic(request, SUCCESS_WITH_CHANGES_LOG_CAUSE, result.stderr, result.return_code, describe_commit_boundaries(before, after))
|
|
746
|
+
# An active pre-commit or post-commit hook is the user's own program, expected to change files around
|
|
747
|
+
# every commit; only a change no such hook explains is worth a notice.
|
|
748
|
+
post_commit_hook_active = before.post_commit_hook_active or after.post_commit_hook_active
|
|
749
|
+
if not post_commit_hook_active and not pre_commit_hook_active:
|
|
750
|
+
notice = commit_change_notice(noticed_paths(before, after), before.index_entries != after.index_entries)
|
|
751
|
+
self._finish(operation, OperationStatus.SUCCESS, after, None, result.status, commit_created=True, notice=notice, log_diagnostic=log_diagnostic)
|
|
752
|
+
return
|
|
753
|
+
if result.status == ProcessStatus.SUCCESS and commit_created and new_head_with_expected_parents and same_branch and pre_commit_hook_active:
|
|
754
|
+
# The tree differs from what was staged, but the user's own pre-commit hook explains it: nothing to warn about.
|
|
755
|
+
log_diagnostic = self._diagnostic(request, PRE_COMMIT_HOOK_CONTENT_LOG_CAUSE, result.stderr, result.return_code, describe_commit_boundaries(before, after))
|
|
756
|
+
self._finish(operation, OperationStatus.SUCCESS, after, None, result.status, commit_created=True, notice="", log_diagnostic=log_diagnostic)
|
|
757
|
+
return
|
|
758
|
+
changed = before.snapshot.head_oid != after.snapshot.head_oid or not unchanged_local
|
|
759
|
+
# Another commit than the one asked for, or none while files changed: partial, a warning to look at.
|
|
760
|
+
status = OperationStatus.PARTIAL if changed else OperationStatus.REFUSED
|
|
761
|
+
if commit_created:
|
|
762
|
+
cause = COMMIT_CHANGED_ELSEWHERE_CAUSE if result.status == ProcessStatus.SUCCESS else result.cause or COMMIT_CHANGED_ELSEWHERE_CAUSE
|
|
763
|
+
elif changed and git_ran:
|
|
764
|
+
refused_by_pre_commit_hook = result.status == ProcessStatus.FAILED and pre_commit_hook_active
|
|
765
|
+
cause = no_commit_change_cause(noticed_paths(before, after), before.index_entries != after.index_entries, refused_by_pre_commit_hook)
|
|
766
|
+
elif result.cause:
|
|
767
|
+
cause = result.cause
|
|
768
|
+
elif changed:
|
|
769
|
+
cause = no_commit_change_cause(noticed_paths(before, after), before.index_entries != after.index_entries, False)
|
|
770
|
+
else:
|
|
771
|
+
cause = NO_COMMIT_FOUND_CAUSE
|
|
772
|
+
technical_detail = describe_commit_boundaries(before, after) + "\n" + REVIEW_BEFORE_RETRY_TEXT
|
|
773
|
+
if result.cause and result.cause != cause:
|
|
774
|
+
technical_detail = GIT_CAUSE_DETAIL_PREFIX + result.cause + "\n" + technical_detail
|
|
775
|
+
diagnostic = self._diagnostic(request, cause, result.stderr, result.return_code, technical_detail)
|
|
776
|
+
self._finish(operation, status, after, diagnostic, result.status, commit_created=commit_created)
|
|
777
|
+
|
|
778
|
+
# Both branches name the displayed snapshot: the state before the write carries it once it was verified. It only
|
|
779
|
+
# seeds the list of files hashed after the write, next to every file hashed before it and every file the new
|
|
780
|
+
# capture lists; a file that appears only in that capture has no earlier bytes and is reported as not verified.
|
|
781
|
+
baseline = operation.before.snapshot if operation.before is not None else request.snapshot
|
|
782
|
+
self.worktree_service.run(request.snapshot.context, request.snapshot.git_filename, self._read_state(baseline, operation.before), inspected, operation_id=request.operation_id)
|
|
783
|
+
|
|
784
|
+
def read_amend_message(self, snapshot: WorktreeSnapshot) -> Generator:
|
|
785
|
+
"""Decode the full commit object the displayed snapshot names, then check once that HEAD still names it.
|
|
786
|
+
|
|
787
|
+
The object is asked for by its ID, which never changes, so HEAD moving before the read cannot change what is
|
|
788
|
+
read: the single check at the end refuses the message instead.
|
|
789
|
+
"""
|
|
790
|
+
if snapshot.head_oid is None:
|
|
791
|
+
raise ValueError("There is no HEAD commit to amend")
|
|
792
|
+
head_oid = validate_oid(snapshot.head_oid)
|
|
793
|
+
raw = yield GitCommand(["cat-file", "commit", head_oid])
|
|
794
|
+
commit = parse_commit(head_oid, raw, head_oid)
|
|
795
|
+
after = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
796
|
+
if after.strip().decode("ascii", "replace") != head_oid:
|
|
797
|
+
raise ValueError("HEAD changed; Refresh and load Amend again")
|
|
798
|
+
return head_oid, commit.message
|
|
799
|
+
|
|
800
|
+
def _diagnostic(self, request: CommitRequest, cause: str, stderr: bytes = b"", return_code: int | None = None, technical_detail: str = "") -> PreparedDiagnostic:
|
|
801
|
+
"""Remove echoed message text as well as credentials before any output surface sees it.
|
|
802
|
+
|
|
803
|
+
The technical details, which only the details and the --log file show, are masked the same way.
|
|
804
|
+
"""
|
|
805
|
+
message = request.message.decode("utf-8", "replace")
|
|
806
|
+
cause = mask_commit_message(message, cause)
|
|
807
|
+
text = mask_commit_message(message, stderr.decode("utf-8", "replace"))
|
|
808
|
+
technical_detail = mask_commit_message(message, technical_detail)
|
|
809
|
+
arguments = ["commit", "--file=-"]
|
|
810
|
+
if request.amend:
|
|
811
|
+
arguments.append("--amend")
|
|
812
|
+
action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
|
|
813
|
+
return Diagnostic(action.commit_label, request.snapshot.context.repository_dir, request.snapshot.git_filename, arguments, cause, text.encode("utf-8"), return_code,
|
|
814
|
+
technical_detail=technical_detail).prepare()
|
|
815
|
+
|
|
816
|
+
def _finish(self, operation: _CommitOperation, status: OperationStatus, after: CommitState | None, diagnostic: PreparedDiagnostic | None, process_status: ProcessStatus | None = None, *,
|
|
817
|
+
commit_created: bool = False, notice: str = "", log_diagnostic: PreparedDiagnostic | None = None) -> None:
|
|
818
|
+
"""Release only this completed operation and publish one retained outcome."""
|
|
819
|
+
request = operation.request
|
|
820
|
+
if self.active_by_id.pop(request.operation_id, None) is None:
|
|
821
|
+
return
|
|
822
|
+
self.mutation.release(request.snapshot.key, request.operation_id)
|
|
823
|
+
self._publish(CommitOutcome(request.operation_id, request.key, status, operation.before, after, diagnostic, process_status, commit_created, notice, log_diagnostic))
|
|
824
|
+
|
|
825
|
+
def _publish(self, outcome: CommitOutcome) -> None:
|
|
826
|
+
"""Retain outcomes independently of any view and notify interested repository windows."""
|
|
827
|
+
if outcome.operation_id in self.outcome_by_id:
|
|
828
|
+
return
|
|
829
|
+
self.outcome_by_id[outcome.operation_id] = outcome
|
|
830
|
+
self.finished.emit(outcome)
|