Class: KKGit::CommitMessage

Inherits:
Object
  • Object
show all
Defined in:
lib/kk/git/commit_message.rb

Overview

Generate Conventional Commits messages from git changes.

  • Supports staged and working-tree changes

  • Supports untracked files

  • Output format: “<type>(<scope>): <subject>nn<body>”

Defined Under Namespace

Classes: Change

Constant Summary collapse

TYPE_PRIORITY =
{
  'feat' => 1,
  'fix' => 2,
  'docs' => 3,
  'refactor' => 4,
  'style' => 5,
  'perf' => 6,
  'test' => 7,
  'ci' => 8,
  'chore' => 9
}.freeze

Class Method Summary collapse

Class Method Details

.append_group(lines, title, items) ⇒ Object



470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/kk/git/commit_message.rb', line 470

def self.append_group(lines, title, items)
  return if items.empty?

  lines << "#{title}:"
  items.each do |c|
    if %w[R C].include?(c.status) && c.old_path
      lines << "  - #{c.old_path} -> #{c.path}"
    else
      lines << "  - #{c.path}"
    end
  end
end

.build_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


363
364
365
366
367
368
369
# File 'lib/kk/git/commit_message.rb', line 363

def self.build_path?(path)
  return false if path.nil?
  path.match?(/\ADockerfile(\..+)?\z/i) ||
    path.match?(/\Adocker-compose(\..+)?\.(yml|yaml)\z/i) ||
    path.match?(/\AMakefile\z/i) ||
    path.match?(/\ARakefile\z/i)
end

.change_priority(change) ⇒ Object



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/kk/git/commit_message.rb', line 196

def self.change_priority(change)
  source_p = case change.source
             when 'staged' then 1
             when 'worktree' then 2
             when 'untracked' then 3
             else 9
             end
  status_p = case change.status
             when 'R', 'C' then 1
             when 'A' then 2
             when 'D' then 3
             when 'M' then 4
             else 9
             end
  source_p * 10 + status_p
end

.ci_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


335
336
337
338
339
340
341
# File 'lib/kk/git/commit_message.rb', line 335

def self.ci_path?(path)
  return false if path.nil?
  path.start_with?('.github/') ||
    path.match?(/\A\.gitlab-ci\.yml\z/i) ||
    path.start_with?('.circleci/') ||
    path.match?(/\A\.travis\.yml\z/i)
end

.code_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


402
403
404
405
# File 'lib/kk/git/commit_message.rb', line 402

def self.code_path?(path)
  return false if path.nil?
  path.match?(/\.(rb|go|ts|tsx|js|jsx|py|java|kt|rs)\z/i)
end

.collect_changes(repo_dir:, mode:) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/kk/git/commit_message.rb', line 118

def self.collect_changes(repo_dir:, mode:)
  staged = (mode == :staged || mode == :all)
  worktree = (mode == :worktree || mode == :all)

  changes = []
  if staged
    changes.concat(parse_name_status_z(run_git(%w[diff --cached --name-status -z], repo_dir: repo_dir),
                                       source: 'staged'))
  end
  if worktree
    changes.concat(parse_name_status_z(run_git(%w[diff --name-status -z], repo_dir: repo_dir),
                                       source: 'worktree'))
  end

  if worktree
    untracked = run_git(%w[ls-files --others --exclude-standard -z], repo_dir: repo_dir)
    untracked.split("\0").each do |path|
      next if path.nil? || path.empty?
      changes << Change.new(status: 'A', path: path, old_path: nil, source: 'untracked')
    end
  end

  normalize_and_dedup(changes)
end

.config_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/kk/git/commit_message.rb', line 371

def self.config_path?(path)
  return false if path.nil?
  path.start_with?('config/') ||
    path.match?(/\A\.gitignore\z/i) ||
    path.match?(/\A\.rubocop(\.yml)?\z/i) ||
    path.match?(/\A\.rubocop_todo\.yml\z/i) ||
    path.match?(/\A\.editorconfig\z/i) ||
    path.match?(/\A\.tool-versions\z/i) ||
    path.match?(/\A\.env(\..+)?\z/i) ||
    path.match?(/\A\.env\.example\z/i) ||
    path.match?(/\.(toml|ini)\z/i)
