kk-git 0.2.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74daf125db0cf895ef3d9c7c6e0315f56a70a1e5de4cfff743ed0bb281d9c73c
4
- data.tar.gz: 0dda4d4d4544b1249d3b95a019ea4da0f6d9d1469b3815b21c5069ea213a2ca6
3
+ metadata.gz: c7ea0b721958d0136a191c3bcbf41255988c8616b73f5aecc328a99f69d9a6dd
4
+ data.tar.gz: 026c9446f331443575102852cf42472e318b7f8af109fe63bcefe3e766a4e178
5
5
  SHA512:
6
- metadata.gz: f9b644cfda470263d0bdef6073c8adf2fa9864d24388e8af13053ce684996c2ec62f2a90d131b82eb9d5daa7c6d073d8d061571ac21c63887d0e0454fe8fdfdf
7
- data.tar.gz: 59a25a6ad4c915ebe7a48f128ad501585340f9ffc3f9ecccc47bb9303729ac2286783b1ea08e1e18128e075a4d2beb5ece410d59d8435ba09cf1cfb0690578d5
6
+ metadata.gz: 637502a892517a38a8f9b06f7fde4bd6106c60960ddbabd0138010755d6030428b1d97bb14624bc1734f27c19de4e98a3b3c0015589155ff584792705ab41d42
7
+ data.tar.gz: 6103f6ba218aafd344a145477185223a18eb896f70fcd7548ed42d063e40f592fc738f49158a8e15e09f0a9e247d8969430ac0b09ac79fca36208ff481be1bf0
data/exe/kk-git CHANGED
@@ -15,7 +15,17 @@ def to_utf8(str)
15
15
 
16
16
  s = str.to_s.dup
17
17
  s.force_encoding(Encoding::UTF_8)
18
- s.scrub('�')
18
+ s.scrub("\uFFFD")
19
+ end
20
+
21
+ def parse_repo_options!(argv)
22
+ options = { repo_dir: '.' }
23
+ parser = OptionParser.new do |opts|
24
+ opts.on('--repo DIR', 'Git repo directory (default: current dir)') { |v| options[:repo_dir] = v }
25
+ opts.on('-h', '--help', 'Show help') { |v| options[:help] = v }
26
+ end
27
+ parser.parse!(argv)
28
+ options
19
29
  end
20
30
 
21
31
  def run_commit_message!(argv)
@@ -81,15 +91,16 @@ def run_commit_message!(argv)
81
91
  end
82
92
 
83
93
  def run_status!(argv)
84
- options = { format: :text }
94
+ options = { format: :text, repo_dir: '.' }
85
95
  parser = OptionParser.new do |opts|
86
96
  opts.banner = 'Usage: kk-git status [options]'
97
+ opts.on('--repo DIR', 'Git repo directory (default: current dir)') { |v| options[:repo_dir] = v }
87
98
  opts.on('--format FORMAT', 'Output format: text/json (default: text)') { |v| options[:format] = v.to_s.downcase.to_sym }
88
99
  opts.on('-h', '--help', 'Show help') { puts opts; exit 0 }
89
100
  end
90
101
  parser.parse!(argv)
91
102
 
92
- hash = KKGit::GitOps.status_hash
103
+ hash = KKGit::GitOps.status_hash(repo_dir: options[:repo_dir])
93
104
  if options[:format] == :json
94
105
  puts JSON.pretty_generate(hash)
95
106
  else
@@ -102,31 +113,35 @@ def run_status!(argv)
102
113
  end
103
114
 
104
115
  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 }
116
+ options = parse_repo_options!(argv)
117
+ if options[:help]
118
+ puts 'Usage: kk-git sync [--repo DIR]'
119
+ exit 0
108
120
  end
109
- parser.parse!(argv)
110
121
 
122
+ repo_dir = options[:repo_dir]
111
123
  remote = KKGit::GitOps.remote
112
- branch = KKGit::GitOps.branch
113
- s = KKGit::GitOps.status(remote: remote, branch: branch)
124
+ branch = KKGit::GitOps.branch(repo_dir: repo_dir)
125
+ s = KKGit::GitOps.status(remote: remote, branch: branch, repo_dir: repo_dir)
114
126
 
115
127
  if s.needs_sync?
116
- KKGit::GitOps.sync_with_remote!(remote, branch)
128
+ KKGit::GitOps.sync_with_remote!(remote, branch, repo_dir: repo_dir)
117
129
  else
118
130
  puts 'Already in sync with remote'
119
131
  end
120
132
  end
121
133
 
122
134
  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 }
135
+ options = parse_repo_options!(argv)
136
+ if options[:help]
137
+ puts 'Usage: kk-git push [--repo DIR]'
138
+ exit 0
126
139
  end
