@icode-js/icode 3.2.0 → 3.4.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.
Files changed (75) hide show
  1. package/README.md +287 -24
  2. package/bin/icode.js +10 -0
  3. package/package.json +3 -3
  4. package/src/cli.js +175 -8
  5. package/src/commands/checkout.js +73 -6
  6. package/src/commands/clean.js +47 -7
  7. package/src/commands/config-ux.js +292 -0
  8. package/src/commands/config.js +199 -48
  9. package/src/commands/continue.js +104 -0
  10. package/src/commands/doctor.js +15 -29
  11. package/src/commands/help.js +54 -34
  12. package/src/commands/history.js +147 -0
  13. package/src/commands/info.js +39 -4
  14. package/src/commands/log.js +142 -0
  15. package/src/commands/migrate.js +19 -12
  16. package/src/commands/open.js +138 -0
  17. package/src/commands/pr.js +219 -0
  18. package/src/commands/push.js +62 -22
  19. package/src/commands/ready.js +18 -48
  20. package/src/commands/release.js +112 -0
  21. package/src/commands/rescue.js +164 -0
  22. package/src/commands/save.js +52 -11
  23. package/src/commands/stash.js +210 -0
  24. package/src/commands/sw.js +89 -0
  25. package/src/commands/sync.js +19 -2
  26. package/src/commands/tag.js +40 -1
  27. package/src/commands/undo.js +9 -3
  28. package/src/core/ai/diff-range.js +1 -1
  29. package/src/core/cli/completion.js +237 -1
  30. package/src/core/cli/output.js +46 -0
  31. package/src/core/cli/render.js +121 -0
  32. package/src/core/commands/shell.js +9 -1
  33. package/src/core/config/repo-config.js +93 -0
  34. package/src/core/config/store.js +60 -10
  35. package/src/core/git/autostash.js +46 -0
  36. package/src/core/git/backup.js +170 -0
  37. package/src/core/git/conflicts.js +58 -0
  38. package/src/core/git/context.js +72 -5
  39. package/src/core/git/file-guard.js +193 -0
  40. package/src/core/git/operation-state.js +63 -0
  41. package/src/core/git/operations.js +32 -0
  42. package/src/core/git/semver.js +194 -0
  43. package/src/core/git/service.js +454 -24
  44. package/src/core/platform/http.js +53 -0
  45. package/src/core/platform/index.js +165 -0
  46. package/src/core/platform/providers.js +454 -0
  47. package/src/core/platform/remote-url.js +92 -0
  48. package/src/core/remote-merge/client.js +33 -3
  49. package/src/core/tools/browser.js +28 -0
  50. package/src/core/tools/commit-prompt.js +137 -0
  51. package/src/core/tools/interactive.js +272 -40
  52. package/src/core/tools/logger.js +33 -23
  53. package/src/core/tools/theme.js +228 -0
  54. package/src/workflows/ai-commit-workflow.js +52 -20
  55. package/src/workflows/checkout-workflow.js +24 -10
  56. package/src/workflows/clean-workflow.js +135 -83
  57. package/src/workflows/continue-workflow.js +233 -0
  58. package/src/workflows/doctor-workflow.js +10 -7
  59. package/src/workflows/history-workflow.js +211 -0
  60. package/src/workflows/migrate-workflow.js +39 -14
  61. package/src/workflows/pr-workflow.js +252 -0
  62. package/src/workflows/push/commit-step.js +123 -0
  63. package/src/workflows/push/local-merge-step.js +213 -0
  64. package/src/workflows/push/plan.js +63 -0
  65. package/src/workflows/push/remote-merge-step.js +224 -0
  66. package/src/workflows/push-workflow.js +185 -492
  67. package/src/workflows/ready-workflow.js +14 -10
  68. package/src/workflows/release-workflow.js +189 -0
  69. package/src/workflows/rescue-workflow.js +137 -0
  70. package/src/workflows/save-workflow.js +106 -41
  71. package/src/workflows/switch-workflow.js +171 -0
  72. package/src/workflows/sync-workflow.js +143 -44
  73. package/src/workflows/tag-workflow.js +103 -17
  74. package/src/workflows/undo-workflow.js +113 -34
  75. package/src/workflows/rollback-workflow.js +0 -99
