@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,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
|
-
|
|
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.
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
if (!options.tagName) {
|
|
39
|
-
|
|
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
|
-
|
|
52
|
-
|
|
124
|
+
if (options.dryRun) {
|
|
125
|
+
return { ...result, dryRun: true }
|
|
126
|
+
}
|
|
53
127
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
128
|
+
if (!options.silentLog) {
|
|
129
|
+
logger.info(`创建 tag: ${tagName}`)
|
|
130
|
+
}
|
|
131
|
+
await git.createAnnotatedTag(tagName, message, options.fromRef)
|
|
58
132
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
}
|