end

.deps_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


351
352
353
354
355
356
357
358
359
360
361
# File 'lib/kk/git/commit_message.rb', line 351

def self.deps_path?(path)
  return false if path.nil?
  path.match?(/\AGemfile(\.lock)?\z/i) ||
    path.match?(/\.gemspec\z/i) ||
    path.match?(/\Apackage\.json\z/i) ||
    path.match?(/\Ayarn\.lock\z/i) ||
    path.match?(/\Apnpm-lock\.yaml\z/i) ||
    path.match?(/\Apackage-lock\.json\z/i) ||
    path.match?(/\Ago\.mod\z/i) ||
    path.match?(/\Ago\.sum\z/i)
end

.detect_breaking_change(repo_dir:, mode:, max_diff_bytes:) ⇒ Object



483
484
485
486
487
488
489
490
491
492
493
# File 'lib/kk/git/commit_message.rb', line 483

def self.detect_breaking_change(repo_dir:, mode:, max_diff_bytes:)
  diffs = []
  diffs << run_git(%w[diff --cached], repo_dir: repo_dir) if mode == :staged || mode == :all
  diffs << run_git(%w[diff], repo_dir: repo_dir) if mode == :worktree || mode == :all

  content = diffs.join("\n")
  content = content.byteslice(0, max_diff_bytes) if content.bytesize > max_diff_bytes
  content.match?(/BREAKING CHANGE:|BREAKING:/i)
rescue StandardError
  false
end

.doc_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


328
329
330
331
332
333
# File 'lib/kk/git/commit_message.rb', line 328

def self.doc_path?(path)
  return false if path.nil?
  path.start_with?('openspec/') ||
    path.match?(/\AREADME(\..+)?\z/i) ||
    path.match?(/\.(md|mdx|txt)\z/i)
end

.generate(repo_dir: '.', mode: :staged, include_body: true, fallback_scope: 'general', type_override: nil, scope_override: nil, subject_override: nil, detect_breaking: true, max_diff_bytes: 300_000) ⇒ String?

Generate a commit message.

Parameters:

  • repo_dir (String) (defaults to: '.')

    git repo directory (default: current dir)

  • mode (Symbol) (defaults to: :staged)

    :staged / :worktree / :all

  • include_body (Boolean) (defaults to: true)

    include body for multi-file changes

  • fallback_scope (String) (defaults to: 'general')

    scope used when inference can’t decide

  • type_override (String, nil) (defaults to: nil)

    force type (feat/fix/docs/…)

  • scope_override (String, nil) (defaults to: nil)

    force scope

  • subject_override (String, nil) (defaults to: nil)

    force subject

  • detect_breaking (Boolean) (defaults to: true)

    detect “BREAKING” markers and emit “type(scope)!:” (default: true)

  • max_diff_bytes (Integer) (defaults to: 300_000)

    cap diff size for breaking detection

Returns:

  • (String, nil)

    returns nil when there are no changes



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/kk/git/commit_message.rb', line 41

def self.generate(
  repo_dir: '.',
  mode: :staged,
  include_body: true,
  fallback_scope: 'general',
  type_override: nil,
  scope_override: nil,
  subject_override: nil,
  detect_breaking: true,
  max_diff_bytes: 300_000
)
  changes = collect_changes(repo_dir: repo_dir, mode: mode)
  return nil if changes.empty?

  inferred = infer(changes: changes, repo_dir: repo_dir, mode: mode, detect_breaking: detect_breaking,
                   max_diff_bytes: max_diff_bytes, fallback_scope: fallback_scope)

  type = (type_override || ENV['KK_GIT_TYPE'] || inferred[:type]).to_s.strip
  scope = (scope_override || ENV['KK_GIT_SCOPE'] || inferred[:scope]).to_s.strip
  subject = (subject_override || ENV['KK_GIT_SUBJECT'] || inferred[:subject]).to_s.strip

  bang = inferred[:breaking] ? '!' : ''
  message = +"#{type}(#{scope})#{bang}: #{subject}"
  if include_body && changes.length > 1
    message << "\n\n"
    message << generate_body(changes)
  end

  message
