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 =
[
  /\A\.env(\.|$)/i,
  /credentials/i,
  /\.pem\z/i,
  /id_rsa/i,
  /\.key\z/i,
  /\Asecrets?\./i
].freeze
SAFE_ENV_TEMPLATE_SUFFIXES =
%w[example sample template dist].freeze
SAFE_CREDENTIALS_CODE_PATTERNS =

Application source that manages credentials in-repo (not secret payloads).

[
  %r{(?:^|/)[a-z0-9_]+_credentials\.go\z}i,
  %r{(?:^|/)[a-z0-9_]+_credentials\.(ts|tsx|js|jsx)\z}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



262
263
264
265
266
# File 'lib/kk/git/git_ops.rb', line 262

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



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

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

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



179
180
181
182
183
184
185
186
187
# File 'lib/kk/git/git_ops.rb', line 179

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)


63
64
65
# File 'lib/kk/git/git_ops.rb', line 63

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:



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/kk/git/git_ops.rb', line 335

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

  AutoFormat.run!(repo_dir: repo_dir) unless skip_format?

  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



189
190
191
192
193
194
195
196
197
# File 'lib/kk/git/git_ops.rb', line 189

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: ENV.fetch('KK_GIT_BRANCH', nil), repo_dir: '.') ⇒ Object



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

def branch(explicit: ENV.fetch('KK_GIT_BRANCH', nil), 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



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/kk/git/git_ops.rb', line 315

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:



307
308
309
310
311
312
313
# File 'lib/kk/git/git_ops.rb', line 307

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



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

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)


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

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

.dry_run?Boolean

Returns:

  • (Boolean)


51
52
53
# File 'lib/kk/git/git_ops.rb', line 51

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

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

Raises:



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

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

Raises:



292
293
294
295
296
297
298
299
300
301
# File 'lib/kk/git/git_ops.rb', line 292

def ensure_no_sensitive_staged!(repo_dir: '.')
  return unless sensitive_check_enabled?

  paths = sensitive_staged_paths(repo_dir: repo_dir)
  return if paths.empty?

  raise Error,
        "Refusing to commit sensitive paths: #{paths.join(', ')}. " \
        'Unset KK_GIT_BLOCK_SENSITIVE or unstage these files.'
end

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

Raises:



164
165
166
# File 'lib/kk/git/git_ops.rb', line 164

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:



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/kk/git/git_ops.rb', line 127

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)


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

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)


123
124
125
# File 'lib/kk/git/git_ops.rb', line 123

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

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



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

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



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

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



242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/kk/git/git_ops.rb', line 242

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



88
89
90
91
# File 'lib/kk/git/git_ops.rb', line 88

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

.remoteObject



67
68
69
# File 'lib/kk/git/git_ops.rb', line 67

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

.remote_fetch_url(repo_dir: '.') ⇒ Object



93
94
95
96
# File 'lib/kk/git/git_ops.rb', line 93

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



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/kk/git/git_ops.rb', line 104

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

.safe_credentials_code?(path) ⇒ Boolean

Returns:

  • (Boolean)


275
276
277
# File 'lib/kk/git/git_ops.rb', line 275

def safe_credentials_code?(path)
  SAFE_CREDENTIALS_CODE_PATTERNS.any? { |pattern| path.match?(pattern) }
end

.safe_env_template?(path) ⇒ Boolean

Returns:

  • (Boolean)


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

def safe_env_template?(path)
  SAFE_ENV_TEMPLATE_SUFFIXES.any? do |suffix|
    path.match?(/\A\.env\.#{suffix}\z/i)
  end
end

.sensitive_check_enabled?Boolean

Returns:

  • (Boolean)


303
304
305
# File 'lib/kk/git/git_ops.rb', line 303

def sensitive_check_enabled?
  ENV['KK_GIT_BLOCK_SENSITIVE'] == '1'
end

.sensitive_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


268
269
270
271
272
273
# File 'lib/kk/git/git_ops.rb', line 268

def sensitive_path?(path)
  return false if safe_env_template?(path)
  return false if safe_credentials_code?(path)

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

.sensitive_staged_paths(repo_dir: '.') ⇒ Object



285
286
287
288
289
290
# File 'lib/kk/git/git_ops.rb', line 285

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_format?Boolean

Returns:

  • (Boolean)


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

def skip_format?
  ENV['KK_GIT_SKIP_FORMAT'] == '1'
end

.skip_pull?Boolean

Returns:

  • (Boolean)


55
56
57
# File 'lib/kk/git/git_ops.rb', line 55

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

.skip_push?Boolean

Returns:

  • (Boolean)


59
60
61
# File 'lib/kk/git/git_ops.rb', line 59

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

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



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

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



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

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



98
99
100
# File 'lib/kk/git/git_ops.rb', line 98

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



256
257
258
259
260
# File 'lib/kk/git/git_ops.rb', line 256

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)


199
200
201
# File 'lib/kk/git/git_ops.rb', line 199

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

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

Returns:

  • (Boolean)


174
175
176
177
# File 'lib/kk/git/git_ops.rb', line 174

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)


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

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