diff --git a/lib/overcommit/hook_context/commit_msg.rb b/lib/overcommit/hook_context/commit_msg.rb index e73aa3ff..ae32e55d 100644 --- a/lib/overcommit/hook_context/commit_msg.rb +++ b/lib/overcommit/hook_context/commit_msg.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true +require_relative 'pre_commit' +require_relative 'helpers/stash_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::FileModifications + def empty_message? commit_message.strip.empty? end diff --git a/lib/overcommit/hook_context/helpers/file_modifications.rb b/lib/overcommit/hook_context/helpers/file_modifications.rb new file mode 100644 index 00000000..9eade907 --- /dev/null +++ b/lib/overcommit/hook_context/helpers/file_modifications.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Overcommit::HookContext + module Helpers + # This module contains methods for determining what files were changed and on what unique line + # numbers did the change occur. + module FileModifications + # Returns whether this hook run was triggered by `git commit --amend` + def amendment? + return @amendment unless @amendment.nil? + + cmd = Overcommit::Utils.parent_command + return unless cmd + amend_pattern = 'commit(\s.*)?\s--amend(\s|$)' + + # Since the ps command can return invalid byte sequences for commands + # containing unicode characters, we replace the offending characters, + # since the pattern we're looking for will consist of ASCII characters + unless cmd.valid_encoding? + cmd = Overcommit::Utils. + parent_command. + encode('UTF-16be', invalid: :replace, replace: '?'). + encode('UTF-8') + end + + return @amendment if + # True if the command is a commit with the --amend flag + @amendment = !(/\s#{amend_pattern}/ =~ cmd).nil? + + # Check for git aliases that call `commit --amend` + `git config --get-regexp "^alias\\." "#{amend_pattern}"`. + scan(/alias\.([-\w]+)/). # Extract the alias + each do |match| + return @amendment if + # True if the command uses a git alias for `commit --amend` + @amendment = !(/git(\.exe)?\s+#{match[0]}/ =~ cmd).nil? + end + + @amendment + end + + # Get a list of added, copied, or modified files that have been staged. + # Renames and deletions are ignored, since there should be nothing to check. + def modified_files + unless @modified_files + currently_staged = Overcommit::GitRepo.modified_files(staged: true) + @modified_files = currently_staged + + # Include files modified in last commit if amending + if amendment? + subcmd = 'show --format=%n' + previously_modified = Overcommit::GitRepo.modified_files(subcmd: subcmd) + @modified_files |= filter_modified_files(previously_modified) + end + end + @modified_files + end + + # Returns the set of line numbers corresponding to the lines that were + # changed in a specified file. + def modified_lines_in_file(file) + @modified_lines ||= {} + unless @modified_lines[file] + @modified_lines[file] = + Overcommit::GitRepo.extract_modified_lines(file, staged: true) + + # Include lines modified in last commit if amending + if amendment? + subcmd = 'show --format=%n' + @modified_lines[file] += + Overcommit::GitRepo.extract_modified_lines(file, subcmd: subcmd) + end + end + @modified_lines[file] + 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 new file mode 100644 index 00000000..e015453a --- /dev/null +++ b/lib/overcommit/hook_context/helpers/stash_unstaged_changes.rb @@ -0,0 +1,141 @@ +# 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 + + # While running hooks make it appear as if nothing changed + restore_modified_times + end + 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 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 + + # 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 --keep-index --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) + 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}" + 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 --quiet]) + unless result.success? + raise Overcommit::Exceptions::HookCleanupFailed, + "Unable to restore working tree after #{hook_script_name} hooks run:" \ + "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}" + end + end + end + end +end diff --git a/lib/overcommit/hook_context/pre_commit.rb b/lib/overcommit/hook_context/pre_commit.rb index 6c73cb3b..cd510605 100644 --- a/lib/overcommit/hook_context/pre_commit.rb +++ b/lib/overcommit/hook_context/pre_commit.rb @@ -2,6 +2,8 @@ require 'fileutils' require 'set' +require_relative 'helpers/stash_unstaged_changes' +require_relative 'helpers/file_modifications' module Overcommit::HookContext # Contains helpers related to contextual information used by pre-commit hooks. @@ -9,204 +11,8 @@ module Overcommit::HookContext # This includes staged files, which lines of those files have been modified, # etc. It is also responsible for saving/restoring the state of the repo so # hooks only inspect staged changes. - class PreCommit < Base # rubocop:disable ClassLength - # Returns whether this hook run was triggered by `git commit --amend` - def amendment? - return @amendment unless @amendment.nil? - - cmd = Overcommit::Utils.parent_command - return unless cmd - amend_pattern = 'commit(\s.*)?\s--amend(\s|$)' - - # Since the ps command can return invalid byte sequences for commands - # containing unicode characters, we replace the offending characters, - # since the pattern we're looking for will consist of ASCII characters - unless cmd.valid_encoding? - cmd = Overcommit::Utils.parent_command.encode('UTF-16be', invalid: :replace, replace: '?'). - encode('UTF-8') - end - - return @amendment if - # True if the command is a commit with the --amend flag - @amendment = !(/\s#{amend_pattern}/ =~ cmd).nil? - - # Check for git aliases that call `commit --amend` - `git config --get-regexp "^alias\\." "#{amend_pattern}"`. - scan(/alias\.([-\w]+)/). # Extract the alias - each do |match| - return @amendment if - # True if the command uses a git alias for `commit --amend` - @amendment = !(/git(\.exe)?\s+#{match[0]}/ =~ cmd).nil? - end - - @amendment - end - - # 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 - - # While running hooks make it appear as if nothing changed - restore_modified_times - end - 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 - - # Get a list of added, copied, or modified files that have been staged. - # Renames and deletions are ignored, since there should be nothing to check. - def modified_files - unless @modified_files - currently_staged = Overcommit::GitRepo.modified_files(staged: true) - @modified_files = currently_staged - - # Include files modified in last commit if amending - if amendment? - subcmd = 'show --format=%n' - previously_modified = Overcommit::GitRepo.modified_files(subcmd: subcmd) - @modified_files |= filter_modified_files(previously_modified) - end - end - @modified_files - end - - # Returns the set of line numbers corresponding to the lines that were - # changed in a specified file. - def modified_lines_in_file(file) - @modified_lines ||= {} - unless @modified_lines[file] - @modified_lines[file] = - Overcommit::GitRepo.extract_modified_lines(file, staged: true) - - # Include lines modified in last commit if amending - if amendment? - subcmd = 'show --format=%n' - @modified_lines[file] += - Overcommit::GitRepo.extract_modified_lines(file, subcmd: subcmd) - end - end - @modified_lines[file] - 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 - - private - - 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 --keep-index --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) - 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}" - 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 --quiet]) - unless result.success? - raise Overcommit::Exceptions::HookCleanupFailed, - "Unable to restore working tree after #{hook_script_name} hooks run:" \ - "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}" - 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 - - # 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 - - # 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 + class PreCommit < Base + include Overcommit::HookContext::Helpers::StashUnstagedChanges + include Overcommit::HookContext::Helpers::FileModifications end end diff --git a/spec/overcommit/hook_context/commit_msg_spec.rb b/spec/overcommit/hook_context/commit_msg_spec.rb index 2078f463..2aa68516 100644 --- a/spec/overcommit/hook_context/commit_msg_spec.rb +++ b/spec/overcommit/hook_context/commit_msg_spec.rb @@ -99,4 +99,669 @@ subject.should end_with "git commit --edit --file=#{commit_message_file}" end end + + describe '#amendment?' do + subject { context.amendment? } + + before do + Overcommit::Utils.stub(:parent_command).and_return(command) + end + + context 'when amending a commit using `git commit --amend`' do + let(:command) { 'git commit --amend' } + + it { should == true } + end + + context 'when the parent command contains invalid byte sequence' do + let(:command) { "git commit --amend -m \xE3M^AM^B" } + + it { should == true } + end + + context 'when amending a commit using a git alias' do + around do |example| + repo do + `git config alias.amend "commit --amend"` + `git config alias.other-amend "commit --amend"` + example.run + end + end + + context 'when using one of multiple aliases' do + let(:command) { 'git amend' } + + it { should == true } + end + + context 'when using another of multiple aliases' do + let(:command) { 'git other-amend' } + + it { should == true } + end + end + + context 'when not amending a commit' do + context 'using `git commit`' do + let(:command) { 'git commit' } + + it { should == false } + end + + context 'using a git alias containing "--amend"' do + let(:command) { 'git no--amend' } + + around do |example| + repo do + `git config alias.no--amend commit` + example.run + end + end + + it { should == false } + end + end + end + + describe '#setup_environment' do + subject { context.setup_environment } + + context 'when there are no staged changes' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + echo('Hello Other World', 'other-tracked-file') + `git add tracked-file other-tracked-file` + `git commit -m "Add tracked-file and other-tracked-file"` + echo('Hello Again', 'untracked-file') + echo('Some more text', 'other-tracked-file', append: true) + example.run + end + end + + it 'keeps already-committed files' do + subject + File.open('tracked-file', 'r').read.should == "Hello World\n" + end + + it 'does not keep unstaged changes' do + subject + File.open('other-tracked-file', 'r').read.should == "Hello Other World\n" + end + + it 'keeps untracked files' do + subject + File.open('untracked-file', 'r').read.should == "Hello Again\n" + end + + it 'keeps modification times the same' do + sleep 1 + expect { subject }.to_not change { + [ + File.mtime('tracked-file'), + File.mtime('other-tracked-file'), + File.mtime('untracked-file') + ] + } + end + end + + context 'when there are staged changes' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + echo('Hello Other World', 'other-tracked-file') + `git add tracked-file other-tracked-file` + `git commit -m "Add tracked-file and other-tracked-file"` + echo('Hello Again', 'untracked-file') + echo('Some more text', 'tracked-file', append: true) + echo('Some more text', 'other-tracked-file', append: true) + `git add tracked-file` + echo('Yet some more text', 'tracked-file', append: true) + example.run + end + end + + it 'keeps staged changes' do + subject + File.open('tracked-file', 'r').read.should == "Hello World\nSome more text\n" + end + + it 'does not keep unstaged changes' do + subject + File.open('other-tracked-file', 'r').read.should == "Hello Other World\n" + end + + it 'keeps untracked files' do + subject + File.open('untracked-file', 'r').read.should == "Hello Again\n" + end + + it 'keeps modification times the same' do + sleep 1 + expect { subject }.to_not change { + [ + File.mtime('tracked-file'), + File.mtime('other-tracked-file'), + File.mtime('untracked-file') + ] + } + end + end + + context 'when all changes have been staged' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + `git add tracked-file` + `git commit -m "Add tracked-file"` + echo('Hello Other World', 'other-tracked-file') + `git add other-tracked-file` + example.run + end + end + + it 'does not stash changes' do + expect(context.private_methods).to include :stash_changes + expect(context).not_to receive(:stash_changes) + subject + end + end + + context 'when renaming a file during an amendment' do + around do |example| + repo do + `git commit --allow-empty -m "Initial commit"` + touch 'some-file' + `git add some-file` + `git commit -m "Add file"` + `git mv some-file renamed-file` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it 'does not try to update modification time of the old non-existent file' do + File.should_receive(:mtime).with(/renamed-file/) + File.should_not_receive(:mtime).with(/some-file/) + subject + end + end + + context 'when only a submodule change is staged' do + around do |example| + submodule = repo do + `git commit --allow-empty -m "Initial commit"` + end + + repo do + `git submodule add #{submodule} sub > #{File::NULL} 2>&1` + `git commit -m "Add submodule"` + echo('Hello World', 'sub/submodule-file') + `git submodule foreach "git add submodule-file" < #{File::NULL}` + `git submodule foreach "git config --local commit.gpgsign false"` + `git submodule foreach "git commit -m \\"Another commit\\"" < #{File::NULL}` + `git add sub` + example.run + end + end + + it 'keeps staged submodule change' do + `git config diff.submodule short` + expect { subject }.to_not change { + (`git diff --cached` =~ /-Subproject commit[\s\S]*\+Subproject commit/).nil? + }.from(false) + end + end + + # Git cannot track Windows symlinks + unless Overcommit::OS.windows? + context 'when a broken symlink is staged' do + around do |example| + repo do + Overcommit::Utils::FileUtils.symlink('non-existent-file', 'symlink') + `git add symlink` + example.run + end + end + + it 'does not attempt to update/restore the modification time of the file' do + File.should_not_receive(:mtime) + File.should_not_receive(:utime) + subject + end + end + end + end + + describe '#cleanup_environment' do + subject { context.cleanup_environment } + + before do + context.setup_environment + end + + context 'when there were no staged changes' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + echo('Hello Other World', 'other-tracked-file') + `git add tracked-file other-tracked-file` + `git commit -m "Add tracked-file and other-tracked-file"` + echo('Hello Again', 'untracked-file') + echo('Some more text', 'other-tracked-file', append: true) + example.run + end + end + + it 'restores the unstaged changes' do + subject + File.open('other-tracked-file', 'r').read. + should == "Hello Other World\nSome more text\n" + end + + it 'keeps already-committed files' do + subject + File.open('tracked-file', 'r').read.should == "Hello World\n" + end + + it 'keeps untracked files' do + subject + File.open('untracked-file', 'r').read.should == "Hello Again\n" + end + + it 'keeps modification times the same' do + sleep 1 + expect { subject }.to_not change { + [ + File.mtime('tracked-file'), + File.mtime('other-tracked-file'), + File.mtime('untracked-file') + ] + } + end + end + + context 'when there were staged changes' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + echo('Hello Other World', 'other-tracked-file') + `git add tracked-file other-tracked-file` + `git commit -m "Add tracked-file and other-tracked-file"` + echo('Hello Again', 'untracked-file') + echo('Some more text', 'tracked-file', append: true) + echo('Some more text', 'other-tracked-file', append: true) + `git add tracked-file` + echo('Yet some more text', 'tracked-file', append: true) + example.run + end + end + + it 'restores the unstaged changes' do + subject + File.open('tracked-file', 'r').read. + should == "Hello World\nSome more text\nYet some more text\n" + end + + it 'keeps staged changes' do + subject + `git show :tracked-file`.should == "Hello World\nSome more text\n" + end + + it 'keeps untracked files' do + subject + File.open('untracked-file', 'r').read.should == "Hello Again\n" + end + + it 'keeps modification times the same' do + sleep 1 + expect { subject }.to_not change { + [ + File.mtime('tracked-file'), + File.mtime('other-tracked-file'), + File.mtime('untracked-file') + ] + } + end + end + + context 'when all changes were staged' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + `git add tracked-file` + `git commit -m "Add tracked-file"` + echo('Hello Other World', 'other-tracked-file') + `git add other-tracked-file` + example.run + end + 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) + subject + end + end + + context 'when there were deleted files' do + around do |example| + repo do + echo('Hello World', 'tracked-file') + `git add tracked-file` + `git commit -m "Add tracked-file"` + `git rm tracked-file` + example.run + end + end + + it 'deletes the file' do + subject + File.exist?('tracked-file').should == false + end + end + + context 'when only a submodule change was staged' do + around do |example| + submodule = repo do + `git commit --allow-empty -m "Initial commit"` + end + + repo do + `git submodule add #{submodule} sub > #{File::NULL} 2>&1` + `git commit -m "Add submodule"` + echo('Hello World', 'sub/submodule-file') + `git submodule foreach "git add submodule-file" < #{File::NULL}` + `git submodule foreach "git config --local commit.gpgsign false"` + `git submodule foreach "git commit -m \\"Another commit\\"" < #{File::NULL}` + `git add sub` + example.run + end + end + + it 'keeps staged submodule change' do + `git config diff.submodule short` + expect { subject }.to_not change { + (`git diff --cached` =~ /-Subproject commit[\s\S]*\+Subproject commit/).nil? + }.from(false) + end + end + + context 'when submodule changes were staged along with other changes' do + around do |example| + submodule = repo do + `git commit --allow-empty -m "Initial commit"` + end + + repo do + `git submodule add #{submodule} sub > #{File::NULL} 2>&1` + `git commit -m "Add submodule"` + echo('Hello World', 'sub/submodule-file') + `git submodule foreach "git add submodule-file" < #{File::NULL}` + `git submodule foreach "git config --local commit.gpgsign false"` + `git submodule foreach "git commit -m \\"Another commit\\"" < #{File::NULL}` + echo('Hello Again', 'tracked-file') + `git add sub tracked-file` + example.run + end + end + + it 'keeps staged submodule change' do + `git config diff.submodule short` + expect { subject }.to_not change { + (`git diff --cached` =~ /-Subproject commit[\s\S]*\+Subproject commit/).nil? + }.from(false) + end + + it 'keeps staged file change' do + subject + `git show :tracked-file`.should == "Hello Again\n" + end + end + + context 'when a submodule removal was staged' do + around do |example| + submodule = repo do + `git commit --allow-empty -m "Initial commit"` + end + + repo do + `git submodule add #{submodule} sub > #{File::NULL} 2>&1` + `git commit -m "Add submodule"` + `git rm sub` + example.run + end + end + + it 'does not leave behind an empty submodule directory' do + subject + File.exist?('sub').should == false + end + end + end + + describe '#modified_files' do + subject { context.modified_files } + + before do + context.stub(:amendment?).and_return(false) + end + + it 'does not include submodules' do + submodule = repo do + touch 'foo' + `git add foo` + `git commit -m "Initial commit"` + end + + repo do + `git submodule add #{submodule} test-sub 2>&1 > #{File::NULL}` + expect(subject).to_not include File.expand_path('test-sub') + end + end + + context 'when no files were staged' do + around do |example| + repo do + example.run + end + end + + it { should be_empty } + end + + context 'when files were added' do + around do |example| + repo do + touch('some-file') + `git add some-file` + example.run + end + end + + it { should == [File.expand_path('some-file')] } + end + + context 'when files were modified' do + around do |example| + repo do + touch('some-file') + `git add some-file` + `git commit -m "Initial commit"` + echo('Hello', 'some-file') + `git add some-file` + example.run + end + end + + it { should == [File.expand_path('some-file')] } + end + + context 'when files were deleted' do + around do |example| + repo do + touch('some-file') + `git add some-file` + `git commit -m "Initial commit"` + `git rm some-file` + example.run + end + end + + it { should be_empty } + end + + context 'when amending last commit' do + around do |example| + repo do + touch('some-file') + `git add some-file` + `git commit -m "Initial commit"` + touch('other-file') + `git add other-file` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it { should =~ [File.expand_path('some-file'), File.expand_path('other-file')] } + end + + context 'when renaming a file during an amendment' do + around do |example| + repo do + `git commit --allow-empty -m "Initial commit"` + touch 'some-file' + `git add some-file` + `git commit -m "Add file"` + `git mv some-file renamed-file` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it 'does not include the old file name in the list of modified files' do + subject.should_not include File.expand_path('some-file') + end + end + + # Git cannot track Windows symlinks + unless Overcommit::OS.windows? + context 'when changing a symlink to a directory during an amendment' do + around do |example| + repo do + `git commit --allow-empty -m "Initial commit"` + FileUtils.mkdir 'some-directory' + symlink('some-directory', 'some-symlink') + `git add some-symlink some-directory` + `git commit -m "Add file"` + `git rm some-symlink` + FileUtils.mkdir 'some-symlink' + touch File.join('some-symlink', 'another-file') + `git add some-symlink` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it 'does not include the directory in the list of modified files' do + subject.should_not include File.expand_path('some-symlink') + end + end + + context 'when breaking a symlink during an amendment' do + around do |example| + repo do + `git commit --allow-empty -m "Initial commit"` + FileUtils.mkdir 'some-directory' + touch File.join('some-directory', 'some-file') + symlink('some-directory', 'some-symlink') + `git add some-symlink some-directory` + `git commit -m "Add file"` + `git rm -rf some-directory` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it 'still includes the broken symlink in the list of modified files' do + subject.should include File.expand_path('some-symlink') + end + end + end + end + + describe '#modified_lines_in_file' do + let(:modified_file) { 'some-file' } + subject { context.modified_lines_in_file(modified_file) } + + before do + context.stub(:amendment?).and_return(false) + end + + context 'when file contains a trailing newline' do + around do |example| + repo do + File.open(modified_file, 'w') { |f| (1..3).each { |i| f.write("#{i}\n") } } + `git add #{modified_file}` + example.run + end + end + + it { should == Set.new(1..3) } + end + + context 'when file does not contain a trailing newline' do + around do |example| + repo do + File.open(modified_file, 'w') do |f| + (1..2).each { |i| f.write("#{i}\n") } + f.write(3) + end + + `git add #{modified_file}` + example.run + end + end + + it { should == Set.new(1..3) } + end + + context 'when amending last commit' do + around do |example| + repo do + File.open(modified_file, 'w') { |f| (1..3).each { |i| f.write("#{i}\n") } } + `git add #{modified_file}` + `git commit -m "Add files"` + File.open(modified_file, 'a') { |f| f.puts 4 } + `git add #{modified_file}` + example.run + end + end + + before do + context.stub(:amendment?).and_return(true) + end + + it { should == Set.new(1..4) } + end + end end