pindo 5.18.17 → 5.19.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/pindo/base/git_handler.rb +44 -83
  3. data/lib/pindo/command/appstore/adhocbuild.rb +13 -2
  4. data/lib/pindo/command/appstore/autobuild.rb +2 -1
  5. data/lib/pindo/command/appstore/iap.rb +58 -54
  6. data/lib/pindo/command/repo/create.rb +1 -1
  7. data/lib/pindo/command/repo/search.rb +1 -1
  8. data/lib/pindo/command/utils/installskills.rb +2 -2
  9. data/lib/pindo/command/utils/renewproj.rb +0 -1
  10. data/lib/pindo/config/build_info_manager.rb +31 -0
  11. data/lib/pindo/config/ios_config_parser.rb +212 -140
  12. data/lib/pindo/module/android/android_build_helper.rb +39 -16
  13. data/lib/pindo/module/android/gradle_helper.rb +8 -14
  14. data/lib/pindo/module/appstore/appstore_in_app_purchase.rb +80 -56
  15. data/lib/pindo/module/cert/cert_helper.rb +2 -2
  16. data/lib/pindo/module/pgyer/pgyerhelper.rb +18 -20
  17. data/lib/pindo/module/task/model/build/android_build_dev_task.rb +1 -37
  18. data/lib/pindo/module/task/model/build/android_build_task.rb +40 -0
  19. data/lib/pindo/module/task/model/build/ios_build_adhoc_task.rb +9 -35
  20. data/lib/pindo/module/task/model/build/ios_build_appstore_task.rb +5 -5
  21. data/lib/pindo/module/task/model/build/ios_build_dev_task.rb +14 -4
  22. data/lib/pindo/module/task/model/build_task.rb +2 -0
  23. data/lib/pindo/module/task/model/git/git_commit_task.rb +11 -1
  24. data/lib/pindo/module/task/model/git/git_tag_task.rb +92 -77
  25. data/lib/pindo/module/task/model/jps/jps_workflow_message_task.rb +8 -10
  26. data/lib/pindo/module/task/model/unity/unity_export_task.rb +44 -0
  27. data/lib/pindo/module/task/pindo_task.rb +2 -2
  28. data/lib/pindo/module/task/task_manager.rb +14 -6
  29. data/lib/pindo/module/task/task_reporter.rb +45 -0
  30. data/lib/pindo/module/unity/nuget_helper.rb +5 -20
  31. data/lib/pindo/module/utils/git_repo_helper.rb +69 -36
  32. data/lib/pindo/module/xcode/ipa_resign_helper.rb +33 -62
  33. data/lib/pindo/module/xcode/res/xcode_res_handler.rb +25 -4
  34. data/lib/pindo/module/xcode/xcode_build_config.rb +7 -1
  35. data/lib/pindo/module/xcode/xcode_build_helper.rb +36 -4
  36. data/lib/pindo/module/xcode/xcode_res_helper.rb +8 -0
  37. data/lib/pindo/options/helpers/bundleid_selector.rb +23 -6
  38. data/lib/pindo/version.rb +1 -1
  39. metadata +2 -2
@@ -12,7 +12,7 @@ module Pindo
12
12
  :git_tag
13
13
  end
14
14
 
15
- # 重试配置:失败后重试 1
15
+ # 重试配置:失败后重试 2 次(共 3 次尝试)
16
16
  def self.default_retry_count
17
17
  2
18
18
  end
@@ -39,7 +39,7 @@ module Pindo
39
39
  protected
40
40
 
41
41
  def do_work
42
- # 检查是否为 Git 仓库,如果不是则直接成功返回
42
+ # 检查是否为 Git 仓库,如果不是则直接成功返回(非 Git 仓库目录下跳过打 tag)
43
43
  unless valid_git_repository?
44
44
  Funlog.instance.fancyinfo_warning("当前目录不是Git仓库,跳过Git打Tag")
