sugarjar 2.0.0.beta.1 → 2.0.2

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.
@@ -0,0 +1,102 @@
1
+ require_relative '../util'
2
+
3
+ class SugarJar
4
+ class Commands
5
+ def smartpullrequest(*args)
6
+ assert_in_repo!
7
+ assert_common_main_branch!
8
+
9
+ # does not use `dirty_check!` because we don't allow overriding here
10
+ if dirty?
11
+ SugarJar::Log.warn(
12
+ 'Your repo is dirty, so I am not going to create a pull request. ' +
13
+ 'You should commit or amend and push it to your remote first.',
14
+ )
15
+ exit(1)
16
+ end
17
+
18
+ user_specified_base = args.include?('-B') || args.include?('--base')
19
+
20
+ curr = current_branch
21
+ base = tracked_branch
22
+ if @pr_autofill
23
+ SugarJar::Log.info('Autofilling in PR from commit message')
24
+ num_commits = git(
25
+ 'rev-list', '--count', curr, "^#{base}"
26
+ ).stdout.strip.to_i
27
+ if num_commits > 1
28
+ args.unshift('--fill-first')
29
+ else
30
+ args.unshift('--fill')
31
+ end
32
+ end
33
+ unless user_specified_base
34
+ if subfeature?(base)
35
+ if upstream_org != push_org
36
+ SugarJar::Log.warn(
37
+ 'Unfortunately you cannot based one PR on another PR when' +
38
+ " using fork-based PRs. We will base this on #{most_main}." +
39
+ ' This just means the PR "Changes" tab will show changes for' +
40
+ ' the full stack until those other PRs are merged and this PR' +
41
+ ' PR is rebased.',
42
+ )
43
+ # nil is prompt, true is always, false is never
44
+ elsif @pr_autostack.nil?
45
+ $stdout.print(
46
+ 'It looks like this is a subfeature, would you like to base ' +
47
+ "this PR on #{base}? [y/n] ",
48
+ )
49
+ ans = $stdin.gets.strip
50
+ args.unshift('--base', base) if %w{Y y}.include?(ans)
51
+ elsif @pr_autostack
52
+ args.unshift('--base', base)
53
+ end
54
+ elsif base.include?('/') && base != most_main
55
+ # If our base is a remote branch, then use that as the
56
+ # base branch of the PR
57
+ args.unshift('--base', base.split('/').last)
58
+ end
59
+ end
60
+
61
+ # <org>:<branch> is the GH API syntax for:
62
+ # look for a branch of name <branch>, from a fork in owner <org>
63
+ args.unshift('--head', "#{push_org}:#{curr}")
64
+ SugarJar::Log.trace("Running: gh pr create #{args.join(' ')}")
65
+ gh = SugarJar::Util.which('gh')
66
+ system(gh, 'pr', 'create', *args)
67
+ end
68
+
69
+ alias spr smartpullrequest
70
+ alias smartpr smartpullrequest
71
+
72
+ private
73
+
74
+ def assert_common_main_branch!
75
+ upstream_branch = main_remote_branch(upstream)
76
+ unless main_branch == upstream_branch
77
+ die(
78
+ "The local main branch is '#{main_branch}', but the main branch " +
79
+ "of the #{upstream} remote is '#{upstream_branch}'. You probably " +
80
+ "want to rename your local branch by doing:\n\t" +
81
+ "git branch -m #{main_branch} #{upstream_branch}\n\t" +
82
+ "git fetch #{upstream}\n\t" +
83
+ "git branch -u #{upstream}/#{upstream_branch} #{upstream_branch}\n" +
84
+ "\tgit remote set-head #{upstream} -a",
85
+ )
86
+ end
87
+ return if upstream_branch == 'origin'
88
+
89
+ origin_branch = main_remote_branch('origin')
90
+ return if origin_branch == upstream_branch
91
+
92
+ die(
93
+ "The main branch of your upstream (#{upstream_branch}) and your " +
94
+ "fork/origin (#{origin_branch}) are not the same. You should go " +
95
+ "to https://#{@ghhost || 'github.com'}/#{@ghuser}/#{repo_name}/" +
96
+ 'branches/ and rename the \'default\' branch to ' +
97
+ "'#{upstream_branch}'. It will then give you some commands to " +
98
+ 'run to update this clone.',
99
+ )
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,124 @@
1
+ class SugarJar
2
+ class Commands
3
+ def up(branch = nil)
4
+ assert_in_repo!
5
+ branch ||= current_branch
6
+ branch = fprefix(branch)
7
+ # get a copy of our current branch, if rebase fails, we won't
8
+ # be able to determine it without backing out
9
+ curr = current_branch
10
+ git('checkout', branch)
11
+ result = rebase
12
+ if result['so'].error?
13
+ backout = ''
14
+ if rebase_in_progress?
15
+ backout = ' You can get out of this with a `git rebase --abort`.'
16
+ end
17
+
18
+ die(
19
+ "#{color(curr, :red)}: Failed to rebase on " +
20
+ "#{result['base']}. Leaving the repo as-is.#{backout} " +
21
+ 'Output from failed rebase is: ' +
22
+ "\nSTDOUT:\n#{result['so'].stdout.lines.map { |x| "\t#{x}" }.join}" +
23
+ "\nSTDERR:\n#{result['so'].stderr.lines.map { |x| "\t#{x}" }.join}",
24
+ )
25
+ else
26
+ SugarJar::Log.info(
27
+ "#{color(current_branch, :green)} rebased on #{result['base']}",
28
+ )
29
+ # go back to where we were if we rebased a different branch
30
+ git('checkout', curr) if branch != curr
31
+ end
32
+ end
33
+
34
+ def upall
35
+ assert_in_repo!
36
+ all_local_branches.each do |branch|
37
+ next if MAIN_BRANCHES.include?(branch)
38
+
39
+ git('checkout', branch)
40
+ result = rebase
41
+ if result['so'].error?
42
+ SugarJar::Log.error(
43
+ "#{color(branch, :red)} failed rebase. Reverting attempt and " +
44
+ 'moving to next branch. Try `sj up` manually on that branch.',
45
+ )
46
+ git('rebase', '--abort') if rebase_in_progress?
47
+ else
48
+ SugarJar::Log.info(
49
+ "#{color(branch, :green)} rebased on " +
50
+ color(result['base'], :green).to_s,
51
+ )
52
+ end
53
+ end
54
+ end
55
+
56
+ def sync
57
+ assert_in_repo!
58
+ dirty_check!
59
+
60
+ src = "origin/#{current_branch}"
61
+ fetch('origin')
62
+ s = git_nofail('merge-base', '--is-ancestor', 'HEAD', src)
63
+ if s.error?
64
+ SugarJar::Log.debug(
65
+ "Choosing rebase sync since this isn't a direct ancestor",
66
+ )
67
+ rebase(src)
68
+ else
69
+ SugarJar::Log.debug('Choosing reset sync since this is an ancestor')
70
+ git('reset', '--hard', src)
71
+ end
72
+ SugarJar::Log.info("Synced to #{src}.")
73
+ end
74
+
75
+ private
76
+
77
+ def rebase(base = nil)
78
+ skip_base_warning = !base.nil?
79
+ SugarJar::Log.debug('Fetching upstream')
80
+ fetch_upstream
81
+ curr = current_branch
82
+ # this isn't a hash, it's a named param, silly rubocop
83
+ # rubocop:disable Style/HashSyntax
84
+ base ||= tracked_branch(fallback: false)
85
+ # rubocop:enable Style/HashSyntax
86
+ unless base
87
+ SugarJar::Log.info(
88
+ 'The brach we were tracking is gone, resetting tracking to ' +
89
+ most_main,
90
+ )
91
+ git('branch', '-u', most_main)
92
+ base = most_main
93
+ end
94
+ # If this is a subfeature based on a local branch which has since
95
+ # been deleted, 'tracked branch' will automatically return <most_main>
96
+ # so we don't need any special handling for that
97
+ if !MAIN_BRANCHES.include?(curr) && base == "origin/#{curr}" &&
98
+ !skip_base_warning
99
+ SugarJar::Log.warn(
100
+ "This branch is tracking origin/#{curr}, which is probably your " +
101
+ 'downstream (where you push _to_) as opposed to your upstream ' +
102
+ '(where you pull _from_). This means that "sj up" is probably ' +
103
+ 'rebasing on the wrong thing and doing nothing. You probably want ' +
104
+ "to do a 'git branch -u #{most_main}'.",
105
+ )
106
+ end
107
+ SugarJar::Log.debug('Rebasing')
108
+ s = git_nofail('rebase', base)
109
+ {
110
+ 'so' => s,
111
+ 'base' => base,
112
+ }
113
+ end
114
+
115
+ def rebase_in_progress?
116
+ # for rebase without -i
117
+ rebase_file = git('rev-parse', '--git-path', 'rebase-apply').stdout.strip
118
+ # for rebase -i
119
+ rebase_merge_file = git('rev-parse', '--git-path', 'rebase-merge').
120
+ stdout.strip
121
+ File.exist?(rebase_file) || File.exist?(rebase_merge_file)
122
+ end
123
+ end
124
+ end
@@ -1,9 +1,5 @@
1
1
  require 'mixlib/shellout'
