Module: KKGit::GitOps

Defined in:
lib/kk/git/git_ops.rb

Overview

Git 仓库操作:供 Rake task 与 CLI 复用。

Defined Under Namespace

Classes: Error, Status

Constant Summary collapse

SENSITIVE_PATH_PATTERNS =
[
  %r{\A\.env(\.|$)}i,
  /credentials/i,
  %r{\.pem\z}i,
  /id_rsa/i,
  %r{\.key\z}i,
  %r{\Asecrets?\.}i
].freeze
MUTATING_GIT_COMMANDS =
%w[add commit push pull merge rebase checkout reset cherry-pick revert tag].freeze

Class Method Summary collapse

Class Method Details

.add_all!(repo_dir: '.') ⇒ Object



248
249
250
251
252
# File 'lib/kk/git/git_ops.rb', line 248

def add_all!(repo_dir: '.')
  paths = add_paths
  out, err, ok = run_cmd('git', 'add', *paths, chdir: repo_dir)
  ensure_ok!(ok, 'git add', stdout: out, stderr: err, repo_dir: repo_dir)
end

.add_pathsObject



65
66
67
# File 'lib/kk/git/git_ops.rb', line 65

def add_paths
  ENV.fetch('KK_GIT_ADD_PATHS', '.').split(/\s+/).reject(&:empty?)
end

.ahead_count(remote, branch, repo_dir: '.') ⇒ Object



165
166
167
168
169
170
171
172
173
# File 'lib/kk/git/git_ops.rb', line 165

def ahead_count(remote, branch, repo_dir: '.')
  out, _err, ok = run_cmd('git', 'rev-list', '--count', '@{u}..HEAD', chdir: repo_dir)
  return out.strip.to_i if ok

  out, _err, ok = run_cmd('git', 'rev-list', '--count', "#{remote}/#{branch}..HEAD", chdir: repo_dir)
  return out.strip.to_i if ok

  0
end

.amend?Boolean

Returns:

  • (Boolean)


53
54
55
# File 'lib/kk/git/git_ops.rb', line 53

def amend?
  ENV['KK_GIT_AMEND'] == '1'
end

.auto_commit_push!(commit_message_generator: nil, repo_dir: '.') ⇒ Symbol

Returns :synced | :committed_and_synced | :noop.

Returns:

  • (Symbol)

    :synced | :committed_and_synced | :noop

Raises:



306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/kk/git/git_ops.rb', line 306

def auto_commit_push!(commit_message_generator: nil, repo_dir: '.')
  ensure_in_repo!(repo_dir: repo_dir)
  remote_name = remote
  branch_name = branch(repo_dir: repo_dir)

  if working_tree_clean?(repo_dir: repo_dir)
    if status(remote: remote_name, branch: branch_name, repo_dir: repo_dir).needs_sync?
      sync_with_remote!(remote_name, branch_name, repo_dir: repo_dir)
      return :synced
    end

    puts 'No changes to commit or push'
    return :noop
  end

  add_all!(repo_dir: repo_dir)
  ensure_no_sensitive_staged!(repo_dir: repo_dir)

  message =
    if commit_message_generator
      commit_message_generator.call
    else
      KKGit::CommitMessage.generate(repo_dir: repo_dir, mode: :all)
    end
  message = message.to_s.strip
  raise Error, 'Could not generate commit message from staged changes' if message.empty?

  confirm_commit!(message)

  committed = commit_with_message!(message, repo_dir: repo_dir)
  if committed || unpushed_commits?(remote_name, branch_name, repo_dir: repo_dir)
    sync_with_remote!(remote_name, branch_name, repo_dir: repo_dir)
    return committed ? :committed_and_synced : :synced
  end

  :noop
end

.behind_count(remote, branch, repo_dir: '.') ⇒ Object



175
176
177
178
179
180
181
182
183
# File 'lib/kk/git/git_ops.rb', line 175

def behind_count(remote, branch, repo_dir: '.')
  out, _err, ok = run_cmd('git', 'rev-list', '--count', 'HEAD..@{u}', chdir: repo_dir)
  return out.strip.to_i if ok

  out, _err, ok = run_cmd('git', 'rev-list', '--count', "HEAD..#{remote}/#{branch}", chdir: repo_dir)
  return out.strip.to_i if ok

  0