end

.generate_body(changes) ⇒ Object



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/kk/git/commit_message.rb', line 445

def self.generate_body(changes)
  groups = {
    'A' => [],
    'M' => [],
    'D' => [],
    'R' => [],
    'C' => [],
    '?' => []
  }

  changes.each do |c|
    key = groups.key?(c.status) ? c.status : '?'
    groups[key] << c
  end

  lines = []
  append_group(lines, 'Added', groups['A'])
  append_group(lines, 'Changed', groups['M'])
  append_group(lines, 'Removed', groups['D'])
  append_group(lines, 'Renamed', groups['R'])
  append_group(lines, 'Copied', groups['C'])
  append_group(lines, 'Other', groups['?'])
  lines.join("\n")
end

.generate_hash(repo_dir: '.', mode: :staged, include_body: true, fallback_scope: 'general', type_override: nil, scope_override: nil, subject_override: nil, detect_breaking: true, max_diff_bytes: 300_000) ⇒ Hash

Generate structured data (useful for scripts/CI).

Returns:

  • (Hash)


75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/kk/git/commit_message.rb', line 75

def self.generate_hash(
  repo_dir: '.',
  mode: :staged,
  include_body: true,
  fallback_scope: 'general',
  type_override: nil,
  scope_override: nil,
  subject_override: nil,
  detect_breaking: true,
  max_diff_bytes: 300_000
)
  changes = collect_changes(repo_dir: repo_dir, mode: mode)
  return { empty: true } if changes.empty?

  inferred = infer(changes: changes, repo_dir: repo_dir, mode: mode, detect_breaking: detect_breaking,
                   max_diff_bytes: max_diff_bytes, fallback_scope: fallback_scope)

  type = (type_override || ENV['KK_GIT_TYPE'] || inferred[:type]).to_s.strip
  scope = (scope_override || ENV['KK_GIT_SCOPE'] || inferred[:scope]).to_s.strip
  subject = (subject_override || ENV['KK_GIT_SUBJECT'] || inferred[:subject]).to_s.strip

  header = "#{type}(#{scope})#{inferred[:breaking] ? '!' : ''}: #{subject}"
  body = include_body && changes.length > 1 ? generate_body(changes) : nil

  {
    empty: false,
    type: type,
    scope: scope,
    breaking: inferred[:breaking],
    subject: subject,
    header: header,
    body: body,
    changes: changes.map do |c|
      {
        status: c.status,
        path: c.path,
        old_path: c.old_path,
        source: c.source
      }
    end
  }
end

.generate_subject(type:, changes:, scope:) ⇒ Object



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/kk/git/commit_message.rb', line 407

def self.generate_subject(type:, changes:, scope:)
  if changes.length == 1
    c = changes.first
    action =
      case c.status
      when 'A' then 'Add'
      when 'D' then 'Remove'
      when 'R' then 'Rename'
      when 'C' then 'Copy'
      else 'Update'
      end

    if %w[R C].include?(c.status) && c.old_path
      return "#{action} #{File.basename(c.old_path)} -> #{File.basename(c.path)}"
    end
    return "#{action} #{File.basename(c.path)}"
  end

  label =
    case scope
    when 'repo' then 'project'
    when 'tools' then 'tools'
    else scope
    end

  case type
  when 'feat' then "Add #{label} features"
  when 'fix' then "Fix #{label} issues"
  when 'docs' then "Update #{label} docs"
  when 'refactor' then "Refactor #{label}"
  when 'style' then "Format #{label}"
  when 'perf' then "Improve #{label} performance"
  when 'test' then "Update #{label} tests"
  when 'ci' then "Update #{label} CI"
  else "Update #{label}"
  end
