@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,211 @@
1
+ import { IcodeError } from '../core/errors.js'
2
+ import { createBackup } from '../core/git/backup.js'
3
+ import { resolveGitContext } from '../core/git/context.js'
4
+ import { assertNoRiskyFiles, inspectPendingFiles } from '../core/git/file-guard.js'
5
+ import { assertNoConflictedFiles, assertNoPendingOperation, assertOnBranch, readGitSafetyState } from '../core/git/preconditions.js'
6
+ import { GitService } from '../core/git/service.js'
7
+ import { chooseOne, confirm, input, isInteractiveTerminal } from '../core/tools/interactive.js'
8
+ import { logger } from '../core/tools/logger.js'
9
+
10
+ // 改写历史类操作:amend(修改上一个提交)与 fixup(把改动合进更早的提交)。
11
+
12
+ async function prepare(options, commandName) {
13
+ const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode, remote: options.remote })
14
+ const git = new GitService(context)
15
+ const state = await readGitSafetyState(git)
16
+ const code = commandName.toUpperCase()
17
+ assertOnBranch(state.currentBranch, commandName, `${code}_DETACHED_HEAD`)
18
+ assertNoPendingOperation(state.operation, commandName, `${code}_GIT_OPERATION_IN_PROGRESS`)
19
+ assertNoConflictedFiles(state.changes, commandName, `${code}_CONFLICTED_FILES`)
20
+ return { context, git, state }
21
+ }
22
+
23
+ /**
24
+ * 判断提交是否已经推送到 upstream(或当前 remote 的同名分支)。
25
+ * 改写已推送的提交后需要强推,这里提前提示。
26
+ */
27
+ async function isCommitPublished(git, sha, branchName) {
28
+ const upstream = (await git.getUpstreamRef()) || (await git.refExists(git.remoteRef(branchName)) ? git.remoteRef(branchName) : '')
29
+ if (!upstream) {
30
+ return { published: false, upstream: '' }
31
+ }
32
+ return { published: await git.isAncestor(sha, upstream), upstream }
33
+ }
34
+
35
+ async function confirmRewritePublished(options, upstream, what) {
36
+ const message = `${what}已推送到 ${upstream},改写后需要执行 icode push --force 才能更新远程。`
37
+ if (options.yes) {
38
+ logger.warn(message)
39
+ return true
40
+ }
41
+ if (!isInteractiveTerminal()) {
42
+ throw new IcodeError(`${message} 确认要继续请加 -y。`, { code: 'HISTORY_REWRITE_PUBLISHED', exitCode: 2 })
43
+ }
44
+ return confirm(`${message} 是否继续?`, false)
45
+ }
46
+
47
+ // 按选项暂存改动:-a 全部,传路径只暂存这些路径,否则只用已暂存的改动。
48
+ async function stageForRewrite(git, context, options) {
49
+ const paths = (options.paths || []).filter(Boolean)
50
+ const mode = options.all ? 'all' : paths.length ? 'paths' : 'staged'
51
+ const { findings } = await inspectPendingFiles(git, context, { mode: mode === 'paths' ? 'all' : mode, paths })
52
+ assertNoRiskyFiles(findings, options.commandName, `${options.commandName.toUpperCase()}_RISKY_FILES`, options.allowRisky === true)
53
+
54
+ if (mode === 'all') {
55
+ await git.stageAll()
56
+ } else if (mode === 'paths') {
57
+ await git.stagePaths(paths)
58
+ }
59
+ return git.hasStagedChanges()
60
+ }
61
+
62
+ /**
63
+ * 修改上一个提交:可追加改动(暂存区 / -a / 路径)和/或改提交信息。
64
+ * 输出 { commitId, previousCommitId, published, backupId }。
65
+ */
66
+ export async function runAmendWorkflow(options = {}) {
67
+ const { context, git, state } = await prepare(options, 'amend')
68
+ const headSha = await git.revParse('HEAD')
69
+ if (!headSha) {
70
+ throw new IcodeError('仓库还没有提交,无法 amend。', { code: 'AMEND_NO_COMMIT', exitCode: 2 })
71
+ }
72
+
73
+ const { published, upstream } = await isCommitPublished(git, headSha, state.currentBranch)
74
+ if (published && !(await confirmRewritePublished(options, upstream, '上一个提交'))) {
75
+ return { canceled: true }
76
+ }
77
+
78
+ // 交互终端里没给 -a / 路径 / -m 且暂存区为空时,直接让用户选要做什么。
79
+ if (options.interactive && !options.all && !(options.paths || []).length && !String(options.message || '').trim() && !(await git.hasStagedChanges())) {
80
+ const current = await git.getCommitMessage('HEAD')
81
+ const hasChanges = !state.changes.clean
82
+ const action = await chooseOne('要怎么修改上一个提交?', [
83
+ ...(hasChanges ? [{ value: 'all', label: '追加全部未提交改动', hint: `${state.changes.total} 个文件` }] : []),
84
+ { value: 'reword', label: '只改提交信息', hint: current?.subject || '' },
85
+ ...(hasChanges ? [{ value: 'both', label: '追加改动并修改提交信息' }] : []),
86
+ { value: 'cancel', label: '取消' }
87
+ ], 0, { filter: false })
88
+ if (action === 'cancel') {
89
+ return { canceled: true }
90
+ }
91
+ if (action === 'all' || action === 'both') {
92
+ options = { ...options, all: true }
93
+ }
94
+ if (action === 'reword' || action === 'both') {
95
+ options = { ...options, message: await input('新的提交信息', current?.subject || '') }
96
+ }
97
+ }
98
+
99
+ const hasStaged = await stageForRewrite(git, context, { ...options, commandName: 'amend' })
100
+ const newMessage = String(options.message || '').trim()
101
+ if (!hasStaged && !newMessage) {
102
+ throw new IcodeError('没有要追加的改动,也没有新的提交信息。用 -a 追加全部改动,或 -m 修改提交信息。', {
103
+ code: 'AMEND_NOTHING_TO_DO',
104
+ exitCode: 2
105
+ })
106
+ }
107
+
108
+ const previous = await git.getCommitMessage('HEAD')
109
+ const message = newMessage || [previous?.subject, previous?.body].filter(Boolean).join('\n\n')
110
+ const backup = await createBackup(git, { reason: `amend ${state.currentBranch}`, silent: options.silentLog })
111
+
112
+ await git.commit(message, { amend: true, noVerify: options.noVerify })
113
+
114
+ return {
115
+ branch: state.currentBranch,
116
+ previousCommitId: headSha.slice(0, 8),
117
+ commitId: await git.revParseShort('HEAD'),
118
+ message,
119
+ published,
120
+ needsForcePush: published,
121
+ backupId: backup.entries.length ? backup.id : '',
122
+ repoRoot: context.topLevelPath
123
+ }
124
+ }
125
+
126
+ // fixup 候选:当前分支上还没进入主分支的提交(不含 merge commit)。
127
+ async function listFixupCandidates(git, context) {
128
+ const baseCandidates = [git.remoteRef(context.defaultBranch), context.defaultBranch]
129
+ let range = ''
130
+ for (const base of baseCandidates) {
131
+ if (base && await git.refExists(base)) {
132
+ range = `${base}..HEAD`
133
+ break
134
+ }
135
+ }
136
+ const entries = await git.logEntries({ range: range || 'HEAD', limit: 30, noMerges: true })
137
+ return entries
138
+ }
139
+
140
+ /**
141
+ * 把改动合进更早的某个提交:git commit --fixup + rebase --autosquash(不打开编辑器)。
142
+ * 输入为 { commit, all, paths, noRebase },输出 { target, fixupCommit, rebased, backupId }。
143
+ */
144
+ export async function runFixupWorkflow(options = {}) {
145
+ const { context, git, state } = await prepare(options, 'fixup')
146
+
147
+ let targetRef = String(options.commit || '').trim()
148
+ if (!targetRef) {
149
+ const candidates = await listFixupCandidates(git, context)
150
+ if (!candidates.length) {
151
+ throw new IcodeError('当前分支没有可 fixup 的提交(都已在主分支里)。可以直接指定: icode fixup <commit>', {
152
+ code: 'FIXUP_NO_CANDIDATES',
153
+ exitCode: 2
154
+ })
155
+ }
156
+ if (options.yes || !isInteractiveTerminal()) {
157
+ throw new IcodeError('请指定要修正的提交: icode fixup <commit>', { code: 'FIXUP_TARGET_REQUIRED', exitCode: 2 })
158
+ }
159
+ targetRef = await chooseOne('把改动合进哪个提交?', candidates.map((item) => ({
160
+ value: item.hash,
161
+ label: `${item.shortHash} ${item.relativeDate} ${item.subject}`
162
+ })), 0)
163
+ }
164
+
165
+ const targetSha = await git.revParse(targetRef)
166
+ if (!targetSha) {
167
+ throw new IcodeError(`提交不存在: ${targetRef}`, { code: 'FIXUP_TARGET_NOT_FOUND', exitCode: 2 })
168
+ }
169
+ if (!(await git.isAncestor(targetSha, 'HEAD'))) {
170
+ throw new IcodeError(`${targetRef} 不在当前分支历史上。`, { code: 'FIXUP_TARGET_NOT_ANCESTOR', exitCode: 2 })
171
+ }
172
+
173
+ const { published, upstream } = await isCommitPublished(git, targetSha, state.currentBranch)
174
+ if (published && !options.noRebase && !(await confirmRewritePublished(options, upstream, '目标提交'))) {
175
+ return { canceled: true }
176
+ }
177
+
178
+ const hasStaged = await stageForRewrite(git, context, { ...options, commandName: 'fixup' })
179
+ if (!hasStaged) {
180
+ throw new IcodeError('暂存区没有改动。先 git add,或用 -a 暂存全部改动。', { code: 'FIXUP_NOTHING_STAGED', exitCode: 2 })
181
+ }
182
+
183
+ const backup = await createBackup(git, { reason: `fixup ${targetSha.slice(0, 8)} on ${state.currentBranch}`, silent: options.silentLog })
184
+ await git.commitFixup(targetSha, { noVerify: options.noVerify })
185
+ const fixupCommit = await git.revParseShort('HEAD')
186
+
187
+ if (options.noRebase) {
188
+ return { target: targetSha.slice(0, 8), fixupCommit, rebased: false, backupId: backup.id, repoRoot: context.topLevelPath }
189
+ }
190
+
191
+ const parentSha = await git.revParse(`${targetSha}^`)
192
+ try {
193
+ await git.rebaseOnto(parentSha, { autosquash: true, autostash: true, root: !parentSha })
194
+ } catch (error) {
195
+ throw new IcodeError(
196
+ 'fixup 自动整理提交时发生冲突。解决冲突后执行 `icode continue`,' +
197
+ `或执行 \`icode abort\` 放弃(之前的状态在恢复点 ${backup.id})。`,
198
+ { code: 'FIXUP_REBASE_CONFLICT', exitCode: 2, cause: error, meta: error.meta }
199
+ )
200
+ }
201
+
202
+ return {
203
+ target: targetSha.slice(0, 8),
204
+ fixupCommit,
205
+ rebased: true,
206
+ published,
207
+ needsForcePush: published,
208
+ backupId: backup.entries.length ? backup.id : '',
209
+ repoRoot: context.topLevelPath
210
+ }
211
+ }
@@ -1,4 +1,7 @@
1
1
  import { IcodeError } from '../core/errors.js'
