├── .gitignore ├── CONTRIBUTING.markdown ├── Gemfile ├── LICENSE.txt ├── README.markdown ├── Rakefile ├── exe └── git-bump ├── git-bump.gemspec └── lib └── git_bump.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.markdown: -------------------------------------------------------------------------------- 1 | * I'm the commit message guy, so you sure as shooting better have a [well 2 | formatted commit message][] if you're submitting a patch. 3 | 4 | * Don't be shy about submitting bugs, especially those of the form, "I did 5 | something wrong, but rather than getting a helpful error message, I got a 6 | Ruby stack trace." 7 | 8 | * I'm open to supporting alternative conventions (e.g. omitting the `v` from 9 | the tag), but this should generally be achieved by automatic detection 10 | rather than command line arguments or configuration. 11 | 12 | * I'd love to make it possible to install as a standalone script. The main 13 | impediment to this is the dependence on Thor. 14 | 15 | [well formatted commit message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Tim Pope 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # git bump 2 | 3 | Here's a popular set of best practices for doing releases for code bases 4 | stored in Git: 5 | 6 | * Update version-related minutiae in the code base. 7 | * Commit with a message like `projectname 1.2.3`. 8 | * Create a signed, annotated tag with the same message with a name like 9 | `v1.2.3`. 10 | 11 | I like these practices. They're the ones used by Git itself for its own 12 | source code. You might quibble over details (why is there a `v` at the 13 | beginning of the tag?), but I think this is a place consistency should 14 | outshine bikeshedding. 15 | 16 | There's one more practice that I'd like to add: 17 | 18 | * Include release notes in the release commit. 19 | 20 | This isn't always a good fit, but for smaller projects, I find this to be a 21 | great alternative to maintaining a formal document inside the repository. You 22 | can easily scrape the information out of the commits later if your needs 23 | change. 24 | 25 | I made git bump to encapsulate these practices. 26 | 27 | ## Installation 28 | 29 | Assuming you want to install with your system's Ruby: 30 | 31 | sudo gem install git-bump 32 | 33 | ## Usage 34 | 35 | The primary interface is `git bump`. Here's how you would use it for a new 36 | project. 37 | 38 | ### Initial release 39 | 40 | Stage the changes needed to create the release (this could be the entire 41 | repository if it's an initial commit), and run `git bump `, where 42 | `` is the version you want to release (try `1.0.0`). You'll be 43 | greeted with a familiar sight: 44 | 45 | spline-reticulator 1.0.0 46 | 47 | # Please enter the commit message for your changes. Lines starting 48 | # with '#' will be ignored, and an empty message aborts the commit. 49 | 50 | Adjust the project name if necessary, and save and quit the editor. Your 51 | commit and tag will be created, and you'll be shown instructions for pushing 52 | once you're sure everything is okay. 53 | 54 | ### Second release 55 | 56 | This is where the fun begins. Stage the changes necessary for release, and 57 | run one of the following commands: 58 | 59 | * `git bump` -- bump the rightmost number in the previous version. 60 | * `git bump point` -- bump the third number in the previous version, and 61 | reset everything afterwards to zero. 62 | * `git bump minor` -- bump the second number in the previous version, and 63 | reset everything afterwards to zero. 64 | * `git bump major` -- bump the first number in the previous version, and 65 | reset everything afterwards to zero. 66 | * `git bump ` -- bump to an exact version. 67 | 68 | The commit message body will be pre-populated with a bulleted list of commit 69 | messages since the previous release. My practice is to heavily edit this into 70 | a higher level list of changes by discarding worthless messages like typo 71 | fixes and making related commits into a single bullet point. If you aren't 72 | interested in this practice, delete the body and `git bump` won't bother you 73 | with it again. 74 | 75 | ### Subsequent releases 76 | 77 | On subsequent releases, if no changes are staged, `git bump` will replay the 78 | previous release commit, replacing the appropriate version numbers. This 79 | works fine as long as your version numbers are committed as literal strings. 80 | If you're doing something more clever like `MAJOR = 1` and `MINOR = 2`, you'll 81 | have to do the edit by hand and stage it. 82 | 83 | ### Existing projects 84 | 85 | You'll need to create one existing release commit and tag in the proper format 86 | by hand, if your project doesn't already have one. After that you can use 87 | `git bump` normally. 88 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | task :default => :install 4 | -------------------------------------------------------------------------------- /exe/git-bump: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift(File.expand_path('../../lib', __FILE__)) 4 | 5 | require 'git_bump' 6 | 7 | GitBump.start 8 | -------------------------------------------------------------------------------- /git-bump.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | Gem::Specification.new do |spec| 3 | spec.name = 'git-bump' 4 | spec.version = '1.1.0' 5 | spec.authors = ['Tim Pope'] 6 | spec.email = ["code\100tpope.net"] 7 | spec.description = 'Git based release management' 8 | spec.summary = 'Create Git release commits and tags with changelogs' 9 | spec.homepage = 'https://tpo.pe/git-bump' 10 | spec.license = 'MIT' 11 | 12 | spec.bindir = 'exe' 13 | spec.files = ['exe/git-bump', 'lib/git_bump.rb'] 14 | spec.executables = ['git-bump'] 15 | spec.require_paths = ['lib'] 16 | 17 | spec.add_development_dependency 'bundler', '~> 1.3' 18 | spec.add_development_dependency 'rake' 19 | spec.add_dependency 'thor' 20 | end 21 | -------------------------------------------------------------------------------- /lib/git_bump.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'thor' 4 | 5 | class GitBump < Thor 6 | 7 | INITIAL = <<-EOS 8 | Looks like this is your first release. Please add the version number to the 9 | work tree (e.g., in your Makefile), stage your changes, and run git bump again. 10 | 11 | If this isn't your first release, tag your most recent prior release so that 12 | git bump can find it: 13 | 14 | git tag -s v1.2.3 8675309 15 | EOS 16 | 17 | def self.start 18 | ARGV.unshift('release') if ARGV.first =~ /^v?\d/ || %w(major minor point).include?(ARGV.first) 19 | super 20 | end 21 | 22 | class Release 23 | attr_reader :tag, :sha1, :name, :version 24 | 25 | def initialize(tag, sha1, name, version) 26 | @tag, @sha1, @name, @version = tag, sha1, name, Version.new(version) 27 | end 28 | 29 | def tag_type 30 | @tag_type ||= %x{git cat-file -t #{tag}}.chomp 31 | end 32 | 33 | def tag_message 34 | if tag_type == 'tag' 35 | @tag_message ||= %x{git cat-file tag #{tag}}.split("\n\n", 2).last 36 | end 37 | end 38 | 39 | def tag_signed? 40 | tag_message.to_s.include?("\n-----BEGIN PGP") 41 | end 42 | 43 | def tag_body? 44 | tag_message.to_s.sub(/\n-----BEGIN PGP.*/m, '').include?("\n\n") 45 | end 46 | 47 | def body 48 | @body ||= %x{git log -1 --pretty=format:%b #{sha1}} 49 | end 50 | 51 | def format 52 | body[/(?:\n |.)*/].sub(/\A([-* ]*)(.*?)(\.?)\z/m, '\1%s\3') unless body.empty? 53 | end 54 | 55 | def inverse_diff(context = 1) 56 | unless defined?(@inverse_diff) 57 | @inverse_diff = 58 | if !%x{git rev-parse --verify -q #{sha1}^}.empty? 59 | %x{git diff -U#{context} #{sha1}..#{sha1}^} 60 | end 61 | end 62 | @inverse_diff 63 | end 64 | 65 | end 66 | 67 | class Version 68 | def initialize(string) 69 | @components = string.split('.') 70 | end 71 | 72 | def to_s 73 | @components.join('.') 74 | end 75 | 76 | def to_a 77 | @components.dup 78 | end 79 | end 80 | 81 | no_tasks do 82 | def releases 83 | @releases ||= 84 | begin 85 | out = %x{git for-each-ref "refs/tags/v[0-9]*" --sort="*committerdate" --format="%(refname:short) %(*objectname) %(subject)"} 86 | exit 1 unless $?.success? 87 | out.scan(/^(\S+) (\w+) (.*) (\d\S*)\s*$/).map do |args| 88 | Release.new(*args) 89 | end 90 | end 91 | end 92 | 93 | def latest 94 | @latest ||= releases.reverse.detect do |release| 95 | %x{git merge-base #{release.sha1} HEAD}.chomp == release.sha1 96 | end 97 | end 98 | 99 | def increment(pos, components) 100 | components[pos].sub!(/^(\d+).*/, '\1') 101 | components[pos].succ! 102 | (components.size-1).downto(pos+1) do |i| 103 | if components[i] =~ /^\d/ 104 | components[i] = '0' 105 | else 106 | components.delete_at(i) 107 | end 108 | end 109 | end 110 | 111 | def generate_version(request) 112 | if request =~ /^v?(\d.*)/ 113 | $1 114 | elsif latest 115 | components = latest.version.to_s.split('.') 116 | case request 117 | when 'major' then increment(0, components) 118 | when 'minor' then increment(1, components) 119 | when 'point' then increment(2, components) 120 | when nil then components.last.succ! 121 | else 122 | abort "Unrecognized version increment #{request}." 123 | end 124 | components.join('.') 125 | else 126 | abort "Appears to be initial release. Version number required." 127 | end 128 | end 129 | 130 | def name 131 | if latest 132 | latest.name 133 | else 134 | File.basename(Dir.getwd) 135 | end 136 | end 137 | 138 | def patch(version, force = false) 139 | diff = latest.inverse_diff(force ? 0 : 1) 140 | return unless diff 141 | deletion = /^-(.*)(#{Regexp.escape(latest.version.to_s)})(.*)\n/ 142 | patch = diff.gsub(/#{deletion}\+\1(.*)\3\n/) do 143 | "-#$1#$2#$3\n+#$1#{version}#$3\n" 144 | end.gsub(/^(@@ -\d+,\d+ \+\d+,)(\d+) @@\n( .*\n)?#{deletion}(?![+-])/) do 145 | "#$1#{$2.succ} @@\n#$3-#$4#$5#$6\n+#$4#{version}#$6\n " 146 | end.scan(/^[d@].*\n(?:[^d@].*\n)+/).reject do |v| 147 | v[0] == ?@ && !v.include?(version) 148 | end.join.gsub(/^diff.*\n([^+-].*\n)*---.*\n\+\+\+.*\n(\Z|diff)/, '\1') 149 | patch unless patch =~ /\Aindex.*\Z/ 150 | end 151 | 152 | def logs 153 | if (releases.size < 2 || latest.format) && !@logs 154 | @logs = %x{git log --no-merges --reverse --pretty=format:"#{latest.format || '* %s.'}" #{latest.sha1}..} 155 | abort unless $?.success? 156 | end 157 | @logs 158 | end 159 | 160 | def tag!(name) 161 | annote = if latest && !latest.tag_signed? then '-a' else '-s' end 162 | format = if releases.size < 2 || latest.tag_body? then '%B' else '%s' end 163 | body = %x{git log -1 --pretty=format:#{format}} 164 | if system('git', 'tag', '-f', annote, name, '-m', body) 165 | branch = %x{git symbolic-ref --short HEAD}.chomp 166 | puts <<-EOS 167 | Successfully created #{name}. If you made a mistake, use `git bump redo` to 168 | try again. Once you are satisfied with the result, run 169 | 170 | git push origin #{branch} #{name} 171 | EOS 172 | else 173 | abort "Tag failed. Create it by hand or use git reset --soft HEAD^ to try again." 174 | end 175 | end 176 | 177 | def system!(*args) 178 | system(*args) 179 | abort "Error running Git." unless $?.success? 180 | end 181 | end 182 | 183 | def self.basename 184 | 'git bump' 185 | end 186 | 187 | default_task 'release' 188 | desc '[version]', 'Create and tag a release for the given version' 189 | method_options %w(force -f) => :boolean 190 | def release(request=nil) 191 | version = generate_version(request) 192 | unless %x{git rev-parse --verify -q v#{version}}.empty? || options[:force] 193 | abort "Tag already exists. If it hasn't been pushed yet, use --force to override." 194 | end 195 | initial_commit = %x{git rev-parse --verify -q HEAD}.empty? 196 | if !initial_commit && %x{git diff HEAD}.empty? 197 | abort INITIAL unless latest 198 | failure = "Couldn't patch. Update the version number in the work tree and try again." 199 | abort failure unless patch = patch(version, options[:force]) 200 | IO.popen(['git', 'apply', '--unidiff-zero', '--index'], 'w') do |o| 201 | o.write patch 202 | end 203 | abort failure unless $?.success? 204 | hard = true 205 | elsif %x{git diff --cached}.empty? 206 | # TODO: what happens on initial with some unstaged changes? 207 | abort "Discard or stage your changes." 208 | end 209 | require 'tempfile' 210 | Tempfile.open('git-commit') do |f| 211 | f.puts [name, version].join(' ') 212 | f.puts 213 | f.write logs if latest 214 | f.flush 215 | system('git', 'commit', '--file', f.path, '--edit', * initial_commit ? [] : ['--verbose']) 216 | unless $?.success? 217 | system('git', 'reset', '-q', '--hard', 'HEAD') if hard 218 | abort 219 | end 220 | end 221 | tag!("v#{version}") 222 | end 223 | 224 | desc 'redo', 'amend the previous release and retag' 225 | method_options %w(force -f) => :boolean 226 | def redo 227 | unless %x{git diff}.empty? 228 | abort "Discard or stage your changes." 229 | end 230 | unless latest && latest.sha1 == %x{git rev-parse HEAD}.chomp 231 | abort "Can only amend the top-most commit." 232 | end 233 | system!('git', 'commit', '--amend', '--verbose', '--reset-author') 234 | tag!(latest.tag) 235 | end 236 | 237 | desc 'log', 'Show the git log since the last release' 238 | def log(*args) 239 | if latest 240 | exec('git', 'log', "#{latest.sha1}..", *args) 241 | else 242 | exec('git', 'log', *args) 243 | end 244 | end 245 | 246 | desc 'show [version]', 'Show the most recent or given release' 247 | method_options :version_only => :boolean 248 | def show(version = latest ? latest.version.to_s : nil) 249 | release = releases.detect do |r| 250 | r.version.to_s == version || r.tag == version 251 | end 252 | if release 253 | if options[:version_only] 254 | puts release.version 255 | else 256 | exec('git', 'log', '-1', '--pretty=format:%B', release.sha1) 257 | end 258 | else 259 | exit 1 260 | end 261 | end 262 | 263 | desc 'next', 'Show the version number that would be released' 264 | def next(specifier = nil) 265 | puts generate_version(specifier) 266 | end 267 | 268 | def self.help(shell, *) 269 | super 270 | shell.say <<-EOS 271 | With no arguments, git bump defaults to creating a release with the least 272 | significant component of the version number incremented. For example, 273 | 1.2.3-rc4 becomes 1.2.3-rc5, while 6.7 becomes 6.8. To override, provide a 274 | version number argument, or one of the following keywords: 275 | 276 | major: bump the most significant component 277 | minor: bump the second most significant component 278 | point: bump the third most significant component 279 | EOS 280 | end 281 | end 282 | --------------------------------------------------------------------------------