kk-git 0.1.6 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e2ef1f50e051f8167139fae0478f01da4a0cae6ea6401a73cdf67765ad13936
4
- data.tar.gz: 71d53513506ebff53f091a7d10c9ab41e0e6b3b60e393d783061aa2f19072288
3
+ metadata.gz: 6db1717b25840058e422b1b8955fb33222666cffdb8692e366e76821b93ea6a9
4
+ data.tar.gz: 3f2c67d3187d973923bc26b49fbc3d2e2bc5fe83b47738bc86d1ca97999ecdff
5
5
  SHA512:
6
- metadata.gz: c25c845bb90eae62b73feff52b0a0043e20004b20bec670460fa90ba6bb09e53fa468986c960305787a311622b30947eda805c1dc9395e7294a3bca663d877b5
7
- data.tar.gz: 0534a91d78bef3ddbd34f85cdc5f7e9392a68d771a2c940a1d435151fe28910b925164c8277e08e2addfa5faf085af0fe27a0375e4900a1ea2f5468e8dd4f966
6
+ metadata.gz: 53560be756360a59510ad02eb3888936b9a63cb9285c5386c765c3173bc320bdbe41ebc8c9c2d984ff511729b556279061d5369ed448050b8642ec64eea99687
7
+ data.tar.gz: 414075a7880e76253a3ba7fda59a93731121ce4979c86ee1ba0629c42c4e899bdae70e3ddd99fdb0252f770c6c16a5690fee805dbbefec17177d3f9caf2b2595
data/exe/kk-git CHANGED
@@ -10,104 +10,174 @@ rescue LoadError
10
10
  require_relative '../lib/kk/git'
11
11
  end
12
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:\n" \
26
- " kk-git --version\n" \
27
- " kk-git commit-message [options]\n\n" \
28
- "根据当前 Git repo 变更生成 Conventional Commit 信息。"
29
-
30
- opts.on('--repo DIR', '指定 repo 目录(默认当前目录)') { |v| options[:repo_dir] = v }
31
-
32
- opts.on('--staged', '仅基于暂存区变更(默认)') { options[:mode] = :staged }
33
- opts.on('--worktree', '仅基于工作区变更(含 untracked)') { options[:mode] = :worktree }
34
- opts.on('--all', '合并暂存区与工作区变更') { options[:mode] = :all }
35
-
36
- opts.on('--[no-]body', '多文件时输出/不输出 body(默认输出)') { |v| options[:include_body] = v }
37
-
38
- opts.on('--type TYPE', '覆盖自动推断的 type(如 feat/fix/docs)') { |v| options[:type] = v }
39
- opts.on('--scope SCOPE', '覆盖自动推断的 scope') { |v| options[:scope] = v }
40
- opts.on('--subject SUBJECT', '覆盖自动推断的 subject') { |v| options[:subject] = v }
41
- opts.on('--[no-]detect-breaking', '是否检测 BREAKING 标记(默认检测)') { |v| options[:detect_breaking] = v }
42
-
43
- opts.on('--format FORMAT', '输出格式:text/json(默认 text)') do |v|
44
- options[:format] = v.to_s.downcase.to_sym
13
+ def to_utf8(str)
14
+ return nil if str.nil?
15
+
16
+ s = str.to_s.dup
17
+ s.force_encoding(Encoding::UTF_8)
18
+ s.scrub('�')
19
+ end
20
+
21
+ def run_commit_message!(argv)
22
+ options = {
23
+ mode: :staged,
24
+ format: :text,
25
+ include_body: true,
26
+ repo_dir: '.',
27
+ type: nil,
28
+ scope: nil,
29
+ subject: nil,
30
+ detect_breaking: true
31
+ }
32
+
33
+ parser = OptionParser.new do |opts|
34
+ opts.banner = 'Usage: kk-git commit-message [options]'
35
+ opts.on('--repo DIR', 'Git repo directory (default: current dir)') { |v| options[:repo_dir] = v }
36
+ opts.on('--staged', 'Use staged changes only (default)') { options[:mode] = :staged }
37
+ opts.on('--worktree', 'Use working-tree changes only (includes untracked)') { options[:mode] = :worktree }
38
+ opts.on('--all', 'Combine staged + working-tree changes') { options[:mode] = :all }
39
+ opts.on('--[no-]body', 'Include body for multi-file changes (default: yes)') { |v| options[:include_body] = v }
40
+ opts.on('--type TYPE', 'Override inferred type') { |v| options[:type] = v }
41
+ opts.on('--scope SCOPE', 'Override inferred scope') { |v| options[:scope] = v }
42
+ opts.on('--subject SUBJECT', 'Override inferred subject') { |v| options[:subject] = v }
43
+ opts.on('--[no-]detect-breaking', 'Detect BREAKING markers (default: yes)') { |v| options[:detect_breaking] = v }
44
+ opts.on('--format FORMAT', 'Output format: text/json (default: text)') { |v| options[:format] = v.to_s.downcase.to_sym }
45
+ opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
46
+ end
47
+ parser.parse!(argv)
48
+
49
+ options[:type] = to_utf8(options[:type])
50
+ options[:scope] = to_utf8(options[:scope])
51
+ options[:subject] = to_utf8(options[:subject])
52
+
53
+ result =
54
+ if options[:format] == :json
55
+ KKGit::CommitMessage.generate_hash(
56
+ repo_dir: options[:repo_dir],
57
+ mode: options[:mode],
58
+ include_body: options[:include_body],
59
+ type_override: options[:type],
60
+ scope_override: options[:scope],
61
+ subject_override: options[:subject],
62
+ detect_breaking: options[:detect_breaking]
63
+ )
64
+ else
65
+ KKGit::CommitMessage.generate(
66
+ repo_dir: options[:repo_dir],
67
+ mode: options[:mode],
68
+ include_body: options[:include_body],
69
+ type_override: options[:type],
70
+ scope_override: options[:scope],
71
+ subject_override: options[:subject],
72
+ detect_breaking: options[:detect_breaking]
73
+ )
74
+ end
75
+
76
+ if result.nil? || (result.is_a?(Hash) && result[:empty])
77
+ exit 1
45
78
  end