127
- parser.parse!(argv)
128
140
 
129
- KKGit::GitOps.auto_commit_push!
141
+ KKGit::GitOps.auto_commit_push!(repo_dir: options[:repo_dir])
142
+ rescue KKGit::GitOps::Error => e
143
+ warn e.message
144
+ exit 1
130
145
  end
131
146
 
132
147
  def main_help
@@ -149,6 +164,11 @@ def main_help
149
164
  KK_GIT_SKIP_PULL=1 skip pull step
150
165
  KK_GIT_SKIP_PUSH=1 skip push step
151
166
  KK_GIT_AMEND=1 amend last commit instead of creating new one
167
+ KK_GIT_CONFIRM=1 print message and require KK_GIT_YES=1
168
+ KK_GIT_ALLOW_SENSITIVE=1 allow committing .env/credentials paths
169
+ KK_GIT_DEFAULT_TYPE default type for code edits (default: chore)
170
+ KK_GIT_PUSH_URL push to explicit URL (bypass SSH remote)
171
+ KK_GIT_PULL_URL pull from explicit URL (bypass SSH remote)
152
172
 
153
173
  Run `kk-git <command> --help` for command-specific options.
154
174
  HELP
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'open3'
4
+ require_relative 'git_runner'
5
5
 
6
6
  module KKGit
7
7
  # Generate Conventional Commits messages from git changes.
@@ -211,14 +211,7 @@ module KKGit
211
211
  end
212
212
 
213
213
  def self.run_git(args, repo_dir:)
214
- stdout, stderr, status = Open3.capture3('git', *args, chdir: repo_dir)
215
- # Open3 stdout/stderr may be ASCII-8BIT (BINARY). Normalize to UTF-8 to avoid concat errors.
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(' ')} failed: #{stderr.strip}" unless status.success?
220
-
221
- stdout
214
+ GitRunner.capture!(args, repo_dir: repo_dir)
222
215
  end
223
216
 
224
217
  # Infer type/scope/subject (with optional breaking detection)
@@ -238,17 +231,6 @@ module KKGit
238
231
  types.min_by { |t| TYPE_PRIORITY[t] || 999 } || 'chore'
239
232
  end
240
233
 
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
- # When multiple scopes exist, prefer "repo"; otherwise fallback.
247
- return 'repo' if uniq.include?('repo')
248
-
249
- fallback_scope
250
- end
251
-
252
234
  def self.infer_scope(paths, fallback_scope:)
253
235
  # Tooling/script/build changes: prefer a stable scope
254
236
  if paths.any? && paths.all? { |p| tooling_path?(p) || doc_path?(p) || ci_path?(p) }
@@ -319,10 +301,14 @@ module KKGit
319
301
  return 'chore' if config_path?(path)
320
302
  return 'chore' if script_path?(path)
321
303
 
322
- return 'chore' unless code_path?(path)
304
+ return default_code_type unless code_path?(path)
305
+
306
+ default_code_type
307
+ end
323
308
 
324
- # Default: treat code changes as fix (conservative). Adds are handled as feat by infer_type.
325
- 'fix'
309
+ def self.default_code_type
310
+ type = ENV.fetch('KK_GIT_DEFAULT_TYPE', 'chore').to_s.strip
311
+ TYPE_PRIORITY.key?(type) ? type : 'chore'
326
312
  end
327
313
 
328
314
  def self.doc_path?(path)
@@ -1,20 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
3
  require 'tempfile'
4
+ require_relative 'git_runner'
5
+ require_relative 'network_hints'
5
6
 
6
7
  module KKGit
7
8
  # Git 仓库操作:供 Rake task 与 CLI 复用。
8
9
  module GitOps
9
10
  class Error < StandardError; end
10
11
 
12
+ SENSITIVE_PATH_PATTERNS = [
13
+ %r{\A\.env(\.|$)}i,
14
+ /credentials/i,
15
+ %r{\.pem\z}i,
16
+ /id_rsa/i,
17
+ %r{\.key\z}i,
18
+ %r{\Asecrets?\.}i
19
+ ].freeze
20
+
11
21
  # 仓库同步状态快照
12
22
  Status = Struct.new(
13
23
  :branch, :remote, :clean, :ahead, :behind,
14
24
  :upstream_configured, :detached,
15
25
  keyword_init: true
16
26
  ) do
17
- # 是否需要与远端同步(有未推送 commit 或落后远端)
18
27
  def needs_sync?
19
28
  ahead.positive? || behind.positive?
20
29
  end
@@ -29,7 +38,6 @@ module KKGit
29
38
  end
30
39
 
31
40
  class << self