end

.branch(explicit: , repo_dir: '.') ⇒ Object



61
62
63
# File 'lib/kk/git/git_ops.rb', line 61

def branch(explicit: ENV['KK_GIT_BRANCH'], repo_dir: '.')
  explicit.to_s.strip.empty? ? current_branch(repo_dir: repo_dir) : explicit.to_s.strip
end

.commit_with_message!(message, repo_dir: '.') ⇒ Object



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/kk/git/git_ops.rb', line 286

def commit_with_message!(message, repo_dir: '.')
  commit_args = amend? ? %w[commit --amend -F] : %w[commit -F]

  Tempfile.create('commit_message') do |f|
    f.write(message)
    f.flush
    out, err, ok = run_cmd('git', *commit_args, f.path, chdir: repo_dir)
    if ok
      true
    elsif err.include?('nothing to commit') || out.include?('nothing to commit')
      puts 'No staged changes to commit'
      false
    else
      ensure_ok!(ok, 'git commit', stdout: out, stderr: err, repo_dir: repo_dir)
      false
    end
  end
end

.confirm_commit!(message) ⇒ Object

Raises:



278
279
280
281
282
283
284
# File 'lib/kk/git/git_ops.rb', line 278

def confirm_commit!(message)
  return unless ENV['KK_GIT_CONFIRM'] == '1'
  return if ENV['KK_GIT_YES'] == '1'

  puts "Commit message:\n#{message}\n"
  raise Error, 'Set KK_GIT_YES=1 to confirm this commit'
end

.current_branch(repo_dir: '.') ⇒ Object



140
141
142
143
144
# File 'lib/kk/git/git_ops.rb', line 140

def current_branch(repo_dir: '.')
  out, err, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: repo_dir)
  ensure_ok!(ok, 'Get current branch', stdout: out, stderr: err, repo_dir: repo_dir)
  out.strip
end

.detached_head?(repo_dir: '.') ⇒ Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/kk/git/git_ops.rb', line 146

def detached_head?(repo_dir: '.')
  current_branch(repo_dir: repo_dir) == 'HEAD'
end

.dry_run?Boolean

Returns:

  • (Boolean)


41
42
43
# File 'lib/kk/git/git_ops.rb', line 41

def dry_run?
  ENV['KK_GIT_DRY_RUN'] == '1'
end

.ensure_in_repo!(repo_dir: '.') ⇒ Object

Raises:



136
137
138
# File 'lib/kk/git/git_ops.rb', line 136

def ensure_in_repo!(repo_dir: '.')
  raise Error, 'Not a git repository' unless in_git_repo?(repo_dir: repo_dir)
end

.ensure_no_sensitive_staged!(repo_dir: '.') ⇒ Object



265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/kk/git/git_ops.rb', line 265

def ensure_no_sensitive_staged!(repo_dir: '.')
  paths = sensitive_staged_paths(repo_dir: repo_dir)
  return if paths.empty?

  if ENV['KK_GIT_ALLOW_SENSITIVE'] == '1'
    warn "Warning: committing sensitive paths: #{paths.join(', ')}"
  else
    raise Error,
          "Refusing to commit sensitive paths: #{paths.join(', ')}. " \
          'Set KK_GIT_ALLOW_SENSITIVE=1 to override.'
  end
end

.ensure_not_detached!(repo_dir: '.') ⇒ Object

Raises:



150
151
152
# File 'lib/kk/git/git_ops.rb', line 150

def ensure_not_detached!(repo_dir: '.')
  raise Error, 'Cannot push from detached HEAD' if detached_head?(repo_dir: repo_dir)
end

.ensure_ok!(ok, title, stdout: nil, stderr: nil, repo_dir: '.') ⇒ Object

Raises:



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/kk/git/git_ops.rb', line 113

def ensure_ok!(ok, title, stdout: nil, stderr: nil, repo_dir: '.')
  return if ok

  msg = +"#{title} failed"
  msg << "\n#{stderr}" unless stderr.to_s.strip.empty?
  msg << "\n#{stdout}" unless stdout.to_s.strip.empty?

  hints = NetworkHints.for_git_failure(
    title: title,
    stderr: stderr.to_s,
    stdout: stdout.to_s,
    suggested_https_url: suggested_https_push_url(repo_dir: repo_dir)
  )
  msg << "\n\n#{hints.join("\n\n")}" unless hints.empty?

  raise Error, msg