2
+ import { ui, shortenHome } from '../core/tools/theme.js'
3
+ import { createBackup } from '../core/git/backup.js'
4
+ import { saveOperationState } from '../core/git/operation-state.js'
2
5
  import { resolveGitContext } from '../core/git/context.js'
3
6
  import { GitService } from '../core/git/service.js'
4
7
  import { chooseMany, chooseOne, isInteractiveTerminal } from '../core/tools/interactive.js'
@@ -40,11 +43,11 @@ function uniqueBranches(branches) {
40
43
  return Array.from(new Set((branches || []).map((item) => item.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b))
41
44
  }
42
45
 
43
- // 读取迁移交互模式的分支候选,输入 GitService,输出本地与 origin 远程分支名。
46
+ // 读取迁移交互模式的分支候选,输入 GitService,输出本地与当前 remote 的远程分支名。
44
47
  async function listBranchCandidates(git) {
45
48
  const [localBranches, remoteBranches] = await Promise.all([
46
49
  git.listLocalBranches(),
47
- git.listRemoteBranches('origin')
50
+ git.listRemoteBranches()
48
51
  ])
49
52
  return uniqueBranches([...localBranches, ...remoteBranches])
50
53
  }
@@ -324,13 +327,13 @@ async function assertMigratePreconditions(git) {
324
327
  }
325
328
 
326
329
  // 将本地/远程分支存在性解析为可用于 rev-list 的 ref,输入分支名和存在性,输出明确 ref。
327
- function resolveBranchRef(branchName, existsLocal, existsRemote, label) {
330
+ function resolveBranchRef(branchName, existsLocal, existsRemote, label, remoteName = 'origin') {
328
331
  if (existsLocal) {
329
332
  return branchName
330
333
  }
331
334
 
332
335
  if (existsRemote) {
333
- return `origin/${branchName}`
336
+ return `${remoteName}/${branchName}`
334
337
  }
335
338
 
336
339
  throw new IcodeError(`${label} 分支不存在: ${branchName}`, {
@@ -360,14 +363,14 @@ function buildMigrateSteps(plan) {
360
363
  action: 'checkout-target',
361
364
  detail: plan.targetExistsLocal
362
365
  ? `切换到本地目标分支 ${plan.targetBranch}`
363
- : `从 origin/${plan.targetBranch} 创建 tracking 分支`
366
+ : `从 ${plan.remote}/${plan.targetBranch} 创建 tracking 分支`
364
367
  }
365
368
  ]
366
369
 
367
370
  if (plan.targetExistsRemote) {
368
371
  steps.push({
369
372
  action: 'pull-target',
370
- detail: `同步 origin/${plan.targetBranch} 到目标分支`
373
+ detail: `同步 ${plan.remote}/${plan.targetBranch} 到目标分支`
371
374
  })
372
375
  }
373
376
 
@@ -399,8 +402,8 @@ function logMigratePlan(options, plan) {
399
402
  const previewCommits = plan.commits.slice(0, previewLimit)
400
403
 
401
404
  logInfo(options, '迁移计划:')
402
- logInfo(options, ` source: ${plan.sourceBranch} (${plan.sourceRef})`)
403
- logInfo(options, ` target: ${plan.targetBranch} (${plan.targetRef})`)
405
+ logInfo(options, ` source: ${ui.branch(plan.sourceBranch)} (${plan.sourceRef})`)
406
+ logInfo(options, ` target: ${ui.branch(plan.targetBranch)} (${plan.targetRef})`)
404
407
  logInfo(options, ` range: ${plan.rangeSpec}`)
405
408
  logInfo(options, ` commits: ${plan.migratedCommits}`)
406
409
 
@@ -441,6 +444,7 @@ async function buildMigratePlan(git, values) {
441
444
  targetExistsLocal: values.targetExistsLocal,
442
445
  targetExistsRemote: values.targetExistsRemote,
443
446
  push: values.push,
447
+ remote: values.remote || 'origin',
444
448
  repoRoot: values.repoRoot
445
449
  }
446
450
 
@@ -470,7 +474,7 @@ export async function runMigrateWorkflow(options) {
470
474
  const precheck = await assertMigratePreconditions(git)
471
475
  const originalBranch = precheck.currentBranch
472
476
 
473
- logInfo(options, `仓库根目录: ${context.topLevelPath}`)
477
+ logInfo(options, `仓库 ${shortenHome(context.topLevelPath)}`)
474
478
  if (context.inheritedFromParent) {
475
479
  logWarn(options, '当前目录继承了父级 Git 仓库,命令将基于父仓库根目录执行。')
476
480
  }
@@ -527,11 +531,11 @@ export async function runMigrateWorkflow(options) {
527
531
 
528
532
  const sourceExistsLocal = await git.branchExistsLocal(sourceBranch)
529
533
  const sourceExistsRemote = await git.branchExistsRemote(sourceBranch)
530
- const sourceRef = resolveBranchRef(sourceBranch, sourceExistsLocal, sourceExistsRemote, 'source')
534
+ const sourceRef = resolveBranchRef(sourceBranch, sourceExistsLocal, sourceExistsRemote, 'source', git.remote)
531
535
 
532
536
  const targetExistsLocal = await git.branchExistsLocal(targetBranch)
533
537
  const targetExistsRemote = await git.branchExistsRemote(targetBranch)
534
- const targetRef = resolveBranchRef(targetBranch, targetExistsLocal, targetExistsRemote, 'target')
538
+ const targetRef = resolveBranchRef(targetBranch, targetExistsLocal, targetExistsRemote, 'target', git.remote)
535
539
 
536
540
  // 默认迁移 source 相对 target 的增量提交;交互模式可选择最近 N 条或手动挑选提交。
537
541
  const effectiveRangeSpec = rangeSpec || `${targetRef}..${sourceRef}`
@@ -547,6 +551,7 @@ export async function runMigrateWorkflow(options) {
547
551
  targetExistsLocal,
548
552
  targetExistsRemote,
549
553
  push: Boolean(options.push),
554
+ remote: git.remote,
550
555
  repoRoot: context.topLevelPath
551
556
  })
552
557
 
@@ -608,6 +613,13 @@ export async function runMigrateWorkflow(options) {
608
613
  })
609
614
  }
610
615
 
616
+ // cherry-pick 会改写目标分支,先记录恢复点。
617
+ const backup = await createBackup(git, {
618
+ reason: `migrate ${sourceBranch} -> ${targetBranch}`,
619
+ branches: [targetBranch],
620
+ silent: options.silentLog
621
+ })
622
+
611
623
  if (selectedCommits.length) {
612
624
  logInfo(options, `迁移范围: 手动选择 ${selectedCommits.length} 个提交(mode=${rangeMode})`)
613
625
  } else {
@@ -617,8 +629,19 @@ export async function runMigrateWorkflow(options) {
617
629
  try {
618
630
  await git.cherryPick(commits)
619
631
  } catch (error) {
632
+ if (await git.getInProgressOperation()) {
633
+ saveOperationState(context, {
634
+ command: 'migrate',
635
+ sourceBranch,
636
+ targetBranch,
637
+ originalBranch,
638
+ push: Boolean(options.push),
639
+ targetExistsRemote,
640
+ options: { noVerify: Boolean(options.noVerify) }
641
+ })
642
+ }
620
643
  throw new IcodeError(
621
- '迁移失败: cherry-pick 发生冲突。请先解决冲突后执行 `icode undo --recover continue`,或执行 `icode undo --recover abort` 回滚。',
644
+ `迁移暂停: cherry-pick 发生冲突,已停在 ${targetBranch}。解决冲突后执行 \`icode continue\` 接着迁移剩下的提交${options.push ? '并推送' : ''},或执行 \`icode abort\` 放弃并切回原分支。`,
622
645
  {
623
646
  code: 'MIGRATE_CHERRY_PICK_FAILED',
624
647
  cause: error,
@@ -642,14 +665,16 @@ export async function runMigrateWorkflow(options) {
642
665
  rangeSpec: effectiveRangeSpec,
643
666
  rangeMode,
644
667
  pushed: Boolean(options.push),
668
+ backupId: backup.entries.length ? backup.id : '',
645
669
  repoRoot: context.topLevelPath
646
670
  }
647
671
  } finally {
648
- if (originalBranch && originalBranch !== targetBranch) {
672
+ // 冲突暂停时留在目标分支上,方便直接解决冲突。
673
+ if (originalBranch && originalBranch !== targetBranch && !(await git.getInProgressOperation())) {
649
674
  try {
650
675
  await git.checkout(originalBranch)
651
676
  } catch (error) {
652
- logWarn(options, `未能自动切回原分支 ${originalBranch}: ${error.message}`)
677
+ logWarn(options, `未能自动切回原分支 ${ui.branch(originalBranch)}: ${error.message}`)
653
678
  }
654
679
  }
655
680
  }
@@ -0,0 +1,252 @@
1
+ import { askAiJson } from '../core/ai/client.js'
2
+ import { buildLimitedDiffPrompt } from '../core/ai/diff-prompt.js'
3
+ import { IcodeError } from '../core/errors.js'
4
+ import { resolveGitContext } from '../core/git/context.js'
5
+ import { GitService } from '../core/git/service.js'
6
+ import { createPlatformClientForGit } from '../core/platform/index.js'
7
+ import { chooseOne, input } from '../core/tools/interactive.js'
8
+ import { logger } from '../core/tools/logger.js'
9
+ import { c, print, ui } from '../core/tools/theme.js'
10
+
11
+ async function openContext(options) {
12
+ const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode, remote: options.remote })
13
+ const git = new GitService(context)
14
+ const client = await createPlatformClientForGit(git, { provider: options.provider })
15
+ return { context, git, client }
16
+ }
17
+
18
+ function parsePrNumber(value) {
19
+ const raw = String(value || '').trim().replace(/^[#!]/, '')
20
+ const number = Number(raw)
21
+ if (!Number.isInteger(number) || number <= 0) {
22
+ throw new IcodeError(`无效的 PR/MR 编号: ${value}`, { code: 'PR_NUMBER_INVALID', exitCode: 2 })
23
+ }
24
+ return number
25
+ }
26
+
27
+ // 计算 PR 的基线 ref:优先远程基线分支,没有时回退本地。
28
+ async function resolveBaseRef(git, base) {
29
+ const remoteBase = git.remoteRef(base)
30
+ if (await git.refExists(remoteBase)) {
31
+ return remoteBase
32
+ }
33
+ if (await git.branchExistsLocal(base)) {
34
+ return base
35
+ }
36
+ throw new IcodeError(`基线分支不存在: ${base}`, { code: 'PR_BASE_MISSING', exitCode: 2 })
37
+ }
38
+
39
+ /**
40
+ * 根据提交生成 PR 标题与正文:单个提交直接用它的标题/正文,多个提交列出提交清单。
41
+ */
42
+ export function buildPrContentFromCommits(commits = [], headBranch = '') {
43
+ if (!commits.length) {
44
+ return { title: headBranch, body: '' }
45
+ }
46
+ if (commits.length === 1) {
47
+ return { title: commits[0].subject, body: commits[0].body || '' }
48
+ }
49
+ const latest = commits[commits.length - 1]
50
+ return {
51
+ title: latest.subject,
52
+ body: ['## 变更', '', ...commits.map((commit) => `- ${commit.subject}`)].join('\n')
53
+ }
54
+ }
55
+
56
+ async function buildAiPrContent(git, baseRef, commits, options) {
57
+ const diff = await git.exec(['diff', `${baseRef}...HEAD`], { allowFailure: true })
58
+ const limited = buildLimitedDiffPrompt(diff.stdout || '', { limit: 12000 })
59
+ const language = (options.lang || 'zh') === 'en' ? 'English' : 'Chinese'
60
+ const { parsed } = await askAiJson({
61
+ systemPrompt: `You write pull request descriptions for code review. Output JSON only. Language: ${language}.`,
62
+ userPrompt: 'Return JSON {"title":"one-line title (Conventional Commit style if commits use it)","body":"markdown with sections: 背景/改动/测试 (or Background/Changes/Testing)"}.\n\n' +
63
+ `Commits:\n${commits.map((commit) => `- ${commit.subject}`).join('\n')}\n\nDiff:\n${limited.diff}`
64
+ }, { profile: options.profile })
65
+ return {
66
+ title: String(parsed?.title || '').trim(),
67
+ body: String(parsed?.body || '').trim()
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 创建 PR/MR:必要时先推送当前分支,再调用平台 API;没有 token 时返回网页创建链接。
73
+ * 输入为 { base, title, body, draft, noPush, ai },输出 { pr, created, existed, webUrl, pushed }。
74
+ */
75
+ export async function runPrCreateWorkflow(options = {}) {
76
+ const { context, git, client } = await openContext(options)
77
+ const head = await git.getCurrentBranch()
78
+ if (!head) {
79
+ throw new IcodeError('当前不在分支上,无法创建 PR。', { code: 'PR_DETACHED_HEAD', exitCode: 2 })
80
+ }
81
+ let base = String(options.base || context.defaultBranch).trim()
82
+ // 交互终端里没指定目标分支、且存在常见的集成分支时,让用户选一下。
83
+ if (!options.base && options.interactive) {
84
+ const candidates = []
85
+ for (const name of [context.defaultBranch, 'develop', 'dev', 'test', 'release', 'staging']) {
86
+ if (name && name !== head && !candidates.includes(name) && (await git.refExists(git.remoteRef(name)))) {
87
+ candidates.push(name)
88
+ }
89
+ }
90
+ if (candidates.length > 1) {
91
+ base = await chooseOne(`合并到哪个分支?`, candidates.map((name) => ({ value: name, label: name, hint: name === context.defaultBranch ? '主分支' : '' })), 0, { filter: false })
92
+ }
93
+ }
94
+ if (head === base) {
95
+ throw new IcodeError(`当前分支就是基线分支 ${base},请在功能分支上创建 PR。`, { code: 'PR_HEAD_IS_BASE', exitCode: 2 })
96
+ }
97
+
98
+ await git.fetch()
99
+ const baseRef = await resolveBaseRef(git, base)
100
+ const commits = await git.listCommitMessages(`${baseRef}..HEAD`)
101
+ if (!commits.length) {
102
+ throw new IcodeError(`${head} 相对 ${baseRef} 没有新提交,无需创建 PR。`, { code: 'PR_NO_COMMITS', exitCode: 2 })
103
+ }
104
+
105
+ // 平台只能看到远程分支,所以先确保当前分支已推送且是最新。
106
+ let pushed = false
107
+ const remoteSha = await git.getRemoteBranchSha(head)
108
+ const localSha = await git.revParse('HEAD')
109
+ if (remoteSha !== localSha) {
110
+ if (options.noPush) {
111
+ throw new IcodeError(`${git.remoteRef(head)} 不是最新,请先推送(或去掉 --no-push)。`, { code: 'PR_HEAD_NOT_PUSHED', exitCode: 2 })
112
+ }
113
+ if (remoteSha && !(await git.isAncestor(remoteSha, 'HEAD'))) {
114
+ throw new IcodeError(`本地 ${head} 与远程已分叉,请先同步或执行 icode push --force。`, { code: 'PR_HEAD_DIVERGED', exitCode: 2 })
115
+ }
116
+ logger.info(`推送 ${head} 到 ${git.remote}`)
117
+ await git.push(head, { setUpstream: !remoteSha, noVerify: options.noVerify })
118
+ pushed = true
119
+ }
120
+
121
+ let content = buildPrContentFromCommits(commits, head)
122
+ if (options.ai) {
123
+ const aiContent = await buildAiPrContent(git, baseRef, commits, options)
124
+ content = { title: aiContent.title || content.title, body: aiContent.body || content.body }
125
+ }
126
+ let title = String(options.title || content.title).trim()
127
+ let body = options.body != null ? String(options.body) : content.body
128
+ let draft = Boolean(options.draft)
129
+ let web = Boolean(options.web)
130
+
131
+ // 创建前确认:直接创建 / 改标题 / 草稿 / 去网页创建,不用重新敲命令。
132
+ if (options.interactive && client.hasToken && !web) {
133
+ for (;;) {
134
+ print('')
135
+ print(` ${c.bold(ui.subject(title))} ${c.gray(`${head} → ${base} · ${commits.length} 个提交`)}`)
136
+ body.split('\n').filter(Boolean).slice(0, 8).forEach((line) => print(` ${c.gray(line)}`))
137
+ print('')
138
+ const action = await chooseOne(`创建 ${client.prNoun}?`, [
139
+ { value: 'create', label: draft ? '创建草稿' : '创建' },
140
+ { value: 'title', label: '修改标题' },
141
+ { value: 'draft', label: draft ? '改为正式' : '改为草稿' },
142
+ ...(options.ai ? [] : [{ value: 'ai', label: 'AI 生成标题和描述' }]),
143
+ { value: 'web', label: '在浏览器中继续编辑' },
144
+ { value: 'cancel', label: '取消' }
145
+ ], 0, { filter: false })
146
+ if (action === 'create') {
147
+ break
148
+ }
149
+ if (action === 'cancel') {
150
+ return { created: false, canceled: true, head, base, pushed, provider: client.name }
151
+ }
152
+ if (action === 'title') {
153
+ title = (await input('标题', title)).trim() || title
154
+ } else if (action === 'draft') {
155
+ draft = !draft
156
+ } else if (action === 'ai') {
157
+ const aiContent = await buildAiPrContent(git, baseRef, commits, options)
158
+ title = aiContent.title || title
159
+ body = aiContent.body || body
160
+ } else if (action === 'web') {
161
+ web = true
162
+ break
163
+ }
164
+ }
165
+ }
166
+
167
+ if (web || !client.hasToken) {
168
+ return {
169
+ created: false,
170
+ reason: client.hasToken ? 'web' : 'no-token',
171
+ webUrl: client.urls.newPr(base, head),
172
+ title,
173
+ body,
174
+ head,
175
+ base,
176
+ pushed,
177
+ provider: client.name
178
+ }
179
+ }
180
+
181
+ const pr = await client.createPullRequest({ title, body, head, base, draft })
182
+ return {
183
+ created: !pr.existed,
184
+ existed: Boolean(pr.existed),
185
+ pr,
186
+ webUrl: pr.url || client.urls.pr(pr.number),
187
+ head,
188
+ base,
189
+ pushed,
190
+ provider: client.name
191
+ }
192
+ }
193
+
194
+ export async function runPrListWorkflow(options = {}) {
195
+ const { client } = await openContext(options)
196
+ const items = await client.listPullRequests({ state: options.state || 'open', base: options.base, head: options.head, limit: options.limit })
197
+ return { provider: client.name, prNoun: client.prNoun, items, webUrl: client.urls.prs() }
198
+ }
199
+
200
+ // 未指定编号时查找当前分支对应的 PR。
201
+ async function resolvePrNumberForCurrentBranch(git, client) {
202
+ const head = await git.getCurrentBranch()
203
+ const [pr] = await client.listPullRequests({ head, state: 'open' })
204
+ if (!pr) {
205
+ throw new IcodeError(`当前分支 ${head} 没有打开的 ${client.prNoun}。创建: icode pr create`, { code: 'PR_NOT_FOUND', exitCode: 2 })
206
+ }
207
+ return pr.number
208
+ }
209
+
210
+ export async function runPrViewWorkflow(options = {}) {
211
+ const { git, client } = await openContext(options)
212
+ const number = options.number ? parsePrNumber(options.number) : await resolvePrNumberForCurrentBranch(git, client)
213
+ const pr = await client.getPullRequest(number)
214
+ return { provider: client.name, prNoun: client.prNoun, pr }
215
+ }
216
+
217
+ /**
218
+ * 把 PR/MR 检出到本地分支 pr/<编号>(fork 来的 PR 也能用)。
219
+ */
220
+ export async function runPrCheckoutWorkflow(options = {}) {
221
+ const { context, git, client } = await openContext(options)
222
+ const number = parsePrNumber(options.number)
223
+ const localBranch = options.branch || `pr/${number}`
224
+ const current = await git.getCurrentBranch()
225
+
226
+ if (current === localBranch) {
227
+ await git.exec(['pull', git.remote, client.prRef(number)])
228
+ } else {
229
+ await git.exec(['fetch', git.remote, `+${client.prRef(number)}:refs/heads/${localBranch}`])
230
+ await git.switchBranch(localBranch)
231
+ }
232
+
233
+ return { number, branch: localBranch, provider: client.name, repoRoot: context.topLevelPath }
234
+ }
235
+
236
+ export async function runPrMergeWorkflow(options = {}) {
237
+ const { git, client } = await openContext(options)
238
+ const number = options.number ? parsePrNumber(options.number) : await resolvePrNumberForCurrentBranch(git, client)
239
+ const pr = await client.getPullRequest(number)
240
+ if (pr.merged) {
241
+ return { number, merged: true, alreadyMerged: true, pr }
242
+ }
243
+ const result = await client.mergePullRequest(number, { method: options.method })
244
+ if (!result.ok) {
245
+ throw new IcodeError(`${client.prNoun} #${number} 合并失败${result.conflict ? '(存在冲突或不满足合并条件)' : ''}: ${result.reason}`, {
246
+ code: 'PR_MERGE_FAILED',
247
+ exitCode: 2,
248
+ meta: result
249
+ })
250
+ }
251
+ return { number, merged: true, pr: { ...pr, merged: true, state: 'merged' }, reason: result.reason }
252
+ }