@@ -0,0 +1,171 @@
1
+ import { IcodeError } from '../core/errors.js'
2
+ import { withAutoStash } from '../core/git/autostash.js'
3
+ import { resolveGitContext } from '../core/git/context.js'
4
+ import { assertNoPendingOperation } from '../core/git/preconditions.js'
5
+ import { GitService } from '../core/git/service.js'
6
+ import { chooseOne, isInteractiveTerminal } from '../core/tools/interactive.js'
7
+
8
+ /**
9
+ * 模糊匹配打分:完全相等 > 前缀 > 包含 > 子序列,不匹配返回 -1。
10
+ * 输入为候选与查询词,输出分数。
11
+ */
12
+ export function fuzzyScore(candidate, query) {
13
+ const name = String(candidate).toLowerCase()
14
+ const q = String(query || '').toLowerCase()
15
+ if (!q) {
16
+ return 1
17
+ }
18
+ if (name === q) {
19
+ return 1000
20
+ }
21
+ const lastSegment = name.split('/').pop()
22
+ if (name.startsWith(q) || lastSegment.startsWith(q)) {
23
+ return 500 - name.length
24
+ }
25
+ const index = name.indexOf(q)
26
+ if (index !== -1) {
27
+ return 300 - index - name.length
28
+ }
29
+ let pointer = 0
30
+ for (const char of name) {
31
+ if (char === q[pointer]) {
32
+ pointer += 1
33
+ if (pointer === q.length) {
34
+ return 100 - name.length
35
+ }
36
+ }
37
+ }
38
+ return -1
39
+ }
40
+
41
+ /**
42
+ * 列出可切换的分支:本地分支(按最近提交排序)+ 可选的远程分支(本地没有的)。
43
+ */
44
+ export async function listSwitchCandidates(git, options = {}) {
45
+ const local = await git.listBranchDetails()
46
+ const candidates = local.map((item) => ({ ...item, kind: 'local' }))
47
+
48
+ if (options.all) {
49
+ const localNames = new Set(local.map((item) => item.name))
50
+ const remoteBranches = await git.listRemoteBranches()
51
+ for (const name of remoteBranches) {
52
+ if (!localNames.has(name)) {
53
+ candidates.push({ name, kind: 'remote', relativeDate: '', ahead: 0, behind: 0, gone: false, subject: '', upstream: git.remoteRef(name) })
54
+ }
55
+ }
56
+ }
57
+
58
+ return candidates
59
+ }
60
+
61
+ function formatCandidateLabel(item, currentBranch) {
62
+ const marks = []
63
+ if (item.name === currentBranch) {
64
+ marks.push('当前')
65
+ }
66
+ if (item.kind === 'remote') {
67
+ marks.push('仅远程')
68
+ }
69
+ if (item.gone) {
70
+ marks.push('远程已删除')
71
+ }
72
+ if (item.ahead) {
73
+ marks.push(`↑${item.ahead}`)
74
+ }
75
+ if (item.behind) {
76
+ marks.push(`↓${item.behind}`)
77
+ }
78
+ const tail = [item.relativeDate, item.subject].filter(Boolean).join(' · ')
79
+ return `${item.name}${marks.length ? ` [${marks.join(' ')}]` : ''}${tail ? ` ${tail}` : ''}`
80
+ }
81
+
82
+ function isDirtyWorktreeError(error) {
83
+ const text = `${error?.meta?.stderr || ''}\n${error?.message || ''}`
84
+ return /would be overwritten|Please commit your changes or stash them/i.test(text)
85
+ }
86
+
87
+ /**
88
+ * 快速切换分支。
89
+ * 输入为 { query, create, from, all, autostash, yes },输出 { branch, previousBranch, mode }。
90
+ * query='-' 表示切回上一个分支;精确命中直接切;多个候选时交互选择。
91
+ */
92
+ export async function runSwitchWorkflow(options = {}) {
93
+ const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode, remote: options.remote })
94
+ const git = new GitService(context)
95
+ const previousBranch = await git.getCurrentBranch()
96
+ assertNoPendingOperation(await git.getInProgressOperation(), 'sw', 'SWITCH_GIT_OPERATION_IN_PROGRESS')
97
+
98
+ const query = String(options.query || '').trim()
99
+ let target = null
100
+
101
+ if (options.create) {
102
+ if (!query) {
103
+ throw new IcodeError('新建分支需要分支名: icode sw -c <branch> [--from <base>]', { code: 'SWITCH_BRANCH_REQUIRED', exitCode: 2 })
104
+ }
105
+ if (await git.branchExistsLocal(query)) {
106
+ throw new IcodeError(`分支已存在: ${query}`, { code: 'SWITCH_BRANCH_EXISTS', exitCode: 2 })
107
+ }
108
+ target = { name: query, kind: 'create', startPoint: options.from || '' }
109
+ } else if (query === '-') {
110
+ target = { name: '-', kind: 'previous' }
111
+ } else if (query && await git.branchExistsLocal(query)) {
112
+ target = { name: query, kind: 'local' }
113
+ } else {
114
+ const candidates = (await listSwitchCandidates(git, { all: options.all || Boolean(query) }))
115
+ .map((item) => ({ item, score: fuzzyScore(item.name, query) }))
116
+ .filter((entry) => entry.score >= 0 && entry.item.name !== previousBranch)
117
+ .sort((a, b) => b.score - a.score)
118
+ .map((entry) => entry.item)
119
+
120
+ if (!candidates.length) {
121
+ throw new IcodeError(
122
+ query ? `没有匹配 "${query}" 的分支。新建请用: icode sw -c ${query}` : '没有其它可切换的分支。',
123
+ { code: 'SWITCH_NO_MATCH', exitCode: 2 }
124
+ )
125
+ }
126
+
127
+ if (candidates.length === 1 && query) {
128
+ target = candidates[0]
129
+ } else if (options.yes || !isInteractiveTerminal()) {
130
+ throw new IcodeError(
131
+ `匹配到多个分支,请写得更具体一些: ${candidates.slice(0, 10).map((item) => item.name).join(', ')}`,
132
+ { code: 'SWITCH_AMBIGUOUS', exitCode: 2, meta: { candidates: candidates.map((item) => item.name) } }
133
+ )
134
+ } else {
135
+ const picked = await chooseOne(
136
+ query ? `匹配 "${query}" 的分支:` : '切换到分支(最近使用在前):',
137
+ candidates.slice(0, 30).map((item, index) => ({ value: String(index), label: formatCandidateLabel(item, previousBranch) })),
138
+ 0
139
+ )
140
+ target = candidates[Number(picked)]
141
+ }
142
+ }
143
+
144
+ return withAutoStash(git, options.autostash === true, `sw ${target.name}`, async () => {
145
+ try {
146
+ if (target.kind === 'create') {
147
+ await git.switchBranch(target.name, { create: true, startPoint: target.startPoint })
148
+ } else if (target.kind === 'remote') {
149
+ await git.switchBranch(target.name, { create: true, startPoint: git.remoteRef(target.name), track: true })
150
+ } else {
151
+ await git.switchBranch(target.name)
152
+ }
153
+ } catch (error) {
154
+ if (isDirtyWorktreeError(error)) {
155
+ throw new IcodeError('未提交的改动会和目标分支冲突,切换已停止。可以加 --autostash 自动暂存后再切换。', {
156
+ code: 'SWITCH_DIRTY_WORKTREE',
157
+ exitCode: 2,
158
+ cause: error
159
+ })
160
+ }
161
+ throw error
162
+ }
163
+
164
+ return {
165
+ branch: await git.getCurrentBranch(),
166
+ previousBranch,
167
+ mode: target.kind,
168
+ repoRoot: context.topLevelPath
169
+ }
170
+ })
171
+ }
@@ -1,4 +1,8 @@
1
1
  import { IcodeError } from '../core/errors.js'