46
79
 
47
- opts.on('-h', '--help', '显示帮助') do
48
- puts opts
49
- exit 0
80
+ puts(options[:format] == :json ? JSON.pretty_generate(result) : result)
81
+ end
82
+
83
+ def run_status!(argv)
84
+ options = { format: :text }
85
+ parser = OptionParser.new do |opts|
86
+ opts.banner = 'Usage: kk-git status [options]'
87
+ opts.on('--format FORMAT', 'Output format: text/json (default: text)') { |v| options[:format] = v.to_s.downcase.to_sym }
88
+ opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
50
89
  end
90
+ parser.parse!(argv)
51
91
 
52
- opts.on('-v', '--version', '显示版本号') do
53
- puts KKGit::VERSION
54
- exit 0
92
+ hash = KKGit::GitOps.status_hash
93
+ if options[:format] == :json
94
+ puts JSON.pretty_generate(hash)
95
+ else
96
+ puts "Branch: #{hash[:branch]} (#{hash[:remote]})"
97
+ puts "Working tree: #{hash[:clean] ? 'clean' : 'dirty'}"
98
+ puts "Ahead: #{hash[:ahead]}, Behind: #{hash[:behind]}"
99
+ puts "Upstream: #{hash[:upstream_configured] ? 'configured' : 'not set'}"
100
+ puts 'Detached HEAD: yes' if hash[:detached]
55
101
  end
56
102
  end
57
103
 
58
- # 支持 `kk-git --version` 这种无子命令形式
59
- if ['--version', '-v'].include?(ARGV[0])
60
- puts KKGit::VERSION
61
- exit 0
62
- end
104
+ def run_sync!(argv)
105
+ parser = OptionParser.new do |opts|
106
+ opts.banner = 'Usage: kk-git sync [options]'
107
+ opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
108
+ end
109
+ parser.parse!(argv)
63
110
 
64
- subcommand = ARGV.shift
65
- if subcommand != 'commit-message'
66
- warn "未知命令: #{subcommand}\n\n#{parser}"
67
- exit 2
111
+ remote = KKGit::GitOps.remote
112
+ branch = KKGit::GitOps.branch
113
+ s = KKGit::GitOps.status(remote: remote, branch: branch)
114
+
115
+ if s.needs_sync?
116
+ KKGit::GitOps.sync_with_remote!(remote, branch)
117
+ else
118
+ puts 'Already in sync with remote'
119
+ end
68
120
  end
69
121
 
70
- parser.parse!(ARGV)
122
+ def run_push!(argv)
123
+ parser = OptionParser.new do |opts|
124
+ opts.banner = 'Usage: kk-git push [options]'
125
+ opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
126
+ end
127
+ parser.parse!(argv)
71
128
 
72
- def to_utf8(str)
73
- return nil if str.nil?
74
- s = str.to_s.dup
75
- # ARGV 在不同 locale 下可能被标记为 ASCII-8BIT/US-ASCII,但字节本身通常是 UTF-8。
76
- # 这里优先按 UTF-8 解释字节,并对非法序列做 scrub。
77
- s.force_encoding(Encoding::UTF_8)
78
- s.scrub('�')
129
+ KKGit::GitOps.auto_commit_push!
79
130
  end
80
131
 