end

.in_git_repo?(repo_dir: '.') ⇒ Boolean

Returns:

  • (Boolean)


131
132
133
134
# File 'lib/kk/git/git_ops.rb', line 131

def in_git_repo?(repo_dir: '.')
  _, _, ok = run_cmd('git', 'rev-parse', '--git-dir', chdir: repo_dir)
  ok
end

.mutating_git_command?(cmd) ⇒ Boolean

Returns:

  • (Boolean)


109
110
111
# File 'lib/kk/git/git_ops.rb', line 109

def mutating_git_command?(cmd)
  cmd[0] == 'git' && MUTATING_GIT_COMMANDS.include?(cmd[1])
end

.pull_remote!(remote, branch, repo_dir: '.') ⇒ Object



219
220
221
222
223
224
225
226
# File 'lib/kk/git/git_ops.rb', line 219

def pull_remote!(remote, branch, repo_dir: '.')
  return if skip_pull?

  pull_args = ENV.fetch('KK_GIT_PULL_ARGS', '--ff-only').split
  target = pull_target(remote)
  out, err, ok = run_cmd('git', 'pull', target, branch, *pull_args, chdir: repo_dir)
  ensure_ok!(ok, 'git pull', stdout: out, stderr: err, repo_dir: repo_dir)
end

.pull_target(remote) ⇒ Object



69
70
71
72
# File 'lib/kk/git/git_ops.rb', line 69

def pull_target(remote)
  url = ENV['KK_GIT_PULL_URL'].to_s.strip
  url.empty? ? remote : url
end

.push_remote!(remote, branch, repo_dir: '.') ⇒ Object



228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/kk/git/git_ops.rb', line 228

def push_remote!(remote, branch, repo_dir: '.')
  return if skip_push?

  ensure_not_detached!(repo_dir: repo_dir) unless dry_run?

  target = push_target(remote)
  if upstream_configured?(repo_dir: repo_dir)
    out, err, ok = run_cmd('git', 'push', target, branch, chdir: repo_dir)
  else
    out, err, ok = run_cmd('git', 'push', '-u', target, branch, chdir: repo_dir)
  end
  ensure_ok!(ok, 'git push', stdout: out, stderr: err, repo_dir: repo_dir)
end

.push_target(remote) ⇒ Object



74
75
76
77
# File 'lib/kk/git/git_ops.rb', line 74

def push_target(remote)
  url = ENV['KK_GIT_PUSH_URL'].to_s.strip
  url.empty? ? remote : url
end

.remoteObject



57
58
59
# File 'lib/kk/git/git_ops.rb', line 57

def remote
  ENV.fetch('KK_GIT_REMOTE', 'origin')
end

.remote_fetch_url(repo_dir: '.') ⇒ Object



79
80
81
82
# File 'lib/kk/git/git_ops.rb', line 79

def remote_fetch_url(repo_dir: '.')
  out, _err, ok = run_cmd('git', 'remote', 'get-url', remote, chdir: repo_dir)
  ok ? out.strip : nil
end

.run_cmd(*cmd, chdir: '.') ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/kk/git/git_ops.rb', line 90

def run_cmd(*cmd, chdir: '.')
  if dry_run? && mutating_git_command?(cmd)
    label = chdir == '.' ? cmd.join(' ') : "(cd #{chdir} && #{cmd.join(' ')})"
    puts "[dry-run] #{label}"
    return ['', '', true]
  end

  if cmd[0] == 'git'
    stdout, stderr, ok = GitRunner.capture(cmd.drop(1), repo_dir: chdir)
  else
    require 'open3'
    stdout, stderr, status = Open3.capture3(*cmd, chdir: chdir == '.' ? nil : chdir)
    ok = status.success?
    stdout = GitRunner.normalize_utf8(stdout)
    stderr = GitRunner.normalize_utf8(stderr)
  end
  [stdout.to_s, stderr.to_s, ok]
end

.sensitive_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