end

.infer(changes:, repo_dir:, mode:, detect_breaking:, max_diff_bytes:, fallback_scope:) ⇒ Hash

Infer type/scope/subject (with optional breaking detection)

Returns:

  • (Hash)

    :type,:scope,:subject,:breaking



227
228
229
230
231
232
233
234
235
# File 'lib/kk/git/commit_message.rb', line 227

def self.infer(changes:, repo_dir:, mode:, detect_breaking:, max_diff_bytes:, fallback_scope:)
  paths = changes.map(&:path)
  scope = infer_scope(paths, fallback_scope: fallback_scope)
  type = infer_type(changes)
  subject = generate_subject(type: type, changes: changes, scope: scope)
  breaking = detect_breaking ? detect_breaking_change(repo_dir: repo_dir, mode: mode, max_diff_bytes: max_diff_bytes) : false

  { type: type, scope: scope, subject: subject, breaking: breaking }
end

.infer_scope(paths, fallback_scope:) ⇒ Object



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/kk/git/commit_message.rb', line 252

def self.infer_scope(paths, fallback_scope:)
  # Tooling/script/build changes: prefer a stable scope
  if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) }
    return 'tools'
  end

  tops = paths.map { |p| top_level_scope(p) }.compact
  uniq = tops.uniq
  return fallback_scope if uniq.empty?
  return uniq.first if uniq.length == 1
  return 'repo' if uniq.include?('repo')

  # Multiple top-level dirs: use repo to keep scope stable/short.
  'repo'
end

.infer_type(changes) ⇒ Object



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/kk/git/commit_message.rb', line 277

def self.infer_type(changes)
  paths = changes.map(&:path)

  # Fast paths
  only_docs = paths.all? { |p| doc_path?(p) }
  return 'docs' if only_docs

  only_ci = paths.all? { |p| ci_path?(p) }
  return 'ci' if only_ci

  only_tests = paths.all? { |p| test_path?(p) || doc_path?(p) }
  return 'test' if only_tests && paths.any? { |p| test_path?(p) }

  only_deps = paths.all? { |p| deps_path?(p) }
  return 'chore' if only_deps

  # Tooling/script/build related: prefer chore (even if code is added)
  if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) || deps_path?(p) }
    return 'chore'
  end

  # Heuristics for code changes
  has_code = paths.any? { |p| code_path?(p) }
  has_new_code = changes.any? { |c| c.status == 'A' && code_path?(c.path) }
  has_fix_keyword = paths.any? { |p| p.match?(/fix|bug|error|issue/i) }
  has_delete = changes.any? { |c| c.status == 'D' }

  return 'feat' if has_new_code
  return 'fix' if has_code && has_fix_keyword
  return 'refactor' if has_code && has_delete

  # Mixed: aggregate by priority
  types = changes.map { |c| type_by_path(c.path) }
  pick_main_type(types)
end

.normalize_and_dedup(changes) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/kk/git/commit_message.rb', line 171

def self.normalize_and_dedup(changes)
  # Keyed by new_path; same path can show up in staged + worktree.
  dedup = {}
  changes.each do |c|
    next if c.path.nil? || c.path.strip.empty?

    key = c.path
    existing = dedup[key]
    if existing.nil?
      dedup[key] = c
      next
    end

    # Priority:
    # - staged wins over worktree (closer to what will be committed)
    # - rename/copy wins over plain modifications
    # - A(add) wins over M(modify)
    priority = change_priority(c)
    existing_priority = change_priority(existing)
    dedup[key] = c if priority < existing_priority
  end

  dedup.values.sort_by(&:path)
end

.parse_name_status_z(output, source:) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/kk/git/commit_message.rb', line 143