81
- options[:type] = to_utf8(options[:type])
82
- options[:scope] = to_utf8(options[:scope])
83
- options[:subject] = to_utf8(options[:subject])
84
-
85
- result =
86
- if options[:format] == :json
87
- KKGit::CommitMessage.generate_hash(
88
- repo_dir: options[:repo_dir],
89
- mode: options[:mode],
90
- include_body: options[:include_body],
91
- type_override: options[:type],
92
- scope_override: options[:scope],
93
- subject_override: options[:subject],
94
- detect_breaking: options[:detect_breaking]
95
- )
96
- else
97
- KKGit::CommitMessage.generate(
98
- repo_dir: options[:repo_dir],
99
- mode: options[:mode],
100
- include_body: options[:include_body],
101
- type_override: options[:type],
102
- scope_override: options[:scope],
103
- subject_override: options[:subject],
104
- detect_breaking: options[:detect_breaking]
105
- )
106
- end
132
+ def main_help
133
+ <<~HELP
134
+ Usage:
135
+ kk-git --version
136
+ kk-git commit-message [options]
137
+ kk-git status [options]
138
+ kk-git sync [options]
139
+ kk-git push [options]
140
+
141
+ Git helper: generate Conventional Commits messages and automate sync.
142
+
143
+ Environment variables (push/sync):
144
+ KK_GIT_REMOTE remote name (default: origin)
145
+ KK_GIT_BRANCH branch name (default: current branch)
146
+ KK_GIT_PULL_ARGS extra args for git pull (default: --ff-only)
147
+ KK_GIT_ADD_PATHS paths for git add (default: .)
148
+ KK_GIT_DRY_RUN=1 print commands without executing
149
+ KK_GIT_SKIP_PULL=1 skip pull step
150
+ KK_GIT_SKIP_PUSH=1 skip push step
151
+ KK_GIT_AMEND=1 amend last commit instead of creating new one
152
+
153
+ Run `kk-git <command> --help` for command-specific options.
154
+ HELP
155
+ end
107
156
 
108
- if result.nil? || (result.is_a?(Hash) && result[:empty])
109
- exit 1
157
+ if ['--version', '-v'].include?(ARGV[0])
158
+ puts KKGit::VERSION
159
+ exit 0
110
160
  end
111
161
 
112
- puts(options[:format] == :json ? JSON.pretty_generate(result) : result)
162
+ if ['--help', '-h'].include?(ARGV[0]) && ARGV.length == 1
163
+ puts main_help
164
+ exit 0
165
+ end
113
166
 
167
+ subcommand = ARGV.shift
168
+ case subcommand
169
+ when 'commit-message'
170
+ run_commit_message!(ARGV)
171
+ when 'status'
172
+ run_status!(ARGV)
173
+ when 'sync'
174
+ run_sync!(ARGV)
175
+ when 'push'
176
+ run_push!(ARGV)
177
+ when nil
178
+ warn "Missing command.\n\n#{main_help}"
179
+ exit 2
180
+ else
181
+ warn "Unknown command: #{subcommand}\n\n#{main_help}"
182
+ exit 2
183
+ end
@@ -4,13 +4,13 @@ require 'json'
4
4
  require 'open3'
5
5
 
6
6
  module KKGit
7
- # 根据当前 repo 的变更生成 Conventional Commits 信息。
7
+ # Generate Conventional Commits messages from git changes.
8
8
  #
9
- # - 支持暂存区(staged)和工作区(working tree)变更
10
- # - 支持识别 untracked 文件
11
- # - 输出格式:"<type>(<scope>): <subject>\n\n<body>"
9
+ # - Supports staged and working-tree changes
10
+ # - Supports untracked files
11
+ # - Output format: "<type>(<scope>): <subject>\n\n<body>"
12
12
  class CommitMessage
13
- # 变更条目(支持 rename/copy old/new
13
+ # Change entry (supports rename/copy old/new paths)
14
14
  Change = Struct.new(:status, :path, :old_path, :source, keyword_init: true)
15
15
 
16
16
  TYPE_PRIORITY = {
@@ -25,19 +25,19 @@ module KKGit
25
25
  'chore' => 9
26
26
  }.freeze
27
27
 
28
- # 生成 commit message
28
+ # Generate a commit message.
29
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 检测的最大字节数(防止超大仓库导致变慢)
30
+ # @param repo_dir [String] git repo directory (default: current dir)
31
+ # @param mode [Symbol] :staged / :worktree / :all
32
+ # @param include_body [Boolean] include body for multi-file changes
33
+ # @param fallback_scope [String] scope used when inference can't decide
34
+ # @param type_override [String, nil] force type (feat/fix/docs/...)
35
+ # @param scope_override [String, nil] force scope
36
+ # @param subject_override [String, nil] force subject
37
+ # @param detect_breaking [Boolean] detect "BREAKING" markers and emit "type(scope)!:" (default: true)
38
+ # @param max_diff_bytes [Integer] cap diff size for breaking detection
39
39
  #