45
45
  return {
@@ -51,93 +51,108 @@ module Pindo
51
51
 
52
52
  root_dir = @git_root_path
53
53
  git_repo_helper = Pindo::GitRepoHelper.share_instance
54
- stash_saved = false
55
54
  stash_name = "pindo_stash_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{rand(1000)}"
56
55
  coding_branch = Pindo::GitHandler.git!(%W(-C #{root_dir} rev-parse --abbrev-ref HEAD)).strip
56
+ git_commit_data = get_data_param_by_key(:git_commit)
57
+ stash_saved = false
57
58
 
58
59
  begin
59
- # 1. 检查工作目录,不干净则 stash
60
- status = Pindo::GitHandler.git!(%W(-C #{root_dir} status --porcelain)).strip
61
- unless status.empty?
62
- Funlog.instance.fancyinfo_update("工作目录有未提交更改,暂存到 stash: #{stash_name}")
63
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash push -u -m #{stash_name}))
64
- stash_saved = true
65
- end
66
-
67
- # 2. 从 GitCommitTask 获取参数(必须在合并之前,确保 release_branch 等参数正确)
68
- git_commit_data = get_data_param_by_key(:git_commit)
69
- if git_commit_data && git_commit_data[:task_param]
70
- param = git_commit_data[:task_param]
71
- @release_branch = param[:release_branch] if param[:release_branch]
72
- @tag_pre = param[:tag_pre] if param[:tag_pre]
73
- @tag_type = param[:tag_type] if param[:tag_type]
74
- @ver_inc = param[:ver_inc] if param[:ver_inc]
75
- end
76
-
77
- # 3. 合并到发布分支(使用已回填的 @release_branch)
78
- if coding_branch != @release_branch
79
- Pindo::GitHandler.merge_branches(
80
- project_dir: root_dir,
81
- source_branch: coding_branch,
82
- target_branch: @release_branch,
83
- sync_branches: true # 双向合并,确保两分支完全同步
84
- )
85
- end
86
-
87
- # 4. 获取版本信息
88
- if git_commit_data && git_commit_data[:task_param] &&
89
- git_commit_data[:task_param][:build_version] && git_commit_data[:task_param][:build_number]
90
- @build_version = git_commit_data[:task_param][:build_version]
91
- @build_number = git_commit_data[:task_param][:build_number]
92
- Funlog.instance.fancyinfo_success("使用 GitCommitTask 的版本号: #{@build_version}, Build: #{@build_number}, Tag前缀: #{@tag_pre}, Tag类型: #{@tag_type}")
93
-
94
- # 降级:自己计算(向后兼容或 GitCommitTask 无有效数据)
95
- else
96
- @build_number, @commit_hash = git_repo_helper.get_build_number_from_commit(project_dir: root_dir)
97
- @build_version = git_repo_helper.calculate_build_version(
98
- project_dir: root_dir,
99
- tag_prefix: @tag_pre,
100
- create_tag_type: @tag_type,
101
- version_increase_type: @ver_inc
102
- )
103
- commit_short = @commit_hash ? @commit_hash[0..7] : "unknown"
104
- Funlog.instance.fancyinfo_warning("未找到 GitCommitTask 依赖,降级为自己计算版本号: #{@build_version}, Build: #{@build_number}, Commit: #{commit_short}")
105
- end
106
-
107
- # 4. 创建并推送 Tag
60
+ # 1. 工作目录不干净则暂存
61
+ stash_saved = stash_if_dirty(root_dir, stash_name)
62
+
63
+ # 2. GitCommitTask 回填参数(须在合并之前,确保 release_branch 等正确)
64
+ backfill_params_from_commit_task(git_commit_data)
65
+
66
+ # 3. 合并到发布分支
67
+ merge_to_release_branch(root_dir, coding_branch)
68
+
69
+ # 4. 解析版本号
70
+ resolve_build_version(root_dir, git_commit_data, git_repo_helper)
71
+
72
+ # 5. 创建并推送 Tag
108
73
  tag_name = "#{@tag_pre}#{@build_version}"
109
74
  git_repo_helper.create_and_push_tag(
110
75
  project_dir: root_dir,
111
76
  tag_name: tag_name,
112
77
  tag_type: @tag_type
113
78
  )
114
-
115
79
  ensure
116
- # 5. 还原 stash
117
-
118
- if stash_saved
119
- begin
120
- # 查找指定名字的 stash
121
- stash_list = Pindo::GitHandler.git!(%W(-C #{root_dir} stash list)).strip
122
- matched_line = stash_list.lines.find { |l| l.include?(stash_name) }
123
-
124
- if matched_line && (stash_id = matched_line.match(/(stash@\{\d+\})/)&.[](1))
125
- # 先应用 stash(不删除)
126
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash apply #{stash_id}))
127
- # 应用成功后再删除 stash
128
- Pindo::GitHandler.git!(%W(-C #{root_dir} stash drop #{stash_id}))
129
- Funlog.instance.fancyinfo_success("已还原 stash: #{stash_name} (#{stash_id})")
130
- else
131
- Funlog.instance.fancyinfo_warning("未找到名为 #{stash_name} 的 stash,请手动检查")
132
- end
133
- rescue => e
134
- # 如果应用失败(通常是有冲突),stash 仍然保留,用户可以手动处理
135
- Funlog.instance.fancyinfo_warning("stash 还原时发生冲突: #{e.message}")
136
- Funlog.instance.fancyinfo_warning("stash 已保留,请手动执行 'git stash pop' 处理冲突")
137
- # 不抛出异常,任务仍然标记为成功(因为 tag 已经创建成功)
138
- end
139
- end
80
+ # 6. 还原 stash
81
+ restore_stash(root_dir, stash_name) if stash_saved
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # 工作目录有未提交更改则暂存,返回是否已暂存
88
+ def stash_if_dirty(root_dir, stash_name)
89
+ status = Pindo::GitHandler.git!(%W(-C #{root_dir} status --porcelain)).strip
90
+ return false if status.empty?
91
+
92
+ Funlog.instance.fancyinfo_update("工作目录有未提交更改,暂存到 stash: #{stash_name}")
93
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash push -u -m #{stash_name}))
94
+ true
95
+ end
96
+
97
+ # GitCommitTask 回填 release_branch / tag_pre / tag_type / ver_inc
98
+ def backfill_params_from_commit_task(git_commit_data)
99
+ return unless git_commit_data && git_commit_data[:task_param]
100
+
101
+ param = git_commit_data[:task_param]
102
+ @release_branch = param[:release_branch] if param[:release_branch]
103
+ @tag_pre = param[:tag_pre] if param[:tag_pre]
104
+ @tag_type = param[:tag_type] if param[:tag_type]
105
+ @ver_inc = param[:ver_inc] if param[:ver_inc]
106
+ end
107
+
108
+ # 将编码分支双向合并到发布分支,确保两分支同步到同一节点
109
+ def merge_to_release_branch(root_dir, coding_branch)
110
+ return if coding_branch == @release_branch
111
+
112
+ Pindo::GitHandler.merge_branches(
113
+ project_dir: root_dir,
114
+ source_branch: coding_branch,
115
+ target_branch: @release_branch,
116
+ sync_branches: true # 双向合并,确保两分支完全同步
117
+ )
118
+ end
119
+
120
+ # 解析构建版本号:优先用 GitCommitTask 的,否则自己计算(向后兼容)
121
+ def resolve_build_version(root_dir, git_commit_data, git_repo_helper)
122
+ if git_commit_data && git_commit_data[:task_param] &&
123
+ git_commit_data[:task_param][:build_version] && git_commit_data[:task_param][:build_number]
124
+ @build_version = git_commit_data[:task_param][:build_version]
125
+ @build_number = git_commit_data[:task_param][:build_number]
126
+ Funlog.instance.fancyinfo_success("使用 GitCommitTask 的版本号: #{@build_version}, Build: #{@build_number}, Tag前缀: #{@tag_pre}, Tag类型: #{@tag_type}")
127
+ else
128
+ @build_number, @commit_hash = git_repo_helper.get_build_number_from_commit(project_dir: root_dir)
129
+ @build_version = git_repo_helper.calculate_build_version(
130
+ project_dir: root_dir,
131
+ tag_prefix: @tag_pre,
132
+ create_tag_type: @tag_type,
133
+ version_increase_type: @ver_inc
134
+ )
135
+ commit_short = @commit_hash ? @commit_hash[0..7] : "unknown"
136
+ Funlog.instance.fancyinfo_warning("未找到 GitCommitTask 依赖,降级为自己计算版本号: #{@build_version}, Build: #{@build_number}, Commit: #{commit_short}")
137
+ end
138
+ end
139
+
140
+ # 还原之前暂存的 stash;冲突时保留 stash 并告警,不抛异常(tag 已创建成功)
141
+ def restore_stash(root_dir, stash_name)
142
+ stash_list = Pindo::GitHandler.git!(%W(-C #{root_dir} stash list)).strip
143
+ matched_line = stash_list.lines.find { |l| l.include?(stash_name) }
144
+
145
+ if matched_line && (stash_id = matched_line.match(/(stash@\{\d+\})/)&.[](1))
146
+ # 先应用 stash(不删除),成功后再删除
147
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash apply #{stash_id}))
148
+ Pindo::GitHandler.git!(%W(-C #{root_dir} stash drop #{stash_id}))
149
+ Funlog.instance.fancyinfo_success("已还原 stash: #{stash_name} (#{stash_id})")
150
+ else
151
+ Funlog.instance.fancyinfo_warning("未找到名为 #{stash_name} 的 stash,请手动检查")
140
152
  end
153
+ rescue => e
154
+ Funlog.instance.fancyinfo_warning("stash 还原时发生冲突: #{e.message}")
155
+ Funlog.instance.fancyinfo_warning("stash 已保留,请手动执行 'git stash pop' 处理冲突")
141
156
  end
142
157
 
143
158
  end
@@ -15,6 +15,11 @@ module Pindo
15
15
  :jps_workflow_message
16
16
  end
17
17
 
18
+ # 工作流消息失败重试 1 次(覆盖 JPSTask 默认的重试 3 次)
19
+ def self.default_retry_count
20
+ 1
21
+ end
22
+
18
23
  # 初始化工作流消息发送任务
19
24
  # @param options [Hash] 选项
20
25
  # @option options [String] :git_commit_id Git commit SHA(可选,从依赖任务获取)
@@ -70,17 +75,10 @@ module Pindo
70
75
  workflow_id: @workflow_id
71
76
  }
72
77
  rescue => e
73
- # 外层错误保护:消息发送失败不应影响整体流程
74
- puts " ⚠️ JPS 工作流消息发送发生错误: #{e.message}"
78
+ # 工作流消息是终端任务,失败需明确报错中断(不再伪造 success)
79
+ puts " JPS 工作流消息发送失败: #{e.message}"
75
80
  puts " ⚠️ 错误堆栈: #{e.backtrace.first(3).join("\n ")}" if ENV['PINDO_DEBUG'] && e.backtrace
76
- puts " ℹ️ 消息发送失败不影响主流程\n"
77
-
78
- # 消息发送失败不抛出异常,返回成功但带警告
79
- {
80
- success: true,
81
- warning: e.message,
82
- git_commit_id: @git_commit_id
83
- }
81
+ raise Informative, "JPS 工作流消息发送失败: #{e.message}"
84
82
  end
85
83
  end
86
84
 
@@ -1,5 +1,6 @@
1
1
  require 'pindo/module/task/model/unity_task'
2
2
  require 'pindo/base/funlog'
3
+ require 'json'
3
4
 
4
5
  module Pindo
5
6
  module TaskSystem
@@ -18,6 +19,7 @@ module Pindo
18
19
  # - :project_path [String] Unity 项目路径(默认当前目录)
19
20
  # - :export_path [String] 导出路径(nil 表示自动检测)
20
21
  # - :deploy_mode [String] 部署模式 ('dev', 'adhoc', 'release'),默认 'dev'
22
+ # - :app_config [Hash] 需写入工程 AppConfig 的键值(传入则在导出前先改写 AppConfig)
21
23
  # - :context [Hash] 上下文信息(如 index_count)
22
24
  def initialize(platform, options = {})
23
25
  @platform = platform
@@ -26,6 +28,7 @@ module Pindo
26
28
  @index_count = options[:context] ? options[:context][:index_count] : nil
27
29
  @export_path = options[:export_path] # 可由参数传入,nil 表示自动检测
28
30
  @deploy_mode = options[:deploy_mode] || 'dev' # 部署模式:dev/adhoc/release,默认 dev
31
+ @app_config = options[:app_config] # 需写入 AppConfig 的键值,nil/空表示跳过
29
32
 
30
33
  name = case platform
31
34
  when 'ios', 'ipa'
@@ -57,6 +60,9 @@ module Pindo
57
60
  protected
58
61
 
59
62
  def do_work
63
+ # 若传入了配置参数,先改写工程内的 AppConfig
64
+ update_app_config_file if @app_config && !@app_config.empty?
65
+
60
66
  # 获取 Unity 版本
61
67
  project_unity_version = get_unity_version
62
68
 
@@ -106,6 +112,44 @@ module Pindo
106
112
 
107
113
  private
108
114
 
115
+ # 使用传入的配置改写 Unity 工程内的 AppConfig(TextAsset JSON)
116
+ # 仅覆盖 @app_config 中提供的键,其余键保持原值
117
+ def update_app_config_file
118
+ app_config_file = File.join(@unity_root_path, "Assets", "Resources", "AppConfig", "AppConfig.json")
119
+ unless File.exist?(app_config_file)
120
+ Funlog.warning("未找到 AppConfig 文件,跳过 AppConfig 更新:#{app_config_file}")
121
+ return
122
+ end
123
+
124
+ puts "更新 AppConfig: #{app_config_file}"
125
+
126
+ begin
127
+ app_config_json = JSON.parse(File.read(app_config_file))
128
+ rescue JSON::ParserError => e
129
+ Funlog.warning("AppConfig 不是合法 JSON,跳过更新:#{e.message}")
130
+ return
131
+ end
132
+
133
+ changed = false
134
+ @app_config.each do |key, value|
135
+ next if value.nil? || value.to_s.strip.empty?
136
+ new_value = value.to_s.strip
137
+ old_value = app_config_json[key]
138
+ next if old_value.to_s == new_value
139
+
140
+ app_config_json[key] = new_value
141
+ puts " ✓ #{key}: #{old_value} → #{new_value}"
142
+ changed = true
143
+ end
144
+
145
+ if changed
146
+ File.open(app_config_file, "w") { |f| f.write(JSON.pretty_generate(app_config_json)) }
147
+ puts "✅ AppConfig 更新完成\n"
148
+ else
149
+ puts "AppConfig 无需更新(值一致)\n"
150
+ end
151
+ end
152
+
109
153
  # 导出 iOS
110
154
  def export_ios
111
155
  platform = 'iOS'
@@ -446,9 +446,9 @@ module Pindo
446
446
  end
447
447
  end
448
448
 
449
- # 计算当前重试次数
449
+ # 计算当前重试次数(调用时 @retry_count 已递减,无需再 +1)
450
450
  def current_retry_attempt
451
- @max_retry_count - @retry_count + 1
451
+ @max_retry_count - @retry_count
452
452
  end
453
453
 
454
454
  # 立即重试
@@ -118,12 +118,19 @@ module Pindo
118
118
  # 输出执行摘要
119
119
  @reporter.print_execution_summary
120
120
 
121
- # 检查失败任务,有失败则抛出异常(确保进程返回非零退出码)
122
- report = execution_report
123
- if report[:failed] > 0
124
- failed_tasks = @queue.completed_snapshot.select { |t| t.status == TaskStatus::FAILED }
125
- failed_names = failed_tasks.map(&:name).join(', ')
126
- raise Informative, "#{report[:failed]} 个任务执行失败: #{failed_names}"
121
+ # 失败闸门:FAILED / CANCELLED / 残留 PENDING(依赖阻塞等导致从未执行)都视为
122
+ # 「未成功完成」,统一抛异常确保进程非零退出码,避免任务没跑成却静默成功
123
+ completed = @queue.completed_snapshot
124
+ failed_tasks = completed.select { |t| t.status == TaskStatus::FAILED }
125
+ cancelled_tasks = completed.select { |t| t.status == TaskStatus::CANCELLED }
126
+ unfinished_tasks = @queue.pending_snapshot
127
+
128
+ if failed_tasks.any? || cancelled_tasks.any? || unfinished_tasks.any?
129
+ parts = []
130
+ parts << "失败: #{failed_tasks.map(&:name).join(', ')}" if failed_tasks.any?
131
+ parts << "取消: #{cancelled_tasks.map(&:name).join(', ')}" if cancelled_tasks.any?
132
+ parts << "未执行: #{unfinished_tasks.map(&:name).join(', ')}" if unfinished_tasks.any?
133
+ raise Informative, "存在未成功完成的任务 —— #{parts.join(';')}"
127
134
  end
128
135
  end
129
136
 
@@ -138,6 +145,7 @@ module Pindo
138
145
  completed: completed.count,
139
146
  success: completed.count { |t| t.status == TaskStatus::SUCCESS },
140
147
  failed: completed.count { |t| t.status == TaskStatus::FAILED },
148
+ cancelled: completed.count { |t| t.status == TaskStatus::CANCELLED },
141
149
  tasks: (pending + completed).map do |task|
142
150
  {
143
151
  id: task.id,
@@ -41,12 +41,57 @@ module Pindo
41
41
  end
42
42
  end
43
43
 
44
+ # 按依赖拓扑排序展示真实执行顺序
45
+ ordered = topological_execution_order(@queue.pending_snapshot)
46
+ is_serial = strategy.respond_to?(:name) && strategy.name.include?("串行")
47
+ puts "\e[34m"
48
+ if is_serial
49
+ puts "\e[34m" + " 实际执行顺序:"
50
+ else
51
+ puts "\e[34m" + " 执行顺序预览(并发模式下实际顺序受运行时调度影响):"
52
+ end
53
+ ordered.each_with_index do |task, index|
54
+ puts "\e[34m" + " #{index + 1}. #{task.name}"
55
+ end
56
+
44
57
  puts "\e[34m"
45
58
  puts "\e[34m" + " 总计: #{@queue.pending_count} 个任务"
46
59
  puts "\e[34m" + "=" * 60 + "\e[0m"
47
60
  puts "\n"
48
61
  end
49
62
 
63
+ # 按依赖关系生成执行顺序(贪心拓扑排序,模拟串行调度:
64
+ # 每轮取「入队顺序中第一个依赖已满足」的任务),与串行实际执行顺序一致
65
+ # @param tasks [Array<PindoTask>] 待排序任务(入队顺序)
66
+ # @return [Array<PindoTask>] 按执行顺序排列的任务
67
+ def topological_execution_order(tasks)
68
+ all_ids = tasks.map(&:id)
69
+ remaining = tasks.dup
70
+ completed_ids = []
71
+ ordered = []
72
+
73
+ until remaining.empty?
74
+ index = remaining.index do |task|
75
+ task.dependencies.all? do |dep_id|
76
+ # 依赖已完成,或依赖不在本批任务中(视为已提前完成)
77
+ completed_ids.include?(dep_id) || !all_ids.include?(dep_id)
78
+ end
79
+ end
80
+
81
+ # 存在循环依赖或依赖缺失,剩余任务按原顺序追加并结束
82
+ if index.nil?
83
+ ordered.concat(remaining)
84
+ break
85
+ end
86
+
87
+ task = remaining.delete_at(index)
88
+ completed_ids << task.id
89
+ ordered << task
90
+ end
91
+
92
+ ordered
93
+ end
94
+
50
95
  # 输出任务执行标题
51
96
  # @param task [PindoTask] 任务对象
52
97
  def print_task_title(task)
@@ -505,9 +505,8 @@ module Pindo
505
505
  puts " 使用工具: nuget pack"
506
506
  puts
507
507
 
508
- cmd = "cd \"#{package_dir}\" && nuget pack \"#{File.basename(nuspec_file)}\" -OutputDirectory \"#{output_dir}\""
509
-
510
- success = system(cmd)
508
+ # 数组参数 + chdir,避免路径中的 shell 元字符注入
509
+ success = system("nuget", "pack", File.basename(nuspec_file), "-OutputDirectory", output_dir, chdir: package_dir)
511
510
 
512
511
  unless success
513
512
  raise Informative, "NuGet 打包失败,请检查 .nuspec 文件格式"
@@ -573,10 +572,8 @@ module Pindo
573
572
  begin
574
573
  # 查找是否存在与 nuspec 版本匹配的 tag(支持 v1.1.3, V1.1.3, 1.1.3 等格式)
575
574
  matching_tag = nil
576
- temp_dir = Dir.pwd
577
- Dir.chdir(git_root)
578
575
 
579
- # 获取所有 tags
576
+ # 获取所有 tags(命令已带 -C,无需 chdir)
580
577
  all_tags = Pindo::GitHandler.git!(%W(-C #{git_root} tag -l)).split("\n")
581
578
 
582
579
  # 查找匹配当前版本的 tag(不区分大小写,支持 v 前缀)
@@ -585,17 +582,12 @@ module Pindo
585
582
  tag_version.downcase == nuspec_version.downcase
586
583
  end
587
584
 
588
- Dir.chdir(temp_dir)
589
-
590
585
  # 确定获取哪个范围的 commits
591
586
  git_range = if matching_tag
592
587
  # 找到匹配的 tag,获取前一个 tag 到这个 tag 的 commits
593
588
  puts "📝 找到版本 tag: #{matching_tag},生成该版本的 release notes..."
594
589
 
595
- temp_dir = Dir.pwd
596
- Dir.chdir(git_root)
597
-
598
- # 获取所有 tags 并按版本号排序
590
+ # 获取所有 tags 并按版本号排序(命令已带 -C,无需 chdir)
599
591
  all_tags = Pindo::GitHandler.git!(%W(-C #{git_root} tag -l)).split("\n")
600
592
  sorted_tags = all_tags.sort_by do |tag|
601
593
  version_str = tag.gsub(/^(v|V|release[\s_-]*)/i, '')
@@ -609,8 +601,6 @@ module Pindo
609
601
  prev_tag = sorted_tags[tag_index - 1]
610
602
  end
611
603
 
612
- Dir.chdir(temp_dir)
613
-
614
604
  if prev_tag
615
605
  "#{prev_tag}..#{matching_tag}"
616
606
  else
@@ -635,10 +625,7 @@ module Pindo
635
625
  end
636
626
  end
637
627
 
638
- # 获取 commit messages(包含完整的 body)
639
- temp_dir = Dir.pwd
640
- Dir.chdir(git_root)
641
-
628
+ # 获取 commit messages(包含完整的 body;命令已带 -C,无需 chdir
642
629
  # 使用特殊分隔符来区分不同的 commits
643
630
  separator = "---COMMIT-SEPARATOR---"
644
631
  commits_raw = Pindo::GitHandler.git!(%W(-C #{git_root} log #{git_range} --pretty=format:%B#{separator}))
@@ -646,8 +633,6 @@ module Pindo
646
633
  # 按分隔符拆分成单个 commits
647
634
  commits = commits_raw.split(separator).map(&:strip).reject(&:empty?)
648
635
 
649
- Dir.chdir(temp_dir)
650
-
651
636
  # 过滤只保留符合规范的 commits(支持带 scope 和不带 scope 格式)
652
637
  # 匹配格式: feat:, feat(scope):, fix:, fix(api): 等
653
638
  conventional_commit_regex = /^(feat|fix|docs|doc|perf|refactor|style|test|chore|revert)(\([^)]+\))?:/i
@@ -330,53 +330,86 @@ module Pindo
330
330
  raise ArgumentError, "项目目录不能为空" if project_dir.nil?
331
331
  raise ArgumentError, "tag名称不能为空" if tag_name.nil? || tag_name.empty?
332
332
 
333
+ local_exists = Pindo::GitHandler.local_tag_exists?(local_repo_dir: project_dir, tag_name: tag_name)
334
+
335
+ # 建本地 tag 是纯本地操作,不依赖远程可达,网络故障也能留下本地 tag;
336
+ # 删远程残留与推送一律交给 push_tag_with_verify,推送失败时本地 tag 仍保留,可事后手动补推。
333
337
  case tag_type
334
338
  when Pindo::CreateTagType::NONE
339
+ # 不创建 tag
335
340
  Funlog.instance.fancyinfo_success("跳过创建 Tag")
336
- return nil
337
- when Pindo::CreateTagType::RECREATE
338
- # 删除旧 tag(本地和远程)
339
- if Pindo::GitHandler.remote_tag_exists?(local_repo_dir: project_dir, tag_name: tag_name)
340
- Funlog.instance.fancyinfo_update("删除远程 Tag: #{tag_name}")
341
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin :refs/tags/#{tag_name}))
342
- end
343
- if Pindo::GitHandler.local_tag_exists?(local_repo_dir: project_dir, tag_name: tag_name)
344
- Funlog.instance.fancyinfo_update("删除本地 Tag: #{tag_name}")
345
- Pindo::GitHandler.git!(%W(-C #{project_dir} tag -d #{tag_name}))
341
+ nil
342
+ when Pindo::CreateTagType::NEW, Pindo::CreateTagType::RECREATE
343
+ # 本地 tag 已在 HEAD:无需重建,仅确保推送到远程(RECREATE 在 HEAD 原地重建也是空转,同样适用)
344
+ if local_exists && Pindo::GitHandler.is_tag_at_head?(git_root_dir: project_dir, tag_name: tag_name)
345
+ push_tag_with_verify(project_dir: project_dir, tag_name: tag_name)
346
+ tag_name
347
+ else
348
+ # 本地 tag 不存在或不在 HEAD:删旧重建后推送
349
+ if local_exists
350
+ Funlog.instance.fancyinfo_update("删除本地旧 Tag: #{tag_name}")
351
+ Pindo::GitHandler.git!(%W(-C #{project_dir} tag -d #{tag_name}))
352
+ end
353
+ Pindo::GitHandler.git!(%W(-C #{project_dir} tag #{tag_name}))
354
+ Funlog.instance.fancyinfo_success("已创建本地 Tag: #{tag_name}")
355
+ push_tag_with_verify(project_dir: project_dir, tag_name: tag_name)
356
+ tag_name
346
357
  end
347
- when Pindo::CreateTagType::NEW
348
- local_exists = Pindo::GitHandler.local_tag_exists?(local_repo_dir: project_dir, tag_name: tag_name)
349
- remote_exists = Pindo::GitHandler.remote_tag_exists?(local_repo_dir: project_dir, tag_name: tag_name)
358
+ else
359
+ raise ArgumentError, "未知的 tag_type: #{tag_type}"
360
+ end
361
+ end
350
362
 
351
- # 如果 tag 在 HEAD 上,不需要重新创建
352
- if local_exists && Pindo::GitHandler.is_tag_at_head?(git_root_dir: project_dir, tag_name: tag_name)
353
- if !remote_exists
354
- # 本地存在且在 HEAD,但远程不存在,推送到远程
355
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
356
- Funlog.instance.fancyinfo_success("推送 Tag 到远程: #{tag_name}")
357
- else
358
- Funlog.instance.fancyinfo_success("Tag 已存在且在 HEAD: #{tag_name}")
363
+ # 推送 tag 到远程并校验是否真正推送成功,失败则重试
364
+ # 校验方式为「远程 tag 指向的 commit == 本地 tag commit」,
365
+ # 一次 ls-remote 同时覆盖「存在性」与「指向正确性」
366
+ # @param project_dir [String] 仓库目录
367
+ # @param tag_name [String] tag 名称
368
+ # @param max_retries [Integer] 最大重试次数(默认 3)
369
+ # @return [Boolean] 是否推送成功(失败到达上限会 raise)
370
+ def push_tag_with_verify(project_dir:, tag_name:, max_retries: 3)
371
+ local_commit = Pindo::GitHandler.git!(%W(-C #{project_dir} rev-parse #{tag_name}^{commit})).strip
372
+
373
+ attempt = 0
374
+ loop do
375
+ attempt += 1
376
+ begin
377
+ # 远程若残留指向不同 commit 的同名 tag,先删除再推送,避免 non-fast-forward 被拒
378
+ remote_commit = Pindo::GitHandler.remote_tag_commit(local_repo_dir: project_dir, tag_name: tag_name)
379
+ if remote_commit == local_commit
380
+ Funlog.instance.fancyinfo_success("已确认 Tag 推送到远程: #{tag_name}")
381
+ return true
382
+ elsif remote_commit
383
+ Funlog.instance.fancyinfo_update("删除远程残留 Tag(远程 #{remote_commit[0..7]} ≠ 本地 #{local_commit[0..7]}): #{tag_name}")
384
+ Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin :refs/tags/#{tag_name}))
359
385
  end
360
- return tag_name
386
+
387
+ Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
388
+ rescue => e
389
+ first_line = e.message.lines.first&.strip
390
+ Funlog.instance.fancyinfo_warning("推送 Tag 失败(第 #{attempt}/#{max_retries} 次): #{first_line}")
361
391
  end
362
392
 
363
- # tag 不在 HEAD 上,需要删除后重新创建
364
- if remote_exists
365
- Funlog.instance.fancyinfo_update("删除远程 Tag: #{tag_name}")
366
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin :refs/tags/#{tag_name}))
393
+ # 校验远程 tag 指向的 commit 是否与本地一致,确认推送成功(探测失败按未确认处理,不中断重试)
394
+ remote_commit = begin
395
+ Pindo::GitHandler.remote_tag_commit(local_repo_dir: project_dir, tag_name: tag_name)
396
+ rescue
397
+ nil
367
398
  end
368
- if local_exists
369
- Funlog.instance.fancyinfo_update("删除本地 Tag: #{tag_name}")
370
- Pindo::GitHandler.git!(%W(-C #{project_dir} tag -d #{tag_name}))
399
+ if remote_commit && remote_commit == local_commit
400
+ Funlog.instance.fancyinfo_success("已确认 Tag 推送到远程: #{tag_name}")
401
+ return true
371
402
  end
372
- # 继续创建新 tag
373
- end
374
403
 
375
- # 创建并推送 tag
376
- Pindo::GitHandler.git!(%W(-C #{project_dir} tag #{tag_name}))
377
- Pindo::GitHandler.git_remote!(project_dir, %W(-C #{project_dir} push origin #{tag_name}))
378
- Funlog.instance.fancyinfo_success("创建并推送 Tag: #{tag_name}")
379
- tag_name
404
+ if attempt >= max_retries
405
+ # 本地 tag 已创建,仅远程未确认 —— 保留本地 tag,提示可手动补推
406
+ Funlog.instance.fancyinfo_warning("本地 Tag 已创建并保留,可稍后手动执行: git push origin #{tag_name}")
407
+ raise Informative, "Tag 推送失败,已重试 #{max_retries} 次仍未在远程确认: #{tag_name}(本地 Tag 已保留,可手动补推)"
408
+ end
409
+
410
+ Funlog.instance.fancyinfo_update("远程未确认 Tag,#{attempt}s 后重试推送: #{tag_name}")
411
+ sleep(attempt)
412
+ end
380
413
  end
381
414
 
382
415
  end