kettle-dev 1.0.10 → 1.0.11

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.
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Kettle
4
6
  module Dev
5
7
  # Minimal Git adapter used by kettle-dev to avoid invoking live shell commands
6
- # directly from the library code. In tests, mock this adapter's methods to
7
- # prevent any real network or repository mutations.
8
+ # directly from the higher-level library code. In tests, mock this adapter's
9
+ # methods to prevent any real network or repository mutations.
8
10
  #
9
- # This adapter requires the 'git' gem at runtime and does not shell out to
10
- # the system git. Specs should stub the git gem API to avoid real pushes.
11
+ # Behavior:
12
+ # - Prefer the 'git' gem when available.
13
+ # - If the 'git' gem is not present (LoadError), fall back to shelling out to
14
+ # the system `git` executable for the small set of operations we need.
11
15
  #
12
16
  # Public API is intentionally small and only includes what we need right now.
13
17
  class GitAdapter
@@ -15,10 +19,21 @@ module Kettle
15
19
  # @return [void]
16
20
  def initialize
17
21
  begin
18
- require "git"
19
- @git = ::Git.open(Dir.pwd)
22
+ # Allow users/CI to opt out of using the 'git' gem even when available.
23
+ # Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
24
+ env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
25
+ # Ruby 2.3 compatibility: String#match? was added in 2.4; use Regexp#=== / =~ instead
26
+ disable_gem = env_val && !!(/\A(1|true|yes)\z/i =~ env_val)
27
+ if disable_gem
28
+ @backend = :cli
29
+ else
30
+ Kernel.require "git"
31
+ @backend = :gem
32
+ @git = ::Git.open(Dir.pwd)
33
+ end
20
34
  rescue LoadError
21
- raise Kettle::Dev::Error, "The 'git' gem is required at runtime. Please add it as a dependency."
35
+ # Optional dependency: fall back to CLI
36
+ @backend = :cli
22
37
  rescue StandardError => e
23
38
  raise Kettle::Dev::Error, "Failed to open git repository: #{e.message}"
24
39
  end
@@ -30,42 +45,73 @@ module Kettle
30
45
  # @param force [Boolean] whether to force push
31
46
  # @return [Boolean] true when the push is reported successful
32
47
  def push(remote, branch, force: false)
33
- # git gem supports force: true option on push
34
- begin
48
+ if @backend == :gem
49
+ begin
50
+ if remote
51
+ @git.push(remote, branch, force: force)
52
+ else
53
+ # Default remote according to repo config
54
+ @git.push(nil, branch, force: force)
55
+ end
56
+ true
57
+ rescue StandardError
58
+ false
59
+ end
60
+ else
61
+ args = ["git", "push"]
62
+ args << "--force" if force
35
63
  if remote
36
- @git.push(remote, branch, force: force)
37
- else
38
- # Default remote according to repo config
39
- @git.push(nil, branch, force: force)
64
+ args << remote.to_s << branch.to_s
40
65
  end
41
- true
42
- rescue StandardError
43
- false
66
+ system(*args)
44
67
  end
45
68
  end
46
69
 
47
70
  # @return [String, nil] current branch name, or nil on error
48
71
  def current_branch
49
- @git.current_branch
72
+ if @backend == :gem
73
+ @git.current_branch
74
+ else
75
+ out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
76
+ status.success? ? out.strip : nil
77
+ end
50
78
  rescue StandardError
51
79
  nil
52
80
  end
53
81
 
54
82
  # @return [Array<String>] list of remote names
55
83
  def remotes
56
- @git.remotes.map(&:name)
84
+ if @backend == :gem
85
+ @git.remotes.map(&:name)
86
+ else
87
+ out, status = Open3.capture2("git", "remote")
88
+ status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
89
+ end
57
90
  rescue StandardError
58
91
  []
59
92
  end
60
93
 
61
94
  # @return [Hash{String=>String}] remote name => fetch URL
62
95
  def remotes_with_urls
63
- @git.remotes.each_with_object({}) do |r, h|
64
- begin
65
- h[r.name] = r.url
66
- rescue StandardError
67
- # ignore
96
+ if @backend == :gem
97
+ @git.remotes.each_with_object({}) do |r, h|
98
+ begin
99
+ h[r.name] = r.url
100
+ rescue StandardError
101
+ # ignore
102
+ end
103
+ end
104
+ else
105
+ out, status = Open3.capture2("git", "remote", "-v")
106
+ return {} unless status.success?
107
+ urls = {}
108
+ out.each_line do |line|
109
+ # Example: origin https://github.com/me/repo.git (fetch)
110
+ if line =~ /^(\S+)\s+(\S+)\s+\(fetch\)/
111
+ urls[Regexp.last_match(1)] = Regexp.last_match(2)
112
+ end
68
113
  end
