Created
September 4, 2011 18:22
-
-
Save danslimmon/1193268 to your computer and use it in GitHub Desktop.
Deprecation git hook
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
require 'rubygems' | |
require 'json' | |
# This is a git update hook that allows you to declare a given class | |
# or method or what-have-you deprecated. If you try to push deprecated | |
# code to the repo, you'll see an error and the push will fail. The | |
# error also gives you a string that you can stick in your commit | |
# message to bypass the deprecation rule. | |
# | |
# To deprecate something, you put a json file in the deprecations | |
# directory of the master branch. For example, if you put this blob in | |
# deprecations/crufty_class.json, | |
# | |
# { | |
# "regex": "crufty_class", | |
# "message": "crufty_class is deprecated in favor of fancy_class. Read more about fancy_class on the Fancy Spline Reticulation wiki page." | |
# } | |
# | |
# then any time somebody tried to push changes containing the string | |
# "crufty_class" (only changes are detected; uses of crufty_class that | |
# were present before the push will be ignored), they'll get an error | |
# like this: | |
# | |
# -------------------------------------------------------------------------------- | |
# DEPRECATION NOTICE | |
# crufty_class is deprecated in favor of fancy_class. Read more about fancy_class on the Fancy Spline Reticulation wiki page. | |
# | |
# The deprecated code occurs in 'splines/reticulate.php' at line 78: | |
# $cc = new crufty_class(); | |
# | |
# To continue anyway, put this line in your commit message: | |
# | |
# @@ignore_deprec[crufty_class.json] | |
# -------------------------------------------------------------------------------- | |
# A deprecation rule for incoming changes. | |
# | |
# Based on a file from the deprecations directory of the repo. | |
class Deprecation | |
attr_accessor :message, :name | |
def from_json!(json_blob) | |
file_contents = JSON.load(json_blob) | |
@_regex = Regexp.compile(file_contents["regex"]) | |
@message = file_contents["message"] | |
end | |
# Determines whether the given line of code matches | |
def match?(line) | |
[:added, :modified].include?(line.type) and | |
# Exclude comments | |
line.text !~ /^\s*(\/\/|#)/ and | |
line.text =~ @_regex | |
end | |
end | |
# A notice to the user that they have pushed deprecated code | |
class DeprecationNotice | |
# Populates the instance from the relevant Deprecation instance, the Hunk where | |
# the violation was found, and the offset in lines from the beginning of the | |
# hunk. | |
def populate!(deprec, hunk, lineno_offset) | |
@deprec, @hunk, @lineno_offset = deprec, hunk, lineno_offset | |
end | |
# Notifies the user that the push cannot proceed | |
def notify_user | |
puts _notify_text | |
end | |
# Returns the text that should be displayed to the user when prompting to continue. | |
def _notify_text | |
lineno = @hunk.start_line + @lineno_offset | |
"-------------------------------------------------------------------------------- | |
DEPRECATION NOTICE | |
#{@deprec.message} | |
The deprecated code occurs in '#{@hunk.file}' at line #{lineno}: | |
#{@hunk.lines[@lineno_offset].text.lstrip} | |
To continue anyway, put this line in your commit message: | |
@@ignore_deprec[#{@deprec.name}] | |
--------------------------------------------------------------------------------" | |
end | |
end | |
# The set of deprecations | |
class DeprecationSet | |
# Populates the instance from the deprecations files in the repository | |
def from_repo!(push_info) | |
paths = `git ls-tree -r --name-only master deprecations`.split("\n") | |
paths.reject! {|p|; _ignored?(p, push_info)} | |
@_deprecs = paths.map {|p|; _deprec_from_file(p)} | |
nil | |
end | |
# Returns a list of DeprecationNotice instances given the Hunks in the changeset. | |
def matches(hunks) | |
notices = [] | |
hunks.each do |hunk| | |
hunk.lines.each_with_index do |line,i| | |
@_deprecs.each do |deprec| | |
if deprec.match?(line) | |
notice = DeprecationNotice.new | |
notice.populate!(deprec, hunk, i) | |
notices << notice | |
end | |
end | |
end | |
end | |
notices | |
end | |
# Returns a Deprecation instance based on a file in the repo. | |
def _deprec_from_file(path) | |
d = Deprecation.new | |
contents = `git cat-file -p master:#{path}` | |
d.from_json!(contents) | |
d.name = path.split("/")[-1] | |
d | |
end | |
# Determines whether the given deprecation path should be ignored. | |
# | |
# This is based on the commit message. If a commit message contains the | |
# line | |
# | |
# @@ignore_deprec[foo.json] | |
# | |
# Then the foo.json deprecation will be ignored. | |
def _ignored?(path, push_info) | |
deprec_basename = path.split("/")[-1] | |
show_output = `git show --no-color #{push_info.new_commit}` | |
ignored = show_output.split("\n").any? do |line| | |
line =~ /^\s*@@ignore_deprec\[#{deprec_basename}\]/ | |
end | |
if ignored | |
puts "Ignoring deprecation '#{deprec_basename}'" | |
end | |
ignored | |
end | |
end | |
# The description of the push that we were given by get when called | |
class PushInfo | |
attr_accessor :branch, :old_commit, :new_commit | |
# Populates the instance given the arguments that were passed to the hook. | |
def from_arguments!(args) | |
@branch, @old_commit, @new_commit = args | |
if @old_commit =~ /^0{40}$/ | |
# If `old_commit` is just a bunch of 0s, we have a new branch. | |
# | |
# Therefore the best we can do is compare `new_commit` against its parent. | |
parent_line = `git show-branch --sha1-name #{@new_commit}^` | |
parent_line =~ /^\[([0-9a-f]+)\]/ | |
@old_commit = $1 | |
end | |
end | |
end | |
class HunkLine | |
attr_accessor :text, :type | |
def populate!(diff_line) | |
diff_line =~ /^( |\+|-|!)(.*)/ | |
@type = {" " => :unchanged, | |
"+" => :added, | |
"-" => :removed, | |
"!" => :modified}[$1] | |
@text = $2 | |
nil | |
end | |
end | |
# A hunk from the changeset. | |
class Hunk | |
attr_accessor :file, :start_line, :lines | |
def initialize; @lines = []; end | |
def add_line!(diff_line) | |
hunk_line = HunkLine.new | |
hunk_line.populate!(diff_line) | |
if hunk_line.type == :removed | |
# Don't want to include lines that aren't in the new commit. | |
return | |
end | |
@lines << hunk_line | |
nil | |
end | |
end | |
# Parses a hunk from a diff | |
class DiffParser | |
# Parses the given list of lines from a diff, returning a Hunk instance | |
def parse(diff_lines) | |
h = Hunk.new | |
diff_lines.each do |diff_line| | |
h.add_line!(diff_line) | |
end | |
h | |
end | |
end | |
# Parses a git diff | |
class PatchParser | |
def new_hunks(patch_text) | |
hunks = [] | |
inside_diff = false | |
diff_lines = [] | |
file = nil | |
start_line = nil | |
patch_text.split("\n").each do |patch_line| | |
# A line starting with 'diff' occurs at the end of every hunk. | |
if patch_line =~ /^diff / and inside_diff | |
new_hunk = DiffParser.new.parse(diff_lines) | |
new_hunk.file, new_hunk.start_line = file, start_line | |
hunks << new_hunk | |
inside_diff = false | |
diff_lines = [] | |
next | |
end | |
# When we get to the actual diff lines, stick them in a list and send them | |
# to a HunkParser instance. By this time, we should have set 'file' and | |
# 'start_line' already. | |
if inside_diff | |
diff_lines << patch_line | |
end | |
# Get the name of the file of which the hunk is part (didn't end that comment | |
# with a preposition: score.) | |
if patch_line =~ /^\+\+\+ b\/(.*)/ | |
file = $1 | |
next | |
end | |
# Get the line number of the first line of the hunk. | |
if patch_line =~ /^@@ -\d+,\d+ \+(\d+),\d+ @@/ | |
start_line = $1.to_i | |
inside_diff = true | |
next | |
end | |
end | |
new_hunk = DiffParser.new.parse(diff_lines) | |
new_hunk.file, new_hunk.start_line = file, start_line | |
hunks << new_hunk | |
hunks | |
end | |
end | |
# The set of changes being pushed | |
class Changeset | |
# Returns the list of hunks modified or added in the commit that's being pushed | |
# | |
# That is, this method will return a list of Hunk instances, each containing in its 'lines' | |
# attribute a list of lines as they appear as a result of the push. | |
def new_hunks(push_info) | |
if @_new_hunks.nil? | |
patch_text = `git diff -p --no-color #{push_info.old_commit} #{push_info.new_commit}` | |
patch_parser = PatchParser.new | |
@_new_hunks = patch_parser.new_hunks(patch_text) | |
end | |
@_new_hunks | |
end | |
end | |
push_info = PushInfo.new() | |
push_info.from_arguments!(ARGV) | |
changeset = Changeset.new() | |
new_hunks = changeset.new_hunks(push_info) | |
new_hunks.reject! do |hunk| | |
# Don't want to compare the deprecation files themselves. | |
hunk.file =~ /^deprecations\// | |
end | |
deprecs = DeprecationSet.new() | |
deprecs.from_repo!(push_info) | |
notices = deprecs.matches(new_hunks) | |
notices.each do |notice| | |
notice.notify_user | |
end | |
unless notices.empty? | |
exit 1 | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment