octopolo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +21 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +6 -0
  6. data/Gemfile +3 -0
  7. data/Guardfile +5 -0
  8. data/MIT-LICENSE +20 -0
  9. data/README.markdown +55 -0
  10. data/Rakefile +38 -0
  11. data/bash_completion.sh +13 -0
  12. data/bin/octopolo +21 -0
  13. data/bin/op +21 -0
  14. data/lib/octopolo.rb +15 -0
  15. data/lib/octopolo/changelog.rb +27 -0
  16. data/lib/octopolo/cli.rb +210 -0
  17. data/lib/octopolo/commands/accept_pull.rb +8 -0
  18. data/lib/octopolo/commands/compare_release.rb +9 -0
  19. data/lib/octopolo/commands/deployable.rb +8 -0
  20. data/lib/octopolo/commands/github_auth.rb +5 -0
  21. data/lib/octopolo/commands/new_branch.rb +9 -0
  22. data/lib/octopolo/commands/new_deployable.rb +8 -0
  23. data/lib/octopolo/commands/new_staging.rb +8 -0
  24. data/lib/octopolo/commands/octopolo_setup.rb +5 -0
  25. data/lib/octopolo/commands/pivotal_auth.rb +5 -0
  26. data/lib/octopolo/commands/pull_request.rb +13 -0
  27. data/lib/octopolo/commands/signoff.rb +10 -0
  28. data/lib/octopolo/commands/stage_up.rb +8 -0
  29. data/lib/octopolo/commands/stale_branches.rb +11 -0
  30. data/lib/octopolo/commands/sync_branch.rb +11 -0
  31. data/lib/octopolo/commands/tag_release.rb +13 -0
  32. data/lib/octopolo/config.rb +146 -0
  33. data/lib/octopolo/convenience_wrappers.rb +46 -0
  34. data/lib/octopolo/dated_branch_creator.rb +81 -0
  35. data/lib/octopolo/git.rb +262 -0
  36. data/lib/octopolo/github.rb +95 -0
  37. data/lib/octopolo/github/commit.rb +45 -0
  38. data/lib/octopolo/github/pull_request.rb +126 -0
  39. data/lib/octopolo/github/pull_request_creator.rb +127 -0
  40. data/lib/octopolo/github/user.rb +40 -0
  41. data/lib/octopolo/jira/story_commenter.rb +26 -0
  42. data/lib/octopolo/pivotal.rb +44 -0
  43. data/lib/octopolo/pivotal/story_commenter.rb +19 -0
  44. data/lib/octopolo/pull_request_merger.rb +99 -0
  45. data/lib/octopolo/renderer.rb +37 -0
  46. data/lib/octopolo/reports.rb +18 -0
  47. data/lib/octopolo/scripts.rb +23 -0
  48. data/lib/octopolo/scripts/accept_pull.rb +67 -0
  49. data/lib/octopolo/scripts/compare_release.rb +52 -0
  50. data/lib/octopolo/scripts/deployable.rb +27 -0
  51. data/lib/octopolo/scripts/github_auth.rb +87 -0
  52. data/lib/octopolo/scripts/new_branch.rb +34 -0
  53. data/lib/octopolo/scripts/new_deployable.rb +14 -0
  54. data/lib/octopolo/scripts/new_staging.rb +15 -0
  55. data/lib/octopolo/scripts/octopolo_setup.rb +55 -0
  56. data/lib/octopolo/scripts/pivotal_auth.rb +44 -0
  57. data/lib/octopolo/scripts/pull_request.rb +127 -0
  58. data/lib/octopolo/scripts/signoff.rb +85 -0
  59. data/lib/octopolo/scripts/stage_up.rb +26 -0
  60. data/lib/octopolo/scripts/stale_branches.rb +54 -0
  61. data/lib/octopolo/scripts/sync_branch.rb +37 -0
  62. data/lib/octopolo/scripts/tag_release.rb +70 -0
  63. data/lib/octopolo/templates/pull_request_body.erb +24 -0
  64. data/lib/octopolo/user_config.rb +112 -0
  65. data/lib/octopolo/version.rb +3 -0
  66. data/lib/octopolo/week.rb +130 -0
  67. data/octopolo.gemspec +31 -0
  68. data/spec/.DS_Store +0 -0
  69. data/spec/octopolo/cli_spec.rb +310 -0
  70. data/spec/octopolo/config_spec.rb +344 -0
  71. data/spec/octopolo/convenience_wrappers_spec.rb +80 -0
  72. data/spec/octopolo/dated_branch_creator_spec.rb +143 -0
  73. data/spec/octopolo/git_spec.rb +419 -0
  74. data/spec/octopolo/github/commit_spec.rb +59 -0
  75. data/spec/octopolo/github/pull_request_creator_spec.rb +174 -0
  76. data/spec/octopolo/github/pull_request_spec.rb +291 -0
  77. data/spec/octopolo/github/user_spec.rb +65 -0
  78. data/spec/octopolo/github_spec.rb +169 -0
  79. data/spec/octopolo/jira/stor_commenter_spec.rb +30 -0
  80. data/spec/octopolo/pivotal/story_commenter_spec.rb +34 -0
  81. data/spec/octopolo/pivotal_spec.rb +61 -0
  82. data/spec/octopolo/pull_request_merger_spec.rb +144 -0
  83. data/spec/octopolo/renderer_spec.rb +35 -0
  84. data/spec/octopolo/scripts/accept_pull_spec.rb +76 -0
  85. data/spec/octopolo/scripts/compare_release_spec.rb +115 -0
  86. data/spec/octopolo/scripts/deployable_spec.rb +52 -0
  87. data/spec/octopolo/scripts/github_auth_spec.rb +156 -0
  88. data/spec/octopolo/scripts/new_branch_spec.rb +41 -0
  89. data/spec/octopolo/scripts/new_deployable_spec.rb +18 -0
  90. data/spec/octopolo/scripts/new_staging_spec.rb +18 -0
  91. data/spec/octopolo/scripts/octopolo_setup_spec.rb +120 -0
  92. data/spec/octopolo/scripts/pivotal_auth_spec.rb +77 -0
  93. data/spec/octopolo/scripts/pull_request_spec.rb +217 -0
  94. data/spec/octopolo/scripts/signoff_spec.rb +139 -0
  95. data/spec/octopolo/scripts/stage_up_spec.rb +52 -0
  96. data/spec/octopolo/scripts/stale_branches_spec.rb +81 -0
  97. data/spec/octopolo/scripts/sync_branch_spec.rb +57 -0
  98. data/spec/octopolo/scripts/tag_release_spec.rb +108 -0
  99. data/spec/octopolo/user_config_spec.rb +167 -0
  100. data/spec/octopolo_spec.rb +7 -0
  101. data/spec/spec_helper.rb +29 -0
  102. data/spec/support/engine_yard.cache +0 -0
  103. data/spec/support/sample_octopolo.yml +2 -0
  104. data/spec/support/sample_user.yml +2 -0
  105. data/templates/lib.erb +23 -0
  106. data/templates/script.erb +7 -0
  107. data/templates/spec.erb +29 -0
  108. metadata +344 -0