2
2
 
3
- require_relative 'util'
4
- require_relative 'repoconfig'
5
- require_relative 'log'
6
- require_relative 'version'
7
3
  require_relative 'commands/amend'
8
4
  require_relative 'commands/bclean'
9
5
  require_relative 'commands/branch'
@@ -15,6 +11,10 @@ require_relative 'commands/push'
15
11
  require_relative 'commands/smartclone'
16
12
  require_relative 'commands/smartpullrequest'
17
13
  require_relative 'commands/up'
14
+ require_relative 'log'
15
+ require_relative 'repoconfig'
16
+ require_relative 'util'
17
+ require_relative 'version'
18
18
 
19
19
  class SugarJar
20
20
  # This is the workhorse of SugarJar. Short of #initialize, all other public
@@ -49,6 +49,336 @@ class SugarJar
49
49
  set_commit_template if @repo_config['commit_template']
50
50
  end
51
51
 
52
- include SugarJar::Util
52
+ private
53
+
54
+ def forked_repo(repo, username)
55
+ repo = if repo.start_with?('http', 'git@')
56
+ File.basename(repo)
57
+ else
58
+ "#{File.basename(repo)}.git"
59
+ end
60
+ "git@#{@ghhost || 'github.com'}:#{username}/#{repo}"
61
+ end
62
+
63
+ # gh utils will default to https, but we should always default to SSH
64
+ # unless otherwise specified since https will cause prompting.
65
+ def canonicalize_repo(repo)
66
+ # if they fully-qualified it, we're good
67
+ return repo if repo.start_with?('http', 'git@')
68
+
69
+ # otherwise, ti's a shortname
70
+ cr = "git@#{@ghhost || 'github.com'}:#{repo}.git"
71
+ SugarJar::Log.debug("canonicalized #{repo} to #{cr}")
72
+ cr
73
+ end
74
+
75
+ def set_commit_template
76
+ unless SugarJar::Util.in_repo?
77
+ SugarJar::Log.debug('Skipping set_commit_template: not in repo')
78
+ return
79
+ end
80
+
81
+ realpath = if @repo_config['commit_template'].start_with?('/')
82
+ @repo_config['commit_template']
83
+ else
84
+ "#{Util.repo_root}/#{@repo_config['commit_template']}"
85
+ end
86
+ unless File.exist?(realpath)
87
+ die(
88
+ "Repo config specifies #{@repo_config['commit_template']} as the " +
89
+ 'commit template, but that file does not exist.',
90
+ )
91
+ end
92
+
93
+ s = git_nofail('config', '--local', 'commit.template')
94
+ unless s.error?
95
+ current = s.stdout.strip
96
+ if current == @repo_config['commit_template']
97
+ SugarJar::Log.debug('Commit template already set correctly')
98
+ return
99
+ else
100
+ SugarJar::Log.warn(
101
+ "Updating repo-specific commit template from #{current} " +
102
+ "to #{@repo_config['commit_template']}",
103
+ )
104
+ end
105
+ end
106
+
107
+ SugarJar::Log.debug(
108
+ 'Setting repo-specific commit template to ' +
109
+ "#{@repo_config['commit_template']} per sugarjar repo config.",
110
+ )
111
+ git(
112
+ 'config', '--local', 'commit.template', @repo_config['commit_template']
113
+ )
114
+ end
115
+
116
+ def assert_in_repo!
117
+ return if SugarJar::Util.in_repo?
118
+
119
+ die('sugarjar must be run from inside a git repo')
120
+ end
121
+
122
+ def dirty_check!
123
+ return unless dirty?
124
+
125
+ if @ignore_dirty
126
+ SugarJar::Log.warn(
127
+ 'Your repo is dirty, but --ignore-dirty was specified, so ' +
128
+ 'carrying on anyway.',
129
+ )
130
+ else
131
+ SugarJar::Log.error(
132
+ 'Your repo is dirty, so I am refusing to continue. Please commit ' +
133
+ 'or amend first (or use --ignore-dirty to override).',
134
+ )
135
+ exit(1)
136
+ end
137
+ end
138
+
139
+ def determine_main_branch(branches)
140
+ branches.include?('main') ? 'main' : 'master'
141
+ end
142
+
143
+ def main_branch
144
+ @main_branch = determine_main_branch(all_local_branches)
145
+ end
146
+
147
+ def main_remote_branch(remote)
148
+ @main_remote_branches[remote] ||=
149
+ determine_main_branch(all_remote_branches(remote))
150
+ end
151
+
152
+ def checkout_main_branch
153
+ git('checkout', main_branch)
154
+ end
155
+
156
+ def all_remote_branches(remote = 'origin')
157
+ branches = []
158
+ git('branch', '-r', '--format', '%(refname)').stdout.lines.each do |line|
159
+ next unless line.start_with?("refs/remotes/#{remote}/")
160
+
161
+ branches << branch_from_ref(line.strip, :remote)
162
+ end
163
+ branches
164
+ end
165
+
166
+ def all_local_branches
167
+ git(
168
+ 'branch', '--format', '%(refname)'
169
+ ).stdout.lines.map do |line|
170
+ if line.start_with?('(')
171
+ SugarJar::Log.debug("Skipping meta-branch: #{line.strip}")
172
+ next
173
+ end
174
+ branch_from_ref(line.strip)
175
+ end
176
+ end
177
+
178
+ def all_remotes
179
+ git('remote').stdout.lines.map(&:strip)
180
+ end
181
+
182
+ def remote_url_map
183
+ m = {}
184
+ git('remote', '-v').stdout.each_line do |line|
185
+ name, url, = line.split
186
+ m[name] = url
187
+ end
188
+ m
189
+ end
190
+
191
+ def current_branch
192
+ branch_from_ref(git('symbolic-ref', 'HEAD').stdout.strip)
193
+ end
194
+
195
+ def fetch_upstream
196
+ us = upstream
197
+ fetch(us) if us
198
+ end
199
+
200
+ def fetch(remote)
201
+ git('fetch', remote)
202
+ end
203
+
204
+ # determine if this branch is based on another local branch (i.e. is a
205
+ # subfeature). Used to figure out of we should stack the PR
206
+ def subfeature?(base)
207
+ all_local_branches.reject { |x| x == most_main }.include?(base)
208
+ end
209
+
210
+ def tracked_branch(branch = nil, fallback: true)
211
+ curr = current_branch
212
+ git('checkout', branch) if branch && branch != curr
213
+ s = git_nofail(
214
+ 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'
215
+ )
216
+ git('checkout', curr) if branch && branch != curr
217
+ if s.error?
218
+ branch = fallback ? most_main : nil
219
+ SugarJar::Log.debug("No specific tracked branch, using #{branch}")
220
+ else
221
+ branch = s.stdout.strip
222
+ SugarJar::Log.debug(
223
+ "Using explicit tracked branch: #{branch}, use " +
224
+ '`git branch -u` to change',
225
+ )
226
+ end
227
+ branch
228
+ end
229
+
230
+ def most_main
231
+ us = upstream
232
+ if us
233
+ "#{us}/#{main_branch}"
234
+ else
235
+ main_branch
236
+ end
237
+ end
238
+
239
+ def upstream
240
+ return @remote if @remote
241
+
242
+ remotes = all_remotes
243
+ SugarJar::Log.debug("remotes is #{remotes}")
244
+ if remotes.empty?
245
+ @remote = nil
246
+ elsif remotes.length == 1
247
+ @remote = remotes[0]
248
+ elsif remotes.include?('upstream')
249
+ @remote = 'upstream'
250
+ elsif remotes.include?('origin')
251
+ @remote = 'origin'
252
+ else
253
+ raise 'Could not determine "upstream" remote to use...'
254
+ end
255
+ @remote
256
+ end
257
+
258
+ def upstream_org
259
+ us = upstream
260
+ remotes = remote_url_map
261
+ extract_org(remotes[us])
262
+ end
263
+
264
+ # Whatever org we push to, regardless of if this is a fork or not
265
+ def push_org
266
+ url = git('remote', 'get-url', 'origin').stdout.strip
267
+ extract_org(url)
268
+ end
269
+
270
+ def color(string, *colors)
271
+ if @color
272
+ pastel.decorate(string, *colors)
273
+ else
274
+ string
275
+ end
276
+ end
277
+
278
+ def pastel
279
+ @pastel ||= begin
280
+ require 'pastel'
281
+ Pastel.new
282
+ end
283
+ end
284
+
285
+ def gh_avail?
286
+ !!SugarJar::Util.which_nofail('gh')
287
+ end
288
+
289
+ def fprefix(name)
290
+ return name unless @feature_prefix
291
+
292
+ return name if name.start_with?(@feature_prefix)
293
+ return name if all_local_branches.include?(name)
294
+
295
+ newname = "#{@feature_prefix}#{name}"
296
+ SugarJar::Log.debug(
297
+ "Munging feature name: #{name} -> #{newname} due to feature prefix",
298
+ )
299
+ newname
300
+ end
301
+
302
+ def dirty?
303
+ s = git_nofail('diff', '--quiet')
304
+ s.error?
305
+ end
306
+
307
+ def repo_name
308
+ SugarJar::Util.repo_root.split('/').last
309
+ end
310
+
311
+ def extract_org(repo)
312
+ if repo.start_with?('http')
313
+ File.basename(File.dirname(repo))
314
+ elsif repo.start_with?('git@')
315
+ repo.split(':')[1].split('/')[0]
316
+ else
317
+ # assume they passed in a ghcli-friendly name
318
+ repo.split('/').first
319
+ end
320
+ end
321
+
322
+ def extract_repo(repo)
323
+ File.basename(repo, '.git')
324
+ end
325
+
326
+ def die(msg)
327
+ SugarJar::Log.fatal(msg)
328
+ exit(1)
329
+ end
330
+
331
+ def worktree_branches
332
+ worktrees.values.map do |wt|
333
+ branch_from_ref(wt['branch'])
334
+ end
335
+ end
336
+
337
+ def worktrees
338
+ root = SugarJar::Util.repo_root
339
+ s = git('worktree', 'list', '--porcelain')
340
+ s.error!
341
+ worktrees = {}
342
+ # each entry is separated by a double newline
343
+ s.stdout.split("\n\n").each do |entry|
344
+ # then each key/val is split by a new line with the key and
345
+ # the value themselves split by a whitespace
346
+ tree = entry.split("\n").to_h(&:split)
347
+ # Skip the one
348
+ next if tree['worktree'] == root
349
+
350
+ worktrees[tree['worktree']] = tree
351
+ end
352
+ worktrees
353
+ end
354
+
355
+ def branch_from_ref(ref, type = :local)
356
+ # local branches are refs/head/XXXX
357
+ # remote branches are refs/remotes/<remote>/XXXX
358
+ base = type == :local ? 2 : 3
359
+ ref.split('/')[base..].join('/')
360
+ end
361
+
362
+ def remote_from_ref(ref)
363
+ return nil unless ref.start_with?('refs/remotes/')
364
+
365
+ ref.split('/')[2]
366
+ end
367
+
368
+ def git(*)
369
+ SugarJar::Util.git(*, :color => @color)
370
+ end
371
+
372
+ def git_nofail(*)
373
+ SugarJar::Util.git_nofail(*, :color => @color)
374
+ end
375
+
376
+ def ghcli(*)
377
+ SugarJar::Util.ghcli(*)
378
+ end
379
+
380
+ def ghcli_nofail(*)
381
+ SugarJar::Util.ghcli_nofail(*)
382
+ end
53
383
  end
54
384
  end
@@ -7,12 +7,10 @@ class SugarJar
7
7
  # This parses SugarJar repoconfigs (not to be confused with configs).
8
8
  # This is lint/unit/on_push configs.
9
9
  class RepoConfig
10
- extend SugarJar::Util
11
-
12
10
  CONFIG_NAME = '.sugarjar.yaml'.freeze
13
11
 
14
12
  def self.repo_config_path(config)
15
- ::File.join(repo_root, config)
13
+ ::File.join(SugarJar::Util.repo_root, config)
16
14
  end
17
15
 
18
16
  def self.hash_from_file(config_file)
@@ -27,7 +25,7 @@ class SugarJar
27
25
 
28
26
  def self.config(config = CONFIG_NAME)
29
27
  data = {}
30
- unless in_repo
28
+ unless SugarJar::Util.in_repo?
31
29
  SugarJar::Log.debug('Not in repo, skipping repoconfig load')
32
30
  return data
33
31
  end