Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,20 +555,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)
Expand Down
4 changes: 2 additions & 2 deletions lib/overcommit/hook_context/commit_msg.rb
Original file line number Diff line number Diff line change
@@ -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?
Expand Down
132 changes: 132 additions & 0 deletions lib/overcommit/hook_context/helpers/hide_unstaged_changes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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' \
"#{Overcommit::OS.windows? ? ' in PowerShell' : ''}:\n" \
' git apply --whitespace=nowarn -- ' \
"#{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)
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
Loading