32
- # @return [Boolean] KK_GIT_DRY_RUN=1 时只打印命令不执行
33
41
  def dry_run?
34
42
  ENV['KK_GIT_DRY_RUN'] == '1'
35
43
  end
@@ -50,127 +58,152 @@ module KKGit
50
58
  ENV.fetch('KK_GIT_REMOTE', 'origin')
51
59
  end
52
60
 
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
61
+ def branch(explicit: ENV['KK_GIT_BRANCH'], repo_dir: '.')
62
+ explicit.to_s.strip.empty? ? current_branch(repo_dir: repo_dir) : explicit.to_s.strip
56
63
  end
57
64
 
58
- # git add 路径,默认 `.`;可用 KK_GIT_ADD_PATHS 指定多个路径(空格分隔)
59
65
  def add_paths
60
66
  ENV.fetch('KK_GIT_ADD_PATHS', '.').split(/\s+/).reject(&:empty?)
61
67
  end
62
68
 
63
- # 会修改仓库状态的 git 子命令;dry-run 时跳过这些命令
64
- MUTATING_GIT_COMMANDS = %w[add commit push pull merge rebase checkout reset cherry-pick revert].freeze
69
+ def pull_target(remote)
70
+ url = ENV['KK_GIT_PULL_URL'].to_s.strip
71
+ url.empty? ? remote : url
72
+ end
73
+
74
+ def push_target(remote)
75
+ url = ENV['KK_GIT_PUSH_URL'].to_s.strip
76
+ url.empty? ? remote : url
77
+ end
78
+
79
+ def remote_fetch_url(repo_dir: '.')
80
+ out, _err, ok = run_cmd('git', 'remote', 'get-url', remote, chdir: repo_dir)
81
+ ok ? out.strip : nil
82
+ end
83
+
84
+ def suggested_https_push_url(repo_dir: '.')
85
+ NetworkHints.ssh_remote_to_https(remote_fetch_url(repo_dir: repo_dir))
86
+ end
65
87
 
66
- def run_cmd(*cmd, chdir: nil)
88
+ MUTATING_GIT_COMMANDS = %w[add commit push pull merge rebase checkout reset cherry-pick revert tag].freeze
89
+
90
+ def run_cmd(*cmd, chdir: '.')
67
91
  if dry_run? && mutating_git_command?(cmd)
68
- label = chdir ? "(cd #{chdir} && #{cmd.join(' ')})" : cmd.join(' ')
92
+ label = chdir == '.' ? cmd.join(' ') : "(cd #{chdir} && #{cmd.join(' ')})"
69
93
  puts "[dry-run] #{label}"
70
94
  return ['', '', true]
71
95
  end
72
96
 
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?]
97
+ if cmd[0] == 'git'
98
+ stdout, stderr, ok = GitRunner.capture(cmd.drop(1), repo_dir: chdir)
99
+ else
100
+ require 'open3'
101
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: chdir == '.' ? nil : chdir)
102
+ ok = status.success?
103
+ stdout = GitRunner.normalize_utf8(stdout)
104
+ stderr = GitRunner.normalize_utf8(stderr)
105
+ end
106
+ [stdout.to_s, stderr.to_s, ok]
80
107
  end
81
108
 
82
109
  def mutating_git_command?(cmd)
83
110
  cmd[0] == 'git' && MUTATING_GIT_COMMANDS.include?(cmd[1])
84
111
  end
85
112
 
86
- def ensure_ok!(ok, title, stdout: nil, stderr: nil)
113
+ def ensure_ok!(ok, title, stdout: nil, stderr: nil, repo_dir: '.')
87
114
  return if ok
88
115
 
89
116
  msg = +"#{title} failed"
90
117
  msg << "\n#{stderr}" unless stderr.to_s.strip.empty?
91
118
  msg << "\n#{stdout}" unless stdout.to_s.strip.empty?
119
+
120
+ hints = NetworkHints.for_git_failure(
121
+ title: title,
122
+ stderr: stderr.to_s,
123
+ stdout: stdout.to_s,
124
+ suggested_https_url: suggested_https_push_url(repo_dir: repo_dir)
125
+ )
126
+ msg << "\n\n#{hints.join("\n\n")}" unless hints.empty?
127
+
92
128
  raise Error, msg
93
129
  end
94
130
 
95
- def in_git_repo?
96
- _, _, ok = run_cmd('git', 'rev-parse', '--git-dir')
131
+ def in_git_repo?(repo_dir: '.')
132
+ _, _, ok = run_cmd('git', 'rev-parse', '--git-dir', chdir: repo_dir)
97
133
  ok
98
134
  end
99
135
 