254
255
256
# File 'lib/kk/git/git_ops.rb', line 254

def sensitive_path?(path)
  SENSITIVE_PATH_PATTERNS.any? { |pattern| path.match?(pattern) }
end

.sensitive_staged_paths(repo_dir: '.') ⇒ Object



258
259
260
261
262
263
# File 'lib/kk/git/git_ops.rb', line 258

def sensitive_staged_paths(repo_dir: '.')
  out, _err, ok = run_cmd('git', 'diff', '--cached', '--name-only', chdir: repo_dir)
  return [] unless ok

  out.split("\n").reject(&:empty?).select { |path| sensitive_path?(path) }
end

.skip_pull?Boolean

Returns:

  • (Boolean)


45
46
47
# File 'lib/kk/git/git_ops.rb', line 45

def skip_pull?
  ENV['KK_GIT_SKIP_PULL'] == '1'
end

.skip_push?Boolean

Returns:

  • (Boolean)


49
50
51
# File 'lib/kk/git/git_ops.rb', line 49

def skip_push?
  ENV['KK_GIT_SKIP_PUSH'] == '1'
end

.status(remote: nil, branch: nil, repo_dir: '.') ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/kk/git/git_ops.rb', line 189

def status(remote: nil, branch: nil, repo_dir: '.')
  ensure_in_repo!(repo_dir: repo_dir)
  remote ||= self.remote
  branch ||= self.branch(repo_dir: repo_dir)

  Status.new(
    branch: branch,
    remote: remote,
    clean: working_tree_clean?(repo_dir: repo_dir),
    ahead: ahead_count(remote, branch, repo_dir: repo_dir),
    behind: behind_count(remote, branch, repo_dir: repo_dir),
    upstream_configured: upstream_configured?(repo_dir: repo_dir),
    detached: detached_head?(repo_dir: repo_dir)
  )
end

.status_hash(remote: nil, branch: nil, repo_dir: '.') ⇒ Object



205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/kk/git/git_ops.rb', line 205

def status_hash(remote: nil, branch: nil, repo_dir: '.')
  s = status(remote: remote, branch: branch, repo_dir: repo_dir)
  {
    branch: s.branch,
    remote: s.remote,
    clean: s.clean,
    ahead: s.ahead,
    behind: s.behind,
    upstream_configured: s.upstream_configured,
    detached: s.detached,
    needs_sync: s.needs_sync?
  }
end

.suggested_https_push_url(repo_dir: '.') ⇒ Object



84
85
86
# File 'lib/kk/git/git_ops.rb', line 84

def suggested_https_push_url(repo_dir: '.')
  NetworkHints.ssh_remote_to_https(remote_fetch_url(repo_dir: repo_dir))
end

.sync_with_remote!(remote, branch, repo_dir: '.') ⇒ Object



242
243
244
245
246
# File 'lib/kk/git/git_ops.rb', line 242

def sync_with_remote!(remote, branch, repo_dir: '.')
  pull_remote!(remote, branch, repo_dir: repo_dir)
  push_remote!(remote, branch, repo_dir: repo_dir)
  puts "Synced: #{remote} #{branch}" unless dry_run?
end

.unpushed_commits?(remote, branch, repo_dir: '.') ⇒ Boolean

Returns:

  • (Boolean)


185
186
187
# File 'lib/kk/git/git_ops.rb', line 185

def unpushed_commits?(remote, branch, repo_dir: '.')
  ahead_count(remote, branch, repo_dir: repo_dir).positive?
end

.upstream_configured?(repo_dir: '.') ⇒ Boolean

Returns:

  • (Boolean)


160
161
162
163
# File 'lib/kk/git/git_ops.rb', line 160

def upstream_configured?(repo_dir: '.')
  _, _, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', '@{u}', chdir: repo_dir)
  ok
end

.working_tree_clean?(repo_dir: '.') ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
157
158
# File 'lib/kk/git/git_ops.rb', line 154

def working_tree_clean?(repo_dir: '.')
  out, err, ok = run_cmd('git', 'status', '--porcelain', chdir: repo_dir)
  ensure_ok!(ok, 'Check git status', stdout: out, stderr: err, repo_dir: repo_dir)
  out.strip.empty?
end