2
+ import { ui, shortenHome } from '../core/tools/theme.js'
3
+ import { withAutoStash } from '../core/git/autostash.js'
4
+ import { createBackup } from '../core/git/backup.js'
5
+ import { saveOperationState } from '../core/git/operation-state.js'
2
6
  import { resolveGitContext } from '../core/git/context.js'
3
7
  import {
4
8
  assertCleanWorktree,
@@ -8,7 +12,7 @@ import {
8
12
  readGitSafetyState
9
13
  } from '../core/git/preconditions.js'
10
14
  import { GitService } from '../core/git/service.js'
11
- import { confirm } from '../core/tools/interactive.js'
15
+ import { chooseMany, confirm, isInteractiveTerminal } from '../core/tools/interactive.js'
12
16
  import { logger } from '../core/tools/logger.js'
13
17
 
14
18
  function unique(list) {
@@ -47,7 +51,8 @@ async function ensureBranchReady(git, branchName) {
47
51
  export async function runSyncWorkflow(options) {
48
52
  const context = await resolveGitContext({
49
53
  cwd: options.cwd,
50
- repoMode: options.repoMode
54
+ repoMode: options.repoMode,
55
+ remote: options.remote
51
56
  })
52
57
 
53
58
  const git = new GitService(context)
@@ -56,12 +61,33 @@ export async function runSyncWorkflow(options) {
56
61
  assertOnBranch(currentBranch, 'sync', 'SYNC_DETACHED_HEAD')
57
62
  assertNoPendingOperation(safetyState.operation, 'sync', 'SYNC_GIT_OPERATION_IN_PROGRESS')
58
63
  assertNoConflictedFiles(safetyState.changes, 'sync', 'SYNC_CONFLICTED_FILES')
59
- assertCleanWorktree(safetyState.changes, 'sync', 'SYNC_DIRTY_WORKTREE')
64
+ if (!options.autostash) {
65
+ assertCleanWorktree(safetyState.changes, 'sync', 'SYNC_DIRTY_WORKTREE')
66
+ }
60
67
 
61
68
  let targetInput = options.branches?.length
62
69
  ? [...options.branches]
63
70
  : [currentBranch, context.defaultBranch]
64
71
 
72
+ // 交互终端里没指定分支时,直接勾选要同步的本地分支(默认当前分支 + 主分支)。
73
+ let confirmedByPicker = false
74
+ if (!options.branches?.length && !options.allLocal && !options.yes && isInteractiveTerminal()) {
75
+ const details = await git.listBranchDetails()
76
+ if (details.length > 1) {
77
+ const picked = await chooseMany('选择要同步的分支', details.map((item) => ({
78
+ value: item.name,
79
+ label: item.name,
80
+ hint: [ui.sync(item.ahead, item.behind), item.gone ? '远程已删除' : '', item.relativeDate].filter(Boolean).join(' · ')
81
+ })), { defaultValues: targetInput, minSelections: 1, doneLabel: '开始同步' })
82
+ if (!picked) {
83
+ logger.warn('已取消同步。')
84
+ return { canceled: true, targets: [], repoRoot: context.topLevelPath }
85
+ }
86
+ targetInput = picked
87
+ confirmedByPicker = true
88
+ }
89
+ }
90
+
65
91
  if (options.allLocal) {
66
92
  // all-local 场景下自动拉取全部本地分支,降低人工逐个输入分支名的成本。
67
93
  const localBranches = await git.listLocalBranches()
@@ -77,12 +103,12 @@ export async function runSyncWorkflow(options) {
77
103
  })
78
104
  }
79
105
 
80
- logger.info(`仓库根目录: ${context.topLevelPath}`)
106
+ logger.hint(`仓库 ${shortenHome(context.topLevelPath)}`)
81
107
  if (context.inheritedFromParent) {
82
108
  logger.warn('当前目录继承了父级 Git 仓库,命令将基于父仓库根目录执行。')
83
109
  }
84
110
 
85
- if (!options.yes) {
111
+ if (!options.yes && !confirmedByPicker) {
86
112
  const ok = await confirm(`确认同步以下分支: ${targets.join(', ')} ?`, true)
87
113
  if (!ok) {
88
114
  logger.warn('已取消同步。')
@@ -94,64 +120,137 @@ export async function runSyncWorkflow(options) {
94
120
  }
95
121
  }
96
122
 
97
- const summary = []
98
123
  await git.fetch()
99
124
  const originalBranch = currentBranch
100
125
 
126
+ // pull/merge 会改动多个本地分支,先记录恢复点。
127
+ const existingLocal = []
128
+ for (const branchName of targets) {
129
+ if (await git.branchExistsLocal(branchName)) {
130
+ existingLocal.push(branchName)
131
+ }
132
+ }
133
+ const backup = await createBackup(git, {
134
+ reason: `sync ${targets.join(',')}`,
135
+ branches: existingLocal,
136
+ silent: true
137
+ })
138
+
139
+ const summary = await withAutoStash(git, options.autostash === true, `sync ${targets.join(',')}`, () =>
140
+ runSyncBranches(git, context, targets, options, originalBranch)
141
+ )
142
+
143
+ return {
144
+ repoRoot: context.topLevelPath,
145
+ remote: git.remote,
146
+ targets,
147
+ backupId: backup.entries.length ? backup.id : '',
148
+ summary
149
+ }
150
+ }
151
+
152
+ // 单个分支的同步步骤,冲突中断后可以从 startAt 指定的步骤接着做。
153
+ const SYNC_STEPS = ['checkout', 'pull', 'merge-main', 'push']
154
+
155
+ async function syncOneBranch(git, context, branchName, options, startAt = 'checkout') {
156
+ const from = SYNC_STEPS.indexOf(startAt)
157
+ let step = startAt
101
158
  try {
102
- for (const branchName of targets) {
103
- logger.info(`同步分支: ${branchName}`)
159
+ let remoteExists = await git.branchExistsRemote(branchName)
160
+ if (from <= 0) {
104
161
  const setup = await ensureBranchReady(git, branchName)
105
-
106
162
  if (!setup.available) {
107
- logger.warn(`分支不存在(本地+远程): ${branchName}`)
108
- summary.push({
109
- branch: branchName,
110
- status: 'missing'
111
- })
112
- continue
163
+ logger.warn(`分支不存在(本地+远程): ${ui.branch(branchName)}`)
164
+ return { branch: branchName, status: 'missing' }
113
165
  }
166
+ remoteExists = setup.remoteExists
167
+ }
114
168
 
115
- if (setup.remoteExists) {
116
- await git.pull(branchName, {
117
- noRebase: !options.rebase,
118
- rebase: options.rebase
119
- })
120
- }
169
+ if (from <= 1 && remoteExists) {
170
+ step = 'pull'
171
+ await git.pull(branchName, {
172
+ noRebase: !options.rebase,
173
+ rebase: options.rebase
174
+ })
175
+ }
121
176
 
122
- if (options.mergeMain && branchName !== context.defaultBranch) {
123
- // 把最新主分支合入目标分支,降低后续提测/发布时的冲突概率。
124
- await git.merge(context.defaultBranch, {
125
- noFf: true,
126
- noEdit: true
127
- })
128
- }
177
+ if (from <= 2 && options.mergeMain && branchName !== context.defaultBranch) {
178
+ step = 'merge-main'
179
+ // 把最新主分支合入目标分支,降低后续提测/发布时的冲突概率。
180
+ await git.merge(context.defaultBranch, {
181
+ noFf: true,
182
+ noEdit: true
183
+ })
184
+ }
129
185
 
130
- if (options.push) {
131
- await git.push(branchName, {
132
- setUpstream: !setup.remoteExists,
133
- noVerify: options.noVerify
186
+ if (options.push) {
187
+ step = 'push'
188
+ await git.push(branchName, {
189
+ setUpstream: !remoteExists,
190
+ noVerify: options.noVerify
191
+ })
192
+ }
193
+ } catch (error) {
194
+ error.syncStep = step
195
+ throw error
196
+ }
197
+
198
+ return {
199
+ branch: branchName,
200
+ status: options.push ? 'synced-and-pushed' : 'synced'
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 依次同步多个分支。某个分支发生冲突时保存进度(icode continue 接着做),并留在冲突分支上。
206
+ * resume = { branch, startAt } 表示从某个分支的某一步继续。
207
+ */
208
+ export async function runSyncBranches(git, context, targets, options, originalBranch, resume = null) {
209
+ const summary = []
210
+ let paused = false
211
+ try {
212
+ for (let index = 0; index < targets.length; index += 1) {
213
+ const branchName = targets[index]
214
+ const startAt = resume && resume.branch === branchName ? resume.startAt : 'checkout'
215
+ logger.info(`同步分支: ${ui.branch(branchName)}`)
216
+ try {
217
+ summary.push(await syncOneBranch(git, context, branchName, options, startAt))
218
+ } catch (error) {
219
+ if (!(await git.getInProgressOperation())) {
220
+ throw error
221
+ }
222
+ // 冲突:记录剩余步骤,下一步从冲突步骤之后开始。
223
+ const nextStep = SYNC_STEPS[SYNC_STEPS.indexOf(error.syncStep || 'pull') + 1] || 'push'
224
+ saveOperationState(context, {
225
+ command: 'sync',
226
+ originalBranch,
227
+ currentBranch: branchName,
228
+ startAt: nextStep,
229
+ remainingTargets: targets.slice(index + 1),
230
+ options: {
231
+ push: Boolean(options.push),
232
+ mergeMain: Boolean(options.mergeMain),
233
+ rebase: Boolean(options.rebase),
234
+ noVerify: Boolean(options.noVerify)
235
+ },
236
+ summary
134
237
  })
238
+ paused = true
239
+ throw new IcodeError(
240
+ `同步 ${branchName} 时发生冲突,已停在该分支。\n解决冲突后执行 \`icode continue\` 接着同步剩下的分支,或执行 \`icode abort\` 放弃并切回 ${originalBranch}。`,
241
+ { code: 'SYNC_CONFLICT_PAUSED', exitCode: 2, cause: error, meta: { branch: branchName, summary } }
242
+ )
135
243
  }
136
-
137
- summary.push({
138
- branch: branchName,
139
- status: options.push ? 'synced-and-pushed' : 'synced'
140
- })
141
244
  }
142
245
  } finally {
143
- if (originalBranch) {
246
+ if (originalBranch && !paused) {
144
247
  try {
145
248
  await git.checkout(originalBranch)
146
249
  } catch (error) {
147
- logger.warn(`未能自动切回原分支 ${originalBranch}: ${error.message}`)
250
+ logger.warn(`未能自动切回原分支 ${ui.branch(originalBranch)}: ${error.message}`)
148
251
  }
149
252
  }
150
253
  }
151
254
 
152
- return {
153
- repoRoot: context.topLevelPath,
154
- targets,
155
- summary
156
- }
255
+ return summary
157
256
  }
@@ -1,7 +1,8 @@
1
1
  import { IcodeError } from '../core/errors.js'
2
2
  import { resolveGitContext } from '../core/git/context.js'
3
+ import { bumpVersion, findLatestVersionTag, formatVersion, inferBump } from '../core/git/semver.js'
3
4
  import { GitService } from '../core/git/service.js'
4
- import { input } from '../core/tools/interactive.js'
5
+ import { chooseOne, input, isInteractiveTerminal } from '../core/tools/interactive.js'
5
6
  import { logger } from '../core/tools/logger.js'
6
7
 
7
8
  function todayStamp() {
@@ -24,19 +25,78 @@ function nextDailyTag(existingTags, dateStamp) {
24
25
  return `${prefix}${next}`
25
26
  }
26
27
 
28
+ /**
29
+ * 计算下一个语义化版本 tag。
30
+ * 输入为 GitService 与 { bump, preid, fromRef, prefix },输出 { tagName, previousTag, bump, commits }。
31
+ * bump=auto 时按 Conventional Commits 推断(破坏性 -> major,feat -> minor,其它 -> patch)。
32
+ */
33
+ export async function resolveNextSemverTag(git, options = {}) {
34
+ const tags = await git.listTagsSorted()
35
+ const latest = findLatestVersionTag(tags, { prefix: options.prefix, includePrerelease: options.bump === 'prerelease' })
36
+ const headRef = options.fromRef || 'HEAD'
37
+ const range = latest ? `${latest.tag}..${headRef}` : headRef
38
+ const commits = await git.listCommitMessages(range)
39
+ const bump = options.bump === 'auto' ? inferBump(commits, latest?.version) : options.bump
40
+ const next = bumpVersion(latest?.version || { prefix: options.prefix ?? 'v', major: 0, minor: 0, patch: 0, prerelease: '' }, bump, options.preid)
41
+
42
+ return {
43
+ tagName: formatVersion(next, latest?.version.prefix ?? options.prefix ?? 'v'),
44
+ previousTag: latest?.tag || '',
45
+ bump,
46
+ commits
47
+ }
48
+ }
49
+
27
50
  export async function runTagWorkflow(options) {
28
51
  const context = await resolveGitContext({
29
52
  cwd: options.cwd,
30
- repoMode: options.repoMode
53
+ repoMode: options.repoMode,
54
+ remote: options.remote
31
55
  })
32
56
 
33
57
  const git = new GitService(context)
34
- const dateStamp = todayStamp()
35
- const candidateTags = await git.listTags(`v${dateStamp}_*`)
58
+ let tagName = options.tagName?.trim() || ''
59
+ let semver = null
60
+
61
+ if (!tagName && options.bump) {
62
+ await git.exec(['fetch', git.remote, '--tags'], { allowFailure: true })
63
+ semver = await resolveNextSemverTag(git, options)
64
+ tagName = semver.tagName
65
+ }
66
+
67
+ if (!tagName) {
68
+ const dateStamp = todayStamp()
69
+ const candidateTags = await git.listTags(`v${dateStamp}_*`)
70
+ tagName = nextDailyTag(candidateTags, dateStamp)
71
+ }
36
72
 
37
- let tagName = options.tagName?.trim() || nextDailyTag(candidateTags, dateStamp)
38
- if (!options.tagName) {
39
- tagName = (await input('请输入 tag 名称', tagName)).trim() || tagName
73
+ // 交互终端里没有指定名字和 --bump 时,列出候选 tag 让用户直接选,不用手输。
74
+ if (!options.tagName && !options.bump && !options.yes && !options.dryRun && isInteractiveTerminal()) {
75
+ await git.exec(['fetch', git.remote, '--tags'], { allowFailure: true })
76
+ const choices = []
77
+ const seen = new Set()
78
+ for (const bump of ['auto', 'patch', 'minor', 'major']) {
79
+ const next = await resolveNextSemverTag(git, { ...options, bump })
80
+ if (seen.has(next.tagName)) {
81
+ continue
82
+ }
83
+ seen.add(next.tagName)
84
+ choices.push({
85
+ value: next.tagName,
86
+ label: next.tagName,
87
+ hint: bump === 'auto' ? `按提交推断(${next.bump})${next.previousTag ? ` · 上一个 ${next.previousTag}` : ''}` : bump
88
+ })
89
+ if (bump === 'auto') {
90
+ semver = next
91
+ }
92
+ }
93
+ choices.push({ value: tagName, label: tagName, hint: '日期命名' })
94
+ choices.push({ value: '__custom__', label: '自己输入…' })
95
+ const picked = await chooseOne('选择 tag', choices, 0, { filter: false })
96
+ tagName = picked === '__custom__' ? (await input('tag 名称', '')).trim() : picked
97
+ if (semver && tagName !== semver.tagName) {
98
+ semver = null
99
+ }
40
100
  }
41
101
 
42
102
  if (!tagName) {
@@ -46,19 +106,45 @@ export async function runTagWorkflow(options) {
46
106
  })
47
107
  }
48
108
 
109
+ if (await git.tagExists(tagName)) {
110
+ throw new IcodeError(`tag 已存在: ${tagName}`, { code: 'TAG_EXISTS', exitCode: 2 })
111
+ }
112
+
49
113
  const message = options.message?.trim() || `release: ${tagName}`
114
+ const result = {
115
+ repoRoot: context.topLevelPath,
116
+ tagName,
117
+ message,
118
+ fromRef: options.fromRef || 'HEAD',
119
+ previousTag: semver?.previousTag || '',
120
+ bump: semver?.bump || '',
121
+ pushed: false
122
+ }
50
123
 
51
- logger.info(`创建 tag: ${tagName}`)
52
- await git.createAnnotatedTag(tagName, message, options.fromRef)
124
+ if (options.dryRun) {
125
+ return { ...result, dryRun: true }
126
+ }
53
127
 
54
- logger.info(`推送 tag: ${tagName}`)
55
- await git.pushTag(tagName, {
56
- noVerify: options.noVerify
57
- })
128
+ if (!options.silentLog) {
129
+ logger.info(`创建 tag: ${tagName}`)
130
+ }
131
+ await git.createAnnotatedTag(tagName, message, options.fromRef)
58
132
 
59
- return {
60
- repoRoot: context.topLevelPath,
61
- tagName,
62
- message
133
+ if (!options.noPush) {
134
+ if (!options.silentLog) {
135
+ logger.info(`推送 tag: ${tagName}`)
136
+ }
137
+ try {
138
+ await git.pushTag(tagName, {
139
+ noVerify: options.noVerify
140
+ })
141
+ } catch (error) {
142
+ // 推送失败时删掉本地 tag,避免下次重试提示“已存在”。
143
+ await git.deleteTag(tagName)
144
+ throw error
145
+ }
146
+ result.pushed = true
63
147
  }
148
+
149
+ return result
64
150
  }