40
- # @return [String, nil] 无变更时返回 nil
40
+ # @return [String, nil] returns nil when there are no changes
41
41
  def self.generate(
42
42
  repo_dir: '.',
43
43
  mode: :staged,
@@ -69,7 +69,7 @@ module KKGit
69
69
  message
70
70
  end
71
71
 
72
- # 生成结构化信息(便于脚本/CI 消费)。
72
+ # Generate structured data (useful for scripts/CI).
73
73
  #
74
74
  # @return [Hash]
75
75
  def self.generate_hash(
@@ -169,7 +169,7 @@ module KKGit
169
169
  end
170
170
 
171
171
  def self.normalize_and_dedup(changes)
172
- # key 维度:new_path;同一路径可能在 staged + worktree 都出现
172
+ # Keyed by new_path; same path can show up in staged + worktree.
173
173
  dedup = {}
174
174
  changes.each do |c|
175
175
  next if c.path.nil? || c.path.strip.empty?
@@ -181,10 +181,10 @@ module KKGit
181
181
  next
182
182
  end
183
183
 
184
- # 优先级:
185
- # - staged 覆盖 worktree(更贴近即将提交的内容)
186
- # - rename/copy 优先于普通修改
187
- # - A(新增) 优先于 M
184
+ # Priority:
185
+ # - staged wins over worktree (closer to what will be committed)
186
+ # - rename/copy wins over plain modifications
187
+ # - A(add) wins over M(modify)
188
188
  priority = change_priority(c)
189
189
  existing_priority = change_priority(existing)
190
190
  dedup[key] = c if priority < existing_priority
@@ -212,16 +212,16 @@ module KKGit
212
212
 
213
213
  def self.run_git(args, repo_dir:)
214
214
  stdout, stderr, status = Open3.capture3('git', *args, chdir: repo_dir)
215
- # Open3 返回的 stdout/stderr 可能是 ASCII-8BITBINARY),统一转为 UTF-8 避免拼接时报编码错误。
215
+ # Open3 stdout/stderr may be ASCII-8BIT (BINARY). Normalize to UTF-8 to avoid concat errors.
216
216
  stdout = stdout.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')
217
217
  stderr = stderr.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '�')
218
218
 
219
- raise "git #{args.join(' ')} 失败: #{stderr.strip}" unless status.success?
219
+ raise "git #{args.join(' ')} failed: #{stderr.strip}" unless status.success?
220
220
 
221
221
  stdout
222
222
  end
223
223
 
224
- # 推断 type/scope/subject(含 breaking 检测)
224
+ # Infer type/scope/subject (with optional breaking detection)
225
225
  #
226
226
  # @return [Hash] {:type,:scope,:subject,:breaking}
227
227
  def self.infer(changes:, repo_dir:, mode:, detect_breaking:, max_diff_bytes:, fallback_scope:)
@@ -243,14 +243,14 @@ module KKGit
243
243
  return fallback_scope if uniq.empty?
244
244
  return uniq.first if uniq.length == 1
245
245
 
246
- # scope 的时候,尽量选 repo;否则 fallback
246
+ # When multiple scopes exist, prefer "repo"; otherwise fallback.
247
247
  return 'repo' if uniq.include?('repo')
248
248
 
249
249
  fallback_scope
250
250
  end
251
251
 
252
252
  def self.infer_scope(paths, fallback_scope:)
253
- # 工具/脚本类变更:尽量统一 scope tools
253
+ # Tooling/script/build changes: prefer a stable scope
254
254
  if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) }
255
255
  return 'tools'
256
256
  end
@@ -261,7 +261,7 @@ module KKGit
261
261
  return uniq.first if uniq.length == 1
262
262
  return 'repo' if uniq.include?('repo')
263
263
 
264
- # 多个顶层目录:统一落到 repo(避免 scope 过长/不稳定)
264
+ # Multiple top-level dirs: use repo to keep scope stable/short.
265
265
  'repo'
266
266
  end
267
267
 
@@ -277,7 +277,7 @@ module KKGit
277
277
  def self.infer_type(changes)
278
278
  paths = changes.map(&:path)
279
279
 
280
- # 快速类别判断
280
+ # Fast paths
281
281
  only_docs = paths.all? { |p| doc_path?(p) }
282
282
  return 'docs' if only_docs
283
283
 
@@ -290,12 +290,12 @@ module KKGit
290
290
  only_deps = paths.all? { |p| deps_path?(p) }
291
291
  return 'chore' if only_deps
292
292
 
293
- # 工具/脚本/构建相关:倾向 chore(即便新增代码文件)
293
+ # Tooling/script/build related: prefer chore (even if code is added)
294
294
  if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) || deps_path?(p) }
295
295
  return 'chore'
296
296
  end
297
297
 
298
- # 代码变更的启发式:新增更偏 feat;否则若命中 fix 关键词则 fix;否则 refactor/chore
298
+ # Heuristics for code changes
299
299
  has_code = paths.any? { |p| code_path?(p) }
300
300
  has_new_code = changes.any? { |c| c.status == 'A' && code_path?(c.path) }
301
301
  has_fix_keyword = paths.any? { |p| p.match?(/fix|bug|error|issue/i) }
@@ -305,7 +305,7 @@ module KKGit
305
305
  return 'fix' if has_code && has_fix_keyword
306
306
  return 'refactor' if has_code && has_delete
307
307
 
308
- # 混合场景:按优先级聚合
308
+ # Mixed: aggregate by priority
309
309
  types = changes.map { |c| type_by_path(c.path) }
310
310
  pick_main_type(types)
311
311
  end
@@ -321,7 +321,7 @@ module KKGit
321
321
 
322
322
  return 'chore' unless code_path?(path)
323
323
 
324
- # 默认:代码修改更接近 fix(更保守);新增则在 infer_type 中处理为 feat
324
+ # Default: treat code changes as fix (conservative). Adds are handled as feat by infer_type.
325
325
  'fix'
326
326
  end
327
327
 