def self.parse_name_status_z(output, source:)
  tokens = output.to_s.split("\0")
  idx = 0
  changes = []
  while idx < tokens.length
    token = tokens[idx]
    break if token.nil? || token.empty?

    status_token = token
    status_char = status_token[0] # 'A' 'M' 'D' 'R' 'C' ...

    case status_char
    when 'R', 'C'
      old_path = tokens[idx + 1]
      new_path = tokens[idx + 2]
      break if old_path.nil? || new_path.nil?
      changes << Change.new(status: status_char, path: new_path, old_path: old_path, source: source)
      idx += 3
    else
      path = tokens[idx + 1]
      break if path.nil?
      changes << Change.new(status: status_char, path: path, old_path: nil, source: source)
      idx += 2
    end
  end
  changes
end

.pick_main_type(types) ⇒ Object



237
238
239
# File 'lib/kk/git/commit_message.rb', line 237

def self.pick_main_type(types)
  types.min_by { |t| TYPE_PRIORITY[t] || 999 } || 'chore'
end

.pick_scope(scopes, fallback_scope:) ⇒ Object



241
242
243
244
245
246
247
248
249
250
# File 'lib/kk/git/commit_message.rb', line 241

def self.pick_scope(scopes, fallback_scope:)
  uniq = scopes.compact.uniq
  return fallback_scope if uniq.empty?
  return uniq.first if uniq.length == 1

  # When multiple scopes exist, prefer "repo"; otherwise fallback.
  return 'repo' if uniq.include?('repo')

  fallback_scope
end

.run_git(args, repo_dir:) ⇒ Object



213
214
215
216
217
218
219
220
221
222
# File 'lib/kk/git/commit_message.rb', line 213

def self.run_git(args, repo_dir:)
  stdout, stderr, status = Open3.capture3('git', *args, chdir: repo_dir)
  # Open3 stdout/stderr may be ASCII-8BIT (BINARY). Normalize to UTF-8 to avoid concat errors.
  stdout = stdout.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')
  stderr = stderr.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')

  raise "git #{args.join(' ')} failed: #{stderr.strip}" unless status.success?

  stdout
end

.script_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


384
385
386
387
388
389
# File 'lib/kk/git/commit_message.rb', line 384

def self.script_path?(path)
  return false if path.nil?
  path.start_with?('scripts/') ||
    path.match?(/\.(sh|bash)\z/i) ||
    path.match?(/\Adeploy\.sh\z/i)
end

.test_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


343
344
345
346
347
348
349
# File 'lib/kk/git/commit_message.rb', line 343

def self.test_path?(path)
  return false if path.nil?
  path.start_with?('spec/') ||
    path.start_with?('test/') ||
    path.include?('__tests__/') ||
    path.match?(/(_spec\.rb|_test\.(rb|go|js|ts|tsx))\z/i)
end

.tooling_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


391
392
393
394
395
396
397
398
399
400
# File 'lib/kk/git/commit_message.rb', line 391

def self.tooling_path?(path)
  return false if path.nil?
  build_path?(path) ||
    script_path?(path) ||
    path.start_with?('exe/') ||
    path.start_with?('lib/') ||
    path.match?(/\A[^\/]+\.rb\z/i) ||
    deps_path?(path) ||
    config_path?(path)
end

.top_level_scope(path) ⇒ Object



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

def self.top_level_scope(path)
  return 'repo' if path.nil? || path.empty?
  return 'ci' if path.start_with?('.github/')
  return 'openspec' if path.start_with?('openspec/')
  return 'repo' unless path.include?('/')

  path.split('/', 2).first
end

.type_by_path(path) ⇒ Object



313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/kk/git/commit_message.rb', line 313

def self.type_by_path(path)
  return 'docs' if doc_path?(path)
  return 'ci' if ci_path?(path)
  return 'test' if test_path?(path)
  return 'chore' if deps_path?(path)
  return 'chore' if build_path?(path)
  return 'chore' if config_path?(path)
  return 'chore' if script_path?(path)

  return 'chore' unless code_path?(path)

  # Default: treat code changes as fix (conservative). Adds are handled as feat by infer_type.
  'fix'
end