@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.
- package/README.md +287 -24
- package/bin/icode.js +10 -0
- package/package.json +3 -3
- package/src/cli.js +175 -8
- package/src/commands/checkout.js +73 -6
- package/src/commands/clean.js +47 -7
- package/src/commands/config-ux.js +292 -0
- package/src/commands/config.js +199 -48
- package/src/commands/continue.js +104 -0
- package/src/commands/doctor.js +15 -29
- package/src/commands/help.js +54 -34
- package/src/commands/history.js +147 -0
- package/src/commands/info.js +39 -4
- package/src/commands/log.js +142 -0
- package/src/commands/migrate.js +19 -12
- package/src/commands/open.js +138 -0
- package/src/commands/pr.js +219 -0
- package/src/commands/push.js +62 -22
- package/src/commands/ready.js +18 -48
- package/src/commands/release.js +112 -0
- package/src/commands/rescue.js +164 -0
- package/src/commands/save.js +52 -11
- package/src/commands/stash.js +210 -0
- package/src/commands/sw.js +89 -0
- package/src/commands/sync.js +19 -2
- package/src/commands/tag.js +40 -1
- package/src/commands/undo.js +9 -3
- package/src/core/ai/diff-range.js +1 -1
- package/src/core/cli/completion.js +237 -1
- package/src/core/cli/output.js +46 -0
- package/src/core/cli/render.js +121 -0
- package/src/core/commands/shell.js +9 -1
- package/src/core/config/repo-config.js +93 -0
- package/src/core/config/store.js +60 -10
- package/src/core/git/autostash.js +46 -0
- package/src/core/git/backup.js +170 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/context.js +72 -5
- package/src/core/git/file-guard.js +193 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/git/semver.js +194 -0
- package/src/core/git/service.js +454 -24
- package/src/core/platform/http.js +53 -0
- package/src/core/platform/index.js +165 -0
- package/src/core/platform/providers.js +454 -0
- package/src/core/platform/remote-url.js +92 -0
- package/src/core/remote-merge/client.js +33 -3
- package/src/core/tools/browser.js +28 -0
- package/src/core/tools/commit-prompt.js +137 -0
- package/src/core/tools/interactive.js +272 -40
- package/src/core/tools/logger.js +33 -23
- package/src/core/tools/theme.js +228 -0
- package/src/workflows/ai-commit-workflow.js +52 -20
- package/src/workflows/checkout-workflow.js +24 -10
- package/src/workflows/clean-workflow.js +135 -83
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/doctor-workflow.js +10 -7
- package/src/workflows/history-workflow.js +211 -0
- package/src/workflows/migrate-workflow.js +39 -14
- package/src/workflows/pr-workflow.js +252 -0
- package/src/workflows/push/commit-step.js +123 -0
- package/src/workflows/push/local-merge-step.js +213 -0
- package/src/workflows/push/plan.js +63 -0
- package/src/workflows/push/remote-merge-step.js +224 -0
- package/src/workflows/push-workflow.js +185 -492
- package/src/workflows/ready-workflow.js +14 -10
- package/src/workflows/release-workflow.js +189 -0
- package/src/workflows/rescue-workflow.js +137 -0
- package/src/workflows/save-workflow.js +106 -41
- package/src/workflows/switch-workflow.js +171 -0
- package/src/workflows/sync-workflow.js +143 -44
- package/src/workflows/tag-workflow.js +103 -17
- package/src/workflows/undo-workflow.js +113 -34
- 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
|
|
46
|
+
// 读取迁移交互模式的分支候选,输入 GitService,输出本地与当前 remote 的远程分支名。
|
|
44
47
|
async function listBranchCandidates(git) {
|
|
45
48
|
const [localBranches, remoteBranches] = await Promise.all([
|
|
46
49
|
git.listLocalBranches(),
|
|
47
|
-
git.listRemoteBranches(
|
|
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
|
|
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
|
-
: `从
|
|
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: `同步
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|