100
- def ensure_in_repo!
101
- raise Error, 'Not a git repository' unless in_git_repo?
136
+ def ensure_in_repo!(repo_dir: '.')
137
+ raise Error, 'Not a git repository' unless in_git_repo?(repo_dir: repo_dir)
102
138
  end
103
139
 
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)
140
+ def current_branch(repo_dir: '.')
141
+ out, err, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: repo_dir)
142
+ ensure_ok!(ok, 'Get current branch', stdout: out, stderr: err, repo_dir: repo_dir)
107
143
  out.strip
108
144
  end
109
145
 
110
- def detached_head?
111
- current_branch == 'HEAD'
146
+ def detached_head?(repo_dir: '.')
147
+ current_branch(repo_dir: repo_dir) == 'HEAD'
112
148
  end
113
149
 
114
- def ensure_not_detached!
115
- raise Error, 'Cannot push from detached HEAD' if detached_head?
150
+ def ensure_not_detached!(repo_dir: '.')
151
+ raise Error, 'Cannot push from detached HEAD' if detached_head?(repo_dir: repo_dir)
116
152
  end
117
153
 
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)
154
+ def working_tree_clean?(repo_dir: '.')
155
+ out, err, ok = run_cmd('git', 'status', '--porcelain', chdir: repo_dir)
156
+ ensure_ok!(ok, 'Check git status', stdout: out, stderr: err, repo_dir: repo_dir)
121
157
  out.strip.empty?
122
158
  end
123
159
 
124
- def upstream_configured?
125
- _, _, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', '@{u}')
160
+ def upstream_configured?(repo_dir: '.')
161
+ _, _, ok = run_cmd('git', 'rev-parse', '--abbrev-ref', '@{u}', chdir: repo_dir)
126
162
  ok
127
163
  end
128
164
 
129
- # 相对 upstream / remote/branch 领先的 commit 数
130
- def ahead_count(remote, branch)
131
- out, _err, ok = run_cmd('git', 'rev-list', '--count', '@{u}..HEAD')
165
+ def ahead_count(remote, branch, repo_dir: '.')
166
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', '@{u}..HEAD', chdir: repo_dir)
132
167
  return out.strip.to_i if ok
133
168
 
134
- out, _err, ok = run_cmd('git', 'rev-list', '--count', "#{remote}/#{branch}..HEAD")
169
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', "#{remote}/#{branch}..HEAD", chdir: repo_dir)
135
170
  return out.strip.to_i if ok
136
171
 
137
172
  0
138
173
  end
139
174
 
140
- # 相对 upstream / remote/branch 落后的 commit 数
141
- def behind_count(remote, branch)
142
- out, _err, ok = run_cmd('git', 'rev-list', '--count', 'HEAD..@{u}')
175
+ def behind_count(remote, branch, repo_dir: '.')
176
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', 'HEAD..@{u}', chdir: repo_dir)
143
177
  return out.strip.to_i if ok
144
178
 
145
- out, _err, ok = run_cmd('git', 'rev-list', '--count', "HEAD..#{remote}/#{branch}")
179
+ out, _err, ok = run_cmd('git', 'rev-list', '--count', "HEAD..#{remote}/#{branch}", chdir: repo_dir)
146
180
  return out.strip.to_i if ok
147
181
 
148
182
  0
149
183
  end
150
184
 
151
- def unpushed_commits?(remote, branch)
152
- ahead_count(remote, branch).positive?
185
+ def unpushed_commits?(remote, branch, repo_dir: '.')
186
+ ahead_count(remote, branch, repo_dir: repo_dir).positive?
153
187
  end
154
188
 
155
- # @return [Status]
156
- def status(remote: nil, branch: nil)
157
- ensure_in_repo!
189
+ def status(remote: nil, branch: nil, repo_dir: '.')
190
+ ensure_in_repo!(repo_dir: repo_dir)
158
191
  remote ||= self.remote
159
- branch ||= self.branch
192
+ branch ||= self.branch(repo_dir: repo_dir)
160
193
 
161
194
  Status.new(
162
195
  branch: branch,
163
196
  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?
197
+ clean: working_tree_clean?(repo_dir: repo_dir),
198
+ ahead: ahead_count(remote, branch, repo_dir: repo_dir),
199
+ behind: behind_count(remote, branch, repo_dir: repo_dir),
200
+ upstream_configured: upstream_configured?(repo_dir: repo_dir),
201
+ detached: detached_head?(repo_dir: repo_dir)
169
202
  )
170
203
  end
171
204
 