114
+ urls
69
115
  end
70
116
  rescue StandardError
71
117
  {}
@@ -74,8 +120,13 @@ module Kettle
74
120
  # @param name [String]
75
121
  # @return [String, nil]
76
122
  def remote_url(name)
77
- r = @git.remotes.find { |x| x.name == name }
78
- r&.url
123
+ if @backend == :gem
124
+ r = @git.remotes.find { |x| x.name == name }
125
+ r&.url
126
+ else
127
+ out, status = Open3.capture2("git", "config", "--get", "remote.#{name}.url")
128
+ status.success? ? out.strip : nil
129
+ end
79
130
  rescue StandardError
80
131
  nil
81
132
  end
@@ -84,8 +135,12 @@ module Kettle
84
135
  # @param branch [String]
85
136
  # @return [Boolean]
86
137
  def checkout(branch)
87
- @git.checkout(branch)
88
- true
138
+ if @backend == :gem
139
+ @git.checkout(branch)
140
+ true
141
+ else
142
+ system("git", "checkout", branch.to_s)
143
+ end
89
144
  rescue StandardError
90
145
  false
91
146
  end
@@ -95,8 +150,12 @@ module Kettle
95
150
  # @param branch [String]
96
151
  # @return [Boolean]
97
152
  def pull(remote, branch)
98
- @git.pull(remote, branch)
99
- true
153
+ if @backend == :gem
154
+ @git.pull(remote, branch)
155
+ true
156
+ else
157
+ system("git", "pull", remote.to_s, branch.to_s)
158
+ end
100
159
  rescue StandardError
101
160
  false
102
161
  end
@@ -106,12 +165,18 @@ module Kettle
106
165
  # @param ref [String, nil]
107
166
  # @return [Boolean]
108
167
  def fetch(remote, ref = nil)
109
- if ref
110
- @git.fetch(remote, ref)
168
+ if @backend == :gem
169
+ if ref
170
+ @git.fetch(remote, ref)
171
+ else
172
+ @git.fetch(remote)
173
+ end
174
+ true
175
+ elsif ref
176
+ system("git", "fetch", remote.to_s, ref.to_s)
111
177
  else
112
- @git.fetch(remote)
178
+ system("git", "fetch", remote.to_s)
113
179
  end
114
- true
115
180
  rescue StandardError
116
181
  false
117
182
  end
@@ -77,7 +77,7 @@ module Kettle
77
77
 
78
78
  if footer_append && goalie_allows_footer?(subject_line)
79
79
  if commit_msg.include?(sentinel)
80
- exit(0)
80
+ Kettle::Dev::ExitAdapter.exit(0)
81
81
  else
82
82
  footer_binding = GitCommitFooter.new
83
83
  File.open(argv[0], "w") do |file|
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Input indirection layer to make interactive prompts safe in tests.
6
+ #
7
+ # Production/default behavior delegates to $stdin.gets (or Kernel#gets)
8
+ # so application code does not read from STDIN directly. In specs, mock
9
+ # this adapter's methods to return deterministic answers without touching
10
+ # global IO.
11
+ #
12
+ # Example (RSpec):
13
+ # allow(Kettle::Dev::InputAdapter).to receive(:gets).and_return("y\n")
14
+ #
15
+ # This mirrors the "mockable adapter" approach used for GitAdapter and ExitAdapter.
16
+ module InputAdapter
17
+ module_function
18
+
19
+ # Read one line from the standard input, including the trailing newline if
20
+ # present. Returns nil on EOF, consistent with IO#gets.
21
+ #
22
+ # @param args [Array] any args are forwarded to $stdin.gets for compatibility
23
+ # @return [String, nil]
24
+ def gets(*args)
25
+ $stdin.gets(*args)
26
+ end
27
+
28
+ # Read one line from standard input, raising EOFError on end-of-file.
29
+ # Provided for convenience symmetry with IO#readline when needed.
30
+ #
31
+ # @param args [Array]
32
+ # @return [String]
33
+ def readline(*args)
34
+ line = gets(*args)
35
+ raise EOFError, "end of file reached" if line.nil?
36
+ line
37
+ end
38
+ end
39
+ end
40
+ end
@@ -16,6 +16,8 @@ require "ruby-progressbar"
16
16
  # Internal
17
17
  require "kettle/dev/git_adapter"
18
18
  require "kettle/dev/exit_adapter"
19
+ require "kettle/dev/input_adapter"
20
+ require "kettle/dev/versioning"
19
21
 
20
22
  module Kettle
21
23
  module Dev
@@ -34,8 +36,6 @@ module Kettle
34
36
  end
35
37
 
36
38
  def run
37
- puts "== kettle-release =="
38
-
39
39
  ensure_bundler_2_7_plus!
40
40
 
41
41
  version = detect_version
@@ -68,11 +68,23 @@ module Kettle
68
68
  else
69
69
  latest_overall
70
70
  end
71
- if target && Gem::Version.new(version) <= Gem::Version.new(target)
72
- series = cur_series.join(".")
73
- warn("version.rb (#{version}) must be greater than the latest released version for series #{series}. Latest for series: #{target}.")
74
- warn("Tip: bump PATCH for a stable branch release, or bump MINOR/MAJOR when on trunk.")
75
- abort("Aborting: version bump required.")
71
+ if target
72
+ bump = Kettle::Dev::Versioning.classify_bump(target, version)
73
+ case bump
74
+ when :same
75
+ series = cur_series.join(".")
76
+ warn("version.rb (#{version}) matches the latest released version for series #{series} (#{target}).")
77
+ abort("Aborting: version bump required. Bump PATCH/MINOR/MAJOR/EPIC.")
78
+ when :downgrade
79
+ series = cur_series.join(".")
80
+ warn("version.rb (#{version}) is lower than the latest released version for series #{series} (#{target}).")
81
+ abort("Aborting: version must be bumped above #{target}.")
82
+ else
83
+ label = {epic: "EPIC", major: "MAJOR", minor: "MINOR", patch: "PATCH"}[bump] || bump.to_s.upcase
84
+ puts "Proposed bump type: #{label} (from #{target} -> #{version})"
85
+ end
86
+ else
87
+ puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
76
88
  end
77
89
  else
78
90
  puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
@@ -80,7 +92,7 @@ module Kettle
80
92
 
81
93
  puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
82
94
  print("> ")
83
- ans = $stdin.gets&.strip
95
+ ans = Kettle::Dev::InputAdapter.gets&.strip
84
96
  abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
85
97
 
86
98
  run_cmd!("bin/setup")
@@ -119,7 +131,7 @@ module Kettle
119
131
  # Prompt on CI to allow an explicit abort when signing would otherwise hang
120
132
  if ENV.fetch("CI", "false").casecmp("true").zero?
121
133
  print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
122
- ans = $stdin.gets&.strip
134
+ ans = Kettle::Dev::InputAdapter.gets&.strip
123
135
  unless ans&.downcase&.start_with?("y")
124
136
  abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
125
137
  end
@@ -269,7 +281,7 @@ module Kettle
269
281
  when "true", "1", "yes", "y" then true
270
282
  when "ask"
271
283
  print("Run local CI with 'act' before pushing? [Y/n] ")
272
- ans = $stdin.gets&.strip
284
+ ans = Kettle::Dev::InputAdapter.gets&.strip
273
285
  ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
274
286
  else
275
287
  false
@@ -329,17 +341,7 @@ module Kettle
329
341
  end
330
342
 
331
343
  def detect_version
332
- candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
333
- abort("Could not find version.rb under lib/**.") if candidates.empty?
334
- versions = candidates.map do |path|
335
- content = File.read(path)
336
- m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
337
- next unless m
338
- m[2]
339
- end.compact
340
- abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
341
- abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
342
- versions.first
344
+ Kettle::Dev::Versioning.detect_version(@root)
343
345
  end
344
346
 
345
347
  def detect_gem_name
@@ -571,7 +573,7 @@ module Kettle
571
573
  puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
572
574
  puts " [a] Abort"
573
575
  print("> ")
574
- choice = $stdin.gets&.strip&.downcase
576
+ choice = Kettle::Dev::InputAdapter.gets&.strip&.downcase
575
577
  case choice
576
578
  when "r"
577
579
  run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
@@ -2,6 +2,7 @@
2
2
 
3
3
  # External
4
4
  require "kettle/dev/exit_adapter"
5
+ require "kettle/dev/input_adapter"
5
6
  require "open3"
6
7
  require "net/http"
7
8
  require "json"
@@ -188,7 +189,7 @@ module Kettle
188
189
  selected = nil
189
190
  input_thread = Thread.new do
190
191
  begin
191
- selected = $stdin.gets&.strip
192
+ selected = Kettle::Dev::InputAdapter.gets&.strip
192
193
  rescue Exception => error
193
194
  # Catch all exceptions in background thread, including SystemExit
194
195
  # NOTE: look into refactoring to minimize potential SystemExit.
@@ -249,8 +250,10 @@ module Kettle
249
250
  sleep(poll_interval)
250
251
  end
251
252
  rescue Exception
253
+ # :nocov:
252
254
  # Catch all exceptions in the worker thread boundary, including SystemExit
253
255
  status_q << [c, f, "err"]
256
+ # :nocov:
254
257
  end
255
258
  end
256
259
  end