kk-git 0.1.2

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8365947d559a7746f3665f7fa7ed53e6cb8459cee534a7366baaa30039653d64
4
+ data.tar.gz: 94ea1cc2783338d9404bf9389f627bb9382e63c9be9c4b69308fa7f3299c2337
5
+ SHA512:
6
+ metadata.gz: 4a17582a600ea34e3c118e9d180c76ea2a4b3c4cb8b8ca8a4528fd4c3d44458e79ba2bb627423f1ddc361f7db00450c1e6ad0bcef6919fb2ab60bfc5a86edba6
7
+ data.tar.gz: 89fa04c6e32821b20c3db5831bcfc26651162d09dddc1f3c750102ed5a94ef0eba146283e7330c9774370f108c8d836da19388752d782b44e5e8c518aef12953
data/exe/kk-git ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'json'
6
+
7
+ begin
8
+ require 'kk/git'
9
+ rescue LoadError
10
+ require_relative '../lib/kk/git'
11
+ end
12
+
13
+ options = {
14
+ mode: :staged,
15
+ format: :text,
16
+ include_body: true,
17
+ repo_dir: '.',
18
+ type: nil,
19
+ scope: nil,
20
+ subject: nil,
21
+ detect_breaking: true
22
+ }
23
+
24
+ parser = OptionParser.new do |opts|
25
+ opts.banner = "Usage: kk-git commit-message [options]\n\n根据当前 Git repo 变更生成 Conventional Commit 信息。"
26
+
27
+ opts.on('--repo DIR', '指定 repo 目录(默认当前目录)') { |v| options[:repo_dir] = v }
28
+
29
+ opts.on('--staged', '仅基于暂存区变更(默认)') { options[:mode] = :staged }
30
+ opts.on('--worktree', '仅基于工作区变更(含 untracked)') { options[:mode] = :worktree }
31
+ opts.on('--all', '合并暂存区与工作区变更') { options[:mode] = :all }
32
+
33
+ opts.on('--[no-]body', '多文件时输出/不输出 body(默认输出)') { |v| options[:include_body] = v }
34
+
35
+ opts.on('--type TYPE', '覆盖自动推断的 type(如 feat/fix/docs)') { |v| options[:type] = v }
36
+ opts.on('--scope SCOPE', '覆盖自动推断的 scope') { |v| options[:scope] = v }
37
+ opts.on('--subject SUBJECT', '覆盖自动推断的 subject') { |v| options[:subject] = v }
38
+ opts.on('--[no-]detect-breaking', '是否检测 BREAKING 标记(默认检测)') { |v| options[:detect_breaking] = v }
39
+
40
+ opts.on('--format FORMAT', '输出格式:text/json(默认 text)') do |v|
41
+ options[:format] = v.to_s.downcase.to_sym
42
+ end
43
+
44
+ opts.on('-h', '--help', '显示帮助') do
45
+ puts opts
46
+ exit 0
47
+ end
48
+ end
49
+
50
+ subcommand = ARGV.shift
51
+ if subcommand != 'commit-message'
52
+ warn "未知命令: #{subcommand}\n\n#{parser}"
53
+ exit 2
54
+ end
55
+
56
+ parser.parse!(ARGV)
57
+
58
+ def to_utf8(str)
59
+ return nil if str.nil?
60
+ s = str.to_s.dup
61
+ # ARGV 在不同 locale 下可能被标记为 ASCII-8BIT/US-ASCII,但字节本身通常是 UTF-8。
62
+ # 这里优先按 UTF-8 解释字节,并对非法序列做 scrub。
63
+ s.force_encoding(Encoding::UTF_8)
64
+ s.scrub('�')
65
+ end
66
+
67
+ options[:type] = to_utf8(options[:type])
68
+ options[:scope] = to_utf8(options[:scope])
69
+ options[:subject] = to_utf8(options[:subject])
70
+
71
+ result =
72
+ if options[:format] == :json
73
+ KKGit::CommitMessage.generate_hash(
74
+ repo_dir: options[:repo_dir],
75
+ mode: options[:mode],
76
+ include_body: options[:include_body],
77
+ type_override: options[:type],
78
+ scope_override: options[:scope],
79
+ subject_override: options[:subject],
80
+ detect_breaking: options[:detect_breaking]
81
+ )
82
+ else
83
+ KKGit::CommitMessage.generate(
84
+ repo_dir: options[:repo_dir],
85
+ mode: options[:mode],
86
+ include_body: options[:include_body],
87
+ type_override: options[:type],
88
+ scope_override: options[:scope],
89
+ subject_override: options[:subject],
90
+ detect_breaking: options[:detect_breaking]
91
+ )
92
+ end
93
+
94
+ if result.nil? || (result.is_a?(Hash) && result[:empty])
95
+ exit 1
96
+ end
97
+
98
+ puts(options[:format] == :json ? JSON.pretty_generate(result) : result)
99
+
@@ -0,0 +1,496 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module KKGit
7
+ # 根据当前 repo 的变更生成 Conventional Commits 信息。
8
+ #
9
+ # - 支持暂存区(staged)和工作区(working tree)变更
10
+ # - 支持识别 untracked 文件
11
+ # - 输出格式:"<type>(<scope>): <subject>\n\n<body>"
12
+ class CommitMessage
13
+ # 变更条目(支持 rename/copy 的 old/new)
14
+ Change = Struct.new(:status, :path, :old_path, :source, keyword_init: true)
15
+
16
+ TYPE_PRIORITY = {
17
+ 'feat' => 1,
18
+ 'fix' => 2,
19
+ 'docs' => 3,
20
+ 'refactor' => 4,
21
+ 'style' => 5,
22
+ 'perf' => 6,
23
+ 'test' => 7,
24
+ 'ci' => 8,
25
+ 'chore' => 9
26
+ }.freeze
27
+
28
+ # 生成 commit message。
29
+ #
30
+ # @param repo_dir [String] Git 仓库目录(默认当前目录)
31
+ # @param mode [Symbol] :staged(仅暂存区)/:worktree(仅工作区)/:all(两者合并)
32
+ # @param include_body [Boolean] 多文件时是否输出 body(文件列表)
33
+ # @param fallback_scope [String] 无法推断 scope 时的默认值
34
+ # @param type_override [String, nil] 强制指定 type(如 feat/fix/docs)
35
+ # @param scope_override [String, nil] 强制指定 scope
36
+ # @param subject_override [String, nil] 强制指定 subject
37
+ # @param detect_breaking [Boolean] 是否从 diff 中检测 BREAKING 标记并生成 "type(scope)!:"(默认 true)
38
+ # @param max_diff_bytes [Integer] diff 检测的最大字节数(防止超大仓库导致变慢)
39
+ #
40
+ # @return [String, nil] 无变更时返回 nil
41
+ def self.generate(
42
+ repo_dir: '.',
43
+ mode: :staged,
44
+ include_body: true,
45
+ fallback_scope: 'general',
46
+ type_override: nil,
47
+ scope_override: nil,
48
+ subject_override: nil,
49
+ detect_breaking: true,
50
+ max_diff_bytes: 300_000
51
+ )
52
+ changes = collect_changes(repo_dir: repo_dir, mode: mode)
53
+ return nil if changes.empty?
54
+
55
+ inferred = infer(changes: changes, repo_dir: repo_dir, mode: mode, detect_breaking: detect_breaking,
56
+ max_diff_bytes: max_diff_bytes, fallback_scope: fallback_scope)
57
+
58
+ type = (type_override || ENV['KK_GIT_TYPE'] || inferred[:type]).to_s.strip
59
+ scope = (scope_override || ENV['KK_GIT_SCOPE'] || inferred[:scope]).to_s.strip
60
+ subject = (subject_override || ENV['KK_GIT_SUBJECT'] || inferred[:subject]).to_s.strip
61
+
62
+ bang = inferred[:breaking] ? '!' : ''
63
+ message = +"#{type}(#{scope})#{bang}: #{subject}"
64
+ if include_body && changes.length > 1
65
+ message << "\n\n"
66
+ message << generate_body(changes)
67
+ end
68
+
69
+ message
70
+ end
71
+
72
+ # 生成结构化信息(便于脚本/CI 消费)。
73
+ #
74
+ # @return [Hash]
75
+ def self.generate_hash(
76
+ repo_dir: '.',
77
+ mode: :staged,
78
+ include_body: true,
79
+ fallback_scope: 'general',
80
+ type_override: nil,
81
+ scope_override: nil,
82
+ subject_override: nil,
83
+ detect_breaking: true,
84
+ max_diff_bytes: 300_000
85
+ )
86
+ changes = collect_changes(repo_dir: repo_dir, mode: mode)
87
+ return { empty: true } if changes.empty?
88
+
89
+ inferred = infer(changes: changes, repo_dir: repo_dir, mode: mode, detect_breaking: detect_breaking,
90
+ max_diff_bytes: max_diff_bytes, fallback_scope: fallback_scope)
91
+
92
+ type = (type_override || ENV['KK_GIT_TYPE'] || inferred[:type]).to_s.strip
93
+ scope = (scope_override || ENV['KK_GIT_SCOPE'] || inferred[:scope]).to_s.strip
94
+ subject = (subject_override || ENV['KK_GIT_SUBJECT'] || inferred[:subject]).to_s.strip
95
+
96
+ header = "#{type}(#{scope})#{inferred[:breaking] ? '!' : ''}: #{subject}"
97
+ body = include_body && changes.length > 1 ? generate_body(changes) : nil
98
+
99
+ {
100
+ empty: false,
101
+ type: type,
102
+ scope: scope,
103
+ breaking: inferred[:breaking],
104
+ subject: subject,
105
+ header: header,
106
+ body: body,
107
+ changes: changes.map do |c|
108
+ {
109
+ status: c.status,
110
+ path: c.path,
111
+ old_path: c.old_path,
112
+ source: c.source
113
+ }
114
+ end
115
+ }
116
+ end
117
+
118
+ def self.collect_changes(repo_dir:, mode:)
119
+ staged = (mode == :staged || mode == :all)
120
+ worktree = (mode == :worktree || mode == :all)
121
+
122
+ changes = []
123
+ if staged
124
+ changes.concat(parse_name_status_z(run_git(%w[diff --cached --name-status -z], repo_dir: repo_dir),
125
+ source: 'staged'))
126
+ end
127
+ if worktree
128
+ changes.concat(parse_name_status_z(run_git(%w[diff --name-status -z], repo_dir: repo_dir),
129
+ source: 'worktree'))
130
+ end
131
+
132
+ if worktree
133
+ untracked = run_git(%w[ls-files --others --exclude-standard -z], repo_dir: repo_dir)
134
+ untracked.split("\0").each do |path|
135
+ next if path.nil? || path.empty?
136
+ changes << Change.new(status: 'A', path: path, old_path: nil, source: 'untracked')
137
+ end
138
+ end
139
+
140
+ normalize_and_dedup(changes)
141
+ end
142
+
143
+ def self.parse_name_status_z(output, source:)
144
+ tokens = output.to_s.split("\0")
145
+ idx = 0
146
+ changes = []
147
+ while idx < tokens.length
148
+ token = tokens[idx]
149
+ break if token.nil? || token.empty?
150
+
151
+ status_token = token
152
+ status_char = status_token[0] # 'A' 'M' 'D' 'R' 'C' ...
153
+
154
+ case status_char
155
+ when 'R', 'C'
156
+ old_path = tokens[idx + 1]
157
+ new_path = tokens[idx + 2]
158
+ break if old_path.nil? || new_path.nil?
159
+ changes << Change.new(status: status_char, path: new_path, old_path: old_path, source: source)
160
+ idx += 3
161
+ else
162
+ path = tokens[idx + 1]
163
+ break if path.nil?
164
+ changes << Change.new(status: status_char, path: path, old_path: nil, source: source)
165
+ idx += 2
166
+ end
167
+ end
168
+ changes
169
+ end
170
+
171
+ def self.normalize_and_dedup(changes)
172
+ # key 维度:new_path;同一路径可能在 staged + worktree 都出现
173
+ dedup = {}
174
+ changes.each do |c|
175
+ next if c.path.nil? || c.path.strip.empty?
176
+
177
+ key = c.path
178
+ existing = dedup[key]
179
+ if existing.nil?
180
+ dedup[key] = c
181
+ next
182
+ end
183
+
184
+ # 优先级:
185
+ # - staged 覆盖 worktree(更贴近即将提交的内容)
186
+ # - rename/copy 优先于普通修改
187
+ # - A(新增) 优先于 M
188
+ priority = change_priority(c)
189
+ existing_priority = change_priority(existing)
190
+ dedup[key] = c if priority < existing_priority
191
+ end
192
+
193
+ dedup.values.sort_by(&:path)
194
+ end
195
+
196
+ def self.change_priority(change)
197
+ source_p = case change.source
198
+ when 'staged' then 1
199
+ when 'worktree' then 2
200
+ when 'untracked' then 3
201
+ else 9
202
+ end
203
+ status_p = case change.status
204
+ when 'R', 'C' then 1
205
+ when 'A' then 2
206
+ when 'D' then 3
207
+ when 'M' then 4
208
+ else 9
209
+ end
210
+ source_p * 10 + status_p
211
+ end
212
+
213
+ def self.run_git(args, repo_dir:)
214
+ stdout, stderr, status = Open3.capture3('git', *args, chdir: repo_dir)
215
+ # Open3 返回的 stdout/stderr 可能是 ASCII-8BIT(BINARY),统一转为 UTF-8 避免拼接时报编码错误。
216
+ stdout = stdout.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')
217
+ stderr = stderr.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')
218
+
219
+ raise "git #{args.join(' ')} 失败: #{stderr.strip}" unless status.success?
220
+
221
+ stdout
222
+ end
223
+
224
+ # 推断 type/scope/subject(含 breaking 检测)
225
+ #
226
+ # @return [Hash] {:type,:scope,:subject,:breaking}
227
+ def self.infer(changes:, repo_dir:, mode:, detect_breaking:, max_diff_bytes:, fallback_scope:)
228
+ paths = changes.map(&:path)
229
+ scope = infer_scope(paths, fallback_scope: fallback_scope)
230
+ type = infer_type(changes)
231
+ subject = generate_subject(type: type, changes: changes, scope: scope)
232
+ breaking = detect_breaking ? detect_breaking_change(repo_dir: repo_dir, mode: mode, max_diff_bytes: max_diff_bytes) : false
233
+
234
+ { type: type, scope: scope, subject: subject, breaking: breaking }
235
+ end
236
+
237
+ def self.pick_main_type(types)
238
+ types.min_by { |t| TYPE_PRIORITY[t] || 999 } || 'chore'
239
+ end
240
+
241
+ def self.pick_scope(scopes, fallback_scope:)
242
+ uniq = scopes.compact.uniq
243
+ return fallback_scope if uniq.empty?
244
+ return uniq.first if uniq.length == 1
245
+
246
+ # 多 scope 的时候,尽量选 repo;否则 fallback
247
+ return 'repo' if uniq.include?('repo')
248
+
249
+ fallback_scope
250
+ end
251
+
252
+ def self.infer_scope(paths, fallback_scope:)
253
+ # 工具/脚本类变更:尽量统一 scope 为 tools
254
+ if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) }
255
+ return 'tools'
256
+ end
257
+
258
+ tops = paths.map { |p| top_level_scope(p) }.compact
259
+ uniq = tops.uniq
260
+ return fallback_scope if uniq.empty?
261
+ return uniq.first if uniq.length == 1
262
+ return 'repo' if uniq.include?('repo')
263
+
264
+ # 多个顶层目录:统一落到 repo(避免 scope 过长/不稳定)
265
+ 'repo'
266
+ end
267
+
268
+ def self.top_level_scope(path)
269
+ return 'repo' if path.nil? || path.empty?
270
+ return 'ci' if path.start_with?('.github/')
271
+ return 'openspec' if path.start_with?('openspec/')
272
+ return 'repo' unless path.include?('/')
273
+
274
+ path.split('/', 2).first
275
+ end
276
+
277
+ def self.infer_type(changes)
278
+ paths = changes.map(&:path)
279
+
280
+ # 快速类别判断
281
+ only_docs = paths.all? { |p| doc_path?(p) }
282
+ return 'docs' if only_docs
283
+
284
+ only_ci = paths.all? { |p| ci_path?(p) }
285
+ return 'ci' if only_ci
286
+
287
+ only_tests = paths.all? { |p| test_path?(p) || doc_path?(p) }
288
+ return 'test' if only_tests && paths.any? { |p| test_path?(p) }
289
+
290
+ only_deps = paths.all? { |p| deps_path?(p) }
291
+ return 'chore' if only_deps
292
+
293
+ # 工具/脚本/构建相关:倾向 chore(即便新增代码文件)
294
+ if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) || deps_path?(p) }
295
+ return 'chore'
296
+ end
297
+
298
+ # 代码变更的启发式:新增更偏 feat;否则若命中 fix 关键词则 fix;否则 refactor/chore
299
+ has_code = paths.any? { |p| code_path?(p) }
300
+ has_new_code = changes.any? { |c| c.status == 'A' && code_path?(c.path) }
301
+ has_fix_keyword = paths.any? { |p| p.match?(/fix|bug|error|issue/i) }
302
+ has_delete = changes.any? { |c| c.status == 'D' }
303
+
304
+ return 'feat' if has_new_code
305
+ return 'fix' if has_code && has_fix_keyword
306
+ return 'refactor' if has_code && has_delete
307
+
308
+ # 混合场景:按优先级聚合
309
+ types = changes.map { |c| type_by_path(c.path) }
310
+ pick_main_type(types)
311
+ end
312
+
313
+ def self.type_by_path(path)
314
+ return 'docs' if doc_path?(path)
315
+ return 'ci' if ci_path?(path)
316
+ return 'test' if test_path?(path)
317
+ return 'chore' if deps_path?(path)
318
+ return 'chore' if build_path?(path)
319
+ return 'chore' if config_path?(path)
320
+ return 'chore' if script_path?(path)
321
+
322
+ return 'chore' unless code_path?(path)
323
+
324
+ # 默认:代码修改更接近 fix(更保守);新增则在 infer_type 中处理为 feat
325
+ 'fix'
326
+ end
327
+
328
+ def self.doc_path?(path)
329
+ return false if path.nil?
330
+ path.start_with?('openspec/') ||
331
+ path.match?(/\AREADME(\..+)?\z/i) ||
332
+ path.match?(/\.(md|mdx|txt)\z/i)
333
+ end
334
+
335
+ def self.ci_path?(path)
336
+ return false if path.nil?
337
+ path.start_with?('.github/') ||
338
+ path.match?(/\A\.gitlab-ci\.yml\z/i) ||
339
+ path.start_with?('.circleci/') ||
340
+ path.match?(/\A\.travis\.yml\z/i)
341
+ end
342
+
343
+ def self.test_path?(path)
344
+ return false if path.nil?
345
+ path.start_with?('spec/') ||
346
+ path.start_with?('test/') ||
347
+ path.include?('__tests__/') ||
348
+ path.match?(/(_spec\.rb|_test\.(rb|go|js|ts|tsx))\z/i)
349
+ end
350
+
351
+ def self.deps_path?(path)
352
+ return false if path.nil?
353
+ path.match?(/\AGemfile(\.lock)?\z/i) ||
354
+ path.match?(/\.gemspec\z/i) ||
355
+ path.match?(/\Apackage\.json\z/i) ||
356
+ path.match?(/\Ayarn\.lock\z/i) ||
357
+ path.match?(/\Apnpm-lock\.yaml\z/i) ||
358
+ path.match?(/\Apackage-lock\.json\z/i) ||
359
+ path.match?(/\Ago\.mod\z/i) ||
360
+ path.match?(/\Ago\.sum\z/i)
361
+ end
362
+
363
+ def self.build_path?(path)
364
+ return false if path.nil?
365
+ path.match?(/\ADockerfile(\..+)?\z/i) ||
366
+ path.match?(/\Adocker-compose(\..+)?\.(yml|yaml)\z/i) ||
367
+ path.match?(/\AMakefile\z/i) ||
368
+ path.match?(/\ARakefile\z/i)
369
+ end
370
+
371
+ def self.config_path?(path)
372
+ return false if path.nil?
373
+ path.start_with?('config/') ||
374
+ path.match?(/\A\.gitignore\z/i) ||
375
+ path.match?(/\A\.rubocop(\.yml)?\z/i) ||
376
+ path.match?(/\A\.rubocop_todo\.yml\z/i) ||
377
+ path.match?(/\A\.editorconfig\z/i) ||
378
+ path.match?(/\A\.tool-versions\z/i) ||
379
+ path.match?(/\A\.env(\..+)?\z/i) ||
380
+ path.match?(/\A\.env\.example\z/i) ||
381
+ path.match?(/\.(toml|ini)\z/i)
382
+ end
383
+
384
+ def self.script_path?(path)
385
+ return false if path.nil?
386
+ path.start_with?('scripts/') ||
387
+ path.match?(/\.(sh|bash)\z/i) ||
388
+ path.match?(/\Adeploy\.sh\z/i)
389
+ end
390
+
391
+ def self.tooling_path?(path)
392
+ return false if path.nil?
393
+ build_path?(path) ||
394
+ script_path?(path) ||
395
+ path.start_with?('exe/') ||
396
+ path.start_with?('lib/') ||
397
+ path.match?(/\A[^\/]+\.rb\z/i) ||
398
+ deps_path?(path) ||
399
+ config_path?(path)
400
+ end
401
+
402
+ def self.code_path?(path)
403
+ return false if path.nil?
404
+ path.match?(/\.(rb|go|ts|tsx|js|jsx|py|java|kt|rs)\z/i)
405
+ end
406
+
407
+ def self.generate_subject(type:, changes:, scope:)
408
+ if changes.length == 1
409
+ c = changes.first
410
+ action =
411
+ case c.status
412
+ when 'A' then '新增'
413
+ when 'D' then '删除'
414
+ when 'R' then '重命名'
415
+ when 'C' then '复制'
416
+ else '更新'
417
+ end
418
+
419
+ if %w[R C].include?(c.status) && c.old_path
420
+ return "#{action} #{File.basename(c.old_path)} -> #{File.basename(c.path)}"
421
+ end
422
+ return "#{action} #{File.basename(c.path)}"
423
+ end
424
+
425
+ label =
426
+ case scope
427
+ when 'repo' then '项目'
428
+ when 'tools' then '工具'
429
+ else scope
430
+ end
431
+
432
+ case type
433
+ when 'feat' then "添加#{label}功能"
434
+ when 'fix' then "修复#{label}问题"
435
+ when 'docs' then "更新#{label}文档"
436
+ when 'refactor' then "重构#{label}代码"
437
+ when 'style' then "调整#{label}代码格式"
438
+ when 'perf' then "优化#{label}性能"
439
+ when 'test' then "更新#{label}测试"
440
+ when 'ci' then "更新#{label}CI配置"
441
+ else "维护#{label}"
442
+ end
443
+ end
444
+
445
+ def self.generate_body(changes)
446
+ groups = {
447
+ 'A' => [],
448
+ 'M' => [],
449
+ 'D' => [],
450
+ 'R' => [],
451
+ 'C' => [],
452
+ '?' => []
453
+ }
454
+
455
+ changes.each do |c|
456
+ key = groups.key?(c.status) ? c.status : '?'
457
+ groups[key] << c
458
+ end
459
+
460
+ lines = []
461
+ append_group(lines, '新增', groups['A'])
462
+ append_group(lines, '修改', groups['M'])
463
+ append_group(lines, '删除', groups['D'])
464
+ append_group(lines, '重命名', groups['R'])
465
+ append_group(lines, '复制', groups['C'])
466
+ append_group(lines, '其他', groups['?'])
467
+ lines.join("\n")
468
+ end
469
+
470
+ def self.append_group(lines, title, items)
471
+ return if items.empty?
472
+
473
+ lines << "#{title}:"
474
+ items.each do |c|
475
+ if %w[R C].include?(c.status) && c.old_path
476
+ lines << " - #{c.old_path} -> #{c.path}"
477
+ else
478
+ lines << " - #{c.path}"
479
+ end
480
+ end
481
+ end
482
+
483
+ def self.detect_breaking_change(repo_dir:, mode:, max_diff_bytes:)
484
+ diffs = []
485
+ diffs << run_git(%w[diff --cached], repo_dir: repo_dir) if mode == :staged || mode == :all
486
+ diffs << run_git(%w[diff], repo_dir: repo_dir) if mode == :worktree || mode == :all
487
+
488
+ content = diffs.join("\n")
489
+ content = content.byteslice(0, max_diff_bytes) if content.bytesize > max_diff_bytes
490
+ content.match?(/BREAKING CHANGE:|BREAKING:/i)
491
+ rescue StandardError
492
+ false
493
+ end
494
+ end
495
+ end
496
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KKGit
4
+ VERSION = '0.1.2'
5
+ end
6
+
data/lib/kk/git.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/version'
4
+ require_relative 'git/commit_message'
5
+
6
+ module KKGit
7
+ end
8
+
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kk-git
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - kk
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-24 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 根据当前 Git repo 的变更(暂存/工作区)自动生成 Conventional Commits 格式的 commit message,便于
14
+ Rake/脚本调用。
15
+ email:
16
+ - ''
17
+ executables:
18
+ - kk-git
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - exe/kk-git
23
+ - lib/kk/git.rb
24
+ - lib/kk/git/commit_message.rb
25
+ - lib/kk/git/version.rb
26
+ homepage: ''
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ rubygems_mfa_required: 'true'
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.5.22
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Git 辅助工具:自动生成 Conventional Commit 信息
50
+ test_files: []