172
- def status_hash(remote: nil, branch: nil)
173
- s = status(remote: remote, branch: branch)
205
+ def status_hash(remote: nil, branch: nil, repo_dir: '.')
206
+ s = status(remote: remote, branch: branch, repo_dir: repo_dir)
174
207
  {
175
208
  branch: s.branch,
176
209
  remote: s.remote,
@@ -183,70 +216,101 @@ module KKGit
183
216
  }
184
217
  end
185
218
 
186
- def pull_remote!(remote, branch)
219
+ def pull_remote!(remote, branch, repo_dir: '.')
187
220
  return if skip_pull?
188
221
 
189
222
  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)
223
+ target = pull_target(remote)
224
+ out, err, ok = run_cmd('git', 'pull', target, branch, *pull_args, chdir: repo_dir)
225
+ ensure_ok!(ok, 'git pull', stdout: out, stderr: err, repo_dir: repo_dir)
192
226
  end
193
227
 
194
- def push_remote!(remote, branch)
228
+ def push_remote!(remote, branch, repo_dir: '.')
195
229
  return if skip_push?
196
230
 
197
- ensure_not_detached! unless dry_run?
231
+ ensure_not_detached!(repo_dir: repo_dir) unless dry_run?
198
232
 
199
- if upstream_configured?
200
- out, err, ok = run_cmd('git', 'push', remote, branch)
233
+ target = push_target(remote)
234
+ if upstream_configured?(repo_dir: repo_dir)
235
+ out, err, ok = run_cmd('git', 'push', target, branch, chdir: repo_dir)
201
236
  else
202
- out, err, ok = run_cmd('git', 'push', '-u', remote, branch)
237
+ out, err, ok = run_cmd('git', 'push', '-u', target, branch, chdir: repo_dir)
203
238
  end
204
- ensure_ok!(ok, 'git push', stdout: out, stderr: err)
239
+ ensure_ok!(ok, 'git push', stdout: out, stderr: err, repo_dir: repo_dir)
205
240
  end
206
241
 
207
- def sync_with_remote!(remote, branch)
208
- pull_remote!(remote, branch)
209
- push_remote!(remote, branch)
242
+ def sync_with_remote!(remote, branch, repo_dir: '.')
243
+ pull_remote!(remote, branch, repo_dir: repo_dir)
244
+ push_remote!(remote, branch, repo_dir: repo_dir)
210
245
  puts "Synced: #{remote} #{branch}" unless dry_run?
211
246
  end
212
247
 
213
- def add_all!
248
+ def add_all!(repo_dir: '.')
214
249
  paths = add_paths
215
- out, err, ok = run_cmd('git', 'add', *paths)
216
- ensure_ok!(ok, 'git add', stdout: out, stderr: err)
250
+ out, err, ok = run_cmd('git', 'add', *paths, chdir: repo_dir)
251
+ ensure_ok!(ok, 'git add', stdout: out, stderr: err, repo_dir: repo_dir)
252
+ end
253
+
254
+ def sensitive_path?(path)
255
+ SENSITIVE_PATH_PATTERNS.any? { |pattern| path.match?(pattern) }
256
+ end
257
+
258
+ def sensitive_staged_paths(repo_dir: '.')
259
+ out, _err, ok = run_cmd('git', 'diff', '--cached', '--name-only', chdir: repo_dir)
260
+ return [] unless ok
261
+
262
+ out.split("\n").reject(&:empty?).select { |path| sensitive_path?(path) }
217
263
  end
218
264
 
219
- # @return [Boolean] commit 是否成功
220
- def commit_with_message!(message)
265
+ def ensure_no_sensitive_staged!(repo_dir: '.')
266
+ paths = sensitive_staged_paths(repo_dir: repo_dir)
267
+ return if paths.empty?
268
+
269
+ if ENV['KK_GIT_ALLOW_SENSITIVE'] == '1'
270
+ warn "Warning: committing sensitive paths: #{paths.join(', ')}"
271
+ else
272
+ raise Error,
273
+ "Refusing to commit sensitive paths: #{paths.join(', ')}. " \
274
+ 'Set KK_GIT_ALLOW_SENSITIVE=1 to override.'
275
+ end
276
+ end
277
+
278
+ def confirm_commit!(message)
279
+ return unless ENV['KK_GIT_CONFIRM'] == '1'
280
+ return if ENV['KK_GIT_YES'] == '1'
281
+
282
+ puts "Commit message:\n#{message}\n"
283
+ raise Error, 'Set KK_GIT_YES=1 to confirm this commit'
284
+ end
285
+
286
+ def commit_with_message!(message, repo_dir: '.')
221
287
  commit_args = amend? ? %w[commit --amend -F] : %w[commit -F]
222
288
 
223
289
  Tempfile.create('commit_message') do |f|
224
290
  f.write(message)
225
291
  f.flush
