Skip to content

Instantly share code, notes, and snippets.

@danslimmon
Created September 4, 2011 18:22
Show Gist options
  • Save danslimmon/1193268 to your computer and use it in GitHub Desktop.
Save danslimmon/1193268 to your computer and use it in GitHub Desktop.
Deprecation git hook
#!/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