@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
@@ -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 { getPlatformConfig, getRepoPolicy } from '../core/config/store.js'
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
- if (!operation) {
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
- '存在未完成的 Git 操作',
106
- `当前操作: ${operation}`,
107
- `请先完成或中止 ${operation},再继续执行 save/push。`
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
- '缺少 provider 或 apiKey,push -r 不可用。',
222
- '如需远程合并,请执行: icode config platform remote-merge set --provider gitlab --api-key <key>'
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 = getPlatformConfig('remoteMerge')
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
- * 构建 dry-run 结果。
63
- * 输入为上下文、分支、改动和计划提交信息,输出不会产生副作用的预览结构。
64
- */
65
- function buildDryRunResult(context, currentBranch, changes, commitMessage, source) {
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: true,
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
- validateMessageSource(options)
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
- validateSavePreconditions(currentBranch, operation, changes)
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
- if (options.dryRun === true) {
112
- return buildDryRunResult(context, currentBranch, changes, aiResult.commitMessage, 'ai')
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
- context: {
122
- topLevelPath: context.topLevelPath,
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 buildDryRunResult(context, currentBranch, changes, commitMessage, 'message')
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
- source: 'message',
146
- context: {
147
- topLevelPath: context.topLevelPath,
148
- currentBranch
149
- },
150
- changes
151
- }
215
+ ...scopeInfo
216
+ })
152
217
  }