226
- out, err, ok = run_cmd('git', *commit_args, f.path)
292
+ out, err, ok = run_cmd('git', *commit_args, f.path, chdir: repo_dir)
227
293
  if ok
228
294
  true
229
295
  elsif err.include?('nothing to commit') || out.include?('nothing to commit')
230
296
  puts 'No staged changes to commit'
231
297
  false
232
298
  else
233
- ensure_ok!(ok, 'git commit', stdout: out, stderr: err)
299
+ ensure_ok!(ok, 'git commit', stdout: out, stderr: err, repo_dir: repo_dir)
234
300
  false
235
301
  end
236
302
  end
237
303
  end
238
304
 
239
- # 自动 add → commit → pull → push 主流程
240
- #
241
305
  # @return [Symbol] :synced | :committed_and_synced | :noop
242
- def auto_commit_push!(commit_message_generator: nil)
243
- ensure_in_repo!
306
+ def auto_commit_push!(commit_message_generator: nil, repo_dir: '.')
307
+ ensure_in_repo!(repo_dir: repo_dir)
244
308
  remote_name = remote
245
- branch_name = branch
309
+ branch_name = branch(repo_dir: repo_dir)
246
310
 
247
- if working_tree_clean?
248
- if status(remote: remote_name, branch: branch_name).needs_sync?
249
- sync_with_remote!(remote_name, branch_name)
311
+ if working_tree_clean?(repo_dir: repo_dir)
312
+ if status(remote: remote_name, branch: branch_name, repo_dir: repo_dir).needs_sync?
313
+ sync_with_remote!(remote_name, branch_name, repo_dir: repo_dir)
250
314
  return :synced
251
315
  end
252
316
 
@@ -254,20 +318,23 @@ module KKGit
254
318
  return :noop
255
319
  end
256
320
 
257
- add_all!
321
+ add_all!(repo_dir: repo_dir)
322
+ ensure_no_sensitive_staged!(repo_dir: repo_dir)
258
323
 
259
324
  message =
260
325
  if commit_message_generator
261
326
  commit_message_generator.call
262
327
  else
263
- KKGit::CommitMessage.generate(mode: :all)
328
+ KKGit::CommitMessage.generate(repo_dir: repo_dir, mode: :all)
264
329
  end
265
330
  message = message.to_s.strip
266
- message = "chore(repo): update project files\n\n#{Time.now}" if message.empty?
331
+ raise Error, 'Could not generate commit message from staged changes' if message.empty?
332
+
333
+ confirm_commit!(message)
267
334
 
268
- committed = commit_with_message!(message)
269
- if committed || unpushed_commits?(remote_name, branch_name)
270
- sync_with_remote!(remote_name, branch_name)
335
+ committed = commit_with_message!(message, repo_dir: repo_dir)
336
+ if committed || unpushed_commits?(remote_name, branch_name, repo_dir: repo_dir)
337
+ sync_with_remote!(remote_name, branch_name, repo_dir: repo_dir)
271
338
  return committed ? :committed_and_synced : :synced
272
339
  end
