From dffb652f8d9f7b66c40aa6e1c39950476b615a3a Mon Sep 17 00:00:00 2001 From: Shane da Silva Date: Tue, 10 Nov 2026 20:21:02 -0800 Subject: [PATCH 1/2] Preserve hook side effects without stashing repository changes --- .github/workflows/tests.yml | 3 + AGENTS.md | 11 + CHANGELOG.md | 6 + CONTRIBUTING.md | 5 + README.md | 35 +- lib/overcommit/hook_context/commit_msg.rb | 4 +- .../helpers/hide_unstaged_changes.rb | 122 +++++ .../helpers/stash_unstaged_changes.rb | 166 ------- lib/overcommit/hook_context/pre_commit.rb | 4 +- script/check_coverage.rb | 28 ++ spec/coverage_check_spec.rb | 61 +++ spec/integration/hook_side_effects_spec.rb | 107 +++++ .../hook_context/commit_msg_spec.rb | 10 +- .../helpers/hide_unstaged_changes_spec.rb | 430 ++++++++++++++++++ .../hook_context/helpers/stash_errors_spec.rb | 32 -- .../helpers/stash_unstaged_changes_spec.rb | 152 ------- .../hook_context/pre_commit_spec.rb | 10 +- 17 files changed, 804 insertions(+), 382 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb delete mode 100644 lib/overcommit/hook_context/helpers/stash_unstaged_changes.rb create mode 100644 script/check_coverage.rb create mode 100644 spec/coverage_check_spec.rb create mode 100644 spec/integration/hook_side_effects_spec.rb create mode 100644 spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb delete mode 100644 spec/overcommit/hook_context/helpers/stash_errors_spec.rb delete mode 100644 spec/overcommit/hook_context/helpers/stash_unstaged_changes_spec.rb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52b9186a..1b78237c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -122,6 +122,9 @@ jobs: pattern: coverage-* path: coverage + - name: Require 100% combined line coverage + run: ruby script/check_coverage.rb coverage/*/lcov.info + # Shard assignments shift when examples are added. Compare the complete # suite's coverage rather than unrelated fractions from individual shards. - name: Report combined code coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..24a2a777 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Repository guidance + +- Maintain **100% line coverage** across the complete test suite. Add thorough + regression tests for new behavior and failure paths; do not exclude production + code from coverage or lower the threshold to make checks pass. +- Preserve existing tests unless they specifically test an implementation that + is being replaced. Prefer updating those tests to assert observable behavior. +- Run `bundle exec ruby script/parallel_rspec.rb` for the full suite and + `bundle exec overcommit --run` for lint checks before submitting changes. +- Keep iterating on pull requests until all CI checks pass. Do not merge unless + explicitly instructed to do so. diff --git a/CHANGELOG.md b/CHANGELOG.md index c062e137..e4ea18c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Overcommit Changelog +## Unreleased + +* Support hooks that modify files and the index, preserving their changes while + hiding/restoring unstaged edits without Git stash or hard resets +* Retain a recovery patch when unstaged edits conflict with hook changes + ## 0.76.0 * Add configurable line parsing for command hooks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f13f25b2..cc7febf6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,11 @@ The best way to see a feature added, however, is to submit a pull request. The final `CI` check depends on all test shards and lint, and reports their combined coverage. Repository rulesets can require this single check instead of individual matrix jobs. +* Maintain **100% line coverage** across the complete suite, including failure + paths. CI merges coverage across all test shards and platforms and rejects any + uncovered lines. Do not lower coverage thresholds or exclude production code + to make a check pass. + * Submit your pull request! All pull requests will be tested against [Travis CI][3], where the following diff --git a/README.md b/README.md index 8b0992a2..0d5fbeb1 100644 --- a/README.md +++ b/README.md @@ -554,20 +554,27 @@ commit message editor is displayed. If a hook fails, the commit will not be created. These hooks are ideal for syntax checkers, linters, and other checks that you want to run before you allow a commit to even be created. -#### WARNING: pre-commit hooks cannot have side effects - -`pre-commit` hooks currently do not support hooks with side effects (such as -modifying files and adding them to the index with `git add`). This is a -consequence of Overcommit's pre-commit hook stashing behavior to ensure hooks -are run against _only the changes you are about to commit_. - -Without Overcommit, the proper way to write a `pre-commit` hook would be to -extract the staged changes into temporary files and lint those files -instead of whatever contents are in your working tree (as you don't want -unstaged changes to taint your results). Overcommit takes care -of this for you, but to do it in a generalized way introduces this -limitation. See the [thread tracking this -issue](https://github.com/sds/overcommit/issues/238) for more details. +#### Hooks that modify files + +`pre-commit` hooks can modify, create, delete, and stage files. Overcommit +preserves these changes even if a hook fails. Hooks must explicitly use `git add` +or `git rm` to include their changes in the commit; Overcommit does not stage +hook edits automatically. Set `parallelize: false` for hooks that modify files +or the Git index so they run without other hooks accessing those resources. + +Overcommit temporarily hides unstaged changes to tracked files, so hooks see +only staged contents at their original paths. Untracked files and submodule +working trees remain available. This also works for the initial commit. +Unstaged changes are saved as a patch in the current worktree's Git directory, +then reapplied after the hooks finish. Overcommit does not use the Git stash or +reset the index, HEAD, or merge/cherry-pick state. + +If hook edits conflict with the saved patch, Overcommit aborts the commit and +keeps both the hook edits and the recovery patch. Follow the error's recovery +instructions after resolving the conflict. The patch is removed only after +successful restoration. If the process is forcibly terminated, recovery patches +remain as `overcommit-unstaged-*.patch` beside the worktree's Git index; inspect +and apply the patch from the repository root, then delete it when recovered. * [`*`AuthorEmail](lib/overcommit/hook/pre_commit/author_email.rb) * [`*`AuthorName](lib/overcommit/hook/pre_commit/author_name.rb) diff --git a/lib/overcommit/hook_context/commit_msg.rb b/lib/overcommit/hook_context/commit_msg.rb index 4a43f150..efd934dc 100644 --- a/lib/overcommit/hook_context/commit_msg.rb +++ b/lib/overcommit/hook_context/commit_msg.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true require_relative 'pre_commit' -require_relative 'helpers/stash_unstaged_changes' +require_relative 'helpers/hide_unstaged_changes' require_relative 'helpers/file_modifications' module Overcommit::HookContext # Contains helpers related to contextual information used by commit-msg hooks. class CommitMsg < Base - include Overcommit::HookContext::Helpers::StashUnstagedChanges + include Overcommit::HookContext::Helpers::HideUnstagedChanges include Overcommit::HookContext::Helpers::FileModifications def empty_message? diff --git a/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb b/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb new file mode 100644 index 00000000..1a9aa85a --- /dev/null +++ b/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'digest' +require 'shellwords' + +module Overcommit::HookContext + module Helpers + # Hide unstaged changes without moving HEAD, rewriting the index, or using + # the stash. Hooks retain the same file paths and can change the index. + module HideUnstagedChanges + def setup_environment + store_modified_times + save_unstaged_changes + return unless @unstaged_patch + + apply_unstaged_changes(reverse: true) + restore_modified_times + @hook_contents = file_contents + end + + # Returns whether the current git branch is empty (has no commits). + def initial_commit? + return @initial_commit unless @initial_commit.nil? + + @initial_commit = Overcommit::GitRepo.initial_commit? + end + + def cleanup_environment + return unless @unstaged_patch + + # Retain modification times for hook edits so file watchers see them. + unchanged_files = file_contents.select { |file, contents| @hook_contents[file] == contents } + apply_unstaged_changes + restore_modified_times(unchanged_files.keys) + File.unlink(@unstaged_patch) + @unstaged_patch = nil + end + + private + + def save_unstaged_changes + result = Overcommit::Utils.execute(%w[ + git diff --binary --full-index --no-color --no-ext-diff --no-textconv + --no-renames --ignore-submodules=all --src-prefix=a/ --dst-prefix=b/ --unified=3 -- + ]) + check_patch_result(result, Overcommit::Exceptions::HookSetupFailed) + return if result.stdout.empty? + + # Tempfile.create returns a regular File: neither GC nor process exit + # removes the recovery patch if setup, hooks, or cleanup are interrupted. + file = Tempfile.create(['overcommit-unstaged-', '.patch'], + File.dirname(Overcommit::Utils.git_path('index'))) + begin + file.binmode + file.write(result.stdout) + file.flush + file.fsync + @unstaged_patch = file.path + ensure + file.close + end + end + + def apply_unstaged_changes(reverse: false) + command = %w[git apply --whitespace=nowarn] + command << '--reverse' if reverse + result = Overcommit::Utils.execute(command + ['--', @unstaged_patch]) + error = if reverse + Overcommit::Exceptions::HookSetupFailed + else + Overcommit::Exceptions::HookCleanupFailed + end + check_patch_result(result, error) + end + + def check_patch_result(result, error) + return if result.success? + + recovery = if @unstaged_patch + "\nYour unstaged changes are preserved in #{@unstaged_patch}.\n" \ + "Hook changes are preserved. Resolve any conflicts, then run:\n" \ + ' git apply --whitespace=nowarn -- ' \ + "#{Shellwords.escape(@unstaged_patch)}\n" + end + raise error, + "Unable to restore or hide unstaged changes for #{hook_script_name} hooks:" \ + "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{recovery}" + end + + def store_modified_times + @modified_times = {} + files = modified_files + Overcommit::GitRepo.modified_files(staged: false) + files.each do |file| + next if Overcommit::Utils::FileUtils.symlink?(file) + next unless File.file?(file) + + @modified_times[file] = File.mtime(file) + end + end + + def file_contents + @modified_times.keys.each_with_object({}) do |file, contents| + contents[file] = if Overcommit::Utils::FileUtils.symlink?(file) + [:symlink, Overcommit::Utils::FileUtils.readlink(file)] + elsif File.file?(file) + [:file, Digest::SHA256.file(file).hexdigest] + end + end + end + + def restore_modified_times(files = @modified_times.keys) + files.each do |file| + next if Overcommit::Utils::FileUtils.symlink?(file) + next unless File.file?(file) + + File.utime(@modified_times.fetch(file), @modified_times.fetch(file), file) + end + end + end + end +end diff --git a/lib/overcommit/hook_context/helpers/stash_unstaged_changes.rb b/lib/overcommit/hook_context/helpers/stash_unstaged_changes.rb deleted file mode 100644 index 9983e1ef..00000000 --- a/lib/overcommit/hook_context/helpers/stash_unstaged_changes.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module Overcommit::HookContext - module Helpers - # This module contains behavior for stashing unstaged changes before hooks are ran and restoring - # them afterwards - module StashUnstagedChanges - # Stash unstaged contents of files so hooks don't see changes that aren't - # about to be committed. - def setup_environment - store_modified_times - Overcommit::GitRepo.store_merge_state - Overcommit::GitRepo.store_cherry_pick_state - - # Don't attempt to stash changes if all changes are staged, as this - # prevents us from modifying files at all, which plays better with - # editors/tools which watch for file changes. - if !initial_commit? && unstaged_changes? - stash_changes - restore_staged_changes if @changes_stashed - - # While running hooks make it appear as if nothing changed - restore_modified_times - end - end - - # Returns whether the current git branch is empty (has no commits). - def initial_commit? - return @initial_commit unless @initial_commit.nil? - - @initial_commit = Overcommit::GitRepo.initial_commit? - end - - # Restore unstaged changes and reset file modification times so it appears - # as if nothing ever changed. - # - # We want to restore the modification times for each of the files after - # every step to ensure as little time as possible has passed while the - # modification time on the file was newer. This helps us play more nicely - # with file watchers. - def cleanup_environment - if @changes_stashed - clear_working_tree - restore_working_tree - restore_modified_times - end - - Overcommit::GitRepo.restore_merge_state - Overcommit::GitRepo.restore_cherry_pick_state - end - - private - - # Stores the modification times for all modified files to make it appear like - # they never changed. - # - # This prevents (some) editors from complaining about files changing when we - # stash changes before running the hooks. - def store_modified_times - @modified_times = {} - - staged_files = modified_files - unstaged_files = Overcommit::GitRepo.modified_files(staged: false) - - (staged_files + unstaged_files).each do |file| - next if Overcommit::Utils.broken_symlink?(file) - next unless File.exist?(file) # Ignore renamed files (old file no longer exists) - - @modified_times[file] = File.mtime(file) - end - end - - # Returns whether there are any changes to tracked files which have not yet - # been staged. - def unstaged_changes? - result = Overcommit::Utils.execute(%w[git --no-pager diff --quiet]) - !result.success? - end - - def stash_changes - @stash_attempted = true - - stash_message = "Overcommit: Stash of repo state before hook run at #{Time.now}" - result = Overcommit::Utils.with_environment('GIT_LITERAL_PATHSPECS' => '0') do - Overcommit::Utils.execute( - %w[git -c commit.gpgsign=false stash save --quiet] + [stash_message] - ) - end - - unless result.success? - # Failure to stash in this case is likely due to a configuration - # issue (e.g. author/email not set or GPG signing key incorrect) - raise Overcommit::Exceptions::HookSetupFailed, - "Unable to setup environment for #{hook_script_name} hook run:" \ - "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}" - end - - @changes_stashed = `git stash list -1`.include?(stash_message) - @stash_commit = `git rev-parse stash@{0}`.strip if @changes_stashed - end - - # Restore the staged tree explicitly: older Git versions' --keep-index - # leave deleted files and the old paths of renamed files in the working tree. - # read-tree updates both the index and working tree without moving HEAD. - def restore_staged_changes - result = Overcommit::Utils.execute(%w[git read-tree --reset -u stash@{0}^2]) - unless result.success? - raise Overcommit::Exceptions::HookSetupFailed, - "Unable to restore staged changes for #{hook_script_name} hook run:" \ - "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}" - end - end - - # Restores the file modification times for all modified files to make it - # appear like they never changed. - def restore_modified_times - @modified_times.each do |file, time| - next if Overcommit::Utils.broken_symlink?(file) - next unless File.exist?(file) - - File.utime(time, time, file) - end - end - - # Clears the working tree so that the stash can be applied. - def clear_working_tree - removed_submodules = Overcommit::GitRepo.staged_submodule_removals - - result = Overcommit::Utils.execute(%w[git reset --hard]) - unless result.success? - raise Overcommit::Exceptions::HookCleanupFailed, - "Unable to cleanup working tree after #{hook_script_name} hooks run:" \ - "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}" - end - - # Hard-resetting a staged submodule removal results in the index being - # reset but the submodule being restored as an empty directory. This empty - # directory prevents us from stashing on a subsequent run if a hook fails. - # - # Work around this by removing these empty submodule directories as there - # doesn't appear any reason to keep them around. - removed_submodules.each do |submodule| - FileUtils.rmdir(submodule.path) - end - end - - # Applies the stash to the working tree to restore the user's state. - def restore_working_tree - result = Overcommit::Utils.execute(%w[git stash pop --index]) - unless result.success? - raise Overcommit::Exceptions::HookCleanupFailed, - "Unable to restore working tree after #{hook_script_name} hooks run:" \ - "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}" - end - end - - def stash_recovery_message - return '' unless @stash_commit - - "\nYour changes are preserved in Git stash #{@stash_commit}.\n" \ - "Release any file locks and use a clean working tree to recover on a new branch:\n" \ - " git stash branch overcommit-recovery #{@stash_commit}\n" - end - end - end -end diff --git a/lib/overcommit/hook_context/pre_commit.rb b/lib/overcommit/hook_context/pre_commit.rb index cd510605..40b48a49 100644 --- a/lib/overcommit/hook_context/pre_commit.rb +++ b/lib/overcommit/hook_context/pre_commit.rb @@ -2,7 +2,7 @@ require 'fileutils' require 'set' -require_relative 'helpers/stash_unstaged_changes' +require_relative 'helpers/hide_unstaged_changes' require_relative 'helpers/file_modifications' module Overcommit::HookContext @@ -12,7 +12,7 @@ module Overcommit::HookContext # etc. It is also responsible for saving/restoring the state of the repo so # hooks only inspect staged changes. class PreCommit < Base - include Overcommit::HookContext::Helpers::StashUnstagedChanges + include Overcommit::HookContext::Helpers::HideUnstagedChanges include Overcommit::HookContext::Helpers::FileModifications end end diff --git a/script/check_coverage.rb b/script/check_coverage.rb new file mode 100644 index 00000000..ced30509 --- /dev/null +++ b/script/check_coverage.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Merge LCOV line hits across platforms and shards before enforcing coverage. +# Individual shards need not cover every line, and platform-specific lines may +# only run on one operating system. +lines = Hash.new(0) +ARGV.each do |path| + source = nil + File.foreach(path) do |record| + case record + when /^SF:(.+)/ + source = Regexp.last_match(1).strip + when /^DA:(\d+),(\d+)/ + abort "Line coverage without a source file in #{path}" unless source + lines[[source, Regexp.last_match(1).to_i]] += Regexp.last_match(2).to_i + when /^end_of_record/ + source = nil + end + end +end +abort 'No line coverage found' if lines.empty? + +uncovered = lines.select { |_, hits| hits == 0 }.keys +unless uncovered.empty? + warn "Uncovered lines:\n#{uncovered.sort.map { |file, line| "#{file}:#{line}" }.join("\n")}" + abort '100% line coverage is required' +end +puts "Line coverage: 100% (#{lines.size}/#{lines.size})" diff --git a/spec/coverage_check_spec.rb b/spec/coverage_check_spec.rb new file mode 100644 index 00000000..a2bf506a --- /dev/null +++ b/spec/coverage_check_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Exercise the same coverage gate used after CI artifacts are combined. +describe 'combined coverage check' do + let(:script) { File.expand_path('../script/check_coverage.rb', __dir__) } + + def check_coverage(*reports) + result = nil + directory do + paths = reports.each_with_index.map do |report, index| + path = "coverage-#{index}.info" + File.write(path, report) + path + end + result = shell([Gem.ruby, script] + paths) + end + result + end + + it 'combines shards and platforms before requiring every line to be covered' do + result = check_coverage("SF:./lib/a.rb\nDA:1,0\nDA:2,1\nend_of_record\n", + "SF:./lib/a.rb\nDA:1,1\nDA:2,0\nend_of_record\n") + result.status.should == 0 + result.stdout.should include '100% (2/2)' + end + + it 'fails for any uncovered line even if the rounded percentage would be 100%' do + result = check_coverage("SF:./lib/a.rb\nDA:1,99999\nDA:2,0\nend_of_record\n") + result.status.should_not == 0 + result.stderr.should include './lib/a.rb:2', '100% line coverage is required' + end + + it 'does not confuse lines at the same position in different source files' do + result = check_coverage("SF:a.rb\nDA:1,1\nend_of_record\nSF:b.rb\nDA:1,0\nend_of_record\n") + result.status.should_not == 0 + result.stderr.should include 'b.rb:1' + end + + it 'fails when no reports are supplied' do + check_coverage.status.should_not == 0 + end + + it 'fails when reports contain no coverage' do + result = check_coverage('') + result.status.should_not == 0 + result.stderr.should include 'No line coverage found' + end + + it 'rejects line records without a source file' do + result = check_coverage("DA:1,1\n") + result.status.should_not == 0 + result.stderr.should include 'Line coverage without a source file' + end + + it 'resets the source at the end of each record' do + result = check_coverage("SF:a.rb\nDA:1,1\nend_of_record\nDA:2,1\n") + result.status.should_not == 0 + end +end diff --git a/spec/integration/hook_side_effects_spec.rb b/spec/integration/hook_side_effects_spec.rb new file mode 100644 index 00000000..79390472 --- /dev/null +++ b/spec/integration/hook_side_effects_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +[false, true].each do |initial_commit| + context_name = initial_commit ? 'on an initial commit' : 'with existing history' + describe "pre-commit side effects #{context_name}" do + let(:hook_result) { ':pass' } + let(:hook_body) do + <<~RUBY_CODE + raise 'Saw unstaged changes' if File.read('source.txt').include?('unstaged') + File.write('generated.txt', "Generated by hook\\n") + execute(%w[git add generated.txt]) + execute(%w[git rm -f removed.txt]) + File.write('source.txt', File.read('source.txt').sub('staged', 'formatted')) + execute(%w[git add source.txt]) + #{hook_result} + RUBY_CODE + end + + around do |example| + repo do + echo({ + 'CommitMsg' => { 'ALL' => { 'enabled' => false } }, + 'PreCommit' => { + 'ALL' => { 'enabled' => false }, + 'SideEffects' => { 'enabled' => true, 'requires_files' => false, + 'parallelize' => false }, + }, + }.to_yaml, '.overcommit.yml') + FileUtils.mkdir_p('.git-hooks/pre_commit') + echo(<<~RUBY_CODE, '.git-hooks/pre_commit/side_effects.rb') + module Overcommit::Hook::PreCommit + class SideEffects < Base + def run + #{hook_body} + end + end + end + RUBY_CODE + echo('original', 'source.txt') + echo('remove me', 'removed.txt') + shell(%w[git add .]).status.should == 0 + shell(%w[git commit -m Initial]).status.should == 0 unless initial_commit + echo((['staged'] + (1..10).map(&:to_s)).join("\n"), 'source.txt') + shell(%w[git add source.txt]).status.should == 0 + echo('unstaged', 'source.txt', append: true) + install_overcommit_hooks + example.run + end + end + + it 'commits hook additions, deletions, and edits while leaving user changes unstaged' do + result = shell(%w[git commit -m Test]) + expect(result.status).to eq(0), result.stdout + result.stderr + shell(%w[git show HEAD:generated.txt]).stdout.should == "Generated by hook\n" + shell(%w[git show HEAD:source.txt]).stdout.should start_with("formatted\n") + shell(%w[git show HEAD:source.txt]).stdout.should_not include 'unstaged' + shell(%w[git ls-tree --name-only HEAD]).stdout.should_not include 'removed.txt' + File.read('source.txt').should end_with("unstaged\n") + shell(%w[git diff --cached]).stdout.should == '' + shell(%w[git stash list]).stdout.should == '' + end + + context 'when the hook fails' do + let(:hook_result) { '[:fail, "Intentional failure"]' } + + it 'aborts the commit while retaining hook edits and restoring user edits' do + head = shell(%w[git rev-parse --verify HEAD]).stdout + result = shell(%w[git commit -m Test]) + result.status.should_not == 0 + (result.stdout + result.stderr).should include 'Intentional failure' + shell(%w[git rev-parse --verify HEAD]).stdout.should == head + shell(%w[git show :generated.txt]).stdout.should == "Generated by hook\n" + shell(%w[git show :source.txt]).stdout.should start_with("formatted\n") + File.read('source.txt').should end_with("unstaged\n") + File.exist?('removed.txt').should == false + shell(%w[git stash list]).stdout.should == '' + end + end + + context 'when the hook is interrupted' do + let(:hook_result) { 'raise Interrupt' } + + it 'aborts the commit and restores user edits without discarding hook edits' do + result = shell(%w[git commit -m Test]) + result.status.should_not == 0 + File.read('source.txt').should start_with("formatted\n") + File.read('source.txt').should end_with("unstaged\n") + shell(%w[git show :generated.txt]).stdout.should == "Generated by hook\n" + end + end + + context 'when the hook raises' do + let(:hook_result) { "raise 'Intentional error'" } + + it 'restores user edits even after a hook exception' do + result = shell(%w[git commit -m Test]) + result.status.should_not == 0 + (result.stdout + result.stderr).should include 'Intentional error' + File.read('source.txt').should start_with("formatted\n") + File.read('source.txt').should end_with("unstaged\n") + shell(%w[git show :generated.txt]).stdout.should == "Generated by hook\n" + end + end + end +end diff --git a/spec/overcommit/hook_context/commit_msg_spec.rb b/spec/overcommit/hook_context/commit_msg_spec.rb index c5131189..7d8b2252 100644 --- a/spec/overcommit/hook_context/commit_msg_spec.rb +++ b/spec/overcommit/hook_context/commit_msg_spec.rb @@ -282,9 +282,8 @@ end end - it 'does not stash changes' do - expect(context.private_methods).to include :stash_changes - expect(context).not_to receive(:stash_changes) + it 'does not rewrite files' do + expect(context).not_to receive(:apply_unstaged_changes) subject end end @@ -460,10 +459,7 @@ end it 'does not touch the working tree' do - expect(context.private_methods).to include :clear_working_tree - expect(context.private_methods).to include :restore_working_tree - expect(context).not_to receive(:clear_working_tree) - expect(context).not_to receive(:restore_working_tree) + expect(context).not_to receive(:apply_unstaged_changes) subject end end diff --git a/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb b/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb new file mode 100644 index 00000000..ee815294 --- /dev/null +++ b/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb @@ -0,0 +1,430 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'overcommit/hook_context/pre_commit' +require 'overcommit/hook_context/commit_msg' + +[Overcommit::HookContext::PreCommit, Overcommit::HookContext::CommitMsg].each do |hook_context| + describe hook_context do + describe 'hiding unstaged changes alongside staged deletions and renames' do + let(:context) { described_class.new(nil, [], nil) } + let(:renamed_file) { 'renamed file.rb' } + + around do |example| + repo do + echo('Renamed content', 'original.rb') + echo('Deleted content', 'deleted.rb') + echo('Partially staged content', 'partial.rb') + echo('Unchanged content', 'other.rb') + shell(%w[git add .]).status.should == 0 + shell(%w[git commit -m Initial]).status.should == 0 + + echo('Previous stash', 'other.rb') + shell(%w[git stash save --quiet previous-stash]).status.should == 0 + shell(['git', 'mv', 'original.rb', renamed_file]).status.should == 0 + shell(%w[git rm deleted.rb]).status.should == 0 + echo('Staged addition', 'partial.rb', append: true) + shell(%w[git add partial.rb]).status.should == 0 + echo('Unstaged addition', 'partial.rb', append: true) + echo('Unstaged rename edit', renamed_file, append: true) + echo('Unstaged other edit', 'other.rb', append: true) + echo('Untracked content', 'untracked.rb') + example.run + end + end + + before do + allow(context).to receive(:amendment?).and_return(false) + end + + def repository_state + { + head: shell(%w[git rev-parse HEAD]).stdout, + index: shell(%w[git write-tree]).stdout, + status: shell(%w[git status --porcelain -z]).stdout, + staged_diff: shell(%w[git diff --cached --binary]).stdout, + unstaged_diff: shell(%w[git diff --binary]).stdout, + files: Dir['*.rb'].sort.map { |path| [path, File.binread(path)] }, + stashes: shell(%w[git stash list]).stdout, + } + end + + it 'exposes the staged tree without resurrecting deleted paths or moving HEAD' do + head = shell(%w[git rev-parse HEAD]).stdout + index = shell(%w[git write-tree]).stdout + context.setup_environment + + File.exist?('original.rb').should == false + File.exist?('deleted.rb').should == false + File.read(renamed_file).should == "Renamed content\n" + File.read('partial.rb').should == "Partially staged content\nStaged addition\n" + File.read('other.rb').should == "Unchanged content\n" + File.read('untracked.rb').should == "Untracked content\n" + shell(%w[git rev-parse HEAD]).stdout.should == head + shell(%w[git write-tree]).stdout.should == index + end + + it 'restores all staged and unstaged changes and preserves the previous stash' do + state = repository_state + context.setup_environment + context.cleanup_environment + + repository_state.should == state + end + end + end + + describe hook_context, 'hook side effects' do + let(:context) { described_class.new(nil, [], nil) } + let(:original) { (1..20).map { |line| "line #{line}\n" }.join } + let(:staged) { original.sub('line 1', 'staged line 1') } + let(:unstaged) { staged.sub('line 20', 'unstaged line 20') } + + around do |example| + repo do + File.write('source.txt', original) + echo('remove me', 'removed.txt') + shell(%w[git add .]).status.should == 0 + shell(%w[git commit -m Initial]).status.should == 0 + File.write('source.txt', staged) + shell(%w[git add source.txt]).status.should == 0 + File.write('source.txt', unstaged) + example.run + end + end + + def patches + Dir[File.join(File.dirname(Overcommit::Utils.git_path('index')), 'overcommit-*.patch')] + end + + [false, true].each do |stage_changes| + description = stage_changes ? 'in the index' : 'unstaged' + it "keeps hook edits #{description} and restores user edits" do + context.setup_environment + hook_contents = staged.sub('line 2', 'formatted line 2') + File.write('source.txt', hook_contents) + shell(%w[git add source.txt]).status.should == 0 if stage_changes + hook_time = Time.at(1_000_000_000) + File.utime(hook_time, hook_time, 'source.txt') + context.cleanup_environment + + File.read('source.txt').should == hook_contents.sub('line 20', 'unstaged line 20') + shell(%w[git show :source.txt]).stdout.should == (stage_changes ? hook_contents : staged) + File.mtime('source.txt').should_not == hook_time # Applying user edits updates the file. + patches.should be_empty + end + end + + it 'retains newly created, removed, renamed, and staged files' do + context.setup_environment + echo('new file', 'generated.txt') + echo('untracked file', 'untracked.txt') + shell(%w[git add generated.txt]).status.should == 0 + shell(%w[git rm removed.txt]).status.should == 0 + shell(%w[git mv generated.txt renamed.txt]).status.should == 0 + index = shell(%w[git write-tree]).stdout + context.cleanup_environment + + shell(%w[git write-tree]).stdout.should == index + File.read('renamed.txt').should == "new file\n" + File.read('untracked.txt').should == "untracked file\n" + File.exist?('removed.txt').should == false + File.exist?('generated.txt').should == false + File.read('source.txt').should == unstaged + end + + it 'keeps edits and modification times for files without hidden changes' do + context.setup_environment + echo('hook changed another file', 'removed.txt') + hook_time = Time.at(1_000_000_000) + File.utime(hook_time, hook_time, 'removed.txt') + context.cleanup_environment + + File.read('removed.txt').should == "hook changed another file\n" + File.mtime('removed.txt').should == hook_time + end + + it 'does not resurrect files deleted by hooks' do + context.setup_environment + File.unlink('removed.txt') + context.cleanup_environment + File.exist?('removed.txt').should == false + end + + it 'preserves hook changes when restoring conflicting unstaged changes fails' do + context.setup_environment + patch = patches.fetch(0) + File.file?(patch).should == true + # A garbage collection must never remove the recovery data. + GC.start + File.file?(patch).should == true + File.write('source.txt', staged.sub('line 20', 'hook line 20')) + shell(%w[git add source.txt]).status.should == 0 + index = shell(%w[git write-tree]).stdout + + expect { context.cleanup_environment }. + to raise_error(Overcommit::Exceptions::HookCleanupFailed) { |error| + error.message.should include(patch, 'git apply --whitespace=nowarn --') + } + shell(%w[git write-tree]).stdout.should == index + File.read('source.txt').should include 'hook line 20' + File.read(patch).should include 'unstaged line 20' + + # Resolve the conflicting hook edit, then follow the advertised command. + File.write('source.txt', staged) + shell(['git', 'apply', '--whitespace=nowarn', '--', patch]).status.should == 0 + File.read('source.txt').should == unstaged + shell(%w[git write-tree]).stdout.should == index + end + + it 'keeps a patch and the original state when hiding changes fails' do + index = shell(%w[git write-tree]).stdout + allow(Overcommit::Utils).to receive(:execute).and_call_original + allow(Overcommit::Utils).to receive(:execute). + with(array_including('apply', '--reverse')). + and_return(Overcommit::Subprocess::Result.new(128, 'output', 'file locked')) + + expect { context.setup_environment }. + to raise_error(Overcommit::Exceptions::HookSetupFailed) { |error| + error.message.should include('STDOUT:output', 'STDERR:file locked', patches.fetch(0)) + } + File.read('source.txt').should == unstaged + shell(%w[git write-tree]).stdout.should == index + end + + it 'does not modify anything when generating the patch fails' do + allow(Overcommit::Utils).to receive(:execute).and_call_original + allow(Overcommit::Utils).to receive(:execute).with(array_including('diff', '--binary')). + and_return(Overcommit::Subprocess::Result.new(128, '', 'corrupt index')) + + expect { context.setup_environment }. + to raise_error(Overcommit::Exceptions::HookSetupFailed, /corrupt index/) + File.read('source.txt').should == unstaged + patches.should be_empty + end + + it 'leaves changes untouched if the backup cannot be written' do + allow(Tempfile).to receive(:create).and_raise(Errno::EACCES) + expect { context.setup_environment }.to raise_error(Errno::EACCES) + File.read('source.txt').should == unstaged + end + + it 'can clean up twice without applying edits twice' do + context.setup_environment + context.cleanup_environment + context.cleanup_environment + File.read('source.txt').should == unstaged + end + + it 'does not need a commit identity or signing key' do + shell(['git', 'config', 'user.name', '']).status.should == 0 + shell(['git', 'config', 'user.email', '']).status.should == 0 + shell(%w[git config commit.gpgsign true]).status.should == 0 + context.setup_environment + context.cleanup_environment + File.read('source.txt').should == unstaged + end + + it 'leaves merge and cherry-pick metadata byte-for-byte intact' do + metadata = %w[MERGE_HEAD MERGE_MSG MERGE_MODE CHERRY_PICK_HEAD].map do |name| + path = Overcommit::Utils.git_path(name) + File.binwrite(path, "operation state\r\n") + [path, File.binread(path)] + end.to_h + context.setup_environment + metadata.each { |path, contents| File.binread(path).should == contents } + context.cleanup_environment + metadata.each { |path, contents| File.binread(path).should == contents } + end + + it 'ignores external diff, text conversion, color, prefixes, and configured context' do + shell(%w[git config diff.external nonexistent-command]).status.should == 0 + shell(%w[git config diff.noprefix true]).status.should == 0 + shell(%w[git config diff.context 100]).status.should == 0 + shell(%w[git config color.ui always]).status.should == 0 + echo('source.txt diff=custom', '.gitattributes') + shell(%w[git config diff.custom.textconv nonexistent-command]).status.should == 0 + context.setup_environment + File.read('source.txt').should == staged + context.cleanup_environment + File.read('source.txt').should == unstaged + end + + it 'restores unstaged deletions and intent-to-add files without changing the index' do + File.unlink('removed.txt') + echo('intent to add', 'new.txt') + shell(%w[git add --intent-to-add new.txt]).status.should == 0 + index = shell(%w[git ls-files --stage -z]).stdout + context.setup_environment + File.read('removed.txt').should == "remove me\n" + File.exist?('new.txt').should == false + context.cleanup_environment + File.exist?('removed.txt').should == false + File.read('new.txt').should == "intent to add\n" + shell(%w[git ls-files --stage -z]).stdout.should == index + end + + it 'preserves binary data and unusual file names' do + names = ["space and 'quote'.bin", 'unicodé.bin', '-dash.bin'] + names << "tab\tand\nnewline.bin" unless Overcommit::OS.windows? + names.each { |name| File.binwrite(name, "\x00\xFFstaged\r\n") } + shell(['git', 'add', '--'] + names).status.should == 0 + names.each { |name| File.binwrite(name, "\x00\xFEunstaged\r\n") } + context.setup_environment + names.each { |name| File.binread(name).should == "\x00\xFFstaged\r\n".b } + context.cleanup_environment + names.each { |name| File.binread(name).should == "\x00\xFEunstaged\r\n".b } + end + + it 'preserves symlinks, including broken targets, without modifying their targets' do + echo('target', 'target.txt') + symlink('target.txt', 'link') + shell(%w[git add link]).status.should == 0 + File.unlink('link') + symlink('missing.txt', 'link') + context.setup_environment + File.readlink('link').should == 'target.txt' + context.cleanup_environment + File.readlink('link').should == 'missing.txt' + File.read('target.txt').should == "target\n" + File.exist?('missing.txt').should == false + end + + it 'handles file type changes without following symlinks' do + File.unlink('removed.txt') + symlink('source.txt', 'removed.txt') + context.setup_environment + File.symlink?('removed.txt').should == false + File.read('removed.txt').should == "remove me\n" + context.cleanup_environment + File.readlink('removed.txt').should == 'source.txt' + File.read('source.txt').should == unstaged + end + + it 'restores patches at their new positions after a hook inserts lines' do + context.setup_environment + File.write('source.txt', "new header\n#{staged}") + context.cleanup_environment + File.read('source.txt').should == "new header\n#{unstaged}" + end + + it 'does not partially restore other files when one file conflicts' do + echo('unstaged removal edit', 'removed.txt') + context.setup_environment + File.write('source.txt', 'conflicting hook content') + expect { context.cleanup_environment }. + to raise_error(Overcommit::Exceptions::HookCleanupFailed) + File.read('removed.txt').should == "remove me\n" + File.read('source.txt').should == 'conflicting hook content' + patches.should_not be_empty + end + + it 'restores timestamps when hiding and restoring an intent-to-add file' do + echo('intent to add', 'intent.txt') + shell(%w[git add --intent-to-add intent.txt]).status.should == 0 + time = Time.at(1_000_000_000) + File.utime(time, time, 'intent.txt') + context.setup_environment + context.cleanup_environment + File.mtime('intent.txt').should == time + end + + it 'restores timestamps when unstaged contents replace a staged symlink' do + symlink('target.txt', 'link') + shell(%w[git add link]).status.should == 0 + File.unlink('link') + echo('replaced link', 'link') + time = Time.at(1_000_000_000) + File.utime(time, time, 'link') + context.setup_environment + context.cleanup_environment + File.read('link').should == "replaced link\n" + File.mtime('link').should == time + end + + it 'caches the initial commit check, including false results' do + expect(Overcommit::GitRepo).to receive(:initial_commit?).once.and_return(false) + 2.times { context.initial_commit?.should == false } + end + end +end + +describe Overcommit::HookContext::PreCommit, 'repository variants' do + let(:context) { described_class.new(nil, [], nil) } + + it 'uses the active alternate index and preserves edits to it made by hooks' do + repo do + echo('original', 'file.txt') + shell(%w[git add .]).status.should == 0 + shell(%w[git commit -m Initial]).status.should == 0 + default_index = shell(%w[git write-tree]).stdout + alternate_index = File.expand_path('.git/alternate-index') + FileUtils.cp('.git/index', alternate_index) + Overcommit::Utils.with_environment('GIT_INDEX_FILE' => alternate_index, + 'GIT_LITERAL_PATHSPECS' => '1') do + echo('staged', 'file.txt') + shell(%w[git add file.txt]).status.should == 0 + echo('unstaged', 'file.txt', append: true) + context.setup_environment + File.read('file.txt').should == "staged\n" + echo('generated', 'generated.txt') + shell(%w[git add generated.txt]).status.should == 0 + index = shell(%w[git write-tree]).stdout + context.cleanup_environment + shell(%w[git write-tree]).stdout.should == index + File.read('file.txt').should == "staged\nunstaged\n" + end + shell(%w[git write-tree]).stdout.should == default_index + end + end + + it 'keeps recovery patches in the linked worktree Git directory' do + repo do + echo('original', 'file.txt') + shell(%w[git add .]).status.should == 0 + shell(%w[git commit -m Initial]).status.should == 0 + main_git_dir = File.expand_path('.git') + linked = File.join(File.dirname(Dir.pwd), 'linked-worktree') + shell(['git', 'worktree', 'add', '-b', 'linked', linked]).status.should == 0 + Dir.chdir(linked) do + echo('unstaged', 'file.txt') + context.setup_environment + patches = Dir[File.join(main_git_dir, 'worktrees', '*', 'overcommit-*.patch')] + patches.size.should == 1 + File.file?(patches.first).should == true + Dir[File.join(main_git_dir, 'overcommit-*.patch')].should be_empty + context.cleanup_environment + File.read('file.txt').should == "unstaged\n" + File.exist?(patches.first).should == false + end + end + end + + it 'preserves CRLF contents with automatic line ending conversion' do + repo do + shell(%w[git config core.autocrlf true]).status.should == 0 + File.binwrite('file.txt', "staged\r\n") + shell(%w[git add .]).status.should == 0 + File.binwrite('file.txt', "staged\r\nunstaged\r\n") + context.setup_environment + File.binread('file.txt').should == "staged\r\n" + context.cleanup_environment + File.binread('file.txt').should == "staged\r\nunstaged\r\n" + end + end + + it 'restores unstaged executable permission changes' do + skip 'Windows does not support POSIX executable bits' if Overcommit::OS.windows? + + repo do + shell(%w[git config core.filemode true]).status.should == 0 + echo('staged', 'file.txt') + shell(%w[git add .]).status.should == 0 + File.chmod(0o755, 'file.txt') + context.setup_environment + (File.stat('file.txt').mode & 0o111).should == 0 + context.cleanup_environment + (File.stat('file.txt').mode & 0o111).should == 0o111 + end + end +end diff --git a/spec/overcommit/hook_context/helpers/stash_errors_spec.rb b/spec/overcommit/hook_context/helpers/stash_errors_spec.rb deleted file mode 100644 index 07c5dc29..00000000 --- a/spec/overcommit/hook_context/helpers/stash_errors_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'overcommit/hook_context/pre_commit' - -describe Overcommit::HookContext::PreCommit, 'stash failures' do - let(:context) { described_class.new(nil, [], nil) } - - { stash_changes: 'HookSetupFailed', restore_staged_changes: 'HookSetupFailed', - clear_working_tree: 'HookCleanupFailed', restore_working_tree: 'HookCleanupFailed' }. - each do |operation, exception| - it "reports #{operation} command failures" do - allow(Overcommit::GitRepo).to receive(:staged_submodule_removals).and_return([]) - allow(Overcommit::Utils).to receive(:execute). - and_return(double('result', success?: false, stdout: 'output', stderr: 'failure')) - expect { context.send(operation) }. - to raise_error(Overcommit::Exceptions.const_get(exception), - /STDOUT:output.*STDERR:failure/m) - end - end - - it 'removes empty directories restored for staged submodule removals' do - directory do - Dir.mkdir('removed') - allow(Overcommit::GitRepo).to receive(:staged_submodule_removals). - and_return([double('submodule', path: 'removed')]) - allow(Overcommit::Utils).to receive(:execute).and_return(double('result', success?: true)) - context.send(:clear_working_tree) - expect(Dir).not_to exist('removed') - end - end -end diff --git a/spec/overcommit/hook_context/helpers/stash_unstaged_changes_spec.rb b/spec/overcommit/hook_context/helpers/stash_unstaged_changes_spec.rb deleted file mode 100644 index 90f215a0..00000000 --- a/spec/overcommit/hook_context/helpers/stash_unstaged_changes_spec.rb +++ /dev/null @@ -1,152 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'overcommit/hook_context/pre_commit' -require 'overcommit/hook_context/commit_msg' - -[Overcommit::HookContext::PreCommit, Overcommit::HookContext::CommitMsg].each do |hook_context| - describe hook_context do - describe 'stashing staged deletions and renames' do - let(:context) { described_class.new(nil, [], nil) } - let(:renamed_file) { 'renamed file.rb' } - - around do |example| - repo do - echo('Renamed content', 'original.rb') - echo('Deleted content', 'deleted.rb') - echo('Partially staged content', 'partial.rb') - echo('Unchanged content', 'other.rb') - shell(%w[git add .]).status.should == 0 - shell(%w[git commit -m Initial]).status.should == 0 - - echo('Previous stash', 'other.rb') - shell(%w[git stash save --quiet previous-stash]).status.should == 0 - shell(['git', 'mv', 'original.rb', renamed_file]).status.should == 0 - shell(%w[git rm deleted.rb]).status.should == 0 - echo('Staged addition', 'partial.rb', append: true) - shell(%w[git add partial.rb]).status.should == 0 - echo('Unstaged addition', 'partial.rb', append: true) - echo('Unstaged rename edit', renamed_file, append: true) - echo('Unstaged other edit', 'other.rb', append: true) - echo('Untracked content', 'untracked.rb') - example.run - end - end - - before do - allow(context).to receive(:amendment?).and_return(false) - end - - def repository_state - { - head: shell(%w[git rev-parse HEAD]).stdout, - index: shell(%w[git write-tree]).stdout, - status: shell(%w[git status --porcelain -z]).stdout, - staged_diff: shell(%w[git diff --cached --binary]).stdout, - unstaged_diff: shell(%w[git diff --binary]).stdout, - files: Dir['*.rb'].sort.map { |path| [path, File.binread(path)] }, - stashes: shell(%w[git stash list]).stdout, - } - end - - # Git's scripted stash restored the index without updating the working tree, - # then checked out modified index entries. Deleted paths stayed on disk. - # Emulate those operations with real Git commands so this regression is - # exercised even on Git versions whose --keep-index has since been fixed. - def emulate_legacy_stash - allow(Overcommit::Utils).to receive(:execute).and_wrap_original do |original, args, *rest| - if args.include?('stash') && args.include?('--keep-index') - result = original.call(args - ['--keep-index'], *rest) - if result.success? - shell(%w[git read-tree --reset stash@{0}^2]).status.should == 0 - modified = shell(%w[git ls-files -z --modified]).stdout - Overcommit::Subprocess.spawn(%w[git checkout-index -z --force --stdin], - input: modified).status.should == 0 - end - result - else - original.call(args, *rest) - end - end - end - - ['current Git', 'legacy Git stash'].each do |git_implementation| - context "with #{git_implementation}" do - before { emulate_legacy_stash if git_implementation == 'legacy Git stash' } - - it 'exposes the staged tree without resurrecting deleted paths or moving HEAD' do - head = shell(%w[git rev-parse HEAD]).stdout - index = shell(%w[git write-tree]).stdout - context.setup_environment - - File.exist?('original.rb').should == false - File.exist?('deleted.rb').should == false - File.read(renamed_file).should == "Renamed content\n" - File.read('partial.rb').should == "Partially staged content\nStaged addition\n" - File.read('other.rb').should == "Unchanged content\n" - File.read('untracked.rb').should == "Untracked content\n" - shell(%w[git rev-parse HEAD]).stdout.should == head - shell(%w[git write-tree]).stdout.should == index - end - - it 'restores all staged and unstaged changes and preserves the previous stash' do - state = repository_state - context.setup_environment - context.cleanup_environment - - repository_state.should == state - end - end - end - - { - 'restoring the staged tree' => %w[git read-tree --reset -u stash@{0}^2], - 'clearing the working tree' => %w[git reset --hard], - 'restoring the working tree' => %w[git stash pop --index], - }.each do |operation, command| - it "preserves the stash and explains recovery when #{operation} fails" do - state = repository_state - result = Overcommit::Subprocess::Result.new(128, '', 'fatal: file is locked') - allow(Overcommit::Utils).to receive(:execute).and_call_original - allow(Overcommit::Utils).to receive(:execute).with(command).and_return(result) - error = if operation == 'restoring the staged tree' - Overcommit::Exceptions::HookSetupFailed - else - Overcommit::Exceptions::HookCleanupFailed - end - - stash_commit = nil - expect do - context.setup_environment - context.cleanup_environment - end.to raise_error(error) { |failure| - stash_commit = shell(%w[git rev-parse stash@{0}]).stdout.strip - failure.message.should include 'fatal: file is locked' - failure.message.should include "git stash branch overcommit-recovery #{stash_commit}" - } - - # Release the simulated lock and follow the advertised recovery command. - allow(Overcommit::Utils).to receive(:execute).with(command).and_call_original - shell(%w[git reset --hard]).status.should == 0 - shell(['git', 'stash', 'branch', 'overcommit-recovery', stash_commit]).status.should == 0 - repository_state.reject { |key, _| key == :stashes }. - should == state.reject { |key, _| key == :stashes } - shell(%w[git stash list]).stdout.should include 'previous-stash' - end - end - - it 'leaves the previous stash alone if there are no longer any changes to stash' do - shell(%w[git reset --hard]).status.should == 0 - state = repository_state - # Simulate changes disappearing between the initial check and git stash. - allow(context).to receive(:unstaged_changes?).and_return(true) - expect(context).not_to receive(:restore_staged_changes) - - context.setup_environment - context.cleanup_environment - - repository_state.should == state - end - end - end -end diff --git a/spec/overcommit/hook_context/pre_commit_spec.rb b/spec/overcommit/hook_context/pre_commit_spec.rb index 75f5f103..7f1f6abb 100644 --- a/spec/overcommit/hook_context/pre_commit_spec.rb +++ b/spec/overcommit/hook_context/pre_commit_spec.rb @@ -170,9 +170,8 @@ end end - it 'does not stash changes' do - expect(context.private_methods).to include :stash_changes - expect(context).not_to receive(:stash_changes) + it 'does not rewrite files' do + expect(context).not_to receive(:apply_unstaged_changes) subject end end @@ -348,10 +347,7 @@ end it 'does not touch the working tree' do - expect(context.private_methods).to include :clear_working_tree - expect(context.private_methods).to include :restore_working_tree - expect(context).not_to receive(:clear_working_tree) - expect(context).not_to receive(:restore_working_tree) + expect(context).not_to receive(:apply_unstaged_changes) subject end end From 143e21b9bc1920796fa3ab24e551414ce54d2ddd Mon Sep 17 00:00:00 2001 From: Shane da Silva Date: Tue, 10 Nov 2026 20:24:07 -0800 Subject: [PATCH 2/2] Update interrupt guidance and quote recovery paths for each shell --- .../helpers/hide_unstaged_changes.rb | 14 ++++++++++++-- lib/overcommit/printer.rb | 2 +- .../helpers/hide_unstaged_changes_spec.rb | 16 ++++++++++++++++ spec/overcommit/printer_spec.rb | 2 +- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb b/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb index 1a9aa85a..b0878435 100644 --- a/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb +++ b/lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb @@ -79,15 +79,25 @@ def check_patch_result(result, error) recovery = if @unstaged_patch "\nYour unstaged changes are preserved in #{@unstaged_patch}.\n" \ - "Hook changes are preserved. Resolve any conflicts, then run:\n" \ + 'Hook changes are preserved. Resolve any conflicts, then run' \ + "#{Overcommit::OS.windows? ? ' in PowerShell' : ''}:\n" \ ' git apply --whitespace=nowarn -- ' \ - "#{Shellwords.escape(@unstaged_patch)}\n" + "#{quoted_patch_path}\n" end raise error, "Unable to restore or hide unstaged changes for #{hook_script_name} hooks:" \ "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{recovery}" end + def quoted_patch_path + if Overcommit::OS.windows? + # Quote for PowerShell, including literal apostrophes in directory names. + "'#{@unstaged_patch.gsub("'", "''")}'" + else + Shellwords.escape(@unstaged_patch) + end + end + def store_modified_times @modified_times = {} files = modified_files + Overcommit::GitRepo.modified_files(staged: false) diff --git a/lib/overcommit/printer.rb b/lib/overcommit/printer.rb index fcb69653..b6bc7978 100644 --- a/lib/overcommit/printer.rb +++ b/lib/overcommit/printer.rb @@ -50,7 +50,7 @@ def interrupt_triggered def run_interrupted log.newline log.warning '⚠ Hook run interrupted by user' - log.warning "⚠ If files appear modified/missing, check your stash to recover them\n" + log.warning "⚠ Hook changes are kept. Check cleanup errors for recovery instructions.\n" end # Executed when one or more hooks by the end of the run. diff --git a/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb b/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb index ee815294..a173c5c6 100644 --- a/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb +++ b/spec/overcommit/hook_context/helpers/hide_unstaged_changes_spec.rb @@ -342,6 +342,22 @@ def patches File.mtime('link').should == time end + [false, true].each do |windows| + it "quotes recovery paths for #{windows ? 'PowerShell' : 'POSIX shells'}" do + allow(Overcommit::OS).to receive(:windows?).and_return(windows) + path = "C:/path with 'quotes'/backup.patch" + context.instance_variable_set(:@unstaged_patch, path) + result = Overcommit::Subprocess::Result.new(1, '', 'conflict') + expected = windows ? "'C:/path with ''quotes''/backup.patch'" : Shellwords.escape(path) + expect do + context.send(:check_patch_result, result, Overcommit::Exceptions::HookCleanupFailed) + end.to raise_error(Overcommit::Exceptions::HookCleanupFailed) { |error| + error.message.should include expected + error.message.should include 'in PowerShell' if windows + } + end + end + it 'caches the initial commit check, including false results' do expect(Overcommit::GitRepo).to receive(:initial_commit?).once.and_return(false) 2.times { context.initial_commit?.should == false } diff --git a/spec/overcommit/printer_spec.rb b/spec/overcommit/printer_spec.rb index 11cbe840..268157a7 100644 --- a/spec/overcommit/printer_spec.rb +++ b/spec/overcommit/printer_spec.rb @@ -45,7 +45,7 @@ it 'explains how to recover from an interrupted run' do expect(logger).to receive(:warning).with('⚠ Hook run interrupted by user') - expect(logger).to receive(:warning).with(/check your stash/) + expect(logger).to receive(:warning).with(/Hook changes are kept.*recovery instructions/) printer.run_interrupted end