@@ -0,0 +1,8 @@
1
+ arg :pull_request_id
2
+ desc 'Accept pull requests. Merges the given pull request into master and updates the changelog.'
3
+ command 'accept-pull' do |c|
4
+ c.action do |global_options, options, args|
5
+ require_relative '../scripts/accept_pull'
6
+ Octopolo::Scripts::AcceptPull.execute args.first
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ arg :start, :name => 'starting_tag', :optional => true
2
+ arg :stop, :name => 'ending_tag', :optional => true
3
+ desc 'Opens up a link to compare releases'
4
+ command 'compare-release' do |c|
5
+ c.action do |global_options, options, args|
6
+ require_relative '../scripts/compare_release'
7
+ Octopolo::Scripts::CompareRelease.execute args[0], args[1]
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ arg :pull_request_id
2
+ desc 'Merges PR into the deployable branch'
3
+ command 'deployable' do |c|
4
+ c.action do |global_options, options, args|
5
+ require_relative '../scripts/deployable'
6
+ Octopolo::Scripts::Deployable.execute args.first
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ desc 'Generate a GitHub auth token for octopolo commands to use.'
2
+ command 'github-auth' do |c|
3
+ require_relative '../scripts/github_auth'
4
+ c.action { Octopolo::Scripts::GithubAuth.execute }
5
+ end
@@ -0,0 +1,9 @@
1
+ arg :new_branch_name, :name => 'new_branch_name'
2
+ arg :source_branch_name, :name => 'source_branch_name', :optional => true
3
+ desc 'Create a new branch for features, bug fixes, or experimentation.'
4
+ command 'new-branch' do |c|
5
+ c.action do |global_options, options, args|
6
+ require_relative '../scripts/new_branch'
7
+ Octopolo::Scripts::NewBranch.execute args[0], args[1]
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ desc "Create a new deployable branch"
2
+ long_desc "Create a new deployable branch with today's date and remove the others.
3
+
4
+ Useful when we have changes in the current deployable branch that we wish to remove."
5
+ command 'new-deployable' do |c|
6
+ require_relative '../scripts/new_deployable'
7
+ c.action { Octopolo::Scripts::NewDeployable.new.execute }
8
+ end
@@ -0,0 +1,8 @@
1
+ desc "Create a new staging branch"
2
+ long_desc "Create a new staging branch with today's date and remove the others.
3
+
4
+ Useful when we have changes in the current staging branch that we wish to remove."
5
+ command 'new-staging' do |c|
6
+ require_relative '../scripts/new_staging'
7
+ c.action { Octopolo::Scripts::NewStaging.new.execute }
8
+ end
@@ -0,0 +1,5 @@
1
+ desc 'Basic setup for octoplo'
2
+ command 'setup' do |c|
3
+ require_relative '../scripts/octopolo_setup'
4
+ c.action { Octopolo::Scripts::OctopoloSetup.invoke }
5
+ end
@@ -0,0 +1,5 @@
1
+ desc 'Generate a Pivotal Tracker auth token for Octopolo commands to use.'
2
+ command 'pivotal-auth' do |c|
3
+ require_relative '../scripts/pivotal_auth'
4
+ c.action { Octopolo::Scripts::PivotalAuth.execute }
5
+ end
@@ -0,0 +1,13 @@
1
+ desc "Create a pull request from the current branch to the application's designated deploy branch."
2
+ command 'pull-request' do |c|
3
+ config = Octopolo::Config.parse
4
+
5
+ c.desc "Branch to create the pull request against"
6
+ c.flag [:d, :dest, :destination], :arg_name => "destination_branch", :default_value => config.deploy_branch
7
+
8
+ c.action do |global_options, options, args|
9
+ require_relative '../scripts/pull_request'
10
+ options = global_options.merge(options)
11
+ Octopolo::Scripts::PullRequest.execute options[:destination_branch]
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ arg :pull_request_id
2
+
3
+ desc 'Provide standardized signoff message to a pull request.'
4
+ long_desc "pull_request_id - The ID of the pull request to sign off on"
5
+ command 'signoff' do |c|
6
+ c.action do |global_options, options, args|
7
+ require_relative '../scripts/signoff'
8
+ Octopolo::Scripts::Signoff.execute args.first
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ arg :pull_request_id
2
+ desc 'Merges PR into the staging branch'
3
+ command 'stage-up' do |c|
4
+ c.action do |global_options, options, args|
5
+ require_relative '../scripts/stage_up'
6
+ Octopolo::Scripts::StageUp.execute args.first
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ desc "View and delete stale branches"
2
+ command 'stale-branches' do |c|
3
+ c.desc "Delete the stale branches (default: false)"
4
+ c.switch :delete, :negatable => false
5
+
6
+ c.action do |global_options, options, args|
7
+ require_relative '../scripts/stale_branches'
8
+ options = global_options.merge(options)
9
+ Octopolo::Scripts::StaleBranches.new(options[:delete]).execute
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ config = Octopolo::Config.parse
2
+ long_desc "branch - Which branch to merge into yours (default: #{config.deploy_branch})"
3
+
4
+ arg :branch
5
+ desc "Merge the #{config.deploy_branch} branch into the current working branch"
6
+ command 'sync-branch' do |c|
7
+ c.action do |global_options, options, args|
8
+ require_relative '../scripts/sync_branch'
9
+ Octopolo::Scripts::SyncBranch.execute args.first
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ arg :suffix, :desc => "Suffix to apply to to the dated tag"
2
+
3
+ desc "Create and push a timestamped tag with an optional suffix"
4
+ command 'tag-release' do |c|
5
+ c.desc "Create tag even if not on deploy branch"
6
+ c.switch :force, :negatable => false
7
+
8
+ c.action do |global_options, options, args|
9
+ require_relative '../scripts/tag_release'
10
+ options = global_options.merge(options)
11
+ Octopolo::Scripts::TagRelease.execute args.first, options[:force]
12
+ end
13
+ end
@@ -0,0 +1,146 @@
1
+ require "date" # necessary to get the Date.today convenience method
2
+ require "yaml"
3
+ require_relative "user_config"
4
+
5
+ module Octopolo
6
+ class Config
7
+ FILE_NAMES = %w[.octopolo.yml .automation.yml]
8
+
9
+ RECENT_TAG_LIMIT = 9
10
+ # we use date-based tags, so look for anything starting with a 4-digit year
11
+ RECENT_TAG_FILTER = /^\d{4}.*/
12
+
13
+ attr_accessor :cli
14
+
15
+ def initialize(attributes={})
16
+ self.cli = Octopolo::CLI
17
+
18
+ assign attributes
19
+ load_plugins
20
+ end
21
+
22
+ # default values for these customizations
23
+ def deploy_branch
24
+ @deploy_branch || "master"
25
+ end
26
+
27
+ def branches_to_keep
28
+ @branches_to_keep || []
29
+ end
30
+
31
+ def deploy_environments
32
+ @deploy_environments || []
33
+ end
34
+
35
+ def deploy_methods
36
+ @deploy_methods || []
37
+ end
38
+
39
+ def github_repo
40
+ @github_repo || raise(MissingRequiredAttribute, "GitHub Repo is required")
41
+ end
42
+
43
+ def user_notifications
44
+ if [NilClass, Array, String].include?(@user_notifications.class)
45
+ Array(@user_notifications) if @user_notifications
46
+ else
47
+ raise(InvalidAttributeSupplied, "User notifications must be an array or string")
48
+ end
49
+ end
50
+
51
+ def plugins
52
+ case @plugins
53
+ when Array, String then Array(@plugins)
54
+ when NilClass then []
55
+ else
56
+ raise(InvalidAttributeSupplied, "Plugins must be an array or string")
57
+ end
58
+ end
59
+
60
+ def use_pivotal_tracker
61
+ !!@use_pivotal_tracker
62
+ end
63
+
64
+ def use_jira
65
+ !!@use_jira
66
+ end
67
+
68
+ def jira_user
69
+ @jira_user || raise(MissingRequiredAttribute, "Jira User is required") if use_jira
70
+ end
71
+
72
+ def jira_password
73
+ @jira_password || raise(MissingRequiredAttribute, "Jira Password is required") if use_jira
74
+ end
75
+
76
+ def jira_url
77
+ @jira_url || raise(MissingRequiredAttribute, "Jira Url is required") if use_jira
78
+ end
79
+
80
+ # end defaults
81
+
82
+ def self.parse
83
+ new(attributes_from_file)
84
+ end
85
+
86
+ def self.attributes_from_file
87
+ YAML.load_file(octopolo_config_path)
88
+ end
89
+
90
+ def self.octopolo_config_path
91
+ if filepath = FILE_NAMES.detect {|filename| File.exists?(filename)}
92
+ File.join(Dir.pwd, filepath)
93
+ else
94
+ old_dir = Dir.pwd
95
+ Dir.chdir('..')
96
+ if old_dir != Dir.pwd
97
+ octopolo_config_path
98
+ else
99
+ Octopolo::CLI.say "Could not find #{FILE_NAMES.join(' or ')}"
100
+ exit
101
+ end
102
+ end
103
+ end
104
+
105
+ def load_plugins
106
+ plugins.each do |plugin|
107
+ begin
108
+ require plugin
109
+ rescue LoadError
110
+ puts "Plugin '#{plugin}' failed to load"
111
+ end
112
+ end
113
+ end
114
+
115
+ def assign(attributes)
116
+ attributes.each do |key, value|
117
+ self.instance_variable_set("@#{key}", value)
118
+ end
119
+ end
120
+
121
+ def basedir
122
+ File.basename File.dirname Config.octopolo_config_path
123
+ end
124
+
125
+ def remote_branch_exists?(branch)
126
+ branches = Octopolo::CLI.perform "git branch -r", false
127
+ branch_list = branches.split(/\r?\n/)
128
+ branch_list.each { |x| x.gsub!(/\*|\s/,'') }
129
+ branch_list.include? "origin/#{branch}"
130
+ end
131
+
132
+ def app_name
133
+ basedir
134
+ end
135
+
136
+ # To be used when attempting to call a Config attribute for which there is
137
+ # a value supplied that is of not the correct type
138
+ InvalidAttributeSupplied = Class.new(StandardError)
139
+ # To be used when attempting to call a Config attribute for which there is
140
+ # no sensible default and one hasn't been supplied by the app
141
+ MissingRequiredAttribute = Class.new(StandardError)
142
+ # To be used when looking for a branch of a given type (like staging or
143
+ # deployable), but none exist.
144
+ NoBranchOfType = Class.new(StandardError)
145
+ end
146
+ end
@@ -0,0 +1,46 @@
1
+ module Octopolo
2
+ # Provide access to the CLI class into other classes in the application
3
+ module CLIWrapper
4
+ attr_accessor :cli
5
+
6
+ # Public: Wrapper method around CLI class
7
+ #
8
+ # Returns the CLI class or equivalent
9
+ def cli
10
+ @cli ||= CLI
11
+ end
12
+ end
13
+
14
+ # Provide access to the config into other classes in the application
15
+ module ConfigWrapper
16
+ attr_accessor :config
17
+
18
+ # Public: Wrapper around the user's and app's configuration
19
+ #
20
+ # Returns an instance of Config or equivalent
21
+ def config
22
+ @config ||= Octopolo.config
23
+ end
24
+ end
25
+
26
+ # Provide access to user-supplied configuration values
27
+ module UserConfigWrapper
28
+ attr_accessor :user_config
29
+
30
+ # Returns an instance of UserConfig or equivalent
31
+ def user_config
32
+ @user_config ||= UserConfig.parse
33
+ end
34
+ end
35
+
36
+ module GitWrapper
37
+ attr_accessor :git
38
+
39
+ # Public: Wrapper method around Git class
40
+ #
41
+ # Returns the Git class or equivalent
42
+ def git
43
+ @git ||= Git
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,81 @@
1
+ require_relative "git"
2
+ require_relative "scripts/new_branch"
3
+ require "date"
4
+
5
+ module Octopolo
6
+ class DatedBranchCreator
7
+ include ConfigWrapper
8
+ include CLIWrapper
9
+ include GitWrapper
10
+
11
+ attr_accessor :branch_type
12
+
13
+ # Public: Initialize a new instance of DatedBranchCreator
14
+ #
15
+ # branch_type - Name of the type of branch (e.g., staging or deployable)
16
+ def initialize(branch_type)
17
+ self.branch_type = branch_type
18
+ end
19
+
20
+ # Public: Create a new branch of the given type for today's date
21
+ #
22
+ # branch_type - Name of the type of branch (e.g., staging or deployable)
23
+ #
24
+ # Returns a DatedBranchCreator
25
+ def self.perform(branch_type)
26
+ new(branch_type).tap do |creator|
27
+ creator.perform
28
+ end
29
+ end
30
+
31
+ # Public: Create the branch and handle related processing
32
+ def perform
33
+ create_branch
34
+ delete_old_branches
35
+ end
36
+
37
+ # Public: Create the desired branch
38
+ def create_branch
39
+ git.new_branch(branch_name, config.deploy_branch)
40
+ end
41
+
42
+ # Public: The date suffix to append to the branch name
43
+ def date_suffix
44
+ Date.today.strftime("%Y.%m.%d")
45
+ end
46
+
47
+ # Public: The name of the branch to create
48
+ def branch_name
49
+ case branch_type
50
+ when Git::DEPLOYABLE_PREFIX, Git::STAGING_PREFIX, Git::QAREADY_PREFIX
51
+ "#{branch_type}.#{date_suffix}"
52
+ else
53
+ raise InvalidBranchType, "'#{branch_type}' is not a valid branch type"
54
+ end
55
+ end
56
+
57
+ # Public: If necessary, and if user opts to, delete old branches of its type
58
+ def delete_old_branches
59
+ if extra_branches.any? && cli.ask_boolean("Do you want to delete the old #{branch_type} branch(es)? (#{extra_branches.join(", ")})")
60
+ extra_branches.each do |extra|
61
+ Git.delete_branch(extra)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Public: The list of extra branches that exist after creating the new branch
67
+ #
68
+ # Returns an Array of Strings of the branch names
69
+ def extra_branches
70
+ case branch_type
71
+ when Git::DEPLOYABLE_PREFIX, Git::STAGING_PREFIX, Git::QAREADY_PREFIX
72
+ Git.branches_for(branch_type) - [branch_name]
73
+ else
74
+ raise InvalidBranchType, "'#{branch_type}' is not a valid branch type"
75
+ end
76
+ end
77
+
78
+ InvalidBranchType = Class.new(StandardError)
79
+ end
80
+ end
81
+
@@ -0,0 +1,262 @@
1
+ module Octopolo
2
+ # Abstraction around local Git commands
3
+ class Git
4
+ NO_BRANCH = "(no branch)"
5
+ DEFAULT_DIRTY_MESSAGE = "Your Git index is not clean. Commit, stash, or otherwise clean up the index before continuing."
6
+ # we use date-based tags, so look for anything starting with a 4-digit year
7
+ RELEASE_TAG_FILTER = /^\d{4}.*/
8
+ RECENT_TAG_LIMIT = 9
9
+ # branch prefixes
10
+ DEPLOYABLE_PREFIX = "deployable"
11
+ STAGING_PREFIX = "staging"
12
+ QAREADY_PREFIX = "qaready"
13
+
14
+ include CLIWrapper
15
+ extend CLIWrapper # add class-level .cli and .cli= methods
16
+
17
+ # Public: Perform the given Git subcommand
18
+ #
19
+ # subcommand - String containing the subcommand and its parameters
20
+ #
21
+ # Example:
22
+ #
23
+ # > Git.perform "status"
24
+ # # => output of `git status`
25
+ def self.perform(subcommand)
26
+ cli.perform "git #{subcommand}"
27
+ end
28
+
29
+ # Public: Perform the given Git subcommand without displaying the output
30
+ #
31
+ # subcommand - String containing the subcommand and its parameters
32
+ #
33
+ # Example:
34
+ #
35
+ # > Git.perform_quietly "status"
36
+ # # => no output
37
+ def self.perform_quietly(subcommand)
38
+ cli.perform_quietly "git #{subcommand}"
39
+ end
40
+
41
+ # Public: The name of the currently check-out branch
42
+ #
43
+ # Returns a String of the branch name
44
+ def self.current_branch
45
+ # cut trims the first three characters (whitespace or "* " for current branch)
46
+ # the chomp removes the newline from the command output
47
+ name = cli.perform_quietly("git branch | grep '^* ' | cut -c 3-").chomp
48
+ if name == NO_BRANCH
49
+ raise NotOnBranch, "Not currently checked out to a particular branch"
50
+ else
51
+ name
52
+ end
53
+ end
54
+
55
+ # Public: Determine if current_branch is reserved
56
+ #
57
+ # Returnsa boolean value
58
+ def self.reserved_branch?
59
+ !(current_branch =~ /^(?:#{Git::STAGING_PREFIX}|#{Git::DEPLOYABLE_PREFIX}|#{Git::QAREADY_PREFIX})/).nil?
60
+ end
61
+
62
+ # Public: Check out the given branch name
63
+ #
64
+ # branch_name - The name of the branch to check out
65
+ def self.check_out branch_name
66
+ fetch
67
+ perform "checkout #{branch_name}"
68
+ pull
69
+ unless current_branch == branch_name
70
+ raise CheckoutFailed, "Failed to check out '#{branch_name}'"
71
+ end
72
+ end
73
+
74
+ # Public: Create a new branch from the given source
75
+ #
76
+ # new_branch_name - The name of the branch to create
77
+ # source_branch_name - The name of the branch to branch from
78
+ #
79
+ # Example:
80
+ #
81
+ # Git.new_branch("bug-123-fix-thing", "master")
82
+ def self.new_branch(new_branch_name, source_branch_name)
83
+ fetch
84
+ perform("branch --no-track #{new_branch_name} origin/#{source_branch_name}")
85
+ check_out new_branch_name
86
+ perform("push --set-upstream origin #{new_branch_name}")
87
+ end
88
+
89
+ # Public: Whether the Git index is clean (has no uncommited changes)
90
+ #
91
+ # Returns a Boolean
92
+ def self.clean?
93
+ # git status --short returns one line for any uncommited changes, if any
94
+ # e.g.,
95
+ # ?? untracked.txt
96
+ # D deleted.txt
97
+ # M modified.txt
98
+ cli.perform_quietly("git status --short").empty?
99
+ end
100
+
101
+ # Public: Perform the block if the Git index is clean
102
+ def self.if_clean(message=DEFAULT_DIRTY_MESSAGE)
103
+ if clean?
104
+ yield
105
+ else
106
+ alert_dirty_index message
107
+ end
108
+ end
109
+
110
+ # Public: Display the message and show the git status
111
+ def self.alert_dirty_index(message)
112
+ cli.say " "
113
+ cli.say message
114
+ cli.say " "
115
+ perform "status"
116
+ end
117
+
118
+ # Public: Merge the given remote branch into the current branch
119
+ def self.merge(branch_name)
120
+ Git.if_clean do
121
+ Git.fetch
122
+ perform "merge --no-ff origin/#{branch_name}"
123
+ raise MergeFailed unless Git.clean?
124
+ Git.push
125
+ end
126
+ end
127
+
128
+ # Public: Fetch the latest changes from GitHub
129
+ def self.fetch
130
+ perform_quietly "fetch --prune"
131
+ end
132
+
133
+ # Public: Push the current branch to GitHub
134
+ def self.push
135
+ if_clean do
136
+ perform "push origin #{current_branch}"
137
+ end
138
+ end
139
+
140
+ # Public: Pull the latest changes for the checked-out branch
141
+ def self.pull
142
+ if_clean do
143
+ perform "pull"
144
+ end
145
+ end
146
+
147
+ # Public: The list of branches on GitHub
148
+ #
149
+ # Returns an Array of Strings containing the branch names
150
+ def self.remote_branches
151
+ Git.fetch
152
+ raw = Git.perform_quietly "branch --remote"
153
+ all_branches = raw.split("\n").map do |raw_name|
154
+ # will come in as " origin/foo", we want just "foo"
155
+ raw_name.split("/").last
156
+ end
157
+
158
+ all_branches.uniq.sort
159
+ end
160
+
161
+ # Public: List of branches starting with the given string
162
+ #
163
+ # prefix - String to match branch names against
164
+ #
165
+ # Returns an Array of Strings containing the branch names
166
+ def self.branches_for(prefix)
167
+ remote_branches.select do |branch_name|
168
+ branch_name =~ /^#{prefix}/
169
+ end
170
+ end
171
+
172
+ def self.latest_branch_for(branch_prefix)
173
+ branches_for(branch_prefix).last || raise(NoBranchOfType, "No #{branch_prefix} branch")
174
+ end
175
+
176
+ # Public: The name of the current deployable branch
177
+ def self.deployable_branch
178
+ latest_branch_for(DEPLOYABLE_PREFIX)
179
+ end
180
+
181
+ # Public: The name of the current staging branch
182
+ def self.staging_branch
183
+ latest_branch_for(STAGING_PREFIX)
184
+ end
185
+
186
+ # Public: The name of the current QA-ready branch
187
+ def self.qaready_branch
188
+ latest_branch_for(QAREADY_PREFIX)
189
+ end
190
+
191
+ # Public: The list of releases which have been tagged
192
+ #
193
+ # Returns an Array of Strings containing the tag names
194
+ def self.release_tags
195
+ Git.perform_quietly("tag").split("\n").select do |tag|
196
+ tag =~ RELEASE_TAG_FILTER
197
+ end
198
+ end
199
+
200
+ # Public: Only the most recent release tags
201
+ #
202
+ # Returns an Array of Strings containing the tag names
203
+ def self.recent_release_tags
204
+ release_tags.last(RECENT_TAG_LIMIT)
205
+ end
206
+
207
+ # Public: Create a new tag with the given name
208
+ #
209
+ # tag_name - The name of the tag to create
210
+ def self.new_tag(tag_name)
211
+ perform "tag #{tag_name}"
212
+ push
213
+ perform "push --tag"
214
+ end
215
+
216
+ # Public: Delete the given branch
217
+ #
218
+ # branch_name - The name of the branch to delete
219
+ def self.delete_branch(branch_name)
220
+ perform "push origin :#{branch_name}"
221
+ perform "branch -D #{branch_name}"
222
+ end
223
+
224
+ # Public: Branches which have been merged into the given branch
225
+ #
226
+ # source_branch_name - The name of the branch to check against
227
+ # branches_to_ignore - An Array of branches to exclude from results
228
+ #
229
+ # Returns an Array of Strings
230
+ def self.stale_branches(source_branch_name="master", branches_to_ignore=[])
231
+ Git.fetch
232
+ command = "branch --remote --merged #{recent_sha(source_branch_name)} | grep -E -v '(#{stale_branches_to_ignore(branches_to_ignore).join("|")})'"
233
+ raw_result = Git.perform_quietly command
234
+ raw_result.split.map { |full_name| full_name.gsub("origin/", "") }
235
+ end
236
+
237
+ # Private: The SHA from 1 day ago for the given branch
238
+ #
239
+ # branch_name - The name of the branch to check
240
+ #
241
+ # Returns a String
242
+ def self.recent_sha(branch_name)
243
+ raw = perform_quietly "rev-list `git rev-parse remotes/origin/#{branch_name} --before=1.day.ago` --max-count=1"
244
+ raw.chomp
245
+ end
246
+ private_class_method :recent_sha
247
+
248
+ # Private: Branches to ignore when looking for stale branches
249
+ #
250
+ # Returns an Array of Strings
251
+ def self.stale_branches_to_ignore(additional_branches=[])
252
+ %w(HEAD master staging deployable) + Array(additional_branches)
253
+ end
254
+ private_class_method :stale_branches_to_ignore
255
+
256
+ # Exceptions
257
+ NotOnBranch = Class.new(StandardError)
258
+ CheckoutFailed = Class.new(StandardError)
259
+ MergeFailed = Class.new(StandardError)
260
+ NoBranchOfType = Class.new(StandardError)
261
+ end
262
+ end