273
340
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module KKGit
6
+ # Shared git subprocess helper for CommitMessage and GitOps.
7
+ module GitRunner
8
+ module_function
9
+
10
+ def capture(args, repo_dir: '.')
11
+ stdout, stderr, status = Open3.capture3('git', *args, chdir: repo_dir)
12
+ [normalize_utf8(stdout), normalize_utf8(stderr), status.success?]
13
+ end
14
+
15
+ def capture!(args, repo_dir: '.')
16
+ stdout, stderr, ok = capture(args, repo_dir: repo_dir)
17
+ raise "git #{args.join(' ')} failed: #{stderr.strip}" unless ok
18
+
19
+ stdout
20
+ end
21
+
22
+ def normalize_utf8(str)
23
+ str.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KKGit
4
+ # Append actionable hints when git network operations fail.
5
+ module NetworkHints
6
+ module_function
7
+
8
+ def for_git_failure(title:, stderr:, stdout: nil, suggested_https_url: nil)
9
+ combined = [stderr, stdout].compact.join("\n")
10
+ return [] if combined.strip.empty?
11
+
12
+ hints = []
13
+ hints << ssh_timeout_hint(suggested_https_url) if ssh_timeout?(combined)
14
+ hints << auth_hint if auth_failure?(combined)
15
+ hints << unreachable_hint if host_unreachable?(combined) && hints.empty?
16
+
17
+ hints
18
+ end
19
+
20
+ def ssh_timeout?(text)
21
+ text.match?(/ssh:.*connect to host .+ port 22.*timed out/i) ||
22
+ (text.match?(/Operation timed out/i) && text.match?(/ssh:/i))
23
+ end
24
+
25
+ def auth_failure?(text)
26
+ text.match?(/Authentication failed/i) ||
27
+ text.match?(/Permission denied \(publickey\)/i) ||
28
+ text.match?(/could not read Username/i)
29
+ end
30
+
31
+ def host_unreachable?(text)
32
+ text.match?(/Could not read from remote repository/i) ||
33
+ text.match?(/unable to access/i) ||
34
+ text.match?(/Connection refused/i)
35
+ end
36
+
37
+ def ssh_timeout_hint(suggested_https_url)
38
+ lines = [
39
+ 'Network hint: SSH to the git host on port 22 timed out (often blocked by firewalls or ISP).',
40
+ 'Fix options:',
41
+ ' 1) Switch remote to HTTPS:',
42
+ ' git remote set-url origin https://github.com/USER/REPO.git',
43
+ ' 2) Use SSH over port 443 (~/.ssh/config):',
44
+ ' Host github.com',
45
+ ' Hostname ssh.github.com',
46
+ ' Port 443',
47
+ ' 3) One-off push/pull without changing remote:',
48
+ ' KK_GIT_PUSH_URL=https://github.com/USER/REPO.git kk-git push'
49
+ ]
50
+ lines << " Suggested KK_GIT_PUSH_URL=#{suggested_https_url}" if suggested_https_url
51
+ lines.join("\n")
52
+ end
53
+
54
+ def auth_hint
55
+ <<~HINT.strip
56
+ Auth hint: remote rejected credentials.
57
+ - HTTPS: use a PAT and credential helper
58
+ - SSH: ensure ssh-agent has the right key (ssh -T git@github.com)
59
+ HINT
60
+ end
61
+
62
+ def unreachable_hint
63
+ 'Network hint: could not reach the remote. Check VPN, proxy, DNS, and `git remote -v`.'
64
+ end
65
+
66
+ # git@github.com:owner/repo.git -> https://github.com/owner/repo.git
67
+ def ssh_remote_to_https(url)
68
+ return nil if url.nil? || url.strip.empty?
69
+
70
+ if (m = url.strip.match(/\Agit@([^:]+):(.+?)(?:\.git)?\z/))
71
+ host = m[1]
72
+ path = m[2]
73
+ return "https://#{host}/#{path}.git"
74
+ end
75
+
76
+ nil
77
+ end
78
+ end
79
+ end
@@ -67,6 +67,9 @@ module KKGit
67
67
  end
68
68
 
69
69
  KKGit::GitOps.auto_commit_push!(commit_message_generator: generator)
70
+ rescue KKGit::GitOps::Error => e
71
+ warn e.message
72
+ exit 1
70
73
  end
71
74
  end
