Class: Kettle::Dev::GitAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/git_adapter.rb

Overview

Minimal Git adapter used by kettle-dev to avoid invoking live shell commands directly from the higher-level library code. In tests, mock this adapter’s methods to prevent any real network or repository mutations.

Behavior:

  • Prefer the ‘git’ gem when available.

  • If the ‘git’ gem is not present (LoadError), fall back to shelling out to the system ‘git` executable for the small set of operations we need.

Public API is intentionally small and only includes what we need right now.

Instance Method Summary collapse

Constructor Details

#initializevoid

Create a new adapter rooted at the current working directory.



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/kettle/dev/git_adapter.rb', line 55

def initialize
  begin
    # Allow users/CI to opt out of using the 'git' gem even when available.
    # Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
    env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
    # Ruby 2.3 compatibility: String#match? was added in 2.4; use Regexp#=== / =~ instead
    disable_gem = env_val && !!(/\A(1|true|yes)\z/i =~ env_val)
    if disable_gem
      @backend = :cli
    else
      Kernel.require "git"
      @backend = :gem
      @git = ::Git.open(Dir.pwd)
    end
  rescue LoadError => e
    Kettle::Dev.debug_error(e, __method__, backtrace: false)
    # Optional dependency: fall back to CLI
    @backend = :cli
  rescue StandardError => e
    raise Kettle::Dev::Error, "Failed to open git repository: #{e.message}"
  end
end

Instance Method Details

#blame_porcelain(path) ⇒ String

Return the raw ‘git blame –porcelain` output for a single tracked file.

Both backends shell out directly because the ‘git` gem does not provide a stable porcelain-blame interface. Callers that need only the output string (e.g. CopyrightCollector) should stub this method in specs.

Parameters:

  • path (String)

    path to the file, relative to the repository root

Returns:

  • (String)

    raw porcelain blame output, or “” on error / untracked file



178
179
180
181
182
183
184
# File 'lib/kettle/dev/git_adapter.rb', line 178

def blame_porcelain(path)
  out, status = Open3.capture2("git", "blame", "--porcelain", path.to_s)
  status.success? ? out : ""
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  ""
end

#capture(args) ⇒ Array<(String, Boolean)>

Execute a git command and capture its stdout and success flag. This is a generic escape hatch used by higher-level code for read-only queries that aren’t covered by the explicit adapter API. Tests can stub this method to avoid shelling out.

Parameters:

  • args (Array<String>)

Returns:

  • (Array<(String, Boolean)>)
    output, success


45
46
47
48
49
50
51
# File 'lib/kettle/dev/git_adapter.rb', line 45

def capture(args)
  out, status = Open3.capture2("git", *args)
  [out.strip, status.success?]
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  ["", false]
end

#checkout(branch) ⇒ Boolean

Checkout the given branch

Parameters:

  • branch (String)

Returns:

  • (Boolean)


246
247
248
249
250
251
252
253
254
255
256
# File 'lib/kettle/dev/git_adapter.rb', line 246

def checkout(branch)
  if @backend == :gem
    @git.checkout(branch)
    true
  else
    system("git", "checkout", branch.to_s)
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  false
end

#clean?Boolean

Determine whether the working tree is clean (no unstaged, staged, or untracked changes).

Returns:

  • (Boolean)

    true if clean, false if any changes or on error



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/kettle/dev/git_adapter.rb', line 20

def clean?
  if @backend == :gem
    begin
      status = @git.status
      # git gem's Status responds to changed, added, deleted, untracked, etc.
      status.changed.empty? && status.added.empty? && status.deleted.empty? && status.untracked.empty?
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      false
    end
  else
    out, st = Open3.capture2("git", "status", "--porcelain")
    st.success? && out.strip.empty?
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  false
end

#current_branchString?

Returns current branch name, or nil on error.

Returns:

  • (String, nil)

    current branch name, or nil on error



138
139
140
141
142
143
144
145
146
147
148
# File 'lib/kettle/dev/git_adapter.rb', line 138

def current_branch
  if @backend == :gem
    @git.current_branch
  else
    out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
    status.success? ? out.strip : nil
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  nil
end

#fetch(remote, ref = nil) ⇒ Boolean

Fetch a ref from a remote (or everything if ref is nil)

Parameters:

  • remote (String)
  • ref (String, nil) (defaults to: nil)

Returns:

  • (Boolean)


278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/kettle/dev/git_adapter.rb', line 278

def fetch(remote, ref = nil)
  if @backend == :gem
    if ref
      @git.fetch(remote, ref)
    else
      @git.fetch(remote)
    end
    true
  elsif ref
    system("git", "fetch", remote.to_s, ref.to_s)
  else
    system("git", "fetch", remote.to_s)
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  false
end

#ls_filesArray<String>

Return the list of files currently tracked by git.

Returns:

  • (Array<String>)

    relative paths of tracked files, empty on error



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/kettle/dev/git_adapter.rb', line 153

def ls_files
  if @backend == :gem
    begin
      @git.ls_files.keys
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      []
    end
  else
    out, status = Open3.capture2("git", "ls-files")
    status.success? ? out.split(/\r?\n/).reject(&:empty?) : []
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  []
end

#pull(remote, branch) ⇒ Boolean

Pull from a remote/branch

Parameters:

  • remote (String)
  • branch (String)

Returns:

  • (Boolean)


262
263
264
265
266
267
268
269
270
271
272
# File 'lib/kettle/dev/git_adapter.rb', line 262

def pull(remote, branch)
  if @backend == :gem
    @git.pull(remote, branch)
    true
  else
    system("git", "pull", remote.to_s, branch.to_s)
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  false
end

#push(remote, branch, force: false) ⇒ Boolean

Push a branch to a remote.

Parameters:

  • remote (String, nil)

    remote name (nil means default remote)

  • branch (String)

    branch name (required)

  • force (Boolean) (defaults to: false)

    whether to force push

Returns:

  • (Boolean)

    true when the push is reported successful



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/kettle/dev/git_adapter.rb', line 83

def push(remote, branch, force: false)
  if @backend == :gem
    begin
      if remote
        @git.push(remote, branch, force: force)
      else
        # Default remote according to repo config
        @git.push(nil, branch, force: force)
      end
      true
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      false
    end
  else
    args = ["git", "push"]
    args << "--force" if force
    if remote
      args << remote.to_s << branch.to_s
    end
    system(*args)
  end
end

#push_tags(remote) ⇒ Boolean

Push all tags to a remote. Notes:

  • The ruby-git gem does not provide a stable API for pushing all tags across versions, so we intentionally shell out to ‘git push –tags` for both backends. Tests should stub this method in higher-level code to avoid mutating any repositories.

Parameters:

  • remote (String, nil)

    The remote name. When nil or empty, uses the repository’s default remote (same behavior as running ‘git push –tags`) which typically uses the current branch’s upstream.

Returns:

  • (Boolean)

    true if the system call reports success; false on failure



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/kettle/dev/git_adapter.rb', line 118

def push_tags(remote)
  if @backend == :gem
    # The ruby-git gem does not expose a dedicated API for "--tags" consistently across versions.
    # Use a shell fallback even when the gem backend is active. Tests should stub this method.
    if remote && !remote.to_s.empty?
      system("git", "push", remote.to_s, "--tags")
    else
      system("git", "push", "--tags")
    end
  elsif remote && !remote.to_s.empty?
    system("git", "push", remote.to_s, "--tags")
  else
    system("git", "push", "--tags")
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  false
end

#remote_url(name) ⇒ String?

Parameters:

  • name (String)

Returns:

  • (String, nil)


230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/kettle/dev/git_adapter.rb', line 230

def remote_url(name)
  if @backend == :gem
    r = @git.remotes.find { |x| x.name == name }
    r&.url
  else
    out, status = Open3.capture2("git", "config", "--get", "remote.#{name}.url")
    status.success? ? out.strip : nil
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  nil
end

#remotesArray<String>

Returns list of remote names.

Returns:

  • (Array<String>)

    list of remote names



187
188
189
190
191
192
193
194
195
196
197
# File 'lib/kettle/dev/git_adapter.rb', line 187

def remotes
  if @backend == :gem
    @git.remotes.map(&:name)
  else
    out, status = Open3.capture2("git", "remote")
    status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  []
end

#remotes_with_urlsHash{String=>String}

Returns remote name => fetch URL.

Returns:

  • (Hash{String=>String})

    remote name => fetch URL



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/kettle/dev/git_adapter.rb', line 200

def remotes_with_urls
  if @backend == :gem
    @git.remotes.each_with_object({}) do |r, h|
      begin
        h[r.name] = r.url
      rescue StandardError => e
        Kettle::Dev.debug_error(e, __method__)
        # ignore
      end
    end
  else
    out, status = Open3.capture2("git", "remote", "-v")
    return {} unless status.success?

    urls = {}
    out.each_line do |line|
      # Example: origin https://github.com/me/repo.git (fetch)
      if line =~ /^(\S+)\s+(\S+)\s+\(fetch\)/
        urls[Regexp.last_match(1)] = Regexp.last_match(2)
      end
    end
    urls
  end
rescue StandardError => e
  Kettle::Dev.debug_error(e, __method__)
  {}
end