|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'tempfile' |
| 4 | +require 'digest' |
| 5 | +require 'shellwords' |
| 6 | + |
| 7 | +module Overcommit::HookContext |
| 8 | + module Helpers |
| 9 | + # Hide unstaged changes without moving HEAD, rewriting the index, or using |
| 10 | + # the stash. Hooks retain the same file paths and can change the index. |
| 11 | + module HideUnstagedChanges |
| 12 | + def setup_environment |
| 13 | + store_modified_times |
| 14 | + save_unstaged_changes |
| 15 | + return unless @unstaged_patch |
| 16 | + |
| 17 | + apply_unstaged_changes(reverse: true) |
| 18 | + restore_modified_times |
| 19 | + @hook_contents = file_contents |
| 20 | + end |
| 21 | + |
| 22 | + # Returns whether the current git branch is empty (has no commits). |
| 23 | + def initial_commit? |
| 24 | + return @initial_commit unless @initial_commit.nil? |
| 25 | + |
| 26 | + @initial_commit = Overcommit::GitRepo.initial_commit? |
| 27 | + end |
| 28 | + |
| 29 | + def cleanup_environment |
| 30 | + return unless @unstaged_patch |
| 31 | + |
| 32 | + # Retain modification times for hook edits so file watchers see them. |
| 33 | + unchanged_files = file_contents.select { |file, contents| @hook_contents[file] == contents } |
| 34 | + apply_unstaged_changes |
| 35 | + restore_modified_times(unchanged_files.keys) |
| 36 | + File.unlink(@unstaged_patch) |
| 37 | + @unstaged_patch = nil |
| 38 | + end |
| 39 | + |
| 40 | + private |
| 41 | + |
| 42 | + def save_unstaged_changes |
| 43 | + result = Overcommit::Utils.execute(%w[ |
| 44 | + git diff --binary --full-index --no-color --no-ext-diff --no-textconv |
| 45 | + --no-renames --ignore-submodules=all --src-prefix=a/ --dst-prefix=b/ --unified=3 -- |
| 46 | + ]) |
| 47 | + check_patch_result(result, Overcommit::Exceptions::HookSetupFailed) |
| 48 | + return if result.stdout.empty? |
| 49 | + |
| 50 | + # Tempfile.create returns a regular File: neither GC nor process exit |
| 51 | + # removes the recovery patch if setup, hooks, or cleanup are interrupted. |
| 52 | + file = Tempfile.create(['overcommit-unstaged-', '.patch'], |
| 53 | + File.dirname(Overcommit::Utils.git_path('index'))) |
| 54 | + begin |
| 55 | + file.binmode |
| 56 | + file.write(result.stdout) |
| 57 | + file.flush |
| 58 | + file.fsync |
| 59 | + @unstaged_patch = file.path |
| 60 | + ensure |
| 61 | + file.close |
| 62 | + end |
| 63 | + end |
| 64 | + |
| 65 | + def apply_unstaged_changes(reverse: false) |
| 66 | + command = %w[git apply --whitespace=nowarn] |
| 67 | + command << '--reverse' if reverse |
| 68 | + result = Overcommit::Utils.execute(command + ['--', @unstaged_patch]) |
| 69 | + error = if reverse |
| 70 | + Overcommit::Exceptions::HookSetupFailed |
| 71 | + else |
| 72 | + Overcommit::Exceptions::HookCleanupFailed |
| 73 | + end |
| 74 | + check_patch_result(result, error) |
| 75 | + end |
| 76 | + |
| 77 | + def check_patch_result(result, error) |
| 78 | + return if result.success? |
| 79 | + |
| 80 | + recovery = if @unstaged_patch |
| 81 | + "\nYour unstaged changes are preserved in #{@unstaged_patch}.\n" \ |
| 82 | + 'Hook changes are preserved. Resolve any conflicts, then run' \ |
| 83 | + "#{Overcommit::OS.windows? ? ' in PowerShell' : ''}:\n" \ |
| 84 | + ' git apply --whitespace=nowarn -- ' \ |
| 85 | + "#{quoted_patch_path}\n" |
| 86 | + end |
| 87 | + raise error, |
| 88 | + "Unable to restore or hide unstaged changes for #{hook_script_name} hooks:" \ |
| 89 | + "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{recovery}" |
| 90 | + end |
| 91 | + |
| 92 | + def quoted_patch_path |
| 93 | + if Overcommit::OS.windows? |
| 94 | + # Quote for PowerShell, including literal apostrophes in directory names. |
| 95 | + "'#{@unstaged_patch.gsub("'", "''")}'" |
| 96 | + else |
| 97 | + Shellwords.escape(@unstaged_patch) |
| 98 | + end |
| 99 | + end |
| 100 | + |
| 101 | + def store_modified_times |
| 102 | + @modified_times = {} |
| 103 | + files = modified_files + Overcommit::GitRepo.modified_files(staged: false) |
| 104 | + files.each do |file| |
| 105 | + next if Overcommit::Utils::FileUtils.symlink?(file) |
| 106 | + next unless File.file?(file) |
| 107 | + |
| 108 | + @modified_times[file] = File.mtime(file) |
| 109 | + end |
| 110 | + end |
| 111 | + |
| 112 | + def file_contents |
| 113 | + @modified_times.keys.each_with_object({}) do |file, contents| |
| 114 | + contents[file] = if Overcommit::Utils::FileUtils.symlink?(file) |
| 115 | + [:symlink, Overcommit::Utils::FileUtils.readlink(file)] |
| 116 | + elsif File.file?(file) |
| 117 | + [:file, Digest::SHA256.file(file).hexdigest] |
| 118 | + end |
| 119 | + end |
| 120 | + end |
| 121 | + |
| 122 | + def restore_modified_times(files = @modified_times.keys) |
| 123 | + files.each do |file| |
| 124 | + next if Overcommit::Utils::FileUtils.symlink?(file) |
| 125 | + next unless File.file?(file) |
| 126 | + |
| 127 | + File.utime(@modified_times.fetch(file), @modified_times.fetch(file), file) |
| 128 | + end |
| 129 | + end |
| 130 | + end |
| 131 | + end |
| 132 | +end |
0 commit comments