@@ -409,11 +409,11 @@ module KKGit
409
409
  c = changes.first
410
410
  action =
411
411
  case c.status
412
- when 'A' then '新增'
413
- when 'D' then '删除'
414
- when 'R' then '重命名'
415
- when 'C' then '复制'
416
- else '更新'
412
+ when 'A' then 'Add'
413
+ when 'D' then 'Remove'
414
+ when 'R' then 'Rename'
415
+ when 'C' then 'Copy'
416
+ else 'Update'
417
417
  end
418
418
 
419
419
  if %w[R C].include?(c.status) && c.old_path
@@ -424,21 +424,21 @@ module KKGit
424
424
 
425
425
  label =
426
426
  case scope
427
- when 'repo' then '项目'
428
- when 'tools' then '工具'
427
+ when 'repo' then 'project'
428
+ when 'tools' then 'tools'
429
429
  else scope
430
430
  end
431
431
 
432
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}"
433
+ when 'feat' then "Add #{label} features"
434
+ when 'fix' then "Fix #{label} issues"
435
+ when 'docs' then "Update #{label} docs"
436
+ when 'refactor' then "Refactor #{label}"
437
+ when 'style' then "Format #{label}"
438
+ when 'perf' then "Improve #{label} performance"
439
+ when 'test' then "Update #{label} tests"
440
+ when 'ci' then "Update #{label} CI"
441
+ else "Update #{label}"
442
442
  end
443
443
  end
444
444
 
@@ -458,12 +458,12 @@ module KKGit
458
458
  end
459
459
 
460
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['?'])
461
+ append_group(lines, 'Added', groups['A'])
462
+ append_group(lines, 'Changed', groups['M'])
463
+ append_group(lines, 'Removed', groups['D'])
464
+ append_group(lines, 'Renamed', groups['R'])
465
+ append_group(lines, 'Copied', groups['C'])
466
+ append_group(lines, 'Other', groups['?'])
467
467
  lines.join("\n")
468
468
  end
469
469
 
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tempfile'
5
+
6
+ module KKGit
7
+ # Git 仓库操作:供 Rake task 与 CLI 复用。
8
+ module GitOps
9
+ class Error < StandardError; end
10
+
11
+ # 仓库同步状态快照
12
+ Status = Struct.new(
13
+ :branch, :remote, :clean, :ahead, :behind,
14
+ :upstream_configured, :detached,
15
+ keyword_init: true
16
+ ) do
17
+ # 是否需要与远端同步(有未推送 commit 或落后远端)
18
+ def needs_sync?
19
+ ahead.positive? || behind.positive?
20
+ end
21
+
22
+ def unpushed?
23
+ ahead.positive?
24
+ end
25
+
26
+ def behind_remote?
27
+ behind.positive?
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # @return [Boolean] KK_GIT_DRY_RUN=1 时只打印命令不执行
33
+ def dry_run?
34
+ ENV['KK_GIT_DRY_RUN'] == '1'
35
+ end
36
+
37
+ def skip_pull?
38
+ ENV['KK_GIT_SKIP_PULL'] == '1'
39
+ end
40
+
41
+ def skip_push?
42
+ ENV['KK_GIT_SKIP_PUSH'] == '1'
43
+ end
44
+
45
+ def amend?
46
+ ENV['KK_GIT_AMEND'] == '1'
47
+ end
48
+
49
+ def remote
50
+ ENV.fetch('KK_GIT_REMOTE', 'origin')
51
+ end
52
+
53
+ # @param explicit [String, nil] KK_GIT_BRANCH 或显式传入
54
+ def branch(explicit: ENV['KK_GIT_BRANCH'])
55
+ explicit.to_s.strip.empty? ? current_branch : explicit.to_s.strip
56
+ end
57
+
58
+ # git add 路径,默认 `.`;可用 KK_GIT_ADD_PATHS 指定多个路径(空格分隔)
59
+ def add_paths
60
+ ENV.fetch('KK_GIT_ADD_PATHS', '.').split(/\s+/).reject(&:empty?)
61
+ end
62
+
63
+ # 会修改仓库状态的 git 子命令;dry-run 时跳过这些命令
64
+ MUTATING_GIT_COMMANDS = %w[add commit push pull merge rebase checkout reset cherry-pick revert].freeze
65
+
66
+ def run_cmd(*cmd, chdir: nil)
67
+ if dry_run? && mutating_git_command?(cmd)
68
+ label = chdir ? "(cd #{chdir} && #{cmd.join(' ')})" : cmd.join(' ')
69
+ puts "[dry-run] #{label}"
70
+ return ['', '', true]
71
+ end
72
+
73
+ stdout, stderr, status =
74
+ if chdir
75
+ Open3.capture3(*cmd, chdir: chdir)
76
+ else
77
+ Open3.capture3(*cmd)
78
+ end
79
+ [stdout.to_s, stderr.to_s, status.success?]
80
+ end
81
+
82
+ def mutating_git_command?(cmd)
83
+ cmd[0] == 'git' && MUTATING_GIT_COMMANDS.include?(cmd[1])
84
+ end
85
+
86
+ def ensure_ok!(ok, title, stdout: nil, stderr: nil)
87
+ return if ok
88
+
89
+ msg = +"#{title} failed"
90
+ msg << "\n#{stderr}" unless stderr.to_s.strip.empty?
91
+ msg << "\n#{stdout}" unless stdout.to_s.strip.empty?
92
+ raise Error, msg
93
+ end
94
+
95
+ def in_git_repo?
96
+ _, _, ok = run_cmd('git', 'rev-parse', '--git-dir')
97
+ ok
98
+ end
99
+
100
+ def ensure_in_repo!
101
+ raise Error, 'Not a git repository' unless in_git_repo?
102
+ end
103
+
104
+ def current_branch
105
+ out, err, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', 'HEAD')
106
+ ensure_ok!(ok, 'Get current branch', stdout: out, stderr: err)
107
+ out.strip
108
+ end
109
+
110
+ def detached_head?
111
+ current_branch == 'HEAD'
112
+ end
113
+
114
+ def ensure_not_detached!
115
+ raise Error, 'Cannot push from detached HEAD' if detached_head?
116
+ end
117
+
118
+ def working_tree_clean?
119
+ out, err, ok = run_cmd('git', 'status', '--porcelain')
120
+ ensure_ok!(ok, 'Check git status', stdout: out, stderr: err)
121
+ out.strip.empty?
122
+ end
123
+
124
+ def upstream_configured?
125
+ _, _, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', '@{u}')
126
+ ok
127
+ end
128
+
129
+ # 相对 upstream / remote/branch 领先的 commit 数
130
+ def ahead_count(remote, branch)
131
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', '@{u}..HEAD')
132
+ return out.strip.to_i if ok
133
+
134
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', "#{remote}/#{branch}..HEAD")
135
+ return out.strip.to_i if ok
136
+
137
+ 0
138
+ end
139
+
140
+ # 相对 upstream / remote/branch 落后的 commit 数
141
+ def behind_count(remote, branch)
142
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', 'HEAD..@{u}')
143
+ return out.strip.to_i if ok
144
+
145
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', "HEAD..#{remote}/#{branch}")
146
+ return out.strip.to_i if ok
147
+
148
+ 0
149
+ end
150
+
151
+ def unpushed_commits?(remote, branch)
152
+ ahead_count(remote, branch).positive?
153
+ end
154
+
155
+ # @return [Status]
156
+ def status(remote: nil, branch: nil)
157
+ ensure_in_repo!
158
+ remote ||= self.remote
159
+ branch ||= self.branch
160
+
161
+ Status.new(
162
+ branch: branch,
163
+ remote: remote,
164
+ clean: working_tree_clean?,
165
+ ahead: ahead_count(remote, branch),
166
+ behind: behind_count(remote, branch),
167
+ upstream_configured: upstream_configured?,
168
+ detached: detached_head?
169
+ )
170
+ end
171
+
172
+ def status_hash(remote: nil, branch: nil)
173
+ s = status(remote: remote, branch: branch)
174
+ {
175
+ branch: s.branch,
176
+ remote: s.remote,
177
+ clean: s.clean,
178
+ ahead: s.ahead,
179
+ behind: s.behind,
180
+ upstream_configured: s.upstream_configured,
181
+ detached: s.detached,
182
+ needs_sync: s.needs_sync?
183
+ }
184
+ end
185
+
186
+ def pull_remote!(remote, branch)
187
+ return if skip_pull?
188
+
189
+ pull_args = ENV.fetch('KK_GIT_PULL_ARGS', '--ff-only').split
190
+ out, err, ok = run_cmd('git', 'pull', remote, branch, *pull_args)
191
+ ensure_ok!(ok, 'git pull', stdout: out, stderr: err)
192
+ end
193
+
194
+ def push_remote!(remote, branch)
195
+ return if skip_push?
196
+
197
+ ensure_not_detached! unless dry_run?
198
+
199
+ if upstream_configured?
200
+ out, err, ok = run_cmd('git', 'push', remote, branch)
201
+ else
202
+ out, err, ok = run_cmd('git', 'push', '-u', remote, branch)
203
+ end
204
+ ensure_ok!(ok, 'git push', stdout: out, stderr: err)
205
+ end
206
+
207
+ def sync_with_remote!(remote, branch)
208
+ pull_remote!(remote, branch)
209
+ push_remote!(remote, branch)
210
+ puts "Synced: #{remote} #{branch}" unless dry_run?
211
+ end
212
+
213
+ def add_all!
214
+ paths = add_paths
215
+ out, err, ok = run_cmd('git', 'add', *paths)
216
+ ensure_ok!(ok, 'git add', stdout: out, stderr: err)
217
+ end
218
+
219
+ # @return [Boolean] commit 是否成功
220
+ def commit_with_message!(message)
221
+ commit_args = amend? ? %w[commit --amend -F] : %w[commit -F]
222
+
223
+ Tempfile.create('commit_message') do |f|
224
+ f.write(message)
225
+ f.flush
226
+ out, err, ok = run_cmd('git', *commit_args, f.path)
227
+ if ok
228
+ true
229
+ elsif err.include?('nothing to commit') || out.include?('nothing to commit')
230
+ puts 'No staged changes to commit'
231
+ false
232
+ else
233
+ ensure_ok!(ok, 'git commit', stdout: out, stderr: err)
234
+ false
235
+ end
236
+ end
237
+ end
238
+
239
+ # 自动 add → commit → pull → push 主流程
240
+ #
241
+ # @return [Symbol] :synced | :committed_and_synced | :noop
242
+ def auto_commit_push!(commit_message_generator: nil)
243
+ ensure_in_repo!
244
+ remote_name = remote
245
+ branch_name = branch
246
+
247
+ if working_tree_clean?
248
+ if status(remote: remote_name, branch: branch_name).needs_sync?
249
+ sync_with_remote!(remote_name, branch_name)
250
+ return :synced
251
+ end
252
+
253
+ puts 'No changes to commit or push'
254
+ return :noop
255
+ end
256
+
257
+ add_all!
258
+
259
+ message =
260
+ if commit_message_generator
261
+ commit_message_generator.call
262
+ else
263
+ KKGit::CommitMessage.generate(mode: :all)
264
+ end
265
+ message = message.to_s.strip
266
+ message = "chore(repo): update project files\n\n#{Time.now}" if message.empty?
267
+
268
+ committed = commit_with_message!(message)
269
+ if committed || unpushed_commits?(remote_name, branch_name)
270
+ sync_with_remote!(remote_name, branch_name)
271
+ return committed ? :committed_and_synced : :synced
272
+ end
273
+
274
+ :noop
275
+ end
276
+ end
277
+ end
278
+ end
@@ -1,113 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rake'
4
- require 'kk/git'
5
- require 'open3'
6
- require 'tempfile'
4
+ require_relative '../git'
7
5
 
8
6
  module KKGit
9
- # Rake 集成:在任意项目的 Rakefile 中 `require 'kk/git/rake_tasks'` 即可注册任务。
7
+ # Rake integration: `require 'kk/git/rake_tasks'` to register tasks.
10
8
  #
11
- # 说明:
12
- # - 这些任务**不会 exit/abort**,以便在其它 task 中被 invoke。
13
- # - 会把生成结果写入 `ENV['KK_GIT_COMMIT_MESSAGE']`,方便上层 task 复用。
9
+ # Notes:
10
+ # - These tasks do NOT exit/abort so they can be invoked by other tasks.
11
+ # - The generated message is stored in `ENV['KK_GIT_COMMIT_MESSAGE']`.
14
12
  module RakeTasks
15
- def self.run_cmd(*cmd)
16
- stdout, stderr, status = Open3.capture3(*cmd)
17
- [stdout.to_s, stderr.to_s, status.success?]
18
- end
19
-
20
- def self.ensure_ok!(ok, title, stdout: nil, stderr: nil)
21
- return if ok
22
-
23
- msg = +"#{title} 失败"
24
- msg << "\n#{stderr}" unless stderr.to_s.strip.empty?
25
- msg << "\n#{stdout}" unless stdout.to_s.strip.empty?
26
- raise msg
27
- end
28
-
29
- def self.current_branch
30
- out, err, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', 'HEAD')
31
- ensure_ok!(ok, '获取当前分支', stdout: out, stderr: err)
32
- out.strip
33
- end
34
-
35
- def self.working_tree_clean?
36
- out, err, ok = run_cmd('git', 'status', '--porcelain')
37
- ensure_ok!(ok, '检查 git 状态', stdout: out, stderr: err)
38
- out.strip.empty?
39
- end
40
-
41
13
  def self.install!
42
14
  extend Rake::DSL
43
15
 
44
16
  namespace :git do
45
- desc '根据暂存区变更生成 commit message(Conventional Commits)'
17
+ desc 'Show branch sync status (ahead/behind/clean)'
18
+ task :status do
19
+ s = KKGit::GitOps.status
20
+ puts "Branch: #{s.branch} (#{s.remote})"
21
+ puts "Working tree: #{s.clean ? 'clean' : 'dirty'}"
22
+ puts "Ahead: #{s.ahead}, Behind: #{s.behind}"
23
+ puts "Upstream: #{s.upstream_configured ? 'configured' : 'not set'}"
24
+ puts 'Detached HEAD: yes' if s.detached
25
+ end
26
+
27
+ desc 'Pull and push without committing (sync unpushed or behind commits)'
28
+ task :sync do
29
+ remote = KKGit::GitOps.remote
30
+ branch = KKGit::GitOps.branch
31
+ s = KKGit::GitOps.status(remote: remote, branch: branch)
32
+
33
+ if s.needs_sync?
34
+ KKGit::GitOps.sync_with_remote!(remote, branch)
35
+ else
36
+ puts 'Already in sync with remote'
37
+ end
38
+ end
39
+
40
+ desc 'Generate commit message from staged changes (Conventional Commits)'
46
41
  task :commit_message do
47
42
  msg = KKGit::CommitMessage.generate(mode: :staged)
48
43
  ENV['KK_GIT_COMMIT_MESSAGE'] = msg.to_s
49
44
  puts msg if msg
50
45
  end
51
46
 
52
- desc '根据工作区变更生成 commit message(含 untracked'
47
+ desc 'Generate commit message from working-tree changes (includes untracked)'
53
48
  task :commit_message_worktree do
54
49
  msg = KKGit::CommitMessage.generate(mode: :worktree)
55
50
  ENV['KK_GIT_COMMIT_MESSAGE'] = msg.to_s
56
51
  puts msg if msg
57
52
  end
58
53
 
59
- desc '合并暂存区+工作区变更生成 commit message'
54
+ desc 'Generate commit message from staged + working-tree changes'
60
55
  task :auto_commit do
61
56
  msg = KKGit::CommitMessage.generate(mode: :all)
62
57
  ENV['KK_GIT_COMMIT_MESSAGE'] = msg.to_s
63
58
  puts msg if msg
64
59
  end
65
60
 
66
- desc '自动 add/commit/pull/push(基于 git:auto_commit'
61
+ desc 'Auto add/commit/pull/push (uses git:auto_commit)'
67
62
  task :auto_commit_push do
68
- if working_tree_clean?
69
- puts '没有变更需要提交'
70
- next
63
+ generator = lambda do
64
+ Rake::Task['git:auto_commit'].reenable
65
+ Rake::Task['git:auto_commit'].invoke
66
+ ENV['KK_GIT_COMMIT_MESSAGE'].to_s
71
67
  end
72
68
 
73
- remote = ENV.fetch('KK_GIT_REMOTE', 'origin')
74
- branch = ENV.fetch('KK_GIT_BRANCH', current_branch)
75
-
76
- # 1) add
77
- out, err, ok = run_cmd('git', 'add', '.')
78
- ensure_ok!(ok, 'git add', stdout: out, stderr: err)
79
-
80
- # 2) 生成 commit message(允许重复 invoke)
81
- Rake::Task['git:auto_commit'].reenable
82
- Rake::Task['git:auto_commit'].invoke
83
- commit_message = ENV['KK_GIT_COMMIT_MESSAGE'].to_s.strip
84
- commit_message = "chore(repo): 更新项目文件\n\n#{Time.now}" if commit_message.empty?
85
-
86
- # 3) commit(用临时文件避免转义问题)
87
- Tempfile.create('commit_message') do |f|
88
- f.write(commit_message)
89
- f.flush
90
- out, err, ok = run_cmd('git', 'commit', '-F', f.path)
91
- # 没有 staged 变更时 git commit 会失败;这里给出更友好的提示
92
- unless ok
93
- if err.include?('nothing to commit') || out.include?('nothing to commit')
94
- puts '没有暂存变更需要提交'
95
- next
96
- end
97
- end
98
- ensure_ok!(ok, 'git commit', stdout: out, stderr: err)
99
- end
100
-
101
- # 4) pull(默认使用 --ff-only,避免非交互环境进入合并流程)
102
- pull_args = ENV.fetch('KK_GIT_PULL_ARGS', '--ff-only').split
103
- out, err, ok = run_cmd('git', 'pull', *pull_args)
104
- ensure_ok!(ok, 'git pull', stdout: out, stderr: err)
105
-
106
- # 5) push
107
- out, err, ok = run_cmd('git', 'push', remote, branch)
108
- ensure_ok!(ok, 'git push', stdout: out, stderr: err)
109
-
110
- puts "✅ 已推送: #{remote} #{branch}"
69
+ KKGit::GitOps.auto_commit_push!(commit_message_generator: generator)
111
70
  end
112
71
  end
113
72
  end
@@ -115,4 +74,3 @@ module KKGit
115
74
  end
116
75
 
117
76
  KKGit::RakeTasks.install!
118
-
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KKGit
4
- VERSION = '0.1.6'
4
+ VERSION = '0.2.0'
5
5
  end
6
6
 
data/lib/kk/git.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'git/version'
4
4
  require_relative 'git/commit_message'
5
+ require_relative 'git/git_ops'
5
6
 
6
7
  module KKGit
7
8
  end
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kk-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-24 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: 根据当前 Git repo 的变更(暂存/工作区)自动生成 Conventional Commits 格式的 commit message,便于
14
- Rake/脚本调用。
13
+ description: Generate Conventional Commits commit messages from current git changes
14
+ (staged/worktree), designed for Rake/script usage.
15
15
  email:
16
16
  - ''
17
17
  executables:
@@ -22,6 +22,7 @@ files:
22
22
  - exe/kk-git
23
23
  - lib/kk/git.rb
24
24
  - lib/kk/git/commit_message.rb
25
+ - lib/kk/git/git_ops.rb
25
26
  - lib/kk/git/rake_tasks.rb
26
27
  - lib/kk/git/version.rb
27
28
  homepage: ''
@@ -47,5 +48,5 @@ requirements: []
47
48
  rubygems_version: 3.5.22
48
49
  signing_key:
49
50
  specification_version: 4
50
- summary: Git 辅助工具:自动生成 Conventional Commit 信息
51
+ summary: 'Git helper: generate Conventional Commits messages'
51
52
  test_files: []