72
75
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KKGit
4
+ # Semver bump and git tag helpers (maintainer release flow).
5
+ module Release
6
+ VERSION_PATH = File.join(__dir__, 'version.rb').freeze
7
+
8
+ module_function
9
+
10
+ def latest_semver_tag(prefix: 'v', repo_dir: '.')
11
+ stdout, _stderr, ok = GitOps.run_cmd(
12
+ 'git', 'tag', '--list', "#{prefix}[0-9]*.[0-9]*.[0-9]*", '--sort=-v:refname',
13
+ chdir: repo_dir
14
+ )
15
+ return nil unless ok
16
+
17
+ stdout.to_s.split("\n").first&.strip
18
+ end
19
+
20
+ def parse_semver(str)
21
+ m = str.to_s.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
22
+ return nil unless m
23
+
24
+ [m[1].to_i, m[2].to_i, m[3].to_i]
25
+ end
26
+
27
+ def bump_semver(version, level)
28
+ major, minor, patch = version
29
+ case level
30
+ when 'major' then [major + 1, 0, 0]
31
+ when 'minor' then [major, minor + 1, 0]
32
+ else [major, minor, patch + 1]
33
+ end
34
+ end
35
+
36
+ def semver_to_s(version)
37
+ "#{version[0]}.#{version[1]}.#{version[2]}"
38
+ end
39
+
40
+ def semver_gt?(a, b)
41
+ return false if a.nil? || b.nil?
42
+
43
+ a.each_with_index do |part, i|
44
+ return true if part > b[i]
45
+ return false if part < b[i]
46
+ end
47
+ false
48
+ end
49
+
50
+ def semver_max(*versions)
51
+ versions.compact.max_by { |v| v }
52
+ end
53
+
54
+ def tag_exists?(tag, repo_dir: '.')
55
+ out, _err, ok = GitOps.run_cmd('git', 'tag', '--list', tag, chdir: repo_dir)
56
+ ok && !out.to_s.strip.empty?
57
+ end
58
+
59
+ def current_version_in_file
60
+ content = File.read(VERSION_PATH, mode: 'r:BOM|UTF-8')
61
+ m = content.match(/VERSION\s*=\s*'([^']*)'/)
62
+ m ? parse_semver(m[1]) : nil
63
+ end
64
+
65
+ # @return [Boolean] true when file was updated
66
+ def update_version!(new_version)
67
+ content = File.read(VERSION_PATH, mode: 'r:BOM|UTF-8')
68
+ return false if content.match?(/VERSION\s*=\s*'#{Regexp.escape(new_version)}'/)
69
+
70
+ replaced = content.sub(/VERSION\s*=\s*'[^']*'/, "VERSION = '#{new_version}'")
71
+ raise "Failed to update version in #{VERSION_PATH}" if replaced == content
72
+
73
+ File.write(VERSION_PATH, replaced)
74
+ true
75
+ end
76
+
77
+ # @return [Array(String, String)] tag, version
78
+ def next_tag_and_version(prefix: nil, bump: nil, repo_dir: '.')
79
+ prefix ||= ENV.fetch('KK_GIT_TAG_PREFIX', 'v')
80
+ bump ||= ENV.fetch('KK_GIT_BUMP', 'patch')
81
+
82
+ latest_tag = latest_semver_tag(prefix: prefix, repo_dir: repo_dir)
83
+ tag_version = latest_tag ? parse_semver(latest_tag.delete_prefix(prefix)) : nil
84
+ file_version = current_version_in_file || parse_semver(KKGit::VERSION)
85
+
86
+ # version.rb already ahead of tags and tag not yet created → release that version
87
+ if file_version && tag_version && semver_gt?(file_version, tag_version)
88
+ candidate = "#{prefix}#{semver_to_s(file_version)}"
89
+ return [candidate, semver_to_s(file_version)] unless tag_exists?(candidate, repo_dir: repo_dir)
90
+ end
91
+
92
+ base = semver_max(tag_version, file_version) || [0, 1, 0]
93
+ version = base
94
+ 50.times do
95
+ version = bump_semver(version, bump)
96
+ candidate = "#{prefix}#{semver_to_s(version)}"
97
+ return [candidate, semver_to_s(version)] unless tag_exists?(candidate, repo_dir: repo_dir)
98
+ end
99
+
100
+ raise 'Failed to generate next release tag: too many attempts'
101
+ end
102
+
103
+ def create_and_push_tag!(tag, remote: nil, repo_dir: '.')
104
+ remote ||= GitOps.remote
105
+ out, err, ok = GitOps.run_cmd('git', 'tag', '-a', tag, '-m', "Release #{tag}", chdir: repo_dir)
106
+ GitOps.ensure_ok!(ok, 'git tag', stdout: out, stderr: err, repo_dir: repo_dir)
107
+
108
+ push_target = GitOps.push_target(remote)
109
+ out, err, ok = GitOps.run_cmd('git', 'push', push_target, tag, chdir: repo_dir)
110
+ GitOps.ensure_ok!(ok, 'git push tag', stdout: out, stderr: err, repo_dir: repo_dir)
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KKGit
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
6
-
data/lib/kk/git.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'git/version'
4
+ require_relative 'git/git_runner'
4
5
  require_relative 'git/commit_message'
5
6
  require_relative 'git/git_ops'
7
+ require_relative 'git/release'
6
8
 
7
9
  module KKGit
8
10
  end
metadata CHANGED
@@ -1,19 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kk-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Generate Conventional Commits commit messages from current git changes
14
14
  (staged/worktree), designed for Rake/script usage.
15
15
  email:
16
- - ''
16
+ - kk@users.noreply.github.com
17
17
  executables:
18
18
  - kk-git
19
19
  extensions: []
@@ -23,13 +23,18 @@ files:
23
23
  - lib/kk/git.rb
24
24
  - lib/kk/git/commit_message.rb
25
25
  - lib/kk/git/git_ops.rb
26
+ - lib/kk/git/git_runner.rb
27
+ - lib/kk/git/network_hints.rb
26
28
  - lib/kk/git/rake_tasks.rb
29
+ - lib/kk/git/release.rb
27
30
  - lib/kk/git/version.rb
28
- homepage: ''
31
+ homepage: https://github.com/kevin197011/kk-git
29
32
  licenses:
30
33
  - MIT
31
34
  metadata:
32
35
  rubygems_mfa_required: 'true'
36
+ source_code_uri: https://github.com/kevin197011/kk-git
37
+ changelog_uri: https://github.com/kevin197011/kk-git/blob/main/CHANGELOG.md
33
38
  post_install_message:
34
39
  rdoc_options: []
35
40
  require_paths: