@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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { resolveRemoteMergePlatform } from './push/remote-merge-step.js'
|
|
2
|
+
import { describeOperationState, loadOperationState } from '../core/git/operation-state.js'
|
|
1
3
|
import { getAiConfig } from '../core/ai/config.js'
|
|
2
|
-
import {
|
|
4
|
+
import { getRepoPolicy } from '../core/config/store.js'
|
|
3
5
|
import { resolveGitContext } from '../core/git/context.js'
|
|
4
6
|
import { GitService } from '../core/git/service.js'
|
|
5
7
|
|
|
@@ -94,17 +96,19 @@ function checkCurrentBranch(currentBranch) {
|
|
|
94
96
|
* 检查是否存在未完成的 Git 操作。
|
|
95
97
|
* 输入为 operation 字符串,输出 ready 检查项。
|
|
96
98
|
*/
|
|
97
|
-
function checkInProgressOperation(operation) {
|
|
98
|
-
|
|
99
|
+
function checkInProgressOperation(operation, pausedState) {
|
|
100
|
+
const paused = describeOperationState(pausedState)
|
|
101
|
+
if (!operation && !pausedState) {
|
|
99
102
|
return buildCheck('git.operation', 'pass', '没有未完成的 Git 操作', '工作区未处于 merge/rebase/cherry-pick/revert 流程中。')
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
const detail = [operation ? `当前操作: ${operation}` : '', paused ? `被中断的 icode 命令: ${paused}` : ''].filter(Boolean).join(';')
|
|
102
106
|
return buildCheck(
|
|
103
107
|
'git.operation',
|
|
104
108
|
'fail',
|
|
105
|
-
'
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
'有被中断的操作',
|
|
110
|
+
detail,
|
|
111
|
+
'解决冲突后执行 icode continue 接着完成;或执行 icode abort 放弃并切回原分支。'
|
|
108
112
|
)
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -218,8 +222,8 @@ function checkRemoteMerge(remoteMergePlatform, repoPolicy) {
|
|
|
218
222
|
'remoteMerge.config',
|
|
219
223
|
'warn',
|
|
220
224
|
'远程合并配置不完整',
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
remoteMergePlatform.provider ? `未找到 ${remoteMergePlatform.provider} token,push -r 不可用。` : '无法从 remote 识别代码托管平台,push -r 不可用。',
|
|
226
|
+
`如需远程合并,请执行: icode config platform token set ${remoteMergePlatform.provider || '<provider>'} <token>`
|
|
223
227
|
)
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -244,11 +248,11 @@ export async function runReadyWorkflow(options = {}) {
|
|
|
244
248
|
])
|
|
245
249
|
const aiConfig = getAiConfig()
|
|
246
250
|
const repoPolicy = getRepoPolicy(context.topLevelPath)
|
|
247
|
-
const remoteMergePlatform =
|
|
251
|
+
const remoteMergePlatform = await resolveRemoteMergePlatform(git)
|
|
248
252
|
|
|
249
253
|
const checks = [
|
|
250
254
|
checkCurrentBranch(currentBranch),
|
|
251
|
-
checkInProgressOperation(operation),
|
|
255
|
+
checkInProgressOperation(operation, loadOperationState(context)),
|
|
252
256
|
checkChanges(changes),
|
|
253
257
|
checkUpstream(upstream),
|
|
254
258
|
checkAiProfile(aiConfig),
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { IcodeError } from '../core/errors.js'
|
|
4
|
+
import { resolveGitContext } from '../core/git/context.js'
|
|
5
|
+
import { assertCleanWorktree, assertNoPendingOperation, assertOnBranch, readGitSafetyState } from '../core/git/preconditions.js'
|
|
6
|
+
import { buildChangelog, parseVersionTag } from '../core/git/semver.js'
|
|
7
|
+
import { GitService } from '../core/git/service.js'
|
|
8
|
+
import { createPlatformClientForGit } from '../core/platform/index.js'
|
|
9
|
+
import { confirm } from '../core/tools/interactive.js'
|
|
10
|
+
import { logger } from '../core/tools/logger.js'
|
|
11
|
+
import { resolveNextSemverTag } from './tag-workflow.js'
|
|
12
|
+
|
|
13
|
+
function today() {
|
|
14
|
+
const now = new Date()
|
|
15
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 平台客户端可选:拿不到时只影响链接和平台 Release,不影响打 tag。
|
|
19
|
+
async function tryCreatePlatformClient(git) {
|
|
20
|
+
try {
|
|
21
|
+
return await createPlatformClientForGit(git)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logger.debug(`未识别代码托管平台: ${error.message}`)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 把新版本段落插到 CHANGELOG 顶部(保留已有的一级标题)。
|
|
29
|
+
function prependChangelog(filePath, section) {
|
|
30
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
|
31
|
+
if (!existing.trim()) {
|
|
32
|
+
fs.writeFileSync(filePath, `# Changelog\n\n${section}`, 'utf8')
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
const headerMatch = existing.match(/^# .*\n+/)
|
|
36
|
+
const next = headerMatch
|
|
37
|
+
? `${headerMatch[0]}${section}\n${existing.slice(headerMatch[0].length)}`
|
|
38
|
+
: `${section}\n${existing}`
|
|
39
|
+
fs.writeFileSync(filePath, next, 'utf8')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 同步 package.json 的 version 字段(只改这一个字段,保持其它格式)。
|
|
43
|
+
function updatePackageVersion(repoRoot, version) {
|
|
44
|
+
const filePath = path.resolve(repoRoot, 'package.json')
|
|
45
|
+
if (!fs.existsSync(filePath)) {
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
49
|
+
if (!/"version"\s*:\s*"[^"]*"/.test(content)) {
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
fs.writeFileSync(filePath, content.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`), 'utf8')
|
|
53
|
+
return filePath
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一条命令发版:计算版本 -> 生成 changelog -> (可选)写 CHANGELOG/package.json 并提交 -> 打 tag 推送 -> 创建平台 Release。
|
|
58
|
+
* 输入为 { bump, name, preid, writeChangelog, changelogFile, updatePackage, platform, draft, prerelease, dryRun, yes }。
|
|
59
|
+
*/
|
|
60
|
+
export async function runReleaseWorkflow(options = {}) {
|
|
61
|
+
const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode, remote: options.remote })
|
|
62
|
+
const git = new GitService(context)
|
|
63
|
+
const state = await readGitSafetyState(git)
|
|
64
|
+
assertOnBranch(state.currentBranch, 'release', 'RELEASE_DETACHED_HEAD')
|
|
65
|
+
assertNoPendingOperation(state.operation, 'release', 'RELEASE_GIT_OPERATION_IN_PROGRESS')
|
|
66
|
+
if (!options.dryRun) {
|
|
67
|
+
assertCleanWorktree(state.changes, 'release', 'RELEASE_DIRTY_WORKTREE')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await git.exec(['fetch', git.remote, '--tags'], { allowFailure: true })
|
|
71
|
+
|
|
72
|
+
const semver = await resolveNextSemverTag(git, { bump: options.bump || 'auto', preid: options.preid, prefix: options.prefix })
|
|
73
|
+
const tagName = String(options.name || semver.tagName).trim()
|
|
74
|
+
if (await git.tagExists(tagName)) {
|
|
75
|
+
throw new IcodeError(`tag 已存在: ${tagName}`, { code: 'RELEASE_TAG_EXISTS', exitCode: 2 })
|
|
76
|
+
}
|
|
77
|
+
if (!semver.commits.length) {
|
|
78
|
+
throw new IcodeError(`自 ${semver.previousTag || '仓库创建'} 以来没有新提交,无需发版。`, { code: 'RELEASE_NO_COMMITS', exitCode: 2 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const client = await tryCreatePlatformClient(git)
|
|
82
|
+
const changelog = buildChangelog(semver.commits, {
|
|
83
|
+
version: tagName,
|
|
84
|
+
date: today(),
|
|
85
|
+
compareUrl: client && semver.previousTag ? client.urls.compare(semver.previousTag, tagName) : '',
|
|
86
|
+
commitUrl: client ? (sha) => client.urls.commit(sha) : null
|
|
87
|
+
})
|
|
88
|
+
const versionNumber = (() => {
|
|
89
|
+
const parsed = parseVersionTag(tagName)
|
|
90
|
+
return parsed ? tagName.slice(parsed.prefix.length) : tagName
|
|
91
|
+
})()
|
|
92
|
+
const changelogFile = options.writeChangelog || options.changelogFile
|
|
93
|
+
? path.resolve(context.topLevelPath, options.changelogFile || 'CHANGELOG.md')
|
|
94
|
+
: ''
|
|
95
|
+
|
|
96
|
+
const plan = {
|
|
97
|
+
repoRoot: context.topLevelPath,
|
|
98
|
+
branch: state.currentBranch,
|
|
99
|
+
previousTag: semver.previousTag,
|
|
100
|
+
tagName,
|
|
101
|
+
version: versionNumber,
|
|
102
|
+
bump: semver.bump,
|
|
103
|
+
commitCount: semver.commits.length,
|
|
104
|
+
changelog,
|
|
105
|
+
changelogFile: changelogFile ? path.relative(context.topLevelPath, changelogFile) : '',
|
|
106
|
+
updatePackage: Boolean(options.updatePackage),
|
|
107
|
+
platform: client && options.platform !== false ? client.name : '',
|
|
108
|
+
platformToken: Boolean(client?.hasToken)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.dryRun) {
|
|
112
|
+
return { ...plan, dryRun: true }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!options.yes) {
|
|
116
|
+
logger.info(`\n${changelog}`)
|
|
117
|
+
const accepted = await confirm(`确认发布 ${tagName}(${semver.previousTag || '首个版本'} 以来 ${semver.commits.length} 个提交)?`, false)
|
|
118
|
+
if (!accepted) {
|
|
119
|
+
return { ...plan, canceled: true }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 1. 可选:写入 CHANGELOG / package.json 并生成发版提交。
|
|
124
|
+
const changedFiles = []
|
|
125
|
+
if (changelogFile) {
|
|
126
|
+
prependChangelog(changelogFile, changelog)
|
|
127
|
+
changedFiles.push(changelogFile)
|
|
128
|
+
}
|
|
129
|
+
if (options.updatePackage) {
|
|
130
|
+
const packageFile = updatePackageVersion(context.topLevelPath, versionNumber)
|
|
131
|
+
if (packageFile) {
|
|
132
|
+
changedFiles.push(packageFile)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let releaseCommit = ''
|
|
136
|
+
if (changedFiles.length) {
|
|
137
|
+
await git.stagePaths(changedFiles.map((file) => path.relative(context.topLevelPath, file)))
|
|
138
|
+
await git.commit(`chore(release): ${tagName}`, { noVerify: options.noVerify })
|
|
139
|
+
releaseCommit = await git.revParseShort('HEAD')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. 打 tag,tag 备注就是 changelog。
|
|
143
|
+
await git.createAnnotatedTag(tagName, changelog)
|
|
144
|
+
|
|
145
|
+
// 3. 推送发版提交和 tag。
|
|
146
|
+
if (!options.noPush) {
|
|
147
|
+
if (releaseCommit) {
|
|
148
|
+
await git.push(state.currentBranch, { noVerify: options.noVerify })
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await git.pushTag(tagName, { noVerify: options.noVerify })
|
|
152
|
+
} catch (error) {
|
|
153
|
+
await git.deleteTag(tagName)
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. 平台 Release(没有 token 时给出网页链接,不算失败)。
|
|
159
|
+
let releaseUrl = ''
|
|
160
|
+
let platformError = ''
|
|
161
|
+
if (client && options.platform !== false && !options.noPush) {
|
|
162
|
+
if (client.hasToken) {
|
|
163
|
+
try {
|
|
164
|
+
const created = await client.createRelease({
|
|
165
|
+
tag: tagName,
|
|
166
|
+
name: tagName,
|
|
167
|
+
body: changelog,
|
|
168
|
+
draft: options.draft,
|
|
169
|
+
prerelease: options.prerelease || Boolean(parseVersionTag(tagName)?.prerelease),
|
|
170
|
+
target: state.currentBranch
|
|
171
|
+
})
|
|
172
|
+
releaseUrl = created.url
|
|
173
|
+
} catch (error) {
|
|
174
|
+
platformError = error.message
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
releaseUrl = client.urls.releases()
|
|
178
|
+
platformError = `未配置 ${client.name} token,未创建平台 Release`
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
...plan,
|
|
184
|
+
releaseCommit,
|
|
185
|
+
pushed: !options.noPush,
|
|
186
|
+
releaseUrl,
|
|
187
|
+
platformError
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { IcodeError } from '../core/errors.js'
|
|
2
|
+
import { createBackup, listBackups, pruneBackups } from '../core/git/backup.js'
|
|
3
|
+
import { resolveGitContext } from '../core/git/context.js'
|
|
4
|
+
import { GitService } from '../core/git/service.js'
|
|
5
|
+
import { chooseOne, isInteractiveTerminal } from '../core/tools/interactive.js'
|
|
6
|
+
import { logger } from '../core/tools/logger.js'
|
|
7
|
+
|
|
8
|
+
async function openGit(options) {
|
|
9
|
+
const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode })
|
|
10
|
+
return { context, git: new GitService(context) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 列出可找回的内容:icode 恢复点 + HEAD reflog。
|
|
15
|
+
* 输入为 { limit },输出 { backups, reflog }。
|
|
16
|
+
*/
|
|
17
|
+
export async function runRescueListWorkflow(options = {}) {
|
|
18
|
+
const { context, git } = await openGit(options)
|
|
19
|
+
const backups = await listBackups(git)
|
|
20
|
+
const reflog = options.noReflog ? [] : await git.reflogEntries('HEAD', options.limit || 20)
|
|
21
|
+
return {
|
|
22
|
+
repoRoot: context.topLevelPath,
|
|
23
|
+
backups,
|
|
24
|
+
reflog
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 根据用户输入定位要恢复的提交:恢复点 id(可带 /分支)或任意 commit/reflog 选择器。
|
|
29
|
+
async function resolveRescueTarget(git, target, options) {
|
|
30
|
+
const backups = await listBackups(git)
|
|
31
|
+
const [groupId, ...entryParts] = String(target).split(':')
|
|
32
|
+
const group = backups.find((item) => item.id === groupId)
|
|
33
|
+
|
|
34
|
+
if (!group) {
|
|
35
|
+
const sha = await git.revParse(target)
|
|
36
|
+
if (!sha) {
|
|
37
|
+
throw new IcodeError(`找不到恢复点或提交: ${target}。先执行 icode rescue list 查看。`, {
|
|
38
|
+
code: 'RESCUE_TARGET_NOT_FOUND',
|
|
39
|
+
exitCode: 2
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
return { sha, branch: '', worktree: false, label: target }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const wanted = entryParts.join(':') || options.entry || ''
|
|
46
|
+
let entries = group.entries
|
|
47
|
+
if (wanted) {
|
|
48
|
+
entries = entries.filter((entry) => entry.branch === wanted || (wanted === 'worktree' && entry.worktree))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (entries.length === 1) {
|
|
52
|
+
return { ...entries[0], label: `${group.id}:${entries[0].branch}` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!entries.length) {
|
|
56
|
+
throw new IcodeError(`恢复点 ${group.id} 中没有 ${wanted}`, { code: 'RESCUE_ENTRY_NOT_FOUND', exitCode: 2 })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.yes || !isInteractiveTerminal()) {
|
|
60
|
+
throw new IcodeError(
|
|
61
|
+
`恢复点 ${group.id} 包含多个分支,请指定其中一个: ${entries.map((entry) => `${group.id}:${entry.worktree ? 'worktree' : entry.branch}`).join(', ')}`,
|
|
62
|
+
{ code: 'RESCUE_ENTRY_AMBIGUOUS', exitCode: 2 }
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const selected = await chooseOne(
|
|
67
|
+
`恢复点 ${group.id}(${group.reason})包含多个分支,选择要恢复的:`,
|
|
68
|
+
entries.map((entry, index) => ({ value: String(index), label: `${entry.branch} @ ${entry.shortSha}` })),
|
|
69
|
+
0
|
|
70
|
+
)
|
|
71
|
+
const entry = entries[Number(selected)]
|
|
72
|
+
return { ...entry, label: `${group.id}:${entry.branch}` }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sanitizeBranchName(value) {
|
|
76
|
+
return String(value).replace(/[^A-Za-z0-9._/-]+/g, '-').replace(/\/+/g, '/').replace(/^-+|-+$/g, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 恢复指定内容。默认新建分支指向它(最安全,不动当前分支);
|
|
81
|
+
* --reset 时把当前分支硬重置过去(先给当前状态再建一个恢复点);未提交改动类型的恢复点会 stash apply 回工作区。
|
|
82
|
+
*/
|
|
83
|
+
export async function runRescueRestoreWorkflow(options = {}) {
|
|
84
|
+
const { context, git } = await openGit(options)
|
|
85
|
+
if (!options.target) {
|
|
86
|
+
throw new IcodeError('缺少要恢复的目标: icode rescue restore <恢复点id[:分支]|commit>', {
|
|
87
|
+
code: 'RESCUE_TARGET_REQUIRED',
|
|
88
|
+
exitCode: 2
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const target = await resolveRescueTarget(git, options.target, options)
|
|
93
|
+
|
|
94
|
+
if (target.worktree) {
|
|
95
|
+
await git.exec(['stash', 'apply', target.sha])
|
|
96
|
+
return { action: 'apply-worktree', target, repoRoot: context.topLevelPath }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.reset) {
|
|
100
|
+
const currentBranch = await git.getCurrentBranch()
|
|
101
|
+
if (!currentBranch) {
|
|
102
|
+
throw new IcodeError('当前不在分支上,无法 --reset。', { code: 'RESCUE_DETACHED_HEAD', exitCode: 2 })
|
|
103
|
+
}
|
|
104
|
+
const backup = await createBackup(git, {
|
|
105
|
+
reason: `rescue reset ${currentBranch} -> ${target.sha.slice(0, 8)}`,
|
|
106
|
+
includeWorktree: true
|
|
107
|
+
})
|
|
108
|
+
await git.reset('hard', target.sha)
|
|
109
|
+
return {
|
|
110
|
+
action: 'reset',
|
|
111
|
+
branch: currentBranch,
|
|
112
|
+
target,
|
|
113
|
+
backupId: backup.entries.length ? backup.id : '',
|
|
114
|
+
repoRoot: context.topLevelPath
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const baseName = target.branch && !target.branch.includes(' ') ? target.branch.replace(/^remote-/, '') : target.sha.slice(0, 8)
|
|
119
|
+
const branchName = sanitizeBranchName(options.branch || `rescue/${baseName}-${Date.now().toString(36)}`)
|
|
120
|
+
if (await git.branchExistsLocal(branchName)) {
|
|
121
|
+
throw new IcodeError(`分支已存在: ${branchName},请用 --branch 换一个名字。`, {
|
|
122
|
+
code: 'RESCUE_BRANCH_EXISTS',
|
|
123
|
+
exitCode: 2
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
await git.createBranch(branchName, target.sha)
|
|
127
|
+
logger.debug(`rescue: ${branchName} -> ${target.sha}`)
|
|
128
|
+
|
|
129
|
+
return { action: 'branch', branch: branchName, target, repoRoot: context.topLevelPath }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function runRescuePruneWorkflow(options = {}) {
|
|
133
|
+
const { context, git } = await openGit(options)
|
|
134
|
+
const keep = Number.isInteger(options.keep) ? options.keep : 20
|
|
135
|
+
const removed = await pruneBackups(git, { keep })
|
|
136
|
+
return { removed, keep, repoRoot: context.topLevelPath }
|
|
137
|
+
}
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
assertOnBranch,
|
|
8
8
|
readGitSafetyState
|
|
9
9
|
} from '../core/git/preconditions.js'
|
|
10
|
+
import { assertNoRiskyFiles, inspectPendingFiles } from '../core/git/file-guard.js'
|
|
10
11
|
import { GitService } from '../core/git/service.js'
|
|
12
|
+
import { promptCommitMessage } from '../core/tools/commit-prompt.js'
|
|
11
13
|
import { runAiCommitWorkflow } from './ai-commit-workflow.js'
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -43,9 +45,9 @@ function validateMessageSource(options) {
|
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* 校验当前仓库是否允许执行 save。
|
|
46
|
-
*
|
|
48
|
+
* 输入为当前分支、未完成操作、改动摘要与提交范围,输出为空;不满足条件时抛出明确错误。
|
|
47
49
|
*/
|
|
48
|
-
function validateSavePreconditions(currentBranch, operation, changes) {
|
|
50
|
+
function validateSavePreconditions(currentBranch, operation, changes, scope) {
|
|
49
51
|
assertOnBranch(currentBranch, 'save', 'SAVE_DETACHED_HEAD')
|
|
50
52
|
assertNoPendingOperation(operation, 'save', 'SAVE_GIT_OPERATION_IN_PROGRESS')
|
|
51
53
|
assertNoConflictedFiles(changes, 'save', 'SAVE_CONFLICTED_FILES')
|
|
@@ -56,42 +58,107 @@ function validateSavePreconditions(currentBranch, operation, changes) {
|
|
|
56
58
|
exitCode: 2
|
|
57
59
|
})
|
|
58
60
|
}
|
|
61
|
+
|
|
62
|
+
if (scope.stagedOnly && scope.paths.length) {
|
|
63
|
+
throw new IcodeError('--staged-only 不能与指定路径同时使用。', {
|
|
64
|
+
code: 'SAVE_SCOPE_CONFLICT',
|
|
65
|
+
exitCode: 2
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (scope.stagedOnly && changes.staged === 0) {
|
|
70
|
+
throw new IcodeError('暂存区没有改动,--staged-only 已停止。请先 git add 需要提交的文件。', {
|
|
71
|
+
code: 'SAVE_NO_STAGED_CHANGES',
|
|
72
|
+
exitCode: 2
|
|
73
|
+
})
|
|
74
|
+
}
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
// 归一化提交范围:all(默认全部改动)/ staged(仅暂存区)/ paths(指定路径)。
|
|
78
|
+
function resolveScope(options) {
|
|
79
|
+
const paths = Array.isArray(options.paths) ? options.paths.map((item) => String(item).trim()).filter(Boolean) : []
|
|
80
|
+
const stagedOnly = options.stagedOnly === true
|
|
81
|
+
return {
|
|
82
|
+
mode: stagedOnly ? 'staged' : paths.length ? 'paths' : 'all',
|
|
83
|
+
stagedOnly,
|
|
84
|
+
paths
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildResult(context, currentBranch, changes, extra) {
|
|
66
89
|
return {
|
|
67
90
|
applied: false,
|
|
68
|
-
dryRun:
|
|
91
|
+
dryRun: false,
|
|
69
92
|
commitId: '',
|
|
70
|
-
commitMessage,
|
|
71
|
-
source,
|
|
93
|
+
commitMessage: '',
|
|
94
|
+
source: 'message',
|
|
72
95
|
context: {
|
|
73
96
|
topLevelPath: context.topLevelPath,
|
|
74
97
|
currentBranch
|
|
75
98
|
},
|
|
76
|
-
changes
|
|
99
|
+
changes,
|
|
100
|
+
...extra
|
|
77
101
|
}
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
/**
|
|
81
105
|
* 执行安全快速提交。
|
|
82
|
-
* 输入为 save 参数,输出提交结果;关键假设:dry-run 不执行 add/commit,AI 模式必须使用有效 profile
|
|
106
|
+
* 输入为 save 参数,输出提交结果;关键假设:dry-run 不执行 add/commit,AI 模式必须使用有效 profile,
|
|
107
|
+
* 提交前会扫描敏感文件 / 疑似密钥 / 大文件,发现风险默认阻断(--allow-risky 放行)。
|
|
83
108
|
*/
|
|
84
109
|
export async function runSaveWorkflow(options = {}) {
|
|
85
|
-
|
|
110
|
+
// 交互终端里没给提交信息时,后面会进入提交信息向导,这里先不报错。
|
|
111
|
+
const needsPrompt = options.interactive === true && !normalizeMessage(options.message) && options.aiCommit !== true
|
|
112
|
+
if (!needsPrompt) {
|
|
113
|
+
validateMessageSource(options)
|
|
114
|
+
}
|
|
86
115
|
|
|
87
116
|
const context = await resolveGitContext({
|
|
88
117
|
cwd: options.cwd,
|
|
89
|
-
repoMode: options.repoMode
|
|
118
|
+
repoMode: options.repoMode,
|
|
119
|
+
remote: options.remote
|
|
90
120
|
})
|
|
91
121
|
const git = new GitService(context)
|
|
92
122
|
const { currentBranch, operation, changes } = await readGitSafetyState(git)
|
|
123
|
+
const scope = resolveScope(options)
|
|
124
|
+
|
|
125
|
+
validateSavePreconditions(currentBranch, operation, changes, scope)
|
|
126
|
+
|
|
127
|
+
const { files, findings } = await inspectPendingFiles(git, context, {
|
|
128
|
+
mode: scope.mode === 'staged' ? 'staged' : 'all',
|
|
129
|
+
paths: scope.paths
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (scope.mode === 'paths' && !files.length) {
|
|
133
|
+
throw new IcodeError(`指定路径下没有可提交的改动: ${scope.paths.join(', ')}`, {
|
|
134
|
+
code: 'SAVE_PATHS_NO_CHANGES',
|
|
135
|
+
exitCode: 2
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const scopeInfo = {
|
|
140
|
+
scope: scope.mode,
|
|
141
|
+
paths: scope.paths,
|
|
142
|
+
files,
|
|
143
|
+
risks: findings
|
|
144
|
+
}
|
|
93
145
|
|
|
94
|
-
|
|
146
|
+
// dry-run 只报告风险,不阻断,方便先看清楚再决定。
|
|
147
|
+
if (options.dryRun !== true) {
|
|
148
|
+
assertNoRiskyFiles(findings, 'save', 'SAVE_RISKY_FILES', options.allowRisky === true)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (needsPrompt) {
|
|
152
|
+
const answer = await promptCommitMessage({ files })
|
|
153
|
+
if (answer.canceled) {
|
|
154
|
+
return buildResult(context, currentBranch, changes, { canceled: true, ...scopeInfo })
|
|
155
|
+
}
|
|
156
|
+
if (answer.useAi) {
|
|
157
|
+
options = { ...options, aiCommit: true }
|
|
158
|
+
} else {
|
|
159
|
+
options = { ...options, message: answer.message }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
95
162
|
|
|
96
163
|
if (options.aiCommit === true) {
|
|
97
164
|
// AI 模式必须先校验 profile 存在,避免 askAiJson 在缺配置时隐式选择其它配置。
|
|
@@ -104,49 +171,47 @@ export async function runSaveWorkflow(options = {}) {
|
|
|
104
171
|
apply: options.dryRun !== true,
|
|
105
172
|
yes: options.yes === true,
|
|
106
173
|
noVerify: options.noVerify === true,
|
|
174
|
+
stagedOnly: scope.stagedOnly,
|
|
175
|
+
paths: scope.paths,
|
|
107
176
|
silentContextLog: true,
|
|
108
177
|
silentResultLog: true
|
|
109
178
|
})
|
|
110
179
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
applied: aiResult.applied,
|
|
117
|
-
dryRun: false,
|
|
118
|
-
commitId: aiResult.commitId || '',
|
|
180
|
+
return buildResult(context, currentBranch, changes, {
|
|
181
|
+
applied: options.dryRun === true ? false : aiResult.applied,
|
|
182
|
+
dryRun: options.dryRun === true,
|
|
183
|
+
commitId: options.dryRun === true ? '' : aiResult.commitId || '',
|
|
119
184
|
commitMessage: aiResult.commitMessage,
|
|
120
185
|
source: 'ai',
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
currentBranch
|
|
124
|
-
},
|
|
125
|
-
changes
|
|
126
|
-
}
|
|
186
|
+
...scopeInfo
|
|
187
|
+
})
|
|
127
188
|
}
|
|
128
189
|
|
|
129
190
|
const commitMessage = normalizeMessage(options.message)
|
|
130
191
|
if (options.dryRun === true) {
|
|
131
|
-
return
|
|
192
|
+
return buildResult(context, currentBranch, changes, {
|
|
193
|
+
dryRun: true,
|
|
194
|
+
commitMessage,
|
|
195
|
+
...scopeInfo
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (scope.mode === 'all') {
|
|
200
|
+
await git.stageAll()
|
|
201
|
+
} else if (scope.mode === 'paths') {
|
|
202
|
+
await git.stagePaths(scope.paths)
|
|
132
203
|
}
|
|
133
204
|
|
|
134
|
-
await git.stageAll()
|
|
135
205
|
await git.commit(commitMessage, {
|
|
136
|
-
noVerify: options.noVerify === true
|
|
206
|
+
noVerify: options.noVerify === true,
|
|
207
|
+
paths: scope.paths
|
|
137
208
|
})
|
|
138
209
|
const commitId = await git.revParseShort('HEAD')
|
|
139
210
|
|
|
140
|
-
return {
|
|
211
|
+
return buildResult(context, currentBranch, changes, {
|
|
141
212
|
applied: true,
|
|
142
|
-
dryRun: false,
|
|
143
213
|
commitId,
|
|
144
214
|
commitMessage,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
topLevelPath: context.topLevelPath,
|
|
148
|
-
currentBranch
|
|
149
|
-
},
|
|
150
|
-
changes
|
|
151
|
-
}
|
|
215
|
+
...scopeInfo
|
|
216
|
+
})
|